S0-R1-C5.5 Pre-Production Contract Freeze Failure-Surface Closure

#83
core/generation_runner.py CHANGED
@@ -49,6 +49,28 @@ TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v8'
49
  FINAL_CITATION_LEDGER_VERSION = 'resolved-final-ledger-v1'
50
  ENGLISH_SECTION_SEAL_VERSION = 'english-section-integrity-seal-v1'
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
  def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
54
  lines = str(text or "").split("\n")
@@ -966,6 +988,107 @@ class GenerationRunner:
966
  rows.append(' | '.join(parts))
967
  return '\n'.join(f'- {row}' for row in rows)
968
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
969
  @staticmethod
970
  def _citation_violation_tags(rows) -> set:
971
  """Extract only citation tags owned by the rejected claim sentence.
@@ -986,24 +1109,10 @@ class GenerationRunner:
986
 
987
  @staticmethod
988
  def _author_facing_internal_workflow_violations(text: str) -> list[dict]:
989
- """Detect SmartPaper control vocabulary that must stay out of author prose.
990
-
991
- The check is intentionally phrase-scoped. A generic word such as
992
- ``frozen`` can be legitimate academic content, but phrases naming the
993
- private evidence/state machinery are never reader-facing manuscript
994
- language.
995
- """
996
  value = str(text or "")
997
- patterns = [
998
- r"\bfrozen\s+(?:research\s+)?evidence\b",
999
- r"\bfrozen\s+evidence\s+packet\b",
1000
- r"\bevidence\s+packet\b",
1001
- r"\bfrozen\s+(?:theme\s+summary|thematic\s+structure|statistical\s+evidence|measurement\s+design|association\s+model|bootstrap\s+and\s+collinearity\s+checks|task-level\s+concepts?)\b",
1002
- r"\b(?:study|result)-owned\s+fact\b",
1003
- r"\bevidence\s+ledger\b",
1004
- ]
1005
  rows = []
1006
- for pattern in patterns:
1007
  for match in re.finditer(pattern, value, flags=re.I):
1008
  start = max(0, match.start() - 80)
1009
  end = min(len(value), match.end() + 120)
@@ -1011,10 +1120,9 @@ class GenerationRunner:
1011
  rows.append({
1012
  "type": "author_facing_internal_workflow_language",
1013
  "preview": preview[:320],
1014
- "reason": "Private SmartPaper evidence/state vocabulary surfaced in reader-facing manuscript prose.",
1015
  "repair_instruction": (
1016
- "Rewrite the sentence as ordinary academic prose describing the study, evidence, method, or limitation directly. "
1017
- "Do not mention frozen evidence, evidence packets, ledgers, study-owned/result-owned facts, or other orchestration labels."
1018
  ),
1019
  })
1020
  if len(rows) >= 6:
@@ -1023,34 +1131,13 @@ class GenerationRunner:
1023
 
1024
  @staticmethod
1025
  def _writer_visible_evidence_context(text: str) -> str:
1026
- """Remove private orchestration labels before evidence reaches a Writer.
1027
-
1028
- Internal evidence/state vocabulary remains unchanged in server-side metadata
1029
- and validators. This view changes only the control labels presented to the
1030
- prose model, so AMJ and ASQ receive ordinary scholarly study descriptions
1031
- instead of phrases that the author-facing purity Gate later has to reject.
1032
- """
1033
  value = str(text or "")
1034
- substitutions = (
1035
- (r"(?im)^(#{1,6}\s+)Frozen\s+", r"\1"),
1036
- (r"(?im)^\|\s*Field\s*\|\s*Frozen value\s*\|$", "| Field | Study value |"),
1037
- (r"\bfrozen\s+research\s+evidence\s+packet\b", "study evidence"),
1038
- (r"\bfrozen\s+evidence\s+packet\b", "study evidence"),
1039
- (r"\bevidence\s+packet\b", "study evidence"),
1040
- (r"\b(?:study\s+)?evidence\s+ledger\b", "study evidence record"),
1041
- (r"\bfrozen\s+(?:research\s+)?evidence\b", "study evidence"),
1042
- (r"\bfrozen\s+study\s+evidence\b", "study evidence"),
1043
- (r"\bfrozen\s+(?:research\s+|study\s+)?(facts?|numbers?|design|setting|sample|measures?|results?|themes?|quotations?|procedures?)\b", r"documented \1"),
1044
- (r"\b(?:study|result)-owned\s+facts?\b", "study-specific facts"),
1045
- (r"\bfrozen\s+task-level\s+concepts?\b", "canonical research concepts"),
1046
- (r"\bfrozen\s+theme\s+summary\b", "theme summary"),
1047
- (r"\bfrozen\s+thematic\s+structure\b", "thematic structure"),
1048
- (r"\bfrozen\s+statistical\s+evidence\b", "study statistical evidence"),
1049
- )
1050
- for pattern, replacement in substitutions:
1051
  value = re.sub(pattern, replacement, value, flags=re.I)
1052
- # Evidence renderers sometimes append an Executor-only sentence after a
1053
- # reader-relevant instruction. Drop only that private suffix.
1054
  value = re.sub(r"(?im);\s*the Executor\b[^\n]*", ".", value)
1055
  value = re.sub(r"(?im)^\s*the Executor\b[^\n]*$", "", value)
1056
  return value
@@ -2392,6 +2479,7 @@ class GenerationRunner:
2392
  else:
2393
  state = self.checkpoints.load_confirmed() or {}
2394
  quality_diagnostics = list(state.get("quality_diagnostics") or [])
 
2395
  self._evidence_coverage_events = list(state.get("evidence_coverage_events") or [])
2396
  if str(state.get("evidence_status") or "") == "limited":
2397
  self._preview_evidence_status = "limited"
@@ -3355,6 +3443,13 @@ class GenerationRunner:
3355
  "\nCURRENT-STUDY PROVENANCE RULE: Do not attach external literature citations to this study's own research question, H1-Hn statement, sample/design fact, analytic choice, result, limitation specific to the available study evidence, or author positioning. "
3356
  "If prior literature provides a rationale, state that external rationale in a separate sentence and cite only that sentence."
3357
  )
 
 
 
 
 
 
 
3358
  if bool(state.get("evidence_risk_detected")) and section_id not in {"results", "findings", "appendix"}:
3359
  citation_rule += (
3360
  "\nEVIDENCE-LIMITED CLAIM-DENSITY CONTRACT: Direct high-relevance literature coverage is limited. Treat assigned claim-evidence items as the ceiling for source-dependent empirical, definitional, prevalence, and field-gap claims in this unit. "
@@ -3465,6 +3560,15 @@ class GenerationRunner:
3465
  prompt_context_tail = context_tail[-2500:]
3466
  if mode == "asq":
3467
  prompt_context_tail = self._redact_qualitative_quotes(prompt_context_tail, data_metadata)
 
 
 
 
 
 
 
 
 
3468
  user_prompt = f"""Write the assigned English manuscript unit.
3469
  Section: {sec_title}
3470
  Subsection: {subsection or 'None'}
@@ -3489,7 +3593,7 @@ AUTHOR-FACING PURITY: Write only reader-facing scholarly prose. Do not mention i
3489
  </WORKING_ABSTRACT_ANCHOR>
