S0-R1-C5 Dual-Engine Final Quality Contracts

#78
core/generation_runner.py CHANGED
@@ -36,6 +36,7 @@ from utils.file_parser import extract_text_from_files
36
  ProgressCallback = Callable[..., None]
37
 
38
  CITATION_SEMANTIC_SURVIVAL_RATIO = 0.78
 
39
  CITATION_ROLE_RULES = {
40
  "introduction": (3, 180),
41
  "theory": (3, 180),
@@ -976,6 +977,220 @@ class GenerationRunner:
976
  tags.add(f'[{token}]')
977
  return tags
978
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
979
  @staticmethod
980
  def _snapshot_section_citation_state(agent, citation_plan: dict, unit_name: str) -> dict:
981
  """Snapshot only Section-owned citation state so a rejected attempt cannot leak forward."""
@@ -1007,7 +1222,7 @@ class GenerationRunner:
1007
  def _section_validation_bundle(
1008
  cls, mode: str, agent, quant_engine, text: str, data_metadata: dict, *,
1009
  section_id: str, section_name: str, evidence_contract: str = "", include_critic: bool = True,
1010
- citation_violations=None,
1011
  ) -> list[dict]:
1012
  """Collect typed S0-R1 validation feedback without mutating manuscript prose."""
1013
  value = str(text or "")
@@ -1027,6 +1242,12 @@ class GenerationRunner:
1027
  violations.extend(cls._study_fact_violations(
1028
  mode, agent, quant_engine, value, data_metadata, section_name=section_name
1029
  ))
 
 
 
 
 
 
1030
  violations.extend(copy.deepcopy(list(citation_violations or [])))
1031
  return violations
1032
 
@@ -1952,8 +2173,10 @@ class GenerationRunner:
1952
  "external_claims": external_claims,
1953
  "planning_minimum": int(planned_minimum),
1954
  })
 
1955
  return {
1956
- "status": str(status or "adequate"),
 
1957
  "early_risk_detected": bool(early_risk),
1958
  "final_reference_count": max(0, int(final_reference_count or 0)),
1959
  "sections": sections,
@@ -1964,6 +2187,55 @@ class GenerationRunner:
1964
  "events": list(events or []),
1965
  }
1966
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1967
  @staticmethod
1968
  def _paragraph_token_set(value):
1969
  text = re.sub(r'\[CITE-\d+\]', ' ', str(value or ''), flags=re.I)
@@ -2147,6 +2419,35 @@ class GenerationRunner:
2147
  )
2148
  self._checkpoint("quantitative_evidence_ledger_upgraded", state, 6)
2149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2150
  self._qualitative_quote_sources = (
2151
  self._qualitative_quote_source_map(data_metadata) if mode == "asq" else {}
2152
  )
@@ -2643,6 +2944,27 @@ class GenerationRunner:
2643
  state['canonical_concept_ledger'] = canonical_concept_ledger
2644
  self._checkpoint('canonical_concept_ledger_ready', state, 15)
2645
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2646
  current_entity_ledger_version = str(
2647
  getattr(translation_agent, 'PROTECTED_ENTITY_LEDGER_VERSION', 'protected-entity-ledger-v1')
2648
  or 'protected-entity-ledger-v1'
@@ -2807,6 +3129,7 @@ class GenerationRunner:
2807
  quote_invocation_plan, adaptive_structure, data_metadata
2808
  )
2809
 
 
2810
  total_steps = len(adaptive_structure)
2811
  for sec in adaptive_structure:
2812
  display_name = sec["display_name"]
@@ -2890,10 +3213,16 @@ class GenerationRunner:
2890
  " Use level-2 or level-3 headings only when they clarify the argument; "
2891
  "never use level-4 headings or numbered headings."
2892
  )
2893
- table_rule = (
2894
- "Present only frozen statistical evidence in standard Markdown tables with plain-text captions."
2895
- if sec["is_method_or_result"] and mode == "amj" else ""
2896
- )
 
 
 
 
 
 
2897
  concepts = ", ".join(sec.get("concepts") or [])
2898
  theme_evidence = [item for item in (sec.get("theme_evidence") or []) if isinstance(item, dict)]
2899
  evidence_profiles = []
