S0-R1-C5.5-R6-R1 Unified Semantic Claim Frame

#89
core/pipeline_agent.py CHANGED
@@ -75,6 +75,7 @@ class SSciPipelineAgent:
75
  MAX_TRANSLATION_CLAIM_TOTAL_REPAIRS = 6
76
  PROTECTED_ENTITY_LEDGER_VERSION = 'protected-entity-ledger-v2'
77
  CANONICAL_GLOSSARY_QUALITY_VERSION = 'canonical-glossary-quality-v1'
 
78
 
79
  @staticmethod
80
  def _translation_defect_family(codes):
@@ -4014,12 +4015,21 @@ INPUT:
4014
  External literature and participant quotations are excluded.
4015
  """
4016
  value = str(text or "")
4017
- facts = dict((metadata or {}).get("study_facts") or {})
4018
- relations = [row for row in (facts.get("data_source_roles") or []) if isinstance(row, dict)]
 
 
 
 
 
 
 
 
 
4019
  allowed_by_actor = {}
4020
  for row in relations:
4021
  role = re.sub(r"\s+", " ", str(row.get("actor_role") or "")).strip()
4022
- family = cls._qual_source_family(row.get("data_source"))
4023
  if not role or not family:
4024
  continue
4025
  for variant in cls._qual_actor_variants(role):
@@ -6214,6 +6224,63 @@ TEXT:
6214
  r'|\|'
6215
  )
6216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6217
  @classmethod
6218
  def _translation_claim_units(cls, value):
6219
  """Create stable source-side claim IDs without constraining target punctuation."""
@@ -7165,6 +7232,7 @@ SECTION: {section_name}
7165
  'claim_id': claim_id,
7166
  'english': item['text'],
7167
  'target': target_claims[claim_id],
 
7168
  })
7169
 
7170
  issues = {}
@@ -7182,12 +7250,12 @@ SECTION: {section_name}
7182
  'target_language': target_lang,
7183
  'glossary': relevant_glossary,
7184
  'allowed_issue_codes': sorted(allowed_codes),
7185
- 'items': [{'id': row['id'], 'english': row['english'], 'target': row['target']} for row in rows],
7186
  }
7187
  profile = self._translation_language_profile(target_lang)
7188
  lexical_contract = self._translation_lexical_retention_contract(target_lang)
7189
  system_prompt = f"""You are a bilingual academic translation QA classifier for {target_lang}.
7190
- Review EACH claim independently. Do not rewrite and do not provide free-form explanations. Check semantic equivalence and language quality: omissions/additions, negation, uncertainty, association-versus-causation strength, terminology, grammar, language contamination, citation attachment, and acronym attachment.
7191
  LANGUAGE ADAPTER: {profile['adapter_id']}; morphology={profile['morphology']}. {profile['term_policy']} {lexical_contract} {profile['contamination_policy']}
7192
  For Russian, do not flag a terminology error merely because a translated term is inflected for grammatical case/number/gender; sustained non-Russian prose remains an additional contamination signal. For Kazakh, do not flag a terminology error merely because productive suffixes attach to the correct term stem; clearly Russian lexical/syntactic intrusion remains an additional language-specific signal when the pair supports that conclusion. Cyrillic script or absence of Kazakh-specific letters alone is never sufficient evidence.
7193
  Return valid JSON only in this exact form: {{"items":[{{"id":"SP-P000001-C000001","ok":true,"codes":[],"evidence_codes":[]}}]}}.
@@ -7599,10 +7667,21 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
7599
  block_id: self._model_safe_translation_manifest(protected_maps.get(block_id, {}))
7600
  for block_id, _source, _protected in protected_batch
7601
  }
 
 
 
 
 
 
 