3490
  ANCHOR PRECEDENCE: The STUDY_EVIDENCE and PROVISIONAL_RESEARCH_LOGIC override the Working Abstract if any tension appears. The Working Abstract is continuity guidance only and must never force a result.
3491
  {memory_block}
3492
- Literature synthesis: {synthesis_result}
3493
  <STUDY_EVIDENCE>
3494
  {writer_scoped_data_md}
3495
  </STUDY_EVIDENCE>
@@ -3601,6 +3705,11 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3601
  if mode == "asq":
3602
  # Model prose has no authority over quote presence/count.
3603
  en_raw = self._strip_model_controlled_qualitative_quotes(en_raw, data_metadata)
 
 
 
 
 
3604
 
3605
  # S0-R1-C4 phase-1 barrier: all writer-owned semantic validators
3606
  # inspect the same attempt before the single same-Section retry budget
@@ -3621,8 +3730,11 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3621
  set(attempt_assigned_tag_list) - set(retry_excluded_tags)
3622
  if not is_quarantined else set()
3623
  )
 
 
 
3624
  en_resolved = agent.audit_and_resolve_citations(
3625
- prefix + en_raw, section_name=display_name, section_id=section_id,
3626
  quarantine=is_quarantined, allowed_tags=allowed_section_citation_tags
3627
  )
3628
  # Capture immediately: later recovery may replace the agent's last-
@@ -3632,51 +3744,67 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3632
  list(getattr(agent, "_last_citation_semantic_violations", []) or [])
3633
  )
3634
  phase1_validation_rows = self._section_validation_bundle(
3635
- mode, agent, quant_engine, prefix + en_raw, data_metadata,
3636
  section_id=section_id, section_name=display_name,
3637
  evidence_contract=evidence_packet,
3638
  include_critic=not deterministic_appendix,
3639
  citation_violations=citation_semantic_rows, hypothesis_ledger=hypothesis_ledger,
3640
  )
3641
- non_citation_count = max(
3642
- 0, len(phase1_validation_rows) - len(citation_semantic_rows)
3643
- )
3644
  if phase1_validation_rows:
3645
  retry_excluded_tags.update(self._citation_violation_tags(citation_semantic_rows))
3646
- if section_attempt < max_section_attempts:
3647
- attempt_feedback = self._section_retry_feedback(phase1_validation_rows)
 
 
 
 
3648
  logging.warning(
3649
  "Unified Phase-1 Section retry | unit=%s attempt=%s count=%s types=%s",
3650
- display_name, section_attempt, len(phase1_validation_rows),
3651
  sorted({
3652
  str(row.get("type") or "")
3653
- for row in phase1_validation_rows if isinstance(row, dict)
3654
  }),
3655
  )
3656
  self._restore_section_citation_state(
3657
  agent, citation_plan, display_name, frozen_citation_state
3658
  )
3659
  continue
3660
- self._restore_section_citation_state(
3661
- agent, citation_plan, display_name, frozen_citation_state
3662
- )
3663
- state.update({
3664
- "citation_plan": citation_plan,
3665
- "agent_state": agent.export_state(),
3666
- "quality_diagnostics": quality_diagnostics,
3667
- })
3668
- self._checkpoint_section_retry_exhausted(
3669
- state, section_id=section_id, section_name=display_name,
3670
- violations=phase1_validation_rows, pct=pct, attempts=max_section_attempts,
3671
- )
3672
- terminal_kind = (
3673
- "section_citation_invalid_after_retry"
3674
- if citation_semantic_rows and non_citation_count == 0
3675
- else "section_content_invalid_after_retry"
3676
- )
3677
- raise RuntimeError(
3678
- f"{terminal_kind}:{display_name}:{phase1_validation_rows[:3]}"
3679
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3680
 
3681
  used_after = set(agent.used_citations)
3682
  survived = used_after - used_before
@@ -3802,32 +3930,47 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3802
  hypothesis_ledger=hypothesis_ledger,
3803
  )
3804
  if post_validation_rows:
3805
- if section_attempt < max_section_attempts:
3806
- attempt_feedback = self._section_retry_feedback(post_validation_rows)
 
 
 
 
3807
  logging.warning(
3808
  "Post-normalization Section retry | unit=%s attempt=%s count=%s types=%s",
3809
- display_name, section_attempt, len(post_validation_rows),
3810
- sorted({str(row.get("type") or "") for row in post_validation_rows if isinstance(row, dict)}),
3811
  )
3812
  self._restore_section_citation_state(
3813
  agent, citation_plan, display_name, frozen_citation_state
3814
  )
3815
  continue
3816
- self._restore_section_citation_state(
3817
- agent, citation_plan, display_name, frozen_citation_state
3818
- )
3819
- state.update({
3820
- "citation_plan": citation_plan,
3821
- "agent_state": agent.export_state(),
3822
- "quality_diagnostics": quality_diagnostics,
3823
- })
3824
- self._checkpoint_section_retry_exhausted(
3825
- state, section_id=section_id, section_name=display_name,
3826
- violations=post_validation_rows, pct=pct, attempts=max_section_attempts,
3827
- )
3828
- raise RuntimeError(
3829
- f"section_content_invalid_after_retry:{display_name}:{post_validation_rows[:3]}"
3830
- )
 
 
 
 
 
 
 
 
 
 
 
3831
 
3832
  quality_diagnostics.extend(attempt_diagnostics)
3833
  assigned_tag_list = list(attempt_assigned_tag_list)
@@ -3942,6 +4085,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3942
  "context_tail": context_tail,
3943
  "accumulated_deviation": accumulated_deviation,
3944
  "quality_diagnostics": quality_diagnostics,
 
3945
  "citation_plan": citation_plan,
3946
  "citation_minimums": citation_minimums,
3947
  "citation_reserve_targets": citation_reserve_targets,
@@ -4388,6 +4532,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
4388
  citation_audit=agent.citation_audit_log,
4389
  literature_metrics=agent.get_source_metrics(),
4390
  quality_diagnostics=quality_diagnostics,
 
4391
  evidence_coverage=state.get("evidence_coverage_summary") or {
4392
  "status": str(state.get("evidence_status") or "adequate"),
4393
  "early_risk_detected": bool(state.get("evidence_risk_detected")),
@@ -4408,6 +4553,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
4408
  "package_validated": True,
4409
  "evidence_status": str(state.get("evidence_status") or "adequate"),
4410
  "evidence_coverage_summary": dict(state.get("evidence_coverage_summary") or {}),
 
4411
  "agent_state": agent.export_state(),
4412
  }
4413
  )
 
49
  FINAL_CITATION_LEDGER_VERSION = 'resolved-final-ledger-v1'
50
  ENGLISH_SECTION_SEAL_VERSION = 'english-section-integrity-seal-v1'
51
 