@@ -2973,6 +3302,8 @@ Planning length: approximately {int(adjusted_target * min_ratio)}-{int(adjusted_
2973
  {heading_directive}
2974
  {citation_rule}
2975
  {table_rule}
 
 
2976
  {str(sec.get('required_content') or '')}
2977
  {qualitative_rule}
2978
  {anti_repeat}
@@ -3117,7 +3448,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3117
  section_id=section_id, section_name=display_name,
3118
  evidence_contract=evidence_packet,
3119
  include_critic=not deterministic_appendix,
3120
- citation_violations=citation_semantic_rows,
3121
  )
3122
  non_citation_count = max(
3123
  0, len(phase1_validation_rows) - len(citation_semantic_rows)
@@ -3219,6 +3550,17 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3219
  quote_invocation_plan=quote_invocation_plan,
3220
  )
3221
 
 
 
 
 
 
 
 
 
 
 
 
3222
  # S0-R1 phase 2: only local/idempotent structural normalizers may
3223
  # mutate the candidate before the final pure validation bundle.
3224
  en_resolved, enumeration_rows = agent.repair_and_validate_enumeration_contract(
@@ -3268,6 +3610,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3268
  mode, agent, quant_engine, en_resolved, data_metadata,
3269
  section_id=section_id, section_name=display_name,
3270
  evidence_contract=evidence_packet, include_critic=False,
 
3271
  )
3272
  if post_validation_rows:
3273
  if section_attempt < max_section_attempts:
@@ -3345,6 +3688,46 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3345
  frozen_section = self._extract_top_level_section(full_draft_en, sec_title)
3346
  if not frozen_section:
3347
  raise RuntimeError(f"english_section_seal_source_missing:{sec_title}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3348
  section_integrity_seals[section_key] = self._build_section_integrity_seal(
3349
  section_key, sec_title, frozen_section
3350
  )
@@ -3424,6 +3807,17 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3424
  agent.validate_enumeration_contract(
3425
  sealed_section, section_name=f"Final sealed section: {section_title}"
3426
  )
 
 
 
 
 
 
 
 
 
 
 
3427
  # Validate the completed body while the Working Abstract is still in
3428
  # place. Final front matter must not be generated until all body
3429
  # sections, citation cleanup, and any Discussion rebalance are frozen.
@@ -3762,6 +4156,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3762
  if str(state.get("evidence_status") or "") == "limited":
3763
  result.update({
3764
  "quality_status": "evidence_limited",
 
3765
  "safe_error_code": "W_EVIDENCE_COVERAGE_LIMITED",
3766
  "safe_error_detail": self._evidence_warning_detail(),
3767
  })
@@ -3807,6 +4202,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3807
  if str(state.get("evidence_status") or "") == "limited":
3808
  result.update({
3809
  "quality_status": "evidence_limited",
 
3810
  "safe_error_code": "W_EVIDENCE_COVERAGE_LIMITED",
3811
  "safe_error_detail": self._evidence_warning_detail(),
3812
  })
 
36
  ProgressCallback = Callable[..., None]
37
 
38
  CITATION_SEMANTIC_SURVIVAL_RATIO = 0.78
39
+ EVIDENCE_COMPLETION_MAX_PER_TOP_LEVEL = 4
40
  CITATION_ROLE_RULES = {
41
  "introduction": (3, 180),
42
  "theory": (3, 180),
 
977
  tags.add(f'[{token}]')
978
  return tags
979
 
980
+ @staticmethod
981
+ def _author_facing_internal_workflow_violations(text: str) -> list[dict]:
982
+ """Detect SmartPaper control vocabulary that must stay out of author prose.
983
+
984
+ The check is intentionally phrase-scoped. A generic word such as
985
+ ``frozen`` can be legitimate academic content, but phrases naming the
986
+ private evidence/state machinery are never reader-facing manuscript
987
+ language.
988
+ """
989
+ value = str(text or "")
990
+ patterns = [
991
+ r"\bfrozen\s+(?:research\s+)?evidence\b",
992
+ r"\bfrozen\s+evidence\s+packet\b",
993
+ r"\bevidence\s+packet\b",
994
+ 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",
995
+ r"\b(?:study|result)-owned\s+fact\b",
996
+ r"\bevidence\s+ledger\b",
997
+ ]
998
+ rows = []
999
+ for pattern in patterns:
1000
+ for match in re.finditer(pattern, value, flags=re.I):
1001
+ start = max(0, match.start() - 80)
1002
+ end = min(len(value), match.end() + 120)
1003
+ preview = re.sub(r"\s+", " ", value[start:end]).strip()
1004
+ rows.append({
1005
+ "type": "author_facing_internal_workflow_language",
1006
+ "preview": preview[:320],
1007
+ "reason": "Private SmartPaper evidence/state vocabulary surfaced in reader-facing manuscript prose.",
1008
+ "repair_instruction": (
1009
+ "Rewrite the sentence as ordinary academic prose describing the study, evidence, method, or limitation directly. "
1010
+ "Do not mention frozen evidence, evidence packets, ledgers, study-owned/result-owned facts, or other orchestration labels."
1011
+ ),
1012
+ })
1013
+ if len(rows) >= 6:
1014
+ return rows
1015
+ return rows
1016
+
1017
+ @staticmethod
1018
+ def _hypothesis_contract_directive(ledger: dict) -> str:
1019
+ record = dict(ledger or {})
1020
+ items = [item for item in (record.get("items") or []) if isinstance(item, dict)]
1021
+ if not items:
1022
+ return ""
1023
+ lines = []
1024
+ for item in items:
1025
+ hid = str(item.get("hypothesis_id") or "").strip()
1026
+ predictor = str(item.get("predictor") or "").strip()
1027
+ outcome = str(item.get("outcome") or "").strip()
1028
+ if hid and predictor and outcome:
1029
+ lines.append(f"{hid}: {predictor} is associated with {outcome}.")
1030
+ if not lines:
1031
+ return ""
1032
+ indirect = dict(record.get("indirect_analysis") or {})
1033
+ indirect_line = ""
1034
+ if indirect:
1035
+ p = str(indirect.get("predictor") or "").strip()
1036
+ m = str(indirect.get("mediator") or "").strip()
1037
+ o = str(indirect.get("outcome") or "").strip()
1038
+ if p and m and o:
1039
+ indirect_line = (
1040
+ f"The estimated indirect association {p} -> {m} -> {o} is an analysis target, not a separately numbered hypothesis."
1041
+ )
1042
+ rendered = " ".join(lines + ([indirect_line] if indirect_line else []))
1043
+ return (
1044
+ "IMMUTABLE HYPOTHESIS CONTRACT: " + rendered
1045
+ + " Use H1/H2/H3-style identifiers exactly as listed whenever hypotheses are defined or referenced. "
1046
+ "Do not renumber, reorder, create an additional hypothesis, replace an H-label with 'first/second/third hypothesis', "
1047
+ "or change the construct pairing to fit observed results. The contract is result-independent."
1048
+ )
1049
+
1050
+ @classmethod
1051
+ def _hypothesis_contract_violations(
1052
+ cls, text: str, ledger: dict, *, section_name: str = "", require_all: bool = False
1053
+ ) -> list[dict]:
1054
+ record = dict(ledger or {})
1055
+ items = [item for item in (record.get("items") or []) if isinstance(item, dict)]
1056
+ if not items:
1057
+ return []
1058
+ value = str(text or "")
1059
+ expected = {
1060
+ str(item.get("hypothesis_id") or "").upper(): item
1061
+ for item in items if str(item.get("hypothesis_id") or "").strip()
1062
+ }
1063
+ if not expected:
1064
+ return []
1065
+ rows = []
1066
+ observed = {f"H{n}" for n in re.findall(r"\bH(\d{1,2})\b", value, flags=re.I)}
1067
+ unknown = sorted(observed - set(expected))
1068
+ if unknown:
1069
+ rows.append({
1070
+ "type": "hypothesis_contract_unknown_id",
1071
+ "observed": unknown,
1072
+ "expected": sorted(expected),
1073
+ "reason": "The manuscript introduced a hypothesis identifier outside the frozen result-independent hypothesis ledger.",
1074
+ "repair_instruction": "Use only the frozen H-identifiers and keep the indirect association unnumbered unless the ledger explicitly numbers it.",
1075
+ })
1076
+ ordinal = re.search(r"\b(?:first|second|third|fourth|fifth|sixth)\s+hypothesis\b", value, flags=re.I)
1077
+ if ordinal:
1078
+ rows.append({
1079
+ "type": "hypothesis_contract_ordinal_alias",
1080
+ "preview": re.sub(r"\s+", " ", value[max(0, ordinal.start()-80):ordinal.end()+120]).strip()[:320],
1081
+ "reason": "Ordinal hypothesis aliases can silently reorder the canonical H1-Hn identity.",
1082
+ "repair_instruction": "Refer to hypotheses only by their frozen H1-Hn identifiers.",
1083
+ })
1084
+ definition_scope = bool(re.search(r"Hypothesis Development|Integrated Research Model", str(section_name or ""), flags=re.I))
1085
+ if definition_scope:
1086
+ all_constructs = {
1087
+ re.sub(r"\s+", " ", str(item.get(key) or "")).strip()
1088
+ for item in items for key in ("predictor", "outcome")
1089
+ if str(item.get(key) or "").strip()
1090
+ }
1091
+ for start, end, sentence in SSciPipelineAgent._sentence_spans(value):
1092
+ ids = list(dict.fromkeys(
1093
+ f"H{n}" for n in re.findall(r"\bH(\d{1,2})\b", sentence, flags=re.I)
1094
+ ))
1095
+ # Collective references such as "H1-H3 are summarized below"
1096
+ # are not definitions and must not be mistaken for remapping.
1097
+ if len(ids) != 1:
1098
+ continue
1099
+ hid = ids[0]
1100
+ item = expected.get(hid)
1101
+ if not item:
1102
+ continue
1103
+ predictor = re.sub(r"\s+", " ", str(item.get("predictor") or "")).strip()
1104
+ outcome = re.sub(r"\s+", " ", str(item.get("outcome") or "")).strip()
1105
+ lower_sentence = re.sub(r"\s+", " ", sentence).casefold()
1106
+ mentioned_constructs = {
1107
+ term for term in all_constructs if term.casefold() in lower_sentence
1108
+ }
1109
+ # Validate pairing only when the sentence actually names one or
1110
+ # more constructs. A bare cross-reference ("H1 is summarized")
1111
+ # remains legal and cannot silently redefine the hypothesis.
1112
+ if mentioned_constructs and predictor and outcome and (
1113
+ predictor.casefold() not in lower_sentence or outcome.casefold() not in lower_sentence
1114
+ ):
1115
+ rows.append({
1116
+ "type": "hypothesis_contract_pairing_mismatch",
1117
+ "id": hid,
1118
+ "sentence": re.sub(r"\s+", " ", sentence).strip()[:420],
1119
+ "expected": f"{predictor} -> {outcome}",
1120
+ "reason": "A canonical hypothesis label was attached to a different construct pairing.",
1121
+ "repair_instruction": f"Define {hid} only for {predictor} and {outcome}, using association language.",
1122
+ })
1123
+ if require_all:
1124
+ missing = sorted(set(expected) - observed)
1125
+ if missing:
1126
+ rows.append({
1127
+ "type": "hypothesis_contract_missing_id",
1128
+ "observed": sorted(observed),
1129
+ "expected": sorted(expected),
1130
+ "reason": "The completed hypothesis architecture omitted one or more frozen hypothesis identifiers.",
1131
+ "repair_instruction": "Preserve every canonical H1-Hn identity from theory through the integrated model and downstream reporting.",
1132
+ })
1133
+ return rows[:8]
1134
+
1135
+ @staticmethod
1136
+ def _strip_model_markdown_tables(text: str) -> str:
1137
+ """Remove writer-created Markdown tables before deterministic AMJ table binding."""
1138
+ lines = str(text or "").splitlines()
1139
+ kept = []
1140
+ index = 0
1141
+ while index < len(lines):
1142
+ line = lines[index]
1143
+ if re.match(r"^\s*(?:#{1,6}\s*)?Table\s+\d+\s*[:.\-]", line, flags=re.I):
1144
+ index += 1
1145
+ while index < len(lines) and not lines[index].strip():
1146
+ index += 1
1147
+ if index < len(lines) and lines[index].lstrip().startswith("|"):
1148
+ while index < len(lines) and lines[index].lstrip().startswith("|"):
1149
+ index += 1
1150
+ if index < len(lines) and re.match(r"^\s*\*?Note\s*:", lines[index], flags=re.I):
1151
+ index += 1
1152
+ continue
1153
+ if line.lstrip().startswith("|"):
1154
+ start = index
1155
+ while index < len(lines) and lines[index].lstrip().startswith("|"):
1156
+ index += 1
1157
+ block = lines[start:index]
1158
+ if len(block) >= 2 and re.match(r"^\s*\|?\s*:?-{3,}", block[1]):
1159
+ continue
1160
+ kept.extend(block)
1161
+ continue
1162
+ kept.append(line)
1163
+ index += 1
1164
+ return re.sub(r"\n{3,}", "\n\n", "\n".join(kept)).strip()
1165
+
1166
+ @classmethod
1167
+ def _apply_quantitative_table_contract(cls, text: str, data_metadata: dict, display_name: str) -> str:
1168
+ manifest = dict((data_metadata or {}).get("quantitative_table_manifest") or {})
1169
+ tables = [item for item in (manifest.get("tables") or []) if isinstance(item, dict)]
1170
+ if not tables:
1171
+ return str(text or "")
1172
+ value = cls._strip_model_markdown_tables(text)
1173
+ assigned = []
1174
+ for item in tables:
1175
+ target = str(item.get("target_subsection") or "").strip()
1176
+ if target and target.casefold() in str(display_name or "").casefold():
1177
+ assigned.append(item)
1178
+ if not assigned:
1179
+ return value
1180
+ blocks = []
1181
+ for item in sorted(assigned, key=lambda row: int(row.get("number") or 0)):
1182
+ number = int(item.get("number") or 0)
1183
+ title = str(item.get("title") or "").strip()
1184
+ markdown = str(item.get("markdown") or "").strip()
1185
+ note = str(item.get("note") or "").strip()
1186
+ if not number or not title or not markdown:
1187
+ raise RuntimeError(f"quantitative_table_manifest_invalid:{display_name}")
1188
+ block = f"Table {number}: {title}\n{markdown}"
1189
+ if note:
1190
+ block += f"\n\n*Note: {note}*"
1191
+ blocks.append(block)
1192
+ return (value.rstrip() + "\n\n" + "\n\n".join(blocks)).strip()
1193
+
1194
  @staticmethod
1195
  def _snapshot_section_citation_state(agent, citation_plan: dict, unit_name: str) -> dict:
1196
  """Snapshot only Section-owned citation state so a rejected attempt cannot leak forward."""
 
1222
  def _section_validation_bundle(
1223
  cls, mode: str, agent, quant_engine, text: str, data_metadata: dict, *,
1224
  section_id: str, section_name: str, evidence_contract: str = "", include_critic: bool = True,
1225
+ citation_violations=None, hypothesis_ledger=None,
1226
  ) -> list[dict]:
1227
  """Collect typed S0-R1 validation feedback without mutating manuscript prose."""
1228
  value = str(text or "")
 
1242
  violations.extend(cls._study_fact_violations(
1243
  mode, agent, quant_engine, value, data_metadata, section_name=section_name
1244
  ))
1245
+ violations.extend(cls._author_facing_internal_workflow_violations(value))
1246
+ if str(mode or "").lower() == "amj":
1247
+ violations.extend(cls._hypothesis_contract_violations(
1248
+ value, hypothesis_ledger or {}, section_name=section_name,
1249
+ require_all=bool(re.search(r"Integrated Research Model", str(section_name or ""), flags=re.I)),
1250
+ ))
1251
  violations.extend(copy.deepcopy(list(citation_violations or [])))
1252
  return violations
1253
 
 
2173
  "external_claims": external_claims,
2174
  "planning_minimum": int(planned_minimum),
2175
  })
2176
+ normalized_status = str(status or "adequate")
2177
  return {
2178
+ "status": normalized_status,
2179
+ "submission_readiness": "review_required" if normalized_status == "limited" else "quality_gate_passed",
2180
  "early_risk_detected": bool(early_risk),
2181
  "final_reference_count": max(0, int(final_reference_count or 0)),
2182
  "sections": sections,
 
2187
  "events": list(events or []),
2188
  }
2189
 
2190
+ def _bounded_top_level_evidence_completion(
2191
+ self, agent, *, topic: str, section_id: str, section_name: str, section_text: str,
2192
+ required: int, existing_tags, excluded_tags=None,
2193
+ ) -> tuple[str, list[str]]:
2194
+ """Give one completed top-level section a single bounded evidence top-up.
2195
+
2196
+ This is not a prose-generation path. It issues at most one contextual JIT
2197
+ retrieval and may attach only semantically validated citations to claims
2198
+ that already exist. If no directly supporting source is found, the prose
2199
+ remains unchanged and the manuscript later reports evidence-limited quality.
2200
+ """
2201
+ value = str(section_text or "")
2202
+ current = {str(tag or "").strip() for tag in (existing_tags or []) if str(tag or "").strip()}
2203
+ needed = max(0, int(required or 0) - len(current))
2204
+ needed = min(EVIDENCE_COMPLETION_MAX_PER_TOP_LEVEL, needed)
2205
+ if needed <= 0 or not value.strip():
2206
+ return value, []
2207
+ before = set(getattr(agent, "used_citations", set()) or set())
2208
+ items = agent.fetch_contextual_jit_recovery_items(
2209
+ topic=topic,
2210
+ section_name=section_name,
2211
+ section_id=section_id,
2212
+ section_context=value,
2213
+ needed=needed,
2214
+ excluded_tags=(before | set(excluded_tags or set())),
2215
+ )
2216
+ if not items:
2217
+ logging.info(
2218
+ "Bounded evidence completion found no eligible source | section=%s needed=%s",
2219
+ section_name, needed,
2220
+ )
2221
+ return value, []
2222
+ recovered = agent.recover_citations_on_existing_claims(
2223
+ value, items, section_name=section_name, section_id=section_id, needed=needed
2224
+ )
2225
+ after = set(getattr(agent, "used_citations", set()) or set())
2226
+ added = sorted(after - before, key=agent._citation_tag_number)
2227
+ if recovered == value or not added:
2228
+ logging.info(
2229
+ "Bounded evidence completion made no validated attachment | section=%s candidates=%s",
2230
+ section_name, len(items),
2231
+ )
2232
+ return value, []
2233
+ logging.info(
2234
+ "Bounded evidence completion accepted | section=%s added=%s required=%s",
2235
+ section_name, len(added), required,
2236
+ )
2237
+ return recovered, added
2238
+
2239
  @staticmethod
2240
  def _paragraph_token_set(value):
2241
  text = re.sub(r'\[CITE-\d+\]', ' ', str(value or ''), flags=re.I)
 
2419
  )