7602
  effective_prompt = system_prompt + (
7603
  '\n<SEMANTIC_ANCHOR_MANIFEST>\n'
7604
  + json.dumps(semantic_manifest, ensure_ascii=False)
7605
  + '\n</SEMANTIC_ANCHOR_MANIFEST>\n'
 
 
 
 
7606
  'This manifest intentionally contains no factual values. Preserve every SP-P, SP-C, and SP-T marker exactly. '
7607
  'Each SP-C marker identifies one source claim. A target claim may contain one or more grammatical sentences, but every protected fact must remain inside its original SP-C claim. '
7608
  'Never infer, spell out, calculate, or recreate a hidden number, date, citation, acronym, name, formula, or identifier. Do not print the manifest.'
@@ -7935,6 +8014,8 @@ Each code must be one of: {sorted(allowed)}. Return one or more specific dimensi
7935
  'block_id': str(block_id),
7936
  'claim_id': str(claim_id),
7937
  'source_hash': hashlib.sha256(str(item['text']).encode('utf-8')).hexdigest(),
 
 
7938
  'target_text': target_text,
7939
  'qa_status': 'repair_pending' if codes else 'passed',
7940
  'issue_codes': [str(code or '').strip().upper() for code in codes if str(code or '').strip()][:5],
@@ -7964,8 +8045,15 @@ Each code must be one of: {sorted(allowed)}. Return one or more specific dimensi
7964
  key = f'SP-P{block_id}-{claim_id}'
7965
  row = states.get(key)
7966
  expected_hash = hashlib.sha256(str(item['text']).encode('utf-8')).hexdigest()
 
7967
  if not isinstance(row, dict) or row.get('source_hash') != expected_hash or not str(row.get('target_text') or '').strip():
7968
  return {}, {}, False
 
 
 
 
 
 
7969
  segments[claim_id] = str(row.get('target_text') or '').strip()
7970
  if str(row.get('qa_status') or '') == 'repair_pending':
7971
  codes = [str(code or '').strip().upper() for code in (row.get('issue_codes') or []) if str(code or '').strip()]
 
75
  MAX_TRANSLATION_CLAIM_TOTAL_REPAIRS = 6
76
  PROTECTED_ENTITY_LEDGER_VERSION = 'protected-entity-ledger-v2'
77
  CANONICAL_GLOSSARY_QUALITY_VERSION = 'canonical-glossary-quality-v1'
78
+ SEMANTIC_CLAIM_FRAME_VERSION = 'smartpaper-core-semantic-claim-frame-v1'
79
 
80
  @staticmethod
81
  def _translation_defect_family(codes):
 
4015
  External literature and participant quotations are excluded.
4016
  """
4017
  value = str(text or "")
4018
+ meta = dict(metadata or {})
4019
+ facts = dict(meta.get("study_facts") or {})
4020
+ semantic_frame = meta.get("semantic_claim_frame") if isinstance(meta.get("semantic_claim_frame"), dict) else None
4021
+ if not semantic_frame:
4022
+ try:
4023
+ from core.qualitative_engine import QualitativeEngine
4024
+ semantic_frame = QualitativeEngine.build_qualitative_semantic_claim_frame(meta)
4025
+ except Exception:
4026
+ semantic_frame = None
4027
+ frame_relations = list((semantic_frame or {}).get("actor_data_source_relations") or [])
4028
+ relations = frame_relations or [row for row in (facts.get("data_source_roles") or []) if isinstance(row, dict)]
4029
  allowed_by_actor = {}
4030
  for row in relations:
4031
  role = re.sub(r"\s+", " ", str(row.get("actor_role") or "")).strip()
4032
+ family = str(row.get("source_family") or "").strip() or cls._qual_source_family(row.get("data_source"))
4033
  if not role or not family:
4034
  continue
4035
  for variant in cls._qual_actor_variants(role):
 
6224
  r'|\|'
6225
  )
6226
 
6227
+ @classmethod
6228
+ def _source_semantic_claim_frame(cls, source_claim):
6229
+ """Derive a conservative language-neutral frame once from frozen English.
6230
+
6231
+ This frame is not a substitute for bilingual QA. It is an immutable
6232
+ source-side contract that target-language QA must preserve, preventing
6233
+ ZH/RU/KK from independently reinterpreting polarity, causal strength, or
6234
+ modality on every retry/resume.
6235
+ """
6236
+ text = re.sub(r"\s+", " ", str(source_claim or "")).strip()
6237
+ folded = text.casefold()
6238
+ causal = bool(re.search(
6239
+ r"\b(?:causes?|caused|causing|leads?\s+to|led\s+to|drives?|drove|driven|determines?|determined|"
6240
+ r"produces?|produced|results?\s+in|resulted\s+in|yields?|yielded|generates?|generated|"
6241
+ r"causal(?:ity|\s+(?:effect|effects|mediation|mechanism|pathway|relationship|relationships)))\b",
6242
+ folded, flags=re.I,
6243
+ ))
6244
+ association = bool(re.search(
6245
+ r"\b(?:association|associations|associated|correlation|correlations|correlated|covariation|covariance|indirect\s+association)\b",
6246
+ folded, flags=re.I,
6247
+ ))
6248
+ scalar_not = bool(re.search(r"\bnot\s+(?:only|merely|solely|exclusively|primarily|mainly)\b", folded, flags=re.I))
6249
+ negative = False
6250
+ if not scalar_not:
6251
+ negative = bool(re.search(
6252
+ r"\b(?:cannot|can't|could\s+not|couldn't|does\s+not|doesn't|do\s+not|don't|did\s+not|didn't|never|"
6253
+ r"is\s+not|are\s+not|was\s+not|were\s+not|no\s+(?:evidence|basis|support|proof)|without\s+(?:evidence|support|identifying|establishing|demonstrating|showing|inferring))\b",
6254
+ folded, flags=re.I,
6255
+ ))
6256
+ if causal and negative:
6257
+ polarity = "NEGATIVE"
6258
+ causal_strength = "CAUSAL_LIMITATION"
6259
+ elif causal:
6260
+ polarity = "AFFIRMATIVE"
6261
+ causal_strength = "CAUSAL_ASSERTION"
6262
+ elif association:
6263
+ polarity = "NONE"
6264
+ causal_strength = "ASSOCIATIONAL"
6265
+ else:
6266
+ polarity = "NONE"
6267
+ causal_strength = "DESCRIPTIVE"
6268
+ if re.search(r"\b(?:may|might|could|appears?\s+to|seems?\s+to|suggests?|consistent\s+with)\b", folded) and not re.search(r"\bcould\s+not\b", folded):
6269
+ modality = "HEDGED"
6270
+ elif negative and causal:
6271
+ modality = "LIMITATION"
6272
+ else:
6273
+ modality = "ASSERTED"
6274
+ frame = {
6275
+ "version": cls.SEMANTIC_CLAIM_FRAME_VERSION,
6276
+ "polarity": polarity,
6277
+ "causal_strength": causal_strength,
6278
+ "modality": modality,
6279
+ }
6280
+ canonical = json.dumps(frame, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
6281
+ frame["frame_hash"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
6282
+ return frame
6283
+
6284
  @classmethod
6285
  def _translation_claim_units(cls, value):
6286
  """Create stable source-side claim IDs without constraining target punctuation."""
 
7232
  'claim_id': claim_id,
7233
  'english': item['text'],
7234
  'target': target_claims[claim_id],
7235
+ 'source_semantic_frame': self._source_semantic_claim_frame(item['text']),
7236
  })
7237
 
7238
  issues = {}
 
7250
  'target_language': target_lang,
7251
  'glossary': relevant_glossary,
7252
  'allowed_issue_codes': sorted(allowed_codes),
7253
+ 'items': [{'id': row['id'], 'english': row['english'], 'target': row['target'], 'source_semantic_frame': row['source_semantic_frame']} for row in rows],
7254
  }
7255
  profile = self._translation_language_profile(target_lang)
7256
  lexical_contract = self._translation_lexical_retention_contract(target_lang)
7257
  system_prompt = f"""You are a bilingual academic translation QA classifier for {target_lang}.
7258
+ Review EACH claim independently. Do not rewrite and do not provide free-form explanations. The supplied source_semantic_frame is server-frozen source authority: the target must preserve its polarity, causal_strength, and modality. Check semantic equivalence and language quality: omissions/additions, negation, uncertainty, association-versus-causation strength, terminology, grammar, language contamination, citation attachment, and acronym attachment.
7259
  LANGUAGE ADAPTER: {profile['adapter_id']}; morphology={profile['morphology']}. {profile['term_policy']} {lexical_contract} {profile['contamination_policy']}
7260
  For Russian, do not flag a terminology error merely because a translated term is inflected for grammatical case/number/gender; sustained non-Russian prose remains an additional contamination signal. For Kazakh, do not flag a terminology error merely because productive suffixes attach to the correct term stem; clearly Russian lexical/syntactic intrusion remains an additional language-specific signal when the pair supports that conclusion. Cyrillic script or absence of Kazakh-specific letters alone is never sufficient evidence.
7261
  Return valid JSON only in this exact form: {{"items":[{{"id":"SP-P000001-C000001","ok":true,"codes":[],"evidence_codes":[]}}]}}.
 
7667
  block_id: self._model_safe_translation_manifest(protected_maps.get(block_id, {}))
7668
  for block_id, _source, _protected in protected_batch
7669
  }