52
+ # S0-R1-C5.5: one shared private-vocabulary registry is consumed by both
53
+ # Writer-facing sanitization and final reader-facing validation. Keeping a
54
+ # single registry prevents the historical drift where a phrase was allowed
55
+ # into the Writer prompt/output but rejected later by the purity Gate.
56
+ AUTHOR_FACING_PRIVATE_VOCABULARY = (
57
+ (r"\bfrozen\s+research\s+evidence\s+packet\b", "study evidence"),
58
+ (r"\bfrozen\s+evidence\s+packet\b", "study evidence"),
59
+ (r"\bevidence\s+packet\b", "study evidence"),
60
+ (r"\b(?:study\s+)?evidence\s+ledger\b", "study evidence record"),
61
+ (r"\bfrozen\s+(?:research\s+)?evidence\b", "study evidence"),
62
+ (r"\bfrozen\s+study\s+evidence\b", "study evidence"),
63
+ (r"\bfrozen\s+(?:research\s+|study\s+)?(facts?|numbers?|design|setting|sample|measures?|results?|themes?|quotations?|procedures?)\b", r"documented \1"),
64
+ (r"\b(?:study|result)-owned\s+facts?\b", "study-specific facts"),
65
+ (r"\bfrozen\s+task-level\s+concepts?\b", "canonical research concepts"),
66
+ (r"\bfrozen\s+theme\s+summary\b", "theme summary"),
67
+ (r"\bfrozen\s+thematic\s+structure\b", "thematic structure"),
68
+ (r"\bfrozen\s+statistical\s+evidence\b", "study statistical evidence"),
69
+ (r"\bfrozen\s+measurement\s+design\b", "documented measurement design"),
70
+ (r"\bfrozen\s+association\s+model\b", "specified association model"),
71
+ (r"\bfrozen\s+bootstrap\s+and\s+collinearity\s+checks\b", "documented bootstrap and collinearity checks"),
72
+ )
73
+
74
 
75
  def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
76
  lines = str(text or "").split("\n")
 
988
  rows.append(' | '.join(parts))
989
  return '\n'.join(f'- {row}' for row in rows)
990
 
991
+ @staticmethod
992
+ def _section_violation_severity(row, *, section_name: str = "", mode: str = "", exhausted: bool = False) -> str:
993
+ """Map one validation issue to REPAIRABLE/FATAL/ADVISORY.
994
+
995
+ Before the one local retry, all substantive correctness issues remain
996
+ repairable. After retry exhaustion only issues that can change study
997
+ facts, core inference, hypothesis identity, core evidence support, or
998
+ citation integrity may terminate the manuscript. Minor reader-facing
999
+ style/background defects become advisory notes instead of destroying an
1000
+ otherwise complete paper.
1001
+ """
1002
+ item = dict(row or {}) if isinstance(row, dict) else {"type": "unknown_validation_issue"}
1003
+ kind = str(item.get("type") or "unknown_validation_issue")
1004
+ section = str(section_name or "")
1005
+ if not exhausted:
1006
+ if kind == "top_journal_critic":
1007
+ return "ADVISORY"
1008
+ return "REPAIRABLE"
1009
+
1010
+ always_fatal = {
1011
+ "causal_language_violation",
1012
+ "sample_size_mismatch", "sample_count_mismatch", "scale_points_mismatch",
1013
+ "construct_role_mismatch", "quantitative_metric_mismatch", "unfrozen_p_value",
1014
+ "unsupported_quantitative_method", "actor_data_source_relation_mismatch",
1015
+ "unsupported_study_procedure", "enumeration_contract_invalid",
1016
+ "hypothesis_contract_missing_id", "hypothesis_contract_ordinal_alias",
1017
+ "hypothesis_contract_pairing_mismatch", "hypothesis_contract_unknown_id",
1018
+ "citation_outside_frozen_section_whitelist", "authorial_ai_meta_trace",
1019
+ }
1020
+ if kind in always_fatal:
1021
+ return "FATAL"
1022
+
1023
+ if kind in {"author_facing_internal_workflow_language", "top_journal_critic"}:
1024
+ return "ADVISORY"
1025
+
1026
+ if kind in {"citation_claim_split_required", "citation_unsupported_claim", "citation_support_unverified"}:
1027
+ core_citation_section = bool(re.search(
1028
+ r"(?i)(?:Theoretical Mechanisms|Hypothesis Development|Methodology|Results?|Findings?)",
1029
+ section,
1030
+ ))
1031
+ return "FATAL" if core_citation_section else "ADVISORY"
1032
+
1033
+ # Unknown validation types remain fail-closed. This keeps C5.5 from
1034
+ # silently downgrading a future integrity contract merely because it is
1035
+ # new to the severity registry.
1036
+ return "FATAL"
1037
+
1038
+ @classmethod
1039
+ def _partition_section_violations(cls, rows, *, section_name: str = "", mode: str = "", exhausted: bool = False):
1040
+ blocking, advisory = [], []
1041
+ for row in list(rows or []):
1042
+ severity = cls._section_violation_severity(
1043
+ row, section_name=section_name, mode=mode, exhausted=exhausted
1044
+ )
1045
+ if severity == "ADVISORY":
1046
+ advisory.append(row)
1047
+ else:
1048
+ blocking.append(row)
1049
+ return blocking, advisory
1050
+
1051
+ @staticmethod
1052
+ def _record_section_advisories(quality_notes: list, rows, *, section_name: str = "") -> None:
1053
+ """Append reader-facing, nonfatal notes without exposing control vocabulary."""
1054
+ for row in list(rows or []):
1055
+ if not isinstance(row, dict):
1056
+ continue
1057
+ kind = str(row.get("type") or "quality_advisory")
1058
+ if kind == "citation_claim_split_required":
1059
+ detail = "A non-core literature sentence may combine more than one externally verifiable proposition."
1060
+ action = "Before formal submission, consider splitting that sentence and checking the citation attached to each proposition."
1061
+ elif kind in {"citation_unsupported_claim", "citation_support_unverified"}:
1062
+ detail = "A non-core literature statement could not be fully verified against the available retrieved evidence."
1063
+ action = "Review the statement and its supporting literature before formal submission; the manuscript was completed because the issue does not alter the study's core results or design."
1064
+ elif kind == "author_facing_internal_workflow_language":
1065
+ detail = "A minor reader-facing wording issue remained after automatic revision."
1066
+ action = "Review the indicated passage for ordinary academic phrasing before formal submission."
1067
+ elif kind == "authorial_ai_meta_trace":
1068
+ detail = "A minor author-facing wording pattern may benefit from manual stylistic review."
1069
+ action = "Review the indicated passage and remove any wording that does not read as ordinary scholarly prose."
1070
+ elif kind == "top_journal_critic":
1071
+ detail = str(row.get("reason") or "An optional journal-style refinement was identified.")[:500]
1072
+ action = "This is a non-blocking style suggestion; revise only if it improves conciseness, accuracy, or rigor."
1073
+ else:
1074
+ detail = "A nonfatal quality issue was recorded for author review."
1075
+ action = "Review the indicated section before formal submission if additional polishing is desired."
1076
+ note = {
1077
+ "type": "section_quality_advisory",
1078
+ "severity": "ADVISORY",
1079
+ "section": str(section_name or "Manuscript"),
1080
+ "issue_type": kind,
1081
+ "detail": detail,
1082
+ "user_action": action,
1083
+ }
1084
+ signature = (note["section"], note["issue_type"], note["detail"])
1085
+ if any(
1086
+ (str(existing.get("section") or ""), str(existing.get("issue_type") or ""), str(existing.get("detail") or "")) == signature
1087
+ for existing in quality_notes if isinstance(existing, dict)
1088
+ ):
1089
+ continue
1090
+ quality_notes.append(note)
1091
+
1092
  @staticmethod