2420
  self._checkpoint("quantitative_evidence_ledger_upgraded", state, 6)
2421
 
2422
+ if str(data_metadata.get("source_type") or "") == "system_generated":
2423
+ table_manifest = dict(data_metadata.get("quantitative_table_manifest") or {})
2424
+ expected_table_version = str(getattr(quant_engine, "QUANTITATIVE_TABLE_MANIFEST_VERSION", ""))
2425
+ if table_manifest.get("version") != expected_table_version:
2426
+ rebuilt_tables = quant_engine.build_table_manifest_from_evidence_markdown(user_data_md)
2427
+ if not rebuilt_tables:
2428
+ raise RuntimeError("quantitative_table_manifest_rebuild_failed")
2429
+ data_metadata["quantitative_table_manifest"] = rebuilt_tables
2430
+ logging.warning(
2431
+ "Quantitative table manifest contract advanced; rebuilt locally version=%s",
2432
+ expected_table_version,
2433
+ )
2434
+
2435
+ hypothesis_ledger = dict(data_metadata.get("hypothesis_ledger") or {})
2436
+ expected_hypothesis_version = str(getattr(quant_engine, "HYPOTHESIS_LEDGER_VERSION", ""))
2437
+ if hypothesis_ledger.get("version") != expected_hypothesis_version:
2438
+ constructs = dict(data_metadata.get("constructs") or {})
2439
+ rebuilt_hypotheses = quant_engine.build_hypothesis_ledger([
2440
+ constructs.get("predictor"), constructs.get("mediator"), constructs.get("outcome")
2441
+ ])
2442
+ if not rebuilt_hypotheses:
2443
+ raise RuntimeError("quantitative_hypothesis_ledger_rebuild_failed")
2444
+ data_metadata["hypothesis_ledger"] = rebuilt_hypotheses
2445
+ logging.warning(
2446
+ "Quantitative hypothesis ledger contract advanced; rebuilt locally version=%s",
2447
+ expected_hypothesis_version,
2448
+ )
2449
+ state["data_metadata"] = data_metadata
2450
+
2451
  self._qualitative_quote_sources = (
2452
  self._qualitative_quote_source_map(data_metadata) if mode == "asq" else {}
2453
  )
 
