S1-R2 final abstract frozen finding coverage

#123
Files changed (1) hide show
  1. core/generation_runner.py +281 -0
core/generation_runner.py CHANGED
@@ -1022,6 +1022,267 @@ class GenerationRunner:
1022
  rows.extend(scope_inspector(text, data_metadata, section_name=section_name) or [])
1023
  return list(rows)
1024
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1025
  @staticmethod
1026
  def _section_retry_feedback(violations, *, limit=8) -> str:
1027
  rows = []
@@ -1090,6 +1351,12 @@ class GenerationRunner:
1090
  "unsupported_moderation_claim", "unsupported_mediation_claim", "unsupported_temporal_scope",
1091
  "unsupported_design_tradition", "unsupported_unit_of_analysis", "unsupported_data_source_claim",
1092
  "unsupported_process_claim",
 
 
 
 
 
 
1093
  }
1094
  if kind in always_fatal:
1095
  return "FATAL"
@@ -1408,6 +1675,10 @@ class GenerationRunner:
1408
  rows.extend(self._hypothesis_contract_violations(
1409
  repaired, hypothesis_ledger, section_name=label
1410
  ))
 
 
 
 
1411
  current, remaining = repaired, rows
1412
  if not remaining:
1413
  return current, []
@@ -5354,6 +5625,9 @@ The previous attempt for this same manuscript unit was rejected by validation. R
5354
  final_front_fact_rows.extend(self._hypothesis_contract_violations(
5355
  final_abstract, hypothesis_ledger, section_name="Final Abstract"
5356
  ))
 
 
 
5357
  if final_front_fact_rows:
5358
  feedback = self._section_retry_feedback(final_front_fact_rows)