1093
  def _citation_violation_tags(rows) -> set:
1094
  """Extract only citation tags owned by the rejected claim sentence.
 
1109
 
1110
  @staticmethod
1111
  def _author_facing_internal_workflow_violations(text: str) -> list[dict]:
1112
+ """Detect reader-facing leakage using the same registry as sanitization."""
 
 
 
 
 
 
1113
  value = str(text or "")
 
 
 
 
 
 
 
 
1114
  rows = []
1115
+ for pattern, _replacement in AUTHOR_FACING_PRIVATE_VOCABULARY:
1116
  for match in re.finditer(pattern, value, flags=re.I):
1117
  start = max(0, match.start() - 80)
1118
  end = min(len(value), match.end() + 120)
 
1120
  rows.append({
1121
  "type": "author_facing_internal_workflow_language",
1122
  "preview": preview[:320],
1123
+ "reason": "Private generation-control vocabulary surfaced in reader-facing manuscript prose.",
1124
  "repair_instruction": (
1125
+ "Rewrite the sentence as ordinary academic prose describing the study, evidence, method, or limitation directly."
 
1126
  ),
1127
  })
1128
  if len(rows) >= 6:
 
1131
 
1132
  @staticmethod
1133
  def _writer_visible_evidence_context(text: str) -> str:
1134
+ """Return a reader-safe Writer view using the shared private registry."""
 
 
 
 
 
 
1135
  value = str(text or "")
1136
+ # Formatting-only labels are sanitized before the phrase registry.
1137
+ value = re.sub(r"(?im)^(#{1,6}\s+)Frozen\s+", r"\1", value)
1138
+ value = re.sub(r"(?im)^\|\s*Field\s*\|\s*Frozen value\s*\|$", "| Field | Study value |", value)
1139
+ for pattern, replacement in AUTHOR_FACING_PRIVATE_VOCABULARY:
 
 
 
 
 
 
 
 
 
 
 
 
 
1140
  value = re.sub(pattern, replacement, value, flags=re.I)
 
 
1141
  value = re.sub(r"(?im);\s*the Executor\b[^\n]*", ".", value)
1142
  value = re.sub(r"(?im)^\s*the Executor\b[^\n]*$", "", value)
1143
  return value
 
2479
  else:
2480
  state = self.checkpoints.load_confirmed() or {}
2481
  quality_diagnostics = list(state.get("quality_diagnostics") or [])
2482
+ quality_notes = list(state.get("quality_notes") or [])
2483
  self._evidence_coverage_events = list(state.get("evidence_coverage_events") or [])
2484
  if str(state.get("evidence_status") or "") == "limited":
2485
  self._preview_evidence_status = "limited"
 
3443
  "\nCURRENT-STUDY PROVENANCE RULE: Do not attach external literature citations to this study's own research question, H1-Hn statement, sample/design fact, analytic choice, result, limitation specific to the available study evidence, or author positioning. "
3444
  "If prior literature provides a rationale, state that external rationale in a separate sentence and cite only that sentence."
3445
  )
3446
+ if section_id not in {"results", "findings", "appendix"}:
3447
+ citation_rule += (
3448
+ "\nATOMIC LITERATURE CLAIM CONTRACT: Write one externally verifiable proposition per sentence. "
3449
+ "Do not combine two literature-dependent findings, mechanisms, definitions, or outcome claims under one citation. "
3450
+ "When two propositions require different sources, write two sentences and attach each sentence only to its own assigned source. "
3451
+ "A current-study fact or H1-Hn/RQ statement must remain citation-free unless a separate prior-literature rationale is stated in its own sentence."
3452
+ )
3453
  if bool(state.get("evidence_risk_detected")) and section_id not in {"results", "findings", "appendix"}:
3454
  citation_rule += (
3455
  "\nEVIDENCE-LIMITED CLAIM-DENSITY CONTRACT: Direct high-relevance literature coverage is limited. Treat assigned claim-evidence items as the ceiling for source-dependent empirical, definitional, prevalence, and field-gap claims in this unit. "
 
3560
  prompt_context_tail = context_tail[-2500:]
3561
  if mode == "asq":
3562
  prompt_context_tail = self._redact_qualitative_quotes(prompt_context_tail, data_metadata)
3563
+ if bool(state.get("evidence_risk_detected")):
3564
+ writer_synthesis_context = (
3565
+ "EVIDENCE-LIMITED SYNTHESIS BOUNDARY: direct claim-specific literature coverage is limited. "
3566
+ "The claim-evidence plan is the only source-dependent manuscript authority for externally verifiable literature statements in this unit. "
3567
+ "Do not convert broad synthesis themes, general model knowledge, or unassigned references into manuscript claims. "
3568
+ "When no supported external claim is assigned, prefer concise study positioning, transparent limitations, and cautious conceptual reasoning without invented citation support."
3569
+ )
3570
+ else:
3571
+ writer_synthesis_context = synthesis_result
3572
  user_prompt = f"""Write the assigned English manuscript unit.
3573
  Section: {sec_title}
3574
  Subsection: {subsection or 'None'}
 
3593
  </WORKING_ABSTRACT_ANCHOR>
3594
  ANCHOR PRECEDENCE: The STUDY_EVIDENCE and PROVISIONAL_RESEARCH_LOGIC override the Working Abstract if any tension appears. The Working Abstract is continuity guidance only and must never force a result.
3595
  {memory_block}
3596
+ Literature synthesis: {writer_synthesis_context}
3597
  <STUDY_EVIDENCE>
3598
  {writer_scoped_data_md}
3599
  </STUDY_EVIDENCE>
 
3705
  if mode == "asq":
3706
  # Model prose has no authority over quote presence/count.
3707
  en_raw = self._strip_model_controlled_qualitative_quotes(en_raw, data_metadata)
3708
+ # C5.5 uses the same private-vocabulary registry for both
3709
+ # Writer-facing evidence and reader-facing output. This
3710
+ # deterministic phrasing cleanup cannot change study facts,
3711
+ # citations, hypotheses, numbers, or quote ownership.
3712
+ en_raw = self._writer_visible_evidence_context(en_raw)
3713
 
3714
  # S0-R1-C4 phase-1 barrier: all writer-owned semantic validators
3715
  # inspect the same attempt before the single same-Section retry budget
 
3730
  set(attempt_assigned_tag_list) - set(retry_excluded_tags)
3731
  if not is_quarantined else set()
3732
  )
3733
+ attempt_section_text = agent.strip_ineligible_current_study_citations(
3734
+ prefix + en_raw, display_name
3735
+ )
3736
  en_resolved = agent.audit_and_resolve_citations(
3737
+ attempt_section_text, section_name=display_name, section_id=section_id,
3738
  quarantine=is_quarantined, allowed_tags=allowed_section_citation_tags
3739
  )
3740
  # Capture immediately: later recovery may replace the agent's last-
 
3744
  list(getattr(agent, "_last_citation_semantic_violations", []) or [])
3745
  )