2944
  state['canonical_concept_ledger'] = canonical_concept_ledger
2945
  self._checkpoint('canonical_concept_ledger_ready', state, 15)
2946
 
2947
+ glossary_quality_version = str(
2948
+ getattr(translation_agent, 'CANONICAL_GLOSSARY_QUALITY_VERSION', 'canonical-glossary-quality-v1')
2949
+ or 'canonical-glossary-quality-v1'
2950
+ )
2951
+ if state.get('canonical_glossary_quality_version') != glossary_quality_version:
2952
+ reviewed_glossary = translation_agent.validate_and_refine_canonical_concept_glossary(
2953
+ canonical_concept_ledger, target_lang, translation_glossary
2954
+ )
2955
+ if reviewed_glossary != translation_glossary:
2956
+ translation_glossary = dict(reviewed_glossary)
2957
+ # A corrected canonical identity invalidates only target-language
2958
+ # work. Sealed English remains authoritative and unchanged.
2959
+ translation_sections = {}
2960
+ state['translation_sections'] = {}
2961
+ state['full_draft_translated'] = ''
2962
+ state['translation_complete'] = False
2963
+ state['package_validated'] = False
2964
+ state['translation_glossary'] = translation_glossary
2965
+ state['canonical_glossary_quality_version'] = glossary_quality_version
2966
+ self._checkpoint('canonical_glossary_quality_ready', state, 15)
2967
+
2968
  current_entity_ledger_version = str(
2969
  getattr(translation_agent, 'PROTECTED_ENTITY_LEDGER_VERSION', 'protected-entity-ledger-v1')
2970
  or 'protected-entity-ledger-v1'
 
3129
  quote_invocation_plan, adaptive_structure, data_metadata
3130
  )
3131
 
3132
+ hypothesis_ledger = dict(data_metadata.get("hypothesis_ledger") or {}) if mode == "amj" else {}
3133
  total_steps = len(adaptive_structure)
3134
  for sec in adaptive_structure:
3135
  display_name = sec["display_name"]
 
3213
  " Use level-2 or level-3 headings only when they clarify the argument; "
3214
  "never use level-4 headings or numbered headings."
3215
  )
3216
+ if mode == "amj" and section_id == "results" and data_metadata.get("quantitative_table_manifest"):
3217
+ table_rule = (
3218
+ "AUTHORITATIVE TABLE CONTRACT: Do not create, reproduce, renumber, or alter any Markdown statistical table. "
3219
+ "The Executor will attach the required reader-facing tables deterministically from the quantitative evidence manifest. "
3220
+ "You may discuss and refer to Table 1-Table 4 where relevant, but do not invent an additional table or change a table number."
3221
+ )
3222
+ elif sec["is_method_or_result"] and mode == "amj":
3223
+ table_rule = "Present only study-supported statistical evidence; do not invent tables or numerical results."
3224
+ else:
3225
+ table_rule = ""
3226
  concepts = ", ".join(sec.get("concepts") or [])
3227
  theme_evidence = [item for item in (sec.get("theme_evidence") or []) if isinstance(item, dict)]
3228
  evidence_profiles = []
 
3302
  {heading_directive}