5359
  logging.warning(
@@ -5386,6 +5660,9 @@ The previous attempt for this same manuscript unit was rejected by validation. R
5386
  remaining_final_fact_rows.extend(self._hypothesis_contract_violations(
5387
  final_abstract, hypothesis_ledger, section_name="Final Abstract"
5388
  ))
 
 
 
5389
  if remaining_final_fact_rows:
5390
  final_abstract, residual_final_rows = self._adaptive_repair_front_matter(
5391
  agent=agent, quant_engine=quant_engine, abstract_text=final_abstract,
@@ -5394,6 +5671,10 @@ The previous attempt for this same manuscript unit was rejected by validation. R
5394
  quality_notes=quality_notes, label="Final Abstract",
5395
  )
5396
  if residual_final_rows:
 
 
 
 
5397
  logging.warning("Adaptive Final Abstract accepted with residual diagnostics | count=%s", len(residual_final_rows))
5398
  quality_diagnostics.append({
5399
  "scope":"final_abstract_study_fact_integrity", "name":"Final Abstract",
 
1022
  rows.extend(scope_inspector(text, data_metadata, section_name=section_name) or [])
1023
  return list(rows)
1024
 
1025
+ @staticmethod
1026
+ def _final_abstract_normalized_text(value: str) -> str:
1027
+ """Normalize reader-facing English prose for deterministic finding coverage checks."""
1028
+ return re.sub(r"[^a-z0-9]+", " ", str(value or "").casefold()).strip()
1029
+
1030
+ @classmethod
1031
+ def _final_abstract_identity_present(cls, sentence: str, label: str) -> bool:
1032
+ needle = cls._final_abstract_normalized_text(label)
1033
+ haystack = cls._final_abstract_normalized_text(sentence)
1034
+ return bool(needle and haystack and needle in haystack)
1035
+
1036
+ @staticmethod
1037
+ def _final_abstract_result_sentences(text: str) -> list[str]:
1038
+ """Return sentences that actually assert a frozen empirical finding.
1039
+
1040
+ Deliberately exclude vague contribution language such as "our findings
1041
+ contribute". A Final Abstract must report what the study found, not
1042
+ merely say that findings exist.
1043
+ """
1044
+ sentences = [
1045
+ part.strip()
1046
+ for part in re.split(r"(?<=[.!?])\s+|\n+", str(text or ""))
1047
+ if str(part or "").strip()
1048
+ ]
1049
+ finding_pattern = re.compile(
1050
+ r"(?i)(?:"
1051
+ r"\b(?:we|the\s+study|the\s+analysis|the\s+results?|our\s+analysis)\s+"
1052
+ r"(?:find|finds|found|show|shows|showed|indicate|indicates|indicated|reveal|reveals|revealed|identify|identifies|identified|support|supports|supported)\b|"
1053
+ r"\bfindings?\s+(?:show|shows|showed|indicate|indicates|indicated|reveal|reveals|revealed|identify|identifies|identified|suggest|suggests|suggested)\b|"
1054
+ r"\b(?:was|were|is|are)\s+(?:positively|negatively|significantly|non[- ]?significantly|not\s+significantly)\s+(?:associated|related)\b|"
1055
+ r"\b(?:association|relationship|interaction|indirect\s+(?:association|effect)|theme|themes|pattern|patterns|mechanism|mechanisms)\b[^.!?]{0,140}\b(?:was|were|is|are|emerged|identified|supported|unsupported|significant|non[- ]?significant|nonzero)\b|"
1056
+ r"\bH\d+\s+(?:was|were|is|are)\s+(?:supported|unsupported|not\s+supported|rejected)\b"
1057
+ r")"
1058
+ )
1059
+ result_sentences = []
1060
+ prospective = re.compile(
1061
+ r"(?i)\b(?:we\s+(?:propose|hypothesize|hypothesise|predict|expect|test)|"
1062
+ r"hypothes(?:is|es|ized|ised)|this\s+study\s+(?:examines|investigates|tests)|"
1063
+ r"we\s+further\s+hypothesize)\b"
1064
+ )
1065
+ retrospective = re.compile(
1066
+ r"(?i)\b(?:results?|findings?\s+(?:show|indicate|reveal|identify|suggest)|"
1067
+ r"analysis\s+(?:show|indicate|reveal|identify)|found|revealed|indicated|"
1068
+ r"supported|unsupported|non[- ]?significant|not\s+significant|nonzero|received\s+no\s+support)\b"
1069
+ )
1070
+ for sentence in sentences:
1071
+ if not finding_pattern.search(sentence):
1072
+ # Explicit frozen-status language can be result-bearing even
1073
+ # without a finite 'was/were' construction.
1074
+ if not re.search(
1075
+ r"(?i)\b(?:nonzero\s+indirect\s+(?:association|effect)|"
1076
+ r"unsupported\s+(?:interaction|moderation|association)|"
1077
+ r"supported\s+(?:interaction|moderation|association))\b",
1078
+ sentence,
1079
+ ):
1080
+ continue
1081
+ if prospective.search(sentence) and not retrospective.search(sentence):
1082
+ continue
1083
+ result_sentences.append(sentence)
1084
+ return result_sentences
1085
+
1086
+ @staticmethod
1087
+ def _final_abstract_ci_status(row: dict) -> str:
1088
+ ci = list((row or {}).get("ci") or [])
1089
+ if len(ci) != 2:
1090
+ return ""
1091
+ try:
1092
+ low, high = float(ci[0]), float(ci[1])
1093
+ except (TypeError, ValueError):
1094
+ return ""
1095
+ return "supported" if (low > 0.0 or high < 0.0) else "unsupported"
1096
+
1097
+ @staticmethod
1098
+ def _final_abstract_support_language(sentence: str, status: str) -> bool:
1099
+ value = str(sentence or "")
1100
+ if status == "unsupported":
1101
+ return bool(re.search(
1102
+ r"(?i)\b(?:not\s+supported|unsupported|no\s+support|did\s+not\s+support|"
1103
+ r"non[- ]?significant|not\s+significant|no\s+(?:meaningful\s+)?(?:association|relationship|interaction|moderation|effect|evidence)|"
1104
+ r"did\s+not\s+(?:moderate|differ|vary)|confidence\s+interval[^.!?]{0,80}(?:included|crossed)\s+zero)\b",
1105
+ value,
1106
+ ))
1107
+ if status == "supported":
1108
+ return bool(re.search(
1109
+ r"(?i)\b(?:supported|significant|nonzero|positive(?:ly)?\s+(?:association|associated|relationship|related)|"
1110
+ r"negative(?:ly)?\s+(?:association|associated|relationship|related)|"
1111
+ r"was\s+associated|were\s+associated|was\s+related|were\s+related)\b",
1112
+ value,
1113
+ ))
1114
+ return True
1115
+
1116
+ @classmethod
1117
+ def _final_abstract_finding_violations(
1118
+ cls, mode: str, text: str, data_metadata: dict, *, hypothesis_ledger: dict | None = None
1119
+ ) -> list[dict]:
1120
+ """S1-R2 deterministic Final-Abstract frozen-finding coverage gate.
1121
+
1122
+ The gate is language-neutral at the manuscript-authority layer: English
1123
+ Final Front Matter is frozen once, then ZH/RU/KK are semantic
1124
+ translations of that exact source. Product tier is intentionally not an
1125
+ input because Standard and Flagship share this correctness contract.
1126
+ """
1127
+ value = str(text or "").strip()
1128
+ meta = dict(data_metadata or {})
1129
+ result_sentences = cls._final_abstract_result_sentences(value)
1130
+ rows: list[dict] = []
1131
+
1132
+ if str(mode or "").strip().lower() == "amj":
1133
+ ledger = dict(hypothesis_ledger or meta.get("hypothesis_ledger") or {})
1134
+ evidence = dict(meta.get("association_results") or {})
1135
+ if not evidence:
1136
+ evidence = dict((meta.get("quantitative_evidence_ledger") or {}).get("association_results") or {})
1137
+ relation_results = dict(evidence.get("relation_results") or {})
1138
+ indirect_results = dict(evidence.get("indirect_results") or {})
1139
+
1140
+ def relation_sentence(item: dict, *, require_status: bool = True) -> bool:
1141
+ relation_id = str(item.get("relation_id") or "")
1142
+ frozen = dict(relation_results.get(relation_id) or {})
1143
+ status = cls._final_abstract_ci_status(frozen)
1144
+ identities = [item.get("predictor"), item.get("outcome")]
1145
+ if str(item.get("relation") or "") == "moderation":
1146
+ identities.append(item.get("moderator"))
1147
+ for sentence in result_sentences:
1148
+ if not all(cls._final_abstract_identity_present(sentence, label) for label in identities if label):
1149
+ continue
1150
+ if str(item.get("relation") or "") == "moderation" and not re.search(r"(?i)\b(?:moderat|interaction)\w*\b", sentence):
1151
+ continue
1152
+ if require_status and status and not cls._final_abstract_support_language(sentence, status):
1153
+ continue
1154
+ return True
1155
+ return False
1156
+
1157
+ numbered = [dict(item) for item in (ledger.get("items") or []) if isinstance(item, dict)]
1158
+ unsupported = []
1159
+ moderation = []
1160
+ for item in numbered:
1161
+ frozen = dict(relation_results.get(str(item.get("relation_id") or "")) or {})
1162
+ status = cls._final_abstract_ci_status(frozen)
1163
+ if status == "unsupported":
1164
+ unsupported.append(item)
1165
+ if str(item.get("relation") or "") == "moderation":
1166
+ moderation.append(item)
1167
+
1168
+ # Unsupported numbered hypotheses are interpretation-critical and
1169
+ # must never disappear from the Final Abstract.
1170
+ for item in unsupported:
1171
+ if relation_sentence(item, require_status=True):
1172
+ continue
1173
+ rows.append({
1174
+ "type": "final_abstract_missing_unsupported_finding",
1175
+ "hypothesis_id": str(item.get("hypothesis_id") or ""),
1176
+ "reason": (
1177
+ "The Final Abstract omits the frozen unsupported/non-significant status of a numbered hypothesis."
1178
+ ),
1179
+ "repair_instruction": (
1180
+ "State the unsupported finding concisely in ordinary results language, preserving the exact construct identities. "
1181
+ "Do not add coefficients unless indispensable."
1182
+ ),
1183
+ })
1184
+
1185
+ # A focal moderation result is a main boundary-condition finding even
1186
+ # when supported; require one moderation result sentence when present.
1187
+ if moderation and not any(relation_sentence(item, require_status=True) for item in moderation):
1188
+ item = moderation[0]
1189
+ rows.append({
1190
+ "type": "final_abstract_missing_moderation_finding",
1191
+ "hypothesis_id": str(item.get("hypothesis_id") or ""),
1192
+ "reason": "The Final Abstract does not report the frozen moderation/interaction finding.",
1193
+ "repair_instruction": (
1194
+ "Report whether the frozen moderator changed the focal association, using the exact predictor, moderator, and outcome identities. "
1195
+ "Keep the statement concise and evidence-bounded."
1196
+ ),
1197
+ })
1198
+
1199
+ # Preserve the main mechanism finding when an indirect analysis was
1200
+ # actually compiled. If several unnumbered paths exist, one main
1201
+ # frozen path is sufficient; numbered indirect hypotheses remain
1202
+ # independently governed by the hypothesis contract.
1203
+ indirect_rows = [dict(item) for item in (ledger.get("indirect_analyses") or []) if isinstance(item, dict)]
1204
+ if indirect_rows:
1205
+ selected = next((item for item in indirect_rows if item.get("numbered_hypothesis")), indirect_rows[0])
1206
+ effect = dict(indirect_results.get(str(selected.get("effect_id") or "")) or {})
1207
+ status = cls._final_abstract_ci_status(effect)
1208
+ path = list(selected.get("path") or [])
1209
+ indirect_found = False
1210
+ for sentence in result_sentences:
1211
+ if not re.search(r"(?i)\b(?:indirect|mediat)\w*\b", sentence):
1212
+ continue
1213
+ required_labels = []
1214
+ if path:
1215
+ required_labels.extend([path[0], path[-1]])
1216
+ if len(path) > 2:
1217
+ required_labels.append(path[1])
1218
+ if required_labels and not all(cls._final_abstract_identity_present(sentence, x) for x in required_labels if x):
1219
+ continue
1220
+ if status and not cls._final_abstract_support_language(sentence, status):
1221
+ continue
1222
+ indirect_found = True
1223
+ break
1224
+ if not indirect_found:
1225
+ rows.append({
1226
+ "type": "final_abstract_missing_indirect_finding",
1227
+ "reason": "The Final Abstract omits the main frozen indirect/mediation finding.",
1228
+ "repair_instruction": (
1229
+ "Summarize the frozen indirect association through the identified mediator in one concise findings sentence. "
1230
+ "Do not claim causal mediation for observational data and do not add unnecessary statistics."
1231
+ ),
1232
+ })
1233
+
1234
+ # Every empirical Final Abstract needs at least one actual result
1235
+ # sentence. Design/hypothesis prose plus 'our findings contribute'
1236
+ # is not sufficient.
1237
+ if relation_results and not result_sentences:
1238
+ rows.append({
1239
+ "type": "final_abstract_missing_empirical_finding",
1240
+ "reason": "The Final Abstract describes the design/hypotheses but states no frozen empirical result.",
1241
+ "repair_instruction": (
1242
+ "Add one or two concise sentences stating the main frozen empirical findings before the contribution. "
1243
+ "Prefer support/non-support and substantive direction over a list of coefficients."
1244
+ ),
1245
+ })
1246
+ return rows
1247
+
1248
+ if str(mode or "").strip().lower() == "asq":
1249
+ themes = [dict(item) for item in (meta.get("themes") or []) if isinstance(item, dict)]
1250
+ if not themes:
1251
+ if not result_sentences:
1252
+ rows.append({
1253
+ "type": "final_abstract_missing_qualitative_finding",
1254
+ "reason": "The Final Abstract states no empirical qualitative finding.",
1255
+ "repair_instruction": "Add a concise findings sentence grounded in the frozen qualitative evidence; do not invent a theme or process claim.",
1256
+ })
1257
+ return rows
1258
+
1259
+ covered = set()
1260
+ for index, theme in enumerate(themes):
1261
+ anchors = [str(theme.get("title") or "").strip()]
1262
+ anchors.extend(str(x or "").strip() for x in (theme.get("concepts") or []))
1263
+ anchors = [x for x in anchors if x]
1264
+ for sentence in result_sentences:
1265
+ if any(cls._final_abstract_identity_present(sentence, anchor) for anchor in anchors):
1266
+ covered.add(index)
1267
+ break
1268
+ required = min(2, len(themes))
1269
+ if len(covered) < required:
1270
+ rows.append({
1271
+ "type": "final_abstract_missing_qualitative_theme_findings",
1272
+ "observed": len(covered),
1273
+ "expected": required,
1274
+ "reason": (
1275
+ "The Final Abstract does not report enough frozen qualitative theme/concept findings to function as a results-bearing final abstract."
1276
+ ),
1277
+ "repair_instruction": (
1278
+ f"State the main qualitative findings in one or two concise sentences grounded in at least {required} frozen theme/concept identities. "
1279
+ "Do not merely say that findings contribute, and do not invent themes, quotations, participants, or procedures."
1280
+ ),
1281
+ })
1282
+ return rows
1283
+
1284
+ return rows
1285
+
1286
  @staticmethod
1287
  def _section_retry_feedback(violations, *, limit=8) -> str:
1288
  rows = []
 
1351
  "unsupported_moderation_claim", "unsupported_mediation_claim", "unsupported_temporal_scope",
1352
  "unsupported_design_tradition", "unsupported_unit_of_analysis", "unsupported_data_source_claim",
1353
  "unsupported_process_claim",
1354
+ "final_abstract_missing_empirical_finding",
1355
+ "final_abstract_missing_unsupported_finding",
1356
+ "final_abstract_missing_moderation_finding",
1357
+ "final_abstract_missing_indirect_finding",
1358
+ "final_abstract_missing_qualitative_finding",
1359
+ "final_abstract_missing_qualitative_theme_findings",
1360
  }
1361
  if kind in always_fatal:
1362
  return "FATAL"
 
1675
  rows.extend(self._hypothesis_contract_violations(
1676
  repaired, hypothesis_ledger, section_name=label
1677
  ))
1678
+ if str(label or "").strip().casefold() == "final abstract":
1679
+ rows.extend(self._final_abstract_finding_violations(
1680
+ mode, repaired, data_metadata, hypothesis_ledger=hypothesis_ledger
1681
+ ))
1682
  current, remaining = repaired, rows
1683
  if not remaining:
1684
  return current, []
 
5625
  final_front_fact_rows.extend(self._hypothesis_contract_violations(
5626
  final_abstract, hypothesis_ledger, section_name="Final Abstract"
5627
  ))
5628
+ final_front_fact_rows.extend(self._final_abstract_finding_violations(
5629
+ mode, final_abstract, data_metadata, hypothesis_ledger=hypothesis_ledger
5630
+ ))
5631
  if final_front_fact_rows:
5632
  feedback = self._section_retry_feedback(final_front_fact_rows)
5633
  logging.warning(
 
5660
  remaining_final_fact_rows.extend(self._hypothesis_contract_violations(
5661
  final_abstract, hypothesis_ledger, section_name="Final Abstract"
5662
  ))
5663
+ remaining_final_fact_rows.extend(self._final_abstract_finding_violations(
5664
+ mode, final_abstract, data_metadata, hypothesis_ledger=hypothesis_ledger
5665
+ ))
5666
  if remaining_final_fact_rows:
5667
  final_abstract, residual_final_rows = self._adaptive_repair_front_matter(
5668
  agent=agent, quant_engine=quant_engine, abstract_text=final_abstract,
 
5671
  quality_notes=quality_notes, label="Final Abstract",
5672
  )
5673
  if residual_final_rows:
5674
+ self._raise_if_fatal_completion_residual(
5675
+ residual_final_rows, section_name="Final Abstract", mode=mode,
5676
+ error_code="final_abstract_finding_contract_unresolved"
5677
+ )
5678
  logging.warning("Adaptive Final Abstract accepted with residual diagnostics | count=%s", len(residual_final_rows))
5679
  quality_diagnostics.append({
5680
  "scope":"final_abstract_study_fact_integrity", "name":"Final Abstract",