3746
  phase1_validation_rows = self._section_validation_bundle(
3747
+ mode, agent, quant_engine, attempt_section_text, data_metadata,
3748
  section_id=section_id, section_name=display_name,
3749
  evidence_contract=evidence_packet,
3750
  include_critic=not deterministic_appendix,
3751
  citation_violations=citation_semantic_rows, hypothesis_ledger=hypothesis_ledger,
3752
  )
 
 
 
3753
  if phase1_validation_rows:
3754
  retry_excluded_tags.update(self._citation_violation_tags(citation_semantic_rows))
3755
+ exhausted = section_attempt >= max_section_attempts
3756
+ blocking_rows, advisory_rows = self._partition_section_violations(
3757
+ phase1_validation_rows, section_name=display_name, mode=mode, exhausted=exhausted
3758
+ )
3759
+ if blocking_rows and not exhausted:
3760
+ attempt_feedback = self._section_retry_feedback(blocking_rows)
3761
  logging.warning(
3762
  "Unified Phase-1 Section retry | unit=%s attempt=%s count=%s types=%s",
3763
+ display_name, section_attempt, len(blocking_rows),
3764
  sorted({
3765
  str(row.get("type") or "")
3766
+ for row in blocking_rows if isinstance(row, dict)
3767
  }),
3768
  )
3769
  self._restore_section_citation_state(
3770
  agent, citation_plan, display_name, frozen_citation_state
3771
  )
3772
  continue
3773
+ if advisory_rows:
3774
+ self._record_section_advisories(
3775
+ quality_notes, advisory_rows, section_name=display_name
3776
+ )
3777
+ logging.warning(
3778
+ "Nonfatal section quality advisory | unit=%s count=%s types=%s",
3779
+ display_name, len(advisory_rows),
3780
+ sorted({str(row.get("type") or "") for row in advisory_rows if isinstance(row, dict)}),
3781
+ )
3782
+ if blocking_rows:
3783
+ self._restore_section_citation_state(
3784
+ agent, citation_plan, display_name, frozen_citation_state
3785
+ )
3786
+ state.update({
3787
+ "citation_plan": citation_plan,
3788
+ "agent_state": agent.export_state(),
3789
+ "quality_diagnostics": quality_diagnostics,
3790
+ "quality_notes": quality_notes,
3791
+ })
3792
+ self._checkpoint_section_retry_exhausted(
3793
+ state, section_id=section_id, section_name=display_name,
3794
+ violations=blocking_rows, pct=pct, attempts=max_section_attempts,
3795
+ )
3796
+ blocking_citation_rows = [
3797
+ row for row in blocking_rows
3798
+ if isinstance(row, dict) and str(row.get("type") or "").startswith("citation_")
3799
+ ]
3800
+ terminal_kind = (
3801
+ "section_citation_invalid_after_retry"
3802
+ if blocking_citation_rows and len(blocking_citation_rows) == len(blocking_rows)
3803
+ else "section_content_invalid_after_retry"
3804
+ )
3805
+ raise RuntimeError(
3806
+ f"{terminal_kind}:{display_name}:{blocking_rows[:3]}"
3807
+ )
3808
 
3809
  used_after = set(agent.used_citations)
3810
  survived = used_after - used_before
 
3930
  hypothesis_ledger=hypothesis_ledger,
3931
  )
3932
  if post_validation_rows:
3933
+ exhausted = section_attempt >= max_section_attempts
3934
+ blocking_rows, advisory_rows = self._partition_section_violations(
3935
+ post_validation_rows, section_name=display_name, mode=mode, exhausted=exhausted
3936
+ )
3937
+ if blocking_rows and not exhausted:
3938
+ attempt_feedback = self._section_retry_feedback(blocking_rows)
3939
  logging.warning(
3940
  "Post-normalization Section retry | unit=%s attempt=%s count=%s types=%s",
3941
+ display_name, section_attempt, len(blocking_rows),
3942
+ sorted({str(row.get("type") or "") for row in blocking_rows if isinstance(row, dict)}),
3943
  )
3944
  self._restore_section_citation_state(
3945
  agent, citation_plan, display_name, frozen_citation_state
3946
  )
3947
  continue
3948
+ if advisory_rows:
3949
+ self._record_section_advisories(
3950
+ quality_notes, advisory_rows, section_name=display_name
3951
+ )
3952
+ logging.warning(
3953
+ "Nonfatal post-normalization quality advisory | unit=%s count=%s types=%s",
3954
+ display_name, len(advisory_rows),
3955
+ sorted({str(row.get("type") or "") for row in advisory_rows if isinstance(row, dict)}),
3956
+ )
3957
+ if blocking_rows:
3958
+ self._restore_section_citation_state(
3959
+ agent, citation_plan, display_name, frozen_citation_state
3960
+ )
3961
+ state.update({
3962
+ "citation_plan": citation_plan,
3963
+ "agent_state": agent.export_state(),
3964
+ "quality_diagnostics": quality_diagnostics,
3965
+ "quality_notes": quality_notes,
3966
+ })
3967
+ self._checkpoint_section_retry_exhausted(
3968
+ state, section_id=section_id, section_name=display_name,
3969
+ violations=blocking_rows, pct=pct, attempts=max_section_attempts,
3970
+ )
3971
+ raise RuntimeError(
3972
+ f"section_content_invalid_after_retry:{display_name}:{blocking_rows[:3]}"
3973
+ )
3974
 
3975
  quality_diagnostics.extend(attempt_diagnostics)
3976
  assigned_tag_list = list(attempt_assigned_tag_list)
 
4085
  "context_tail": context_tail,
4086
  "accumulated_deviation": accumulated_deviation,
4087
  "quality_diagnostics": quality_diagnostics,
4088
+ "quality_notes": quality_notes,
4089
  "citation_plan": citation_plan,
4090
  "citation_minimums": citation_minimums,
4091
  "citation_reserve_targets": citation_reserve_targets,
 
4532
  citation_audit=agent.citation_audit_log,
4533
  literature_metrics=agent.get_source_metrics(),
4534
  quality_diagnostics=quality_diagnostics,
4535
+ quality_notes=quality_notes,
4536
  evidence_coverage=state.get("evidence_coverage_summary") or {
4537
  "status": str(state.get("evidence_status") or "adequate"),
4538
  "early_risk_detected": bool(state.get("evidence_risk_detected")),
 
4553
  "package_validated": True,
4554
  "evidence_status": str(state.get("evidence_status") or "adequate"),
4555
  "evidence_coverage_summary": dict(state.get("evidence_coverage_summary") or {}),
4556
+ "quality_notes": quality_notes,
4557
  "agent_state": agent.export_state(),
4558
  }
4559
  )
core/pipeline_agent.py CHANGED
@@ -3304,6 +3304,39 @@ INPUT:
3304
  def _citation_recovery_eligible_claim(cls, sentence, section_name=''):
3305
  return cls._citation_claim_provenance(sentence, section_name) == 'EXTERNAL_LITERATURE_CLAIM'
3306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3307
  @classmethod
3308
  def count_external_literature_claims(cls, text, section_name=''):
3309
  return sum(
@@ -4883,7 +4916,7 @@ Return exactly:
4883
  self, text, section_name='', section_id='', quarantine=False, allowed_tags=None
4884
  ):