3303
  {citation_rule}
3304
  {table_rule}
3305
+ AUTHOR-FACING PURITY: Write only reader-facing scholarly prose. Never mention SmartPaper control labels such as frozen evidence, evidence packet, evidence ledger, study-owned/result-owned fact, frozen theme summary, frozen thematic structure, or frozen task-level concepts. Describe the study, method, evidence, and limitations directly.
3306
+ {self._hypothesis_contract_directive(hypothesis_ledger)}
3307
  {str(sec.get('required_content') or '')}
3308
  {qualitative_rule}
3309
  {anti_repeat}
 
3448
  section_id=section_id, section_name=display_name,
3449
  evidence_contract=evidence_packet,
3450
  include_critic=not deterministic_appendix,
3451
+ citation_violations=citation_semantic_rows, hypothesis_ledger=hypothesis_ledger,
3452
  )
3453
  non_citation_count = max(
3454
  0, len(phase1_validation_rows) - len(citation_semantic_rows)
 
3550
  quote_invocation_plan=quote_invocation_plan,
3551
  )
3552
 
3553
+ if mode == "amj" and data_metadata.get("quantitative_table_manifest"):
3554
+ if section_id == "results":
3555
+ en_resolved = self._apply_quantitative_table_contract(
3556
+ en_resolved, data_metadata, display_name
3557
+ )
3558
+ else:
3559
+ # System-generated AMJ tables have one authoritative
3560
+ # manifest. Writer-created tables anywhere else are not
3561
+ # permitted to create a fifth/competing artifact table.
3562
+ en_resolved = self._strip_model_markdown_tables(en_resolved)
3563
+
3564
  # S0-R1 phase 2: only local/idempotent structural normalizers may
3565
  # mutate the candidate before the final pure validation bundle.
3566
  en_resolved, enumeration_rows = agent.repair_and_validate_enumeration_contract(
 
3610
  mode, agent, quant_engine, en_resolved, data_metadata,
3611
  section_id=section_id, section_name=display_name,
3612
  evidence_contract=evidence_packet, include_critic=False,
3613
+ hypothesis_ledger=hypothesis_ledger,
3614
  )
3615
  if post_validation_rows:
3616
  if section_attempt < max_section_attempts:
 
3688
  frozen_section = self._extract_top_level_section(full_draft_en, sec_title)
3689
  if not frozen_section:
3690
  raise RuntimeError(f"english_section_seal_source_missing:{sec_title}")
3691
+
3692
+ # C5: one bounded publication-quality evidence opportunity is
3693
+ # allowed before the English top-level Section is sealed and
3694
+ # before translation begins. It never writes new prose and may
3695
+ # attach only citations that survive the same semantic auditor.
3696
+ if section_key in citation_top_level_minimums:
3697
+ top_units = [
3698
+ unit for unit, owner in citation_unit_section_map.items()
3699
+ if str(owner) == str(section_key)
3700
+ ]
3701
+ top_validated = set()
3702
+ top_claims = 0
3703
+ for unit in top_units:
3704
+ top_validated.update(citation_validated_tags_by_unit.get(unit) or [])
3705
+ top_claims += int(citation_required_claim_counts_by_unit.get(unit, 0) or 0)
3706
+ top_required = min(
3707
+ int(citation_top_level_minimums.get(section_key, 0) or 0),
3708
+ max(0, top_claims),
3709
+ ) if top_claims else 0
3710
+ if len(top_validated) < top_required:
3711
+ completed_section, added_tags = self._bounded_top_level_evidence_completion(
3712
+ agent, topic=topic, section_id=section_key, section_name=sec_title,
3713
+ section_text=frozen_section, required=top_required,
3714
+ existing_tags=top_validated, excluded_tags=set(agent.used_citations),
3715
+ )
3716
+ if added_tags and completed_section != frozen_section:
3717
+ full_draft_en = self._replace_top_level_section(
3718
+ full_draft_en, sec_title, completed_section
3719
+ )
3720
+ frozen_section = completed_section
3721
+ merged_tags = set(citation_validated_tags_by_unit.get(display_name) or [])
3722
+ merged_tags.update(added_tags)
3723
+ citation_validated_tags_by_unit[display_name] = sorted(
3724
+ merged_tags, key=agent._citation_tag_number
3725
+ )
3726
+ logging.info(
3727
+ "Top-level evidence completion sealed | section=%s added=%s",
3728
+ sec_title, len(added_tags),
3729
+ )
3730
+
3731
  section_integrity_seals[section_key] = self._build_section_integrity_seal(
3732
  section_key, sec_title, frozen_section
3733
  )
 
3807
  agent.validate_enumeration_contract(
3808
  sealed_section, section_name=f"Final sealed section: {section_title}"
3809
  )
3810
+ internal_rows = self._author_facing_internal_workflow_violations(sealed_section)
3811
+ if internal_rows:
3812
+ raise RuntimeError(
3813
+ f"final_author_facing_purity_failed:{section_title}:{internal_rows[:3]}"
3814
+ )
3815
+ if mode == "amj" and hypothesis_ledger:
3816
+ hypothesis_rows = self._hypothesis_contract_violations(
3817
+ full_draft_en, hypothesis_ledger, section_name="Full Manuscript", require_all=True
3818
+ )
3819
+ if hypothesis_rows:
3820
+ raise RuntimeError(f"final_hypothesis_contract_failed:{hypothesis_rows[:3]}")
3821
  # Validate the completed body while the Working Abstract is still in
3822
  # place. Final front matter must not be generated until all body
3823
  # sections, citation cleanup, and any Discussion rebalance are frozen.
 
4156
  if str(state.get("evidence_status") or "") == "limited":
4157
  result.update({
4158
  "quality_status": "evidence_limited",
4159
+ "submission_readiness": "review_required",
4160
  "safe_error_code": "W_EVIDENCE_COVERAGE_LIMITED",
4161
  "safe_error_detail": self._evidence_warning_detail(),
4162
  })
 
4202
  if str(state.get("evidence_status") or "") == "limited":
4203
  result.update({
4204
  "quality_status": "evidence_limited",
4205
+ "submission_readiness": "review_required",
4206
  "safe_error_code": "W_EVIDENCE_COVERAGE_LIMITED",
4207
  "safe_error_detail": self._evidence_warning_detail(),
4208
  })
core/pipeline_agent.py CHANGED
@@ -73,6 +73,7 @@ class SSciPipelineAgent:
73
  TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v8'
74
  MAX_TRANSLATION_CLAIM_REPAIR_ATTEMPTS = 2
75
  PROTECTED_ENTITY_LEDGER_VERSION = 'protected-entity-ledger-v2'
 
76
 
77
  def __init__(self, ds_key, s2_key=""):
78
  self.ds_key = str(ds_key or "").strip()
@@ -6491,6 +6492,106 @@ TEXT:
6491
  mapping[term] = target
6492
  return mapping
6493
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6494
  def extend_translation_glossary(self, body, target_lang, existing=None):
6495
  """Extend a task-level glossary without remapping an existing term."""
6496
  mapping = dict(existing or {})
 
73
  TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v8'
74
  MAX_TRANSLATION_CLAIM_REPAIR_ATTEMPTS = 2