7670
+ claim_semantic_manifest = {
7671
+ block_id: {
7672
+ claim_id: self._source_semantic_claim_frame(source_claim)
7673
+ for claim_id, source_claim in dict(claim_sources_by_block.get(block_id) or {}).items()
7674
+ }
7675
+ for block_id, _source, _protected in protected_batch
7676
+ }
7677
  effective_prompt = system_prompt + (
7678
  '\n<SEMANTIC_ANCHOR_MANIFEST>\n'
7679
  + json.dumps(semantic_manifest, ensure_ascii=False)
7680
  + '\n</SEMANTIC_ANCHOR_MANIFEST>\n'
7681
+ + '<SOURCE_SEMANTIC_CLAIM_FRAMES>\n'
7682
+ + json.dumps(claim_semantic_manifest, ensure_ascii=False)
7683
+ + '\n</SOURCE_SEMANTIC_CLAIM_FRAMES>\n'
7684
+ 'The source semantic claim frames are server-frozen. Preserve polarity, causal strength, and modality for each SP-C claim exactly; do not strengthen an association into causation or drop a negation/hedge. '
7685
  'This manifest intentionally contains no factual values. Preserve every SP-P, SP-C, and SP-T marker exactly. '
7686
  'Each SP-C marker identifies one source claim. A target claim may contain one or more grammatical sentences, but every protected fact must remain inside its original SP-C claim. '
7687
  'Never infer, spell out, calculate, or recreate a hidden number, date, citation, acronym, name, formula, or identifier. Do not print the manifest.'
 
8014
  'block_id': str(block_id),
8015
  'claim_id': str(claim_id),
8016
  'source_hash': hashlib.sha256(str(item['text']).encode('utf-8')).hexdigest(),
8017
+ 'semantic_frame': cls._source_semantic_claim_frame(item['text']),
8018
+ 'semantic_frame_hash': cls._source_semantic_claim_frame(item['text']).get('frame_hash', ''),
8019
  'target_text': target_text,
8020
  'qa_status': 'repair_pending' if codes else 'passed',
8021
  'issue_codes': [str(code or '').strip().upper() for code in codes if str(code or '').strip()][:5],
 
8045
  key = f'SP-P{block_id}-{claim_id}'
8046
  row = states.get(key)
8047
  expected_hash = hashlib.sha256(str(item['text']).encode('utf-8')).hexdigest()
8048
+ expected_semantic_hash = cls._source_semantic_claim_frame(item['text']).get('frame_hash', '')
8049
  if not isinstance(row, dict) or row.get('source_hash') != expected_hash or not str(row.get('target_text') or '').strip():
8050
  return {}, {}, False
8051
+ # R6-R1: new states are semantic-frame bound. Pre-R6 states do
8052
+ # not carry this optional field and retain legacy fail-closed
8053
+ # repair history instead of being silently "revived".
8054
+ stored_semantic_hash = str(row.get('semantic_frame_hash') or '')
8055
+ if stored_semantic_hash and stored_semantic_hash != expected_semantic_hash:
8056
+ return {}, {}, False
8057
  segments[claim_id] = str(row.get('target_text') or '').strip()
8058
  if str(row.get('qa_status') or '') == 'repair_pending':
8059
  codes = [str(code or '').strip().upper() for code in (row.get('issue_codes') or []) if str(code or '').strip()]
core/qualitative_engine.py CHANGED
@@ -14,6 +14,7 @@ from config import APP_CONFIG
14
  class QualitativeEngine:
15
  QUALITATIVE_APPENDIX_MANIFEST_VERSION = "qualitative-appendix-manifest-v1"
16
  TARGET_APPENDIX_BINDING_VERSION = "target-qualitative-appendix-binding-v1"
 
17
  APPENDIX_FIELDS = ("theme", "concept", "stage", "case_label", "evidence_role")
18
  APPENDIX_HEADERS = (
19
  "Higher-order Theme", "Empirical Concept", "Process Stage",
@@ -94,6 +95,91 @@ class QualitativeEngine:
94
  rows.append(row)
95
  return rows
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  @classmethod
98
  def build_scoped_evidence_md(
99
  cls,
@@ -862,6 +948,7 @@ Rules:
862
  "evidence": clean_evidence,
863
  "themes": themes,
864
  }
 
865
  status_message = "✅ 定性研究事实��分析设计与证据结构已冻结。"
866
  return md, status_message, metadata
867
 
 
14
  class QualitativeEngine:
15
  QUALITATIVE_APPENDIX_MANIFEST_VERSION = "qualitative-appendix-manifest-v1"
16
  TARGET_APPENDIX_BINDING_VERSION = "target-qualitative-appendix-binding-v1"
17
+ SEMANTIC_CLAIM_FRAME_VERSION = "smartpaper-core-semantic-claim-frame-v1"
18
  APPENDIX_FIELDS = ("theme", "concept", "stage", "case_label", "evidence_role")
19
  APPENDIX_HEADERS = (
20
  "Higher-order Theme", "Empirical Concept", "Process Stage",
 
95
  rows.append(row)
96
  return rows
97
 
98
+ @classmethod
99
+ def build_qualitative_semantic_claim_frame(cls, metadata):
100
+ """Freeze actor/source, process-evidence, and quote identities in one frame.
101
+
102
+ The frame is server-side authority. It contains no model-inferred prose
103
+ meaning and no verbatim quotation payload, only typed identities/hashes.
104
+ """
105
+ meta = dict(metadata or {})
106
+ facts = dict(meta.get("study_facts") or {})
107
+ relations = []
108
+ for row in cls._normalize_data_source_roles(facts.get("data_source_roles") or []):
109
+ actor = cls._clean_cell(row.get("actor_role"), "")
110
+ source = cls._clean_cell(row.get("data_source"), "")
111
+ folded = source.casefold()
112
+ if "interview" in folded:
113
+ family = "interview"
114
+ elif "focus" in folded and "group" in folded:
115
+ family = "focus_group"
116
+ elif "observ" in folded:
117
+ family = "observation"
118
+ elif "log" in folded:
119
+ family = "log"
120
+ elif "document" in folded or "archiv" in folded or "record" in folded:
121
+ family = "document"
122
+ else:
123
+ family = "other"
124
+ relation = {
125
+ "actor_role": actor,
126
+ "data_source": source,
127
+ "source_family": family,
128
+ "count": row.get("count"),
129
+ "relation": "EVIDENCED_BY",
130
+ }
131
+ relation_id_payload = json.dumps(relation, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
132
+ relation["relation_id"] = hashlib.sha256(relation_id_payload.encode("utf-8")).hexdigest()[:16]
133
+ relations.append(relation)
134
+
135
+ quotes = []
136
+ for item in meta.get("evidence") or []:
137
+ if not isinstance(item, dict):
138
+ continue
139
+ quote_id = str(item.get("tag") or "").strip()
140
+ if not re.fullmatch(r"QUOTE-\d+", quote_id):
141
+ continue
142
+ quote_hash = str(item.get("quote_hash") or "").strip()
143
+ if not quote_hash and str(item.get("quote") or "").strip():
144
+ normalized = re.sub(r"\s+", " ", str(item.get("quote") or "")).strip()
145
+ quote_hash = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
146
+ quotes.append({
147
+ "quote_id": quote_id,
148
+ "quote_hash": quote_hash,
149
+ "theme_id": str(item.get("theme_id") or "").strip(),
150
+ "stage": cls._clean_cell(item.get("stage")),
151
+ "case_label": cls._clean_cell(item.get("case_label")),
152
+ "evidence_role": str(item.get("evidence_role") or "typical").strip().lower(),
153
+ })
154
+
155
+ process_evidence = []
156
+ for theme in meta.get("themes") or []:
157
+ if not isinstance(theme, dict):
158
+ continue
159
+ theme_id = str(theme.get("theme_id") or "").strip()
160
+ if not theme_id:
161
+ continue
162
+ process_evidence.append({
163
+ "theme_id": theme_id,
164
+ "stages": [cls._clean_cell(x, "") for x in (theme.get("stages") or []) if cls._clean_cell(x, "")],
165
+ "has_deviant_evidence": bool(theme.get("has_deviant_evidence")),
166
+ "quote_ids": [
167
+ str(row.get("tag") or "").strip()
168
+ for row in (theme.get("evidence") or []) if isinstance(row, dict) and str(row.get("tag") or "").strip()
169
+ ],
170
+ })
171
+
172
+ frame = {
173
+ "version": cls.SEMANTIC_CLAIM_FRAME_VERSION,
174
+ "mode": "qualitative",
175
+ "actor_data_source_relations": sorted(relations, key=lambda row: (row["actor_role"].casefold(), row["data_source"].casefold())),
176
+ "process_evidence": sorted(process_evidence, key=lambda row: row["theme_id"]),
177
+ "quote_identities": sorted(quotes, key=lambda row: row["quote_id"]),
178
+ }
179
+ canonical = json.dumps(frame, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
180
+ frame["frame_hash"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
181
+ return frame
182
+
183
  @classmethod
184
  def build_scoped_evidence_md(
185
  cls,
 
948
  "evidence": clean_evidence,
949
  "themes": themes,
950
  }
951
+ metadata["semantic_claim_frame"] = self.build_qualitative_semantic_claim_frame(metadata)
952
  status_message = "✅ 定性研究事实��分析设计与证据结构已冻结。"
953
  return md, status_message, metadata
954
 
core/quantitative_engine.py CHANGED
@@ -3,6 +3,7 @@
3
 
4
  from __future__ import annotations
5
 
 
6
  import json
7
  import logging
8
  import re
@@ -21,6 +22,7 @@ class QuantitativeEngine:
21
  QUANTITATIVE_TABLE_MANIFEST_VERSION = "quantitative-table-manifest-v1"
22
  HYPOTHESIS_LEDGER_VERSION = "quantitative-hypothesis-ledger-v1"
23
  TARGET_TABLE_BINDING_VERSION = "target-quantitative-table-binding-v1"
 
24
 
25
  _TARGET_TABLE_LOCALIZATION = {
26
  "zh": {
@@ -859,6 +861,130 @@ Keywords: {keywords}"""
859
  ] if strict else [],
860
  }
861
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
862
  @classmethod
863
  def _classify_quantitative_causal_clause(cls, clause):
864
  """Classify one clause by causal *assertion scope*, not token presence.
@@ -987,6 +1113,33 @@ Keywords: {keywords}"""
987
 
988
  def direct_match_is_disclaimed(match):
989
  prefix = value[max(0, match.start() - 220):match.start()]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
990
  if re.search(
991
  r"(?:\b(?:does|do|did|can|could|would|should|must|may|might)\s+not|"
992
  r"\bcannot|\bcan't|\bcouldn't|\bnever)\s+(?:\w+\s+){0,2}$",
@@ -1074,6 +1227,22 @@ Keywords: {keywords}"""
1074
  def classify_quantitative_causal_sentence(cls, sentence):
1075
  """Three-state causal contract shared by inspection, repair, and tests."""
1076
  value = str(sentence or "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1077
  # Contrast clauses must not let an early disclaimer shield a later assertion.
1078
  clauses = re.split(
1079
  r"(?i)(?:;|\bbut\b|\bhowever\b|\bwhereas\b|\byet\b|\bnevertheless\b|\balthough\b|\bthough\b)",
@@ -1206,10 +1375,10 @@ Keywords: {keywords}"""
1206
  methodological_bias_scope = cls._is_methodological_bias_mechanism_sentence(
1207
  sentence, known_constructs
1208
  )
1209
- causal_state = (
1210
- "NON_CAUSAL" if methodological_bias_scope
1211
- else cls.classify_quantitative_causal_sentence(sentence)
1212
  )
 
1213
  if causal_state == "AFFIRMATIVE_CAUSAL":
1214
  violations.append({
1215
  "type":"causal_language_violation",
@@ -1580,6 +1749,7 @@ Keywords: {keywords}"""
1580
  "Frozen bootstrap and collinearity checks",
1581
  ],
1582
  }
 
1583
  metadata["hypothesis_ledger"] = self.build_hypothesis_ledger(construct_names)
1584
  metadata["quantitative_table_manifest"] = self.build_table_manifest(
1585
  validity_df, fl_matrix_df, path_table, spearman
 
3
 
4
  from __future__ import annotations
5
 
6
+ import hashlib
7
  import json
8
  import logging
9
  import re
 
22
  QUANTITATIVE_TABLE_MANIFEST_VERSION = "quantitative-table-manifest-v1"
23
  HYPOTHESIS_LEDGER_VERSION = "quantitative-hypothesis-ledger-v1"
24
  TARGET_TABLE_BINDING_VERSION = "target-quantitative-table-binding-v1"
25
+ SEMANTIC_CLAIM_FRAME_VERSION = "smartpaper-core-semantic-claim-frame-v1"
26
 
27
  _TARGET_TABLE_LOCALIZATION = {
28
  "zh": {
 
861
  ] if strict else [],
862
  }
863
 
864
+ @staticmethod
865
+ def _semantic_frame_hash(payload):
866
+ canonical = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
867
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
868
+
869
+ @classmethod
870
+ def build_quantitative_semantic_claim_frame(cls, sentence, metadata=None, section_name=""):
871
+ """Freeze a compact server-side semantic frame for one quantitative claim.
872
+
873
+ R6-R1 does not ask the model to decide this frame. It records the
874
+ source-side claim role, causal polarity/strength, methodological domain,
875
+ hypothesis identity, and explicit result direction so downstream
876
+ validation/translation can inherit one stable semantic authority.
877
+ """
878
+ text = re.sub(r"\s+", " ", str(sentence or "")).strip()
879
+ folded = text.casefold()
880
+ meta = dict(metadata or {})
881
+ ledger = dict(meta.get("quantitative_evidence_ledger") or {})
882
+ if not ledger and meta:
883
+ try:
884
+ ledger = cls.build_quantitative_evidence_ledger(meta)
885
+ except Exception:
886
+ ledger = {}
887
+ constructs = dict(ledger.get("constructs") or {})
888
+ known_constructs = [str(value) for value in constructs.values() if str(value)]
889
+ ownership = cls._quantitative_sentence_ownership(text, section_name, known_constructs) if text else "other"
890
+
891
+ relation_specs = (
892
+ (r"\b(?:causes?|caused|causing)\b", "CAUSE"),
893
+ (r"\b(?:leads?|led)\s+to\b", "LEAD_TO"),
894
+ (r"\b(?:drives?|drove|driven)\b", "DRIVE"),
895
+ (r"\b(?:determines?|determined)\b", "DETERMINE"),
896
+ (r"\b(?:produces?|produced)\b", "PRODUCE"),
897
+ (r"\b(?:results?|resulted)\s+in\b", "RESULT_IN"),
898
+ (r"\b(?:yields?|yielded)\b", "YIELD"),
899
+ (r"\b(?:generates?|generated)\b", "GENERATE"),
900
+ (r"\bcausal(?:ity|\s+(?:effect|effects|mediation|mechanism|pathway|relationship|relationships|identification))\b", "CAUSAL_NOUN"),
901
+ )
902
+ relation = "NONE"
903
+ for pattern, label in relation_specs:
904
+ if re.search(pattern, folded, flags=re.I):
905
+ relation = label
906
+ break
907
+
908
+ methodological_bias = cls._is_methodological_bias_mechanism_sentence(text, known_constructs)
909
+ robustness_scope = bool(re.search(
910
+ r"\b(?:robustness|sensitivity|spearman|rank[- ]order|extreme\s+values?|outliers?|influential\s+(?:cases|observations)|"
911
+ r"response\s+styles?|common\s+method|same[- ]source|single\s+questionnaire|measurement\s+(?:error|bias))\b",
912
+ folded, flags=re.I,
913
+ ))
914
+ if methodological_bias:
915
+ domain = "METHODOLOGICAL_BIAS"
916
+ elif robustness_scope:
917
+ domain = "METHODOLOGICAL_ROBUSTNESS"
918
+ elif re.search(r"\b(?:analysis|analyses|bootstrap|regression|correlation|estimation|statistical\s+(?:test|model|analysis)|vif)\b", folded):
919
+ domain = "ANALYTICAL_REPORTING"
920
+ elif any(name.casefold() in folded for name in known_constructs):
921
+ domain = "SUBSTANTIVE_CONSTRUCT"
922
+ else:
923
+ domain = "GENERAL"
924
+
925
+ raw_causal_state = cls.classify_quantitative_causal_sentence(text) if text else "NON_CAUSAL"
926
+ validation_state = "NON_CAUSAL" if methodological_bias else raw_causal_state
927
+ if raw_causal_state == "EXPLICIT_CAUSAL_LIMITATION":
928
+ polarity = "NEGATIVE"
929
+ causal_strength = "CAUSAL_LIMITATION"
930
+ elif raw_causal_state == "AFFIRMATIVE_CAUSAL":
931
+ polarity = "AFFIRMATIVE"
932
+ causal_strength = "CAUSAL_ASSERTION"
933
+ elif re.search(r"\b(?:association|associations|associated|correlation|correlations|correlated|covariation|covariance)\b", folded):
934
+ polarity = "NONE"
935
+ causal_strength = "ASSOCIATIONAL"
936
+ else:
937
+ polarity = "NONE"
938
+ causal_strength = "NON_CAUSAL"
939
+
940
+ if re.search(r"\b(?:may|might|could|appears?\s+to|seems?\s+to|suggests?|consistent\s+with)\b", folded) and not re.search(r"\bcould\s+not\b", folded):
941
+ modality = "HEDGED"
942
+ elif raw_causal_state == "EXPLICIT_CAUSAL_LIMITATION":
943
+ modality = "LIMITATION"
944
+ else:
945
+ modality = "ASSERTED"
946
+
947
+ hypothesis_ids = sorted(set(re.findall(r"(?i)\bH\d+[A-Za-z]?\b", text)), key=str.casefold)
948
+ if re.search(r"\b(?:non[- ]?significant|not\s+significant|did\s+not\s+support|was\s+not\s+supported)\b", folded):
949
+ result_direction = "NOT_SUPPORTED_OR_NONSIGNIFICANT"
950
+ elif re.search(r"\b(?:positive|positively)\b[^.;]{0,80}\b(?:association|associated|relationship|correlation|estimate|coefficient)\b", folded):
951
+ result_direction = "POSITIVE"
952
+ elif re.search(r"\b(?:negative|negatively)\b[^.;]{0,80}\b(?:association|associated|relationship|correlation|estimate|coefficient)\b", folded):
953
+ result_direction = "NEGATIVE"
954
+ else:
955
+ result_direction = "UNSPECIFIED"
956
+
957
+ if hypothesis_ids:
958
+ claim_type = "HYPOTHESIS_CLAIM"
959
+ elif domain in {"METHODOLOGICAL_BIAS", "METHODOLOGICAL_ROBUSTNESS"}:
960
+ claim_type = "METHODOLOGICAL_CLAIM"
961
+ elif ownership == "result":
962
+ claim_type = "RESULT_CLAIM"
963
+ elif causal_strength == "ASSOCIATIONAL":
964
+ claim_type = "ASSOCIATION_CLAIM"
965
+ elif raw_causal_state != "NON_CAUSAL":
966
+ claim_type = "CAUSAL_SCOPE_CLAIM"
967
+ else:
968
+ claim_type = "OTHER"
969
+
970
+ frame = {
971
+ "version": cls.SEMANTIC_CLAIM_FRAME_VERSION,
972
+ "mode": "quantitative",
973
+ "claim_type": claim_type,
974
+ "ownership": ownership,
975
+ "domain": domain,
976
+ "relation": relation,
977
+ "polarity": polarity,
978
+ "causal_strength": causal_strength,
979
+ "causal_state": raw_causal_state,
980
+ "validation_state": validation_state,
981
+ "modality": modality,
982
+ "hypothesis_ids": hypothesis_ids,
983
+ "result_direction": result_direction,
984
+ }
985
+ frame["frame_hash"] = cls._semantic_frame_hash(frame)
986
+ return frame
987
+
988
  @classmethod
989
  def _classify_quantitative_causal_clause(cls, clause):
990
  """Classify one clause by causal *assertion scope*, not token presence.
 
1113
 
1114
  def direct_match_is_disclaimed(match):
1115
  prefix = value[max(0, match.start() - 220):match.start()]
1116
+ suffix = value[match.end():min(len(value), match.end() + 220)]
1117
+
1118
+ # R6-R1 predicate polarity closure: passive negation is a semantic
1119
+ # limitation ("associations are not driven by outliers"), not an
1120
+ # affirmative causal claim. Do not exempt contrastive constructions
1121
+ # that merely replace one asserted cause with another ("not driven by
1122
+ # X but rather by Y") or scalar forms such as "not only driven".
1123
+ passive_negative = bool(re.search(
1124
+ r"\b(?:is|are|was|were|be|been|being)\s+"
1125
+ r"(?:(?:clearly|necessarily|directly|statistically)\s+){0,2}"
1126
+ r"(?:not|never)\s+"
1127
+ r"(?:(?:clearly|necessarily|directly|statistically)\s+){0,2}$",
1128
+ prefix, flags=re.I,
1129
+ ))
1130
+ if passive_negative:
1131
+ alternate_cause = re.search(
1132
+ r"\b(?:but|rather|instead)\b\s*(?:rather\s+|instead\s+|also\s+)?by\b",
1133
+ suffix, flags=re.I,
1134
+ )
1135
+ alternate_negated = re.search(
1136
+ r"\b(?:but|rather|instead)\b\s*(?:also\s+)?(?:not|never)\s+by\b",
1137
+ suffix, flags=re.I,
1138
+ )
1139
+ if alternate_cause and not alternate_negated:
1140
+ return False
1141
+ return True
1142
+
1143
  if re.search(
1144
  r"(?:\b(?:does|do|did|can|could|would|should|must|may|might)\s+not|"
1145
  r"\bcannot|\bcan't|\bcouldn't|\bnever)\s+(?:\w+\s+){0,2}$",
 
1227
  def classify_quantitative_causal_sentence(cls, sentence):
1228
  """Three-state causal contract shared by inspection, repair, and tests."""
1229
  value = str(sentence or "")
1230
+ folded = re.sub(r"\s+", " ", value).strip().casefold()
1231
+ # A negated passive can still assert an alternative cause: "not driven
1232
+ # by X but rather by Y". Detect this before contrast-clause splitting,
1233
+ # otherwise the second fragment loses the causal predicate surface.
1234
+ alternate_cause = re.search(
1235
+ r"\b(?:is|are|was|were|be|been|being)\s+"
1236
+ r"(?:(?:clearly|necessarily|directly|statistically)\s+){0,2}"
1237
+ r"(?:not|never)\s+"
1238
+ r"(?:(?:clearly|necessarily|directly|statistically)\s+){0,2}"
1239
+ r"(?:caused|driven|determined|produced|generated|yielded)\b"
1240
+ r"[^.;]{0,180}\b(?:but|rather|instead)\b\s*"
1241
+ r"(?:rather\s+|instead\s+|also\s+)?by\b",
1242
+ folded, flags=re.I,
1243
+ )
1244
+ if alternate_cause:
1245
+ return "AFFIRMATIVE_CAUSAL"
1246
  # Contrast clauses must not let an early disclaimer shield a later assertion.
1247
  clauses = re.split(
1248
  r"(?i)(?:;|\bbut\b|\bhowever\b|\bwhereas\b|\byet\b|\bnevertheless\b|\balthough\b|\bthough\b)",
 
1375
  methodological_bias_scope = cls._is_methodological_bias_mechanism_sentence(
1376
  sentence, known_constructs
1377
  )
1378
+ semantic_frame = cls.build_quantitative_semantic_claim_frame(
1379
+ sentence, meta, section_name
 
1380
  )
1381
+ causal_state = str(semantic_frame.get("validation_state") or "NON_CAUSAL")
1382
  if causal_state == "AFFIRMATIVE_CAUSAL":
1383
  violations.append({
1384
  "type":"causal_language_violation",
 
1749
  "Frozen bootstrap and collinearity checks",
1750
  ],
1751
  }
1752
+ metadata["semantic_claim_frame_version"] = self.SEMANTIC_CLAIM_FRAME_VERSION
1753
  metadata["hypothesis_ledger"] = self.build_hypothesis_ledger(construct_names)
1754
  metadata["quantitative_table_manifest"] = self.build_table_manifest(
1755
  validity_df, fl_matrix_df, path_table, spearman