4885
  self._last_citation_semantic_violations = []
4886
- value = str(text or '')
4887
  if quarantine:
4888
  return self.resolve_citations(value, quarantine=True)
4889
  unknown = sorted({f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', value, re.I)} - set(self.citation_map))
 
3304
  def _citation_recovery_eligible_claim(cls, sentence, section_name=''):
3305
  return cls._citation_claim_provenance(sentence, section_name) == 'EXTERNAL_LITERATURE_CLAIM'
3306
 
3307
+ def strip_ineligible_current_study_citations(self, text, section_name=''):
3308
+ """Remove external citation tags from current-study/provenance-owned prose.
3309
+
3310
+ C5.5 moves provenance enforcement in front of semantic citation audit.
3311
+ This is TAG-ONLY mutation: study facts, H1-Hn statements, research
3312
+ questions, result-owned claims, and structural sentences keep their
3313
+ prose unchanged while any model-added [CITE-XX] tag is removed.
3314
+ """
3315
+ value = str(text or '')
3316
+ rebuilt = []
3317
+ cursor = 0
3318
+ for start, end, sentence in self._sentence_spans(value):
3319
+ rebuilt.append(value[cursor:start])
3320
+ original = str(sentence or '')
3321
+ if re.search(r'CITE-\d+', original, re.I):
3322
+ provenance = self._citation_claim_provenance(original, section_name)
3323
+ if provenance != 'EXTERNAL_LITERATURE_CLAIM':
3324
+ revised = self._citation_tag_only_rewrite(original, None)
3325
+ self._append_citation_audit(
3326
+ section_name,
3327
+ 'remove_nonliterature_citation_by_provenance',
3328
+ original, revised, provenance,
3329
+ )
3330
+ rebuilt.append(revised)
3331
+ cursor = end
3332
+ continue
3333
+ rebuilt.append(original)
3334
+ cursor = end
3335
+ rebuilt.append(value[cursor:])
3336
+ cleaned = ''.join(rebuilt)
3337
+ cleaned = re.sub(r'[ \t]+\n', '\n', cleaned)
3338
+ return re.sub(r'\n{3,}', '\n\n', cleaned).strip()
3339
+
3340
  @classmethod
3341
  def count_external_literature_claims(cls, text, section_name=''):
3342
  return sum(
 
4916
  self, text, section_name='', section_id='', quarantine=False, allowed_tags=None
4917
  ):
4918
  self._last_citation_semantic_violations = []
4919
+ value = self.strip_ineligible_current_study_citations(str(text or ''), section_name)
4920
  if quarantine:
4921
  return self.resolve_citations(value, quarantine=True)
4922
  unknown = sorted({f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', value, re.I)} - set(self.citation_map))
core/production_guard.py CHANGED
@@ -572,6 +572,45 @@ def build_word_count_quality_report(diagnostics: list[dict[str, Any]] | None) ->
572
  ])
573
  return "\n".join(lines).strip() + "\n"
574
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
575
  def build_evidence_coverage_report(summary: dict[str, Any] | None) -> str:
576
  """Build a neutral reader-facing literature evidence coverage report.
577
 
@@ -644,6 +683,7 @@ def build_validated_package(
644
  citation_audit: list[dict[str, Any]],
645
  literature_metrics: dict[str, Any],
646
  quality_diagnostics: list[dict[str, Any]] | None = None,
 
647
  evidence_coverage: dict[str, Any] | None = None,
648
  ) -> PackageResult:
649
  validate_markdown_manuscript(english_markdown, language_name="English")
@@ -680,6 +720,7 @@ def build_validated_package(
680
  "CITATION_AUDIT.json": json.dumps(citation_audit or [], ensure_ascii=False, indent=2).encode("utf-8"),
681
  "LITERATURE_METRICS.json": json.dumps(literature_metrics or {}, ensure_ascii=False, indent=2).encode("utf-8"),
682
  "WORD_COUNT_QUALITY_REPORT.txt": build_word_count_quality_report(quality_diagnostics).encode("utf-8"),
 
683
  "EVIDENCE_COVERAGE_REPORT.txt": build_evidence_coverage_report(evidence_coverage).encode("utf-8"),
684
  }
685
  manifest = _manifest(entries)
 
572
  ])
573
  return "\n".join(lines).strip() + "\n"
574
 
575
+ def build_quality_notes(notes: list[dict[str, Any]] | None) -> str:
576
+ """Build a concise reader-facing record of nonfatal quality advisories.
577
+
578
+ C5.5 intentionally separates these notes from the manuscript itself. The
579
+ paper remains clean scholarly prose; only issues that do not alter core
580
+ study facts, inference, hypothesis identity, translation meaning, or file
581
+ integrity may appear here.
582
+ """
583
+ items = [dict(item) for item in (notes or []) if isinstance(item, dict)]
584
+ lines = [
585
+ "SmartPaper Quality Notes",
586
+ "",
587
+ "Writing standard: Conciseness, Accuracy, and Rigor (CAR).",
588
+ "Policy: The manuscript was completed because the items below were judged nonfatal and did not alter the study's core content, main findings, research design, hypothesis identity, or file integrity.",
589
+ "These notes are for author review before formal submission and are intentionally kept outside the manuscript body.",
590
+ "",
591
+ ]
592
+ advisory = [item for item in items if str(item.get("severity") or "").upper() == "ADVISORY"]
593
+ if not advisory:
594
+ lines.append("No nonfatal quality advisory was recorded.")
595
+ else:
596
+ lines.append("Items for author review:")
597
+ for index, item in enumerate(advisory, 1):
598
+ section = str(item.get("section") or "Manuscript")
599
+ issue = str(item.get("issue_type") or "quality_advisory")
600
+ detail = str(item.get("detail") or "A nonfatal quality issue was recorded.").strip()
601
+ action = str(item.get("user_action") or "Review before formal submission if additional polishing is desired.").strip()
602
+ lines.append(f"{index}. Section: {section}")
603
+ lines.append(f" Issue: {issue}")
604
+ lines.append(f" Note: {detail}")
605
+ lines.append(f" Suggested check: {action}")
606
+ lines.extend([
607
+ "",
608
+ "Important boundary:",
609
+ "Fatal integrity problems are not listed here because they must stop generation rather than be downgraded to advisory status.",
610
+ ])
611
+ return "\n".join(lines).strip() + "\n"
612
+
613
+
614
  def build_evidence_coverage_report(summary: dict[str, Any] | None) -> str:
615
  """Build a neutral reader-facing literature evidence coverage report.
616
 
 
683
  citation_audit: list[dict[str, Any]],
684
  literature_metrics: dict[str, Any],
685
  quality_diagnostics: list[dict[str, Any]] | None = None,
686
+ quality_notes: list[dict[str, Any]] | None = None,
687
  evidence_coverage: dict[str, Any] | None = None,
688
  ) -> PackageResult:
689
  validate_markdown_manuscript(english_markdown, language_name="English")
 
720
  "CITATION_AUDIT.json": json.dumps(citation_audit or [], ensure_ascii=False, indent=2).encode("utf-8"),
721
  "LITERATURE_METRICS.json": json.dumps(literature_metrics or {}, ensure_ascii=False, indent=2).encode("utf-8"),
722
  "WORD_COUNT_QUALITY_REPORT.txt": build_word_count_quality_report(quality_diagnostics).encode("utf-8"),
723
+ "SmartPaper_Quality_Notes.txt": build_quality_notes(quality_notes).encode("utf-8"),
724
  "EVIDENCE_COVERAGE_REPORT.txt": build_evidence_coverage_report(evidence_coverage).encode("utf-8"),
725
  }