75
  PROTECTED_ENTITY_LEDGER_VERSION = 'protected-entity-ledger-v2'
76
+ CANONICAL_GLOSSARY_QUALITY_VERSION = 'canonical-glossary-quality-v1'
77
 
78
  def __init__(self, ds_key, s2_key=""):
79
  self.ds_key = str(ds_key or "").strip()
 
6492
  mapping[term] = target
6493
  return mapping
6494
 
6495
+ @staticmethod
6496
+ def _canonical_target_surface_suspicious(target, target_lang):
6497
+ """Detect only high-confidence malformed canonical-term surfaces."""
6498
+ value = re.sub(r'\s+', ' ', str(target or '')).strip()
6499
+ if not value:
6500
+ return True
6501
+ profile = SSciPipelineAgent._translation_language_profile(target_lang)
6502
+ if profile.get('language_code') == 'zh':
6503
+ compact = re.sub(r'\s+', '', value)
6504
+ # Repeated multi-character fragments such as 情境感知感知 are a
6505
+ # high-confidence malformed canonical surface, while single-character
6506
+ # reduplication is left alone because it can be grammatical Chinese.
6507
+ if re.search(r'([\u4e00-\u9fff]{2,4})\1', compact):
6508
+ return True
6509
+ return False
6510
+
6511
+ def validate_and_refine_canonical_concept_glossary(self, concept_ledger, target_lang, glossary):
6512
+ """Independently review strict canonical terms once before body translation.
6513
+
6514
+ The first glossary call establishes candidate identities. This second,
6515
+ bounded call verifies semantic identity and natural academic surface form
6516
+ before those candidates become immutable for all later ZH/RU/KK sections.
6517
+ At most one corrected target is accepted for each strict construct; no
6518
+ section-level repair loop is involved.
6519
+ """
6520
+ mapping = dict(glossary or {})
6521
+ rows = (dict(concept_ledger or {}).get('items') or []) if isinstance(concept_ledger, dict) else []
6522
+ strict_sources = []
6523
+ for row in rows:
6524
+ if not isinstance(row, dict):
6525
+ continue
6526
+ if str(row.get('policy') or '').strip().upper() != 'STRICT_CONSTRUCT':
6527
+ continue
6528
+ source = re.sub(r'\s+', ' ', str(row.get('source') or '')).strip()
6529
+ if source and source in mapping and source not in strict_sources:
6530
+ strict_sources.append(source)
6531
+ if not strict_sources:
6532
+ return mapping
6533
+
6534
+ payload = [
6535
+ {'source': source, 'candidate_target': str(mapping.get(source) or '').strip()}
6536
+ for source in strict_sources
6537
+ ]
6538
+ if any(not row['candidate_target'] for row in payload):
6539
+ raise DeepSeekRequestError('Canonical glossary quality review received an empty strict target.')
6540
+ language_rule = self._translation_language_rule(target_lang)
6541
+ response = self.call_deepseek(
6542
+ (
6543
+ f"Independently review strict research-construct translations for {target_lang}. {language_rule} "
6544
+ "Judge semantic identity and natural academic terminology, not stylistic preference. "
6545
+ "Reject duplicated/malformed wording, concept narrowing/broadening, or a target that changes the construct. "
6546
+ "If the candidate is sound, return it unchanged with accepted=true. If it is materially flawed, return exactly one corrected canonical target with accepted=false. "
6547
+ "Do not merge distinct constructs. Return JSON only as "
6548
+ '{"terms":[{"source":"...","candidate_target":"...","accepted":true,"target":"..."}]}'
6549
+ " and return every source exactly once."
6550
+ ),
6551
+ json.dumps({'terms': payload}, ensure_ascii=False),
6552
+ max_tokens=max(1200, min(4200, 180 + len(payload) * 120)),
6553
+ temp=0.0, model_role='translation', thinking=False, allow_fallback=False,
6554
+ )
6555
+ parsed = self._extract_json_from_text(response)
6556
+ items = parsed.get('terms', []) if isinstance(parsed, dict) else []
6557
+ returned = {}
6558
+ for item in items:
6559
+ if not isinstance(item, dict):
6560
+ continue
6561
+ source = re.sub(r'\s+', ' ', str(item.get('source') or '')).strip()
6562
+ if source:
6563
+ returned[source] = item
6564
+ if set(returned) != set(strict_sources):
6565
+ raise DeepSeekRequestError('Canonical glossary quality review omitted or changed a strict source identity.')
6566
+
6567
+ refined = dict(mapping)
6568
+ for source in strict_sources:
6569
+ item = returned[source]
6570
+ candidate = str(mapping.get(source) or '').strip()
6571
+ accepted_raw = item.get('accepted')
6572
+ accepted = accepted_raw is True or str(accepted_raw).strip().casefold() == 'true'
6573
+ reviewed_target = re.sub(r'\s+', ' ', str(item.get('target') or '')).strip()
6574
+ suspicious = self._canonical_target_surface_suspicious(candidate, target_lang)
6575
+ if accepted and not suspicious:
6576
+ # An acceptance cannot silently remap the frozen candidate.
6577
+ if reviewed_target and reviewed_target != candidate:
6578
+ raise DeepSeekRequestError('Canonical glossary review changed an accepted strict target.')
6579
+ continue
6580
+ if not reviewed_target or reviewed_target == candidate:
6581
+ raise DeepSeekRequestError(
6582
+ f'Canonical concept quality review did not provide a usable correction: {source[:80]}'
6583
+ )
6584
+ if reviewed_target.casefold() == source.casefold() and re.search(r'[a-z]{4,}', source):
6585
+ raise DeepSeekRequestError(f'Canonical concept remained untranslated after quality review: {source[:80]}')
6586
+ if self._canonical_target_surface_suspicious(reviewed_target, target_lang):
6587
+ raise DeepSeekRequestError(f'Canonical concept correction remains malformed: {source[:80]}')
6588
+ refined[source] = reviewed_target
6589
+ self._record_translation_assurance(
6590
+ 'CANONICAL_GLOSSARY_REFINED', target_lang=target_lang, severity='repair',
6591
+ decision='REPAIR_LOCAL', detail={'source': source[:120]},
6592
+ )
6593
+ return refined
6594
+
6595
  def extend_translation_glossary(self, body, target_lang, existing=None):
6596
  """Extend a task-level glossary without remapping an existing term."""
6597
  mapping = dict(existing or {})
core/production_guard.py CHANGED
@@ -56,6 +56,80 @@ def validate_docx_bytes(data: bytes, *, minimum_paragraphs: int = 8) -> None:
56
  )