726
  manifest = _manifest(entries)
core/quantitative_engine.py CHANGED
@@ -602,40 +602,23 @@ Keywords: {keywords}"""
602
 
603
  @classmethod
604
  def _classify_quantitative_causal_clause(cls, clause):
605
- """Classify study/result prose as causal assertion, explicit limitation, or non-causal.
606
 
607
- The contract is semantic at the clause level rather than a blacklist of the
608
- token ``causal``. Explicit statements that restrict interpretation to
609
- association/covariation are permitted, while affirmative causal verbs and
610
- causal-identification claims remain fail-closed.
 
611
  """
612
  value = re.sub(r"\s+", " ", str(clause or "")).strip().casefold()
613
  if not value:
614
  return "NON_CAUSAL"
615
 
616
- causal_nouns = r"(?:causal(?:ity|\s+(?:effect|effects|mediation|mechanism|pathway|relationship|relationships|identification))|cause(?:s|d)?|causation)"
617
- limitation_patterns = (
618
- rf"\b(?:cannot|can't|could not|couldn't|does not|doesn't|do not|don't|did not|didn't|fails? to|failed to|is not able to|are not able to|was not able to|were not able to|unable to|insufficient to)\s+(?:identify|establish|demonstrate|show|infer|support|imply|claim|justify|warrant|permit|allow|prove)\b[^.;]{{0,80}}\b{causal_nouns}\b",
619
- rf"\b(?:is|are|was|were)\s+insufficient\s+to\s+(?:identify|establish|demonstrate|show|infer|support|prove)\b[^.;]{{0,80}}\b{causal_nouns}\b",
620
- rf"\b(?:is|are|was|were)\s+not\s+(?:designed|intended)\s+to\s+(?:identify|establish|demonstrate|show|infer|support|prove)\b[^.;]{{0,80}}\b{causal_nouns}\b",
621
- rf"\bwithout\s+(?:identifying|establishing|demonstrating|showing|inferring|supporting|proving)\b[^.;]{{0,80}}\b{causal_nouns}\b",
622
- rf"\b(?:no|without)\s+(?:evidence|basis|support|identification|proof)\b[^.;]{{0,60}}\b{causal_nouns}\b",
623
- rf"\b(?:not|never)\s+(?:evidence|proof)\s+(?:of|for)\b[^.;]{{0,50}}\b{causal_nouns}\b",
624
- rf"\b(?:should not|shouldn't|must not|cannot|can't|could not|couldn't)\s+be\s+(?:interpreted|read|treated|understood)\s+as\b[^.;]{{0,60}}\b{causal_nouns}\b",
625
- rf"\b(?:is|are|was|were)\s+(?:restricted|limited|confined)\s+to\b[^.;]{{0,120}}\b(?:not|rather than|instead of)\s+(?:to\s+)?\b{causal_nouns}\b",
626
- rf"\b(?:restricted|limited|confined)\s+to\b[^.;]{{0,120}}\b(?:not|rather than|instead of)\s+(?:to\s+)?\b{causal_nouns}\b",
627
- rf"\b(?:association|associations|covariation|covariance|correlation|correlations|indirect association|descriptive pattern|descriptive patterns)\b[^.;]{{0,80}}\b(?:not(?:\s+as)?|rather than|instead of)\s+(?:an?\s+|the\s+)?{causal_nouns}\b",
628
- rf"\b{causal_nouns}\b\s+(?:is|are|was|were)\s+(?:not|never)\s+(?:identified|established|demonstrated|shown|inferred|supported|claimed|justified|warranted|proven)\b",
629
- rf"\b{causal_nouns}\b\s+(?:cannot|can't|could not|couldn't)\s+be\s+(?:identified|established|demonstrated|shown|inferred|supported|claimed|justified|warranted|proven)\b",
630
- rf"\b(?:inability|lack\s+of\s+(?:an?\s+)?ability)\s+to\s+(?:identify|establish|demonstrate|show|infer|support|justify|warrant|prove)\b[^.;]{{0,80}}\b{causal_nouns}\b",
631
- rf"\b(?:not|no)\s+(?:an?\s+|the\s+)?{causal_nouns}\b",
632
- rf"\b(?:cannot|can't|could not|couldn't)\s+be\s+used\s+to\s+(?:infer|establish|demonstrate|show|prove)\s+(?:an?\s+|the\s+)?{causal_nouns}\b",
633
- rf"\bwithout\s+(?:proving|establishing|demonstrating|showing|identifying)\s+(?:an?\s+|the\s+)?{causal_nouns}\b",
634
  )
635
- if any(re.search(pattern, value, flags=re.I) for pattern in limitation_patterns):
636
- return "EXPLICIT_CAUSAL_LIMITATION"
637
-
638
- affirmative_patterns = (
639
  r"\b(?:causes?|caused|causing)\b",
640
  r"\b(?:leads?|led)\s+to\b",
641
  r"\b(?:drives?|drove|driven)\b",
@@ -643,12 +626,91 @@ Keywords: {keywords}"""
643
  r"\b(?:produces?|produced)\b",
644
  r"\b(?:results?|resulted)\s+in\b",
645
  r"\b(?:transmits?|transmitted)\s+(?:an?\s+|the\s+)?causal\s+effect\b",
 
646
  r"\b(?:has|have|had|exerts?|exerted)\s+(?:an?\s+|the\s+)?causal\s+effect\b",
647
- r"\bcausal\s+(?:effect|effects|mediation|mechanism|pathway|relationship|relationships|identification)\b",
648
- r"\b(?:demonstrates?|demonstrated|shows?|showed|establishes?|established|identifies?|identified|proves?|proved)\s+(?:a\s+|the\s+)?causal(?:ity|\s+(?:effect|effects|mechanism|pathway|relationship|relationships|mediation))\b",
649
  )
650
- if any(re.search(pattern, value, flags=re.I) for pattern in affirmative_patterns):
651
- return "AFFIRMATIVE_CAUSAL"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  return "NON_CAUSAL"
653
 
654
  @classmethod
 
602
 
603
  @classmethod
604
  def _classify_quantitative_causal_clause(cls, clause):
605
+ """Classify one clause by causal *assertion scope*, not token presence.
606
 
607
+ C5.5 freezes a single semantic contract for Writer/Validator/Retry. A
608
+ causal phrase inside an explicit limitation (for example, ``cannot
609
+ establish that X causes Y``) is not an affirmative claim. Conversely,
610
+ an actual causal assertion remains fail-closed even when another part
611
+ of the sentence contains a disclaimer.
612
  """
613
  value = re.sub(r"\s+", " ", str(clause or "")).strip().casefold()
614
  if not value:
615
  return "NON_CAUSAL"
616
 