57
 
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  def validate_qualitative_appendix_docx_layout(data: bytes, *, label: str = "DOCX") -> None:
60
  """Fail closed if the qualitative evidence Appendix lost its layout contract.
61
 
@@ -512,6 +586,7 @@ def build_evidence_coverage_report(summary: dict[str, Any] | None) -> str:
512
  "SmartPaper Evidence Coverage Report",
513
  "",
514
  f"Overall evidence status: {'LIMITED' if limited else 'ADEQUATE'}",
 
515
  f"Validated references in final manuscript: {max(0, int(data.get('final_reference_count') or 0))}",
516
  f"Early coverage risk detected: {'yes' if bool(data.get('early_risk_detected')) else 'no'}",
517
  "",
@@ -546,6 +621,7 @@ def build_evidence_coverage_report(summary: dict[str, Any] | None) -> str:
546
  "Author guidance:",
547
  "The manuscript was completed because structural, translation, citation-correctness, quotation/fact, DOCX, and ZIP integrity gates are independent from literature coverage quantity.",
548
  "Before formal submission, review the evidence gaps and consider adding more directly relevant core literature where appropriate.",
 
549
  ])
550
  return "\n".join(lines).strip() + "\n"
551
 
@@ -585,6 +661,14 @@ def build_validated_package(
585
  raise OutputValidationError(str(exc)) from exc
586
  validate_docx_bytes(english_docx)
587
  validate_docx_bytes(translated_docx)
 
 
 
 
 
 
 
 
588
  if qualitative_mode:
589
  validate_qualitative_appendix_docx_layout(english_docx, label="English DOCX")
590
  validate_qualitative_appendix_docx_layout(translated_docx, label="Target-language DOCX")
@@ -621,6 +705,12 @@ def build_validated_package(
621
  for name in (english_filename, translated_filename):
622
  archived_docx = archive.read(name)
623
  validate_docx_bytes(archived_docx)
 
 
 
 
 
 
624
  if qualitative_mode:
625
  validate_qualitative_appendix_docx_layout(
626
  archived_docx,
 
56
  )
57
 
58
 
59
+ def _table_reference_numbers(markdown: str) -> set[int]:
60
+ value = str(markdown or "")
61
+ # The References tail can contain article titles with the word "Table"; it
62
+ # is bibliographic data and must not be mistaken for a manuscript table
63
+ # cross-reference.
64
+ reference_heading = re.search(
65
+ r"(?mi)^#\s+(?:References|Reference List|参考文献|Литература|Список литературы|Әдебиеттер|Пайдаланылған әдебиеттер)\s*$",
66
+ value,
67
+ )
68
+ if reference_heading:
69
+ value = value[:reference_heading.start()]
70
+ numbers = set()
71
+ patterns = [
72
+ r"\bTable\s+(\d+)\b",
73
+ r"表\s*(\d+)",
74
+ r"\bТаблиц(?:а|ы|е|у|ей|ами|ах)?\s*(\d+)\b",
75
+ r"\bКесте\s*(\d+)\b",
76
+ r"\b(\d+)\s*[-–—]?\s*кесте\b",
77
+ ]
78
+ for pattern in patterns:
79
+ for match in re.finditer(pattern, value, flags=re.I):
80
+ try:
81
+ numbers.add(int(match.group(1)))
82
+ except Exception:
83
+ continue
84
+ return numbers
85
+
86
+
87
+ def validate_quantitative_table_contract(
88
+ markdown: str, data: bytes, manifest: dict[str, Any] | None, *, label: str = "DOCX"
89
+ ) -> None:
90
+ """Validate table inventory/reference parity for the system-generated AMJ path."""
91
+ record = dict(manifest or {})
92
+ rows = [item for item in (record.get("tables") or []) if isinstance(item, dict)]
93
+ if not rows:
94
+ return
95
+ expected_numbers = {
96
+ int(item.get("number") or 0) for item in rows if int(item.get("number") or 0) > 0
97
+ }
98
+ if not expected_numbers:
99
+ raise OutputValidationError(f"{label} quantitative table manifest is empty or invalid.")
100
+ try:
101
+ document = Document(io.BytesIO(data))
102
+ except Exception as exc:
103
+ raise OutputValidationError(f"{label} quantitative table contract cannot inspect DOCX: {exc}") from exc
104
+ if len(document.tables) != len(expected_numbers):
105
+ raise OutputValidationError(
106
+ f"{label} quantitative table inventory mismatch: actual={len(document.tables)}, expected={len(expected_numbers)}."
107
+ )
108
+ referenced = _table_reference_numbers(markdown)
109
+ unexpected = sorted(referenced - expected_numbers)
110
+ missing = sorted(expected_numbers - referenced)
111
+ if unexpected:
112
+ raise OutputValidationError(
113
+ f"{label} references unexpected quantitative table number(s): {unexpected}."
114
+ )
115
+ if missing:
116
+ raise OutputValidationError(
117
+ f"{label} is missing reader-facing table number(s): {missing}."
118
+ )
119
+ if str(label).casefold().startswith("english"):
120
+ for item in rows:
121
+ number = int(item.get("number") or 0)
122
+ title = str(item.get("title") or "").strip()
123
+ if not number or not title:
124
+ continue
125
+ if not re.search(
126
+ rf"(?mi)^\s*Table\s+{number}\s*:\s*{re.escape(title)}\s*$", str(markdown or "")
127
+ ):
128
+ raise OutputValidationError(
129
+ f"English manuscript table caption mismatch for Table {number}: {title}."
130
+ )
131
+
132
+
133
  def validate_qualitative_appendix_docx_layout(data: bytes, *, label: str = "DOCX") -> None:
134
  """Fail closed if the qualitative evidence Appendix lost its layout contract.
135
 
 
586
  "SmartPaper Evidence Coverage Report",
587
  "",
588
  f"Overall evidence status: {'LIMITED' if limited else 'ADEQUATE'}",
589
+ f"Submission readiness: {'REVIEW REQUIRED — evidence coverage is limited' if limited else 'QUALITY GATE PASSED'}",
590
  f"Validated references in final manuscript: {max(0, int(data.get('final_reference_count') or 0))}",
591
  f"Early coverage risk detected: {'yes' if bool(data.get('early_risk_detected')) else 'no'}",
592
  "",
 
621
  "Author guidance:",
622
  "The manuscript was completed because structural, translation, citation-correctness, quotation/fact, DOCX, and ZIP integrity gates are independent from literature coverage quantity.",
623
  "Before formal submission, review the evidence gaps and consider adding more directly relevant core literature where appropriate.",
624
+ "Do not treat an evidence-limited package as submission-ready until the evidence gaps have been reviewed and, where possible, strengthened.",
625
  ])
626
  return "\n".join(lines).strip() + "\n"
627
 
 
661
  raise OutputValidationError(str(exc)) from exc
662
  validate_docx_bytes(english_docx)
663
  validate_docx_bytes(translated_docx)
664
+ quantitative_manifest = (data_metadata or {}).get("quantitative_table_manifest") if str(mode or "").strip().casefold() == "amj" else None
665
+ if quantitative_manifest:
666
+ validate_quantitative_table_contract(
667
+ english_markdown, english_docx, quantitative_manifest, label="English DOCX"
668
+ )
669
+ validate_quantitative_table_contract(
670
+ translated_markdown, translated_docx, quantitative_manifest, label="Target-language DOCX"
671
+ )
672
  if qualitative_mode:
673
  validate_qualitative_appendix_docx_layout(english_docx, label="English DOCX")
674
  validate_qualitative_appendix_docx_layout(translated_docx, label="Target-language DOCX")
 
705
  for name in (english_filename, translated_filename):
706
  archived_docx = archive.read(name)
707
  validate_docx_bytes(archived_docx)
708
+ if quantitative_manifest:
709
+ archived_markdown = english_markdown if name == english_filename else translated_markdown
710
+ validate_quantitative_table_contract(
711
+ archived_markdown, archived_docx, quantitative_manifest,
712
+ label="Archived English DOCX" if name == english_filename else "Archived target-language DOCX",
713
+ )
714
  if qualitative_mode:
715
  validate_qualitative_appendix_docx_layout(
716
  archived_docx,
core/quantitative_engine.py CHANGED
@@ -18,6 +18,113 @@ class QuantitativeEngine:
18
  """Prepare one frozen quantitative evidence packet before manuscript writing."""
19
 
20
  QUANTITATIVE_EVIDENCE_LEDGER_VERSION = "quantitative-evidence-ledger-v2"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  @staticmethod
23
  def generate_psd_corr_matrix(n_constructs, max_corr=0.5):
@@ -989,6 +1096,10 @@ Keywords: {keywords}"""
989
  "Frozen bootstrap and collinearity checks",
990
  ],
991
  }
 
 
 
 
992
  metadata["quantitative_evidence_ledger"] = self.build_quantitative_evidence_ledger(metadata, generated_prompt)
993
  return (
994
  df,
 
18
  """Prepare one frozen quantitative evidence packet before manuscript writing."""
19
 
20
  QUANTITATIVE_EVIDENCE_LEDGER_VERSION = "quantitative-evidence-ledger-v2"
21
+ QUANTITATIVE_TABLE_MANIFEST_VERSION = "quantitative-table-manifest-v1"
22
+ HYPOTHESIS_LEDGER_VERSION = "quantitative-hypothesis-ledger-v1"
23
+
24
+ @classmethod
25
+ def build_hypothesis_ledger(cls, constructs):
26
+ """Freeze one result-independent AMJ hypothesis identity contract.
27
+
28
+ The system-generated quantitative design is observational and does not
29
+ justify result-driven directional hypotheses. The three numbered
30
+ hypotheses therefore freeze only the construct pairing/association
31
+ identity. The indirect association is an analysis target, not H4.
32
+ """
33
+ values = [re.sub(r"\s+", " ", str(item or "")).strip() for item in (constructs or [])]
34
+ if len(values) != 3 or any(not item for item in values):
35
+ return {}
36
+ predictor, mediator, outcome = values
37
+ items = [
38
+ {"hypothesis_id": "H1", "predictor": predictor, "outcome": outcome, "relation": "association"},
39
+ {"hypothesis_id": "H2", "predictor": predictor, "outcome": mediator, "relation": "association"},
40
+ {"hypothesis_id": "H3", "predictor": mediator, "outcome": outcome, "relation": "association"},
41
+ ]
42
+ return {
43
+ "version": cls.HYPOTHESIS_LEDGER_VERSION,
44
+ "items": items,
45
+ "indirect_analysis": {
46
+ "predictor": predictor,
47
+ "mediator": mediator,
48
+ "outcome": outcome,
49
+ "numbered_hypothesis": False,
50
+ "relation": "estimated_indirect_association",
51
+ },
52
+ }
53
+
54
+ @classmethod
55
+ def build_table_manifest_from_evidence_markdown(cls, evidence_markdown):
56
+ """Rebuild the synthetic table manifest from a durable pre-C5 evidence packet."""
57
+ value = str(evidence_markdown or "")
58
+ targets = {
59
+ 1: "Descriptive Statistics and Measurement Assessment",
60
+ 2: "Descriptive Statistics and Measurement Assessment",
61
+ 3: "Hypothesis and Association Tests",
62
+ 4: "Additional Analyses and Robustness Checks",
63
+ }
64
+ rows = []
65
+ pattern = re.compile(
66
+ r"(?ms)^###\s+Table\s+(?P<number>[1-4])\s*:\s*(?P<title>[^\n]+)\n(?P<body>.*?)(?=^###\s+|\Z)"
67
+ )
68
+ for match in pattern.finditer(value):
69
+ number = int(match.group("number"))
70
+ body = match.group("body").strip()
71
+ table_lines = []
72
+ note = ""
73
+ for line in body.splitlines():
74
+ stripped = line.strip()
75
+ if stripped.startswith("|"):
76
+ table_lines.append(line.rstrip())
77
+ elif re.match(r"^\*?Note\s*:", stripped, flags=re.I):
78
+ note = re.sub(r"^\*?Note\s*:\s*", "", stripped, flags=re.I).strip("* ")
79
+ elif table_lines:
80
+ break
81
+ if len(table_lines) < 2:
82
+ continue
83
+ title = re.sub(r"^Frozen\s+", "", match.group("title").strip(), flags=re.I)
84
+ row = {
85
+ "number": number,
86
+ "title": title,
87
+ "target_subsection": targets[number],
88
+ "markdown": "\n".join(table_lines),
89
+ }
90
+ if note:
91
+ row["note"] = note
92
+ rows.append(row)
93
+ if {row["number"] for row in rows} != {1, 2, 3, 4}:
94
+ return {}
95
+ return {"version": cls.QUANTITATIVE_TABLE_MANIFEST_VERSION, "tables": sorted(rows, key=lambda row: row["number"])}
96
+
97
+ @classmethod
98
+ def build_table_manifest(cls, validity_df, fl_matrix_df, path_table, spearman):
99
+ """Return the exact reader-facing tables required by the synthetic AMJ path."""
100
+ rows = [
101
+ {
102
+ "number": 1,
103
+ "title": "Reliability and Convergent Validity",
104
+ "target_subsection": "Descriptive Statistics and Measurement Assessment",
105
+ "markdown": validity_df.to_markdown(index=False),
106
+ },
107
+ {
108
+ "number": 2,
109
+ "title": "Descriptive Statistics and Discriminant Validity (Fornell-Larcker Criterion)",
110
+ "target_subsection": "Descriptive Statistics and Measurement Assessment",
111
+ "note": "Diagonal elements in parentheses are the square roots of AVE.",
112
+ "markdown": fl_matrix_df.to_markdown(),
113
+ },
114
+ {
115
+ "number": 3,
116
+ "title": "Association Model Results",
117
+ "target_subsection": "Hypothesis and Association Tests",
118
+ "markdown": path_table.to_markdown(index=False),
119
+ },
120
+ {
121
+ "number": 4,
122
+ "title": "Rank-Order Robustness Correlations (Spearman)",
123
+ "target_subsection": "Additional Analyses and Robustness Checks",
124
+ "markdown": spearman.round(3).to_markdown(),
125
+ },
126
+ ]
127
+ return {"version": cls.QUANTITATIVE_TABLE_MANIFEST_VERSION, "tables": rows}
128
 
129
  @staticmethod
130
  def generate_psd_corr_matrix(n_constructs, max_corr=0.5):
 
1096
  "Frozen bootstrap and collinearity checks",
1097
  ],
1098
  }
1099
+ metadata["hypothesis_ledger"] = self.build_hypothesis_ledger(construct_names)
1100
+ metadata["quantitative_table_manifest"] = self.build_table_manifest(
1101
+ validity_df, fl_matrix_df, path_table, spearman
1102
+ )
1103
  metadata["quantitative_evidence_ledger"] = self.build_quantitative_evidence_ledger(metadata, generated_prompt)
1104
  return (
1105
  df,