617
+ causal_term = (
618
+ r"(?:causal(?:ity|\s+(?:effect|effects|mediation|mechanism|pathway|relationship|relationships|identification))|"
619
+ r"causation|cause(?:s|d|ing)?)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
620
  )
621
+ direct_patterns = (
 
 
 
622
  r"\b(?:causes?|caused|causing)\b",
623
  r"\b(?:leads?|led)\s+to\b",
624
  r"\b(?:drives?|drove|driven)\b",
 
626
  r"\b(?:produces?|produced)\b",
627
  r"\b(?:results?|resulted)\s+in\b",
628
  r"\b(?:transmits?|transmitted)\s+(?:an?\s+|the\s+)?causal\s+effect\b",
629
+ r"\bcausally\s+(?:transmits?|mediates?|affects?|influences?)\b",
630
  r"\b(?:has|have|had|exerts?|exerted)\s+(?:an?\s+|the\s+)?causal\s+effect\b",
 
 
631
  )
632
+
633
+ def direct_match_is_disclaimed(match):
634
+ prefix = value[max(0, match.start() - 220):match.start()]
635
+ if re.search(
636
+ r"(?:\b(?:does|do|did|can|could|would|should|must|may|might)\s+not|"
637
+ r"\bcannot|\bcan't|\bcouldn't|\bnever)\s+(?:\w+\s+){0,2}$",
638
+ prefix,
639
+ flags=re.I,
640
+ ):
641
+ return True
642
+ if re.search(
643
+ r"\b(?:cannot|can't|could not|couldn't|does not|doesn't|do not|don't|did not|didn't|"
644
+ r"fails? to|failed to|unable to|insufficient to|not able to)\s+"
645
+ r"(?:identify|establish|demonstrate|show|infer|support|imply|mean|claim|justify|warrant|prove)"
646
+ r"\b[^.;]{0,180}?(?:\bthat\b|\bwhether\b)[^.;]{0,120}$",
647
+ prefix,
648
+ flags=re.I,
649
+ ):
650
+ return True
651
+ if re.search(
652
+ r"\b(?:should not|shouldn't|must not|cannot|can't|could not|couldn't)\s+be\s+"
653
+ r"(?:interpreted|read|treated|understood)\s+(?:as|to mean)\b[^.;]{0,140}$",
654
+ prefix,
655
+ flags=re.I,
656
+ ):
657
+ return True
658
+ if re.search(
659
+ r"\bwithout\s+(?:identifying|establishing|demonstrating|showing|inferring|supporting|proving)"
660
+ r"\b[^.;]{0,180}?(?:\bthat\b|\bwhether\b)[^.;]{0,120}$",
661
+ prefix,
662
+ flags=re.I,
663
+ ):
664
+ return True
665
+ return False
666
+
667
+ saw_disclaimed_direct = False
668
+ for pattern in direct_patterns:
669
+ for match in re.finditer(pattern, value, flags=re.I):
670
+ if direct_match_is_disclaimed(match):
671
+ saw_disclaimed_direct = True
672
+ continue
673
+ return "AFFIRMATIVE_CAUSAL"
674
+
675
+ affirmative_noun_patterns = (
676
+ rf"\b(?:is|are|was|were|represent(?:s|ed)?|constitut(?:es|ed))\s+(?:an?\s+|the\s+)?{causal_term}\b",
677
+ rf"\b(?:identify|identifies|identified|establish|establishes|established|demonstrate|demonstrates|demonstrated|show|shows|showed|prove|proves|proved|provide|provides|provided)\b[^.;]{{0,80}}\b{causal_term}\b",
678
+ rf"\b(?:evidence|proof)\s+(?:of|for)\s+(?:an?\s+|the\s+)?{causal_term}\b",
679
+ rf"\b{causal_term}\b[^.;]{{0,80}}\b(?:is|are|was|were)\s+(?!not\b|never\b)",
680
+ )
681
+ for pattern in affirmative_noun_patterns:
682
+ for match in re.finditer(pattern, value, flags=re.I):
683
+ prefix = value[max(0, match.start() - 80):match.start()]
684
+ if re.search(
685
+ r"\b(?:no|not|without|cannot|can't|insufficient|unable|does not|doesn't|do not|don't|did not|didn't|not able)\b",
686
+ prefix,
687
+ flags=re.I,
688
+ ):
689
+ continue
690
+ return "AFFIRMATIVE_CAUSAL"
691
+
692
+ limitation_patterns = (
693
+ rf"\b(?:cannot|can't|could not|couldn't|does not|doesn't|do not|don't|did not|didn't|fails? to|failed to|"
694
+ rf"is not able to|are not able to|was not able to|were not able to|unable to|insufficient to|not able to)\s+"
695
+ rf"(?:identify|establish|demonstrate|show|infer|support|imply|mean|claim|justify|warrant|permit|allow|prove)\b[^.;]*\b{causal_term}\b",
696
+ rf"\b(?:is|are|was|were)\s+not\s+(?:designed|intended)\s+to\s+"
697
+ rf"(?:identify|establish|demonstrate|show|infer|support|prove)\b[^.;]*\b{causal_term}\b",
698
+ rf"\bwithout\s+(?:identifying|establishing|demonstrating|showing|inferring|supporting|proving)\b[^.;]*\b{causal_term}\b",
699
+ rf"\b(?:no|without)\s+(?:evidence|basis|support|identification|proof)\b[^.;]*\b{causal_term}\b",
700
+ rf"\b(?:is|are|was|were)\s+not\s+(?:evidence|proof)\s+(?:of|for)\s+(?:an?\s+|the\s+)?{causal_term}\b",
701
+ rf"\b(?:cannot|can't|could not|couldn't)\s+be\s+used\s+to\s+(?:infer|establish|demonstrate|show|prove)\b[^.;]*\b{causal_term}\b",
702
+ rf"\b(?:should not|shouldn't|must not|cannot|can't|could not|couldn't)\s+be\s+"
703
+ rf"(?:interpreted|read|treated|understood)\s+(?:as|to mean)\b[^.;]*\b{causal_term}\b",
704
+ rf"\b(?:is|are|was|were)?\s*(?:restricted|limited|confined)\s+to\b[^.;]*"
705
+ rf"\b(?:not|rather than|instead of)\s+(?:to\s+)?(?:an?\s+|the\s+)?{causal_term}\b",
706
+ rf"\b(?:association|associations|covariation|covariance|correlation|correlations|indirect association|descriptive pattern|descriptive patterns)\b[^.;]*"
707
+ rf"\b(?:not(?:\s+as)?|rather than|instead of)\s+(?:an?\s+|the\s+)?{causal_term}\b",
708
+ rf"\b{causal_term}\b\s+(?:is|are|was|were)\s+(?:not|never)\s+"
709
+ rf"(?:identified|established|demonstrated|shown|inferred|supported|claimed|justified|warranted|proven)\b",
710
+ rf"\b(?:not|no)\s+(?:an?\s+|the\s+)?{causal_term}\b",
711
+ )
712
+ if saw_disclaimed_direct or any(re.search(pattern, value, flags=re.I) for pattern in limitation_patterns):
713
+ return "EXPLICIT_CAUSAL_LIMITATION"
714
  return "NON_CAUSAL"
715
 
716
  @classmethod