P0-R1.1-R2-R1 Evidence-Sealed Finalization

#71
core/generation_runner.py CHANGED
@@ -45,6 +45,7 @@ CITATION_ROLE_RULES = {
45
  }
46
  TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v8'
47
  FINAL_CITATION_LEDGER_VERSION = 'resolved-final-ledger-v1'
 
48
 
49
 
50
  def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
@@ -929,6 +930,43 @@ class GenerationRunner:
929
  )
930
  return value, list(diagnostics or [])
931
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
932
  @staticmethod
933
  def _replace_keywords(manuscript: str, keywords) -> str:
934
  value = str(manuscript or '')
@@ -1444,8 +1482,8 @@ class GenerationRunner:
1444
  except PipelineCancelledError:
1445
  raise
1446
  except Exception as error:
1447
- if not defer_failures:
1448
- raise
1449
  code_method = getattr(agent, 'translation_error_code', None)
1450
  error_code = str(code_method(error) if callable(code_method) else 'TRANSLATION_PENDING')
1451
  updated_sections[unit_key] = {
@@ -1466,8 +1504,8 @@ class GenerationRunner:
1466
  'deferred_at': datetime.now(timezone.utc).isoformat(),
1467
  }
1468
  logging.warning(
1469
- 'Translation deferred until final gate | unit=%s code=%s error=%s',
1470
- unit_name, error_code, type(error).__name__,
1471
  )
1472
  self._translation_progress_sections = dict(updated_sections)
1473
  self._translation_progress_inflight.discard(str(unit_key))
@@ -1922,6 +1960,13 @@ class GenerationRunner:
1922
  state["english_finalized"] = False
1923
  state["translation_complete"] = False
1924
  state["package_validated"] = False
 
 
 
 
 
 
 
1925
 
1926
  user_lit_context = str(state.get("user_lit_context") or "")
1927
  user_data_md = str(state.get("user_data_md") or "")
@@ -1961,6 +2006,20 @@ class GenerationRunner:
1961
  )
1962
  self._checkpoint("evidence_ready", state, 6)
1963
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1964
  self._qualitative_quote_sources = (
1965
  self._qualitative_quote_source_map(data_metadata) if mode == "asq" else {}
1966
  )
@@ -2227,6 +2286,8 @@ class GenerationRunner:
2227
  "citation_unit_section_map": citation_unit_section_map,
2228
  "citation_validated_tags_by_unit": {},
2229
  "citation_required_claim_counts_by_unit": {},
 
 
2230
  "research_logic_contract": research_logic_contract,
2231
  "working_abstract": working_abstract,
2232
  "working_keywords": working_keywords,
@@ -2271,6 +2332,11 @@ class GenerationRunner:
2271
  str(name): list(tags or [])
2272
  for name, tags in dict(state.get("citation_validated_tags_by_unit") or {}).items()
2273
  }
 
 
 
 
 
2274
  if not citation_minimums:
2275
  citation_minimums, citation_reserve_targets = self._adaptive_citation_requirements(
2276
  adaptive_structure, mode
@@ -2317,6 +2383,77 @@ class GenerationRunner:
2317
  if self._preview_target_text == '':
2318
  self._preview_target_text = str(state.get('full_draft_translated') or '')
2319
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2320
  # Target-language work remains Executor-owned but no longer blocks the
2321
  # English writer. A dedicated single translation agent/worker keeps
2322
  # glossary order deterministic and avoids sharing mutable writer state.
@@ -2769,19 +2906,6 @@ Output only final academic prose. Do not report the word count."""
2769
  en_resolved = recovered
2770
  used_after = set(agent.used_citations)
2771
  survived = used_after - used_before
2772
- if not is_quarantined and section_id in self._citation_eligible_section_ids(mode):
2773
- citation_required_claim_counts_by_unit[display_name] = agent.count_external_literature_claims(
2774
- en_resolved, section_name=display_name
2775
- )
2776
- if assigned_tag_list:
2777
- citation_validated_tags_by_unit[display_name] = sorted(
2778
- survived, key=agent._citation_tag_number
2779
- )
2780
- logging.info(
2781
- "Top-level citation accumulation: unit=%s section=%s validated=%s external_claims=%s",
2782
- display_name, citation_unit_section_map.get(display_name, section_id),
2783
- len(survived), citation_required_claim_counts_by_unit[display_name],
2784
- )
2785
  if mode == "asq" and section_id == "findings":
2786
  en_resolved = self._render_planned_qualitative_quotes(
2787
  en_resolved,
@@ -2789,6 +2913,43 @@ Output only final academic prose. Do not report the word count."""
2789
  data_metadata=data_metadata,
2790
  quote_invocation_plan=quote_invocation_plan,
2791
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2792
  if mode == "asq":
2793
  en_resolved, study_fact_rows = agent.repair_qualitative_study_fact_contract(
2794
  en_resolved, data_metadata, section_name=display_name
@@ -2825,37 +2986,28 @@ Output only final academic prose. Do not report the word count."""
2825
  quant_engine.validate_quantitative_study_fact_contract(
2826
  en_resolved, data_metadata, section_name=display_name
2827
  )
2828
- en_resolved, enumeration_rows = agent.repair_and_validate_enumeration_contract(
2829
- en_resolved, section_name=display_name
2830
- )
2831
- if enumeration_rows:
2832
- logging.warning(
2833
- "Structural enumeration repaired | unit=%s count=%s details=%s",
2834
- display_name, len(enumeration_rows), enumeration_rows[:3],
 
 
 
 
 
2835
  )
2836
- quality_diagnostics.append({
2837
- "scope":"structural_enumeration", "name":display_name,
2838
- "status":"deterministic_repair", "count":len(enumeration_rows),
2839
- "examples":enumeration_rows[:3],
2840
- })
2841
- en_resolved, duplicate_rows = self._remove_severe_cross_unit_duplicates(en_resolved, full_draft_en)
2842
- if duplicate_rows:
2843
- logging.warning("Severe cross-unit redundancy removed | unit=%s count=%s max_similarity=%s", display_name, len(duplicate_rows), max(row["similarity"] for row in duplicate_rows))
2844
- quality_diagnostics.append({"scope":"manuscript_redundancy","name":display_name,"status":"bounded_duplicate_removed","count":len(duplicate_rows),"examples":duplicate_rows[:3]})
2845
- en_resolved, sentence_echo_rows = agent.remove_severe_cross_unit_sentence_echoes(
2846
- en_resolved, full_draft_en
2847
- )
2848
- if sentence_echo_rows:
2849
- logging.warning(
2850
- "Severe cross-unit sentence echo removed | unit=%s count=%s max_similarity=%s",
2851
- display_name, len(sentence_echo_rows),
2852
- max(row["similarity"] for row in sentence_echo_rows),
2853
  )
2854
- quality_diagnostics.append({
2855
- "scope":"manuscript_sentence_echo", "name":display_name,
2856
- "status":"bounded_duplicate_removed", "count":len(sentence_echo_rows),
2857
- "examples":sentence_echo_rows[:3],
2858
- })
2859
  full_draft_en += en_resolved + "\n\n"
2860
  if assigned_tag_list:
2861
  logging.info(
@@ -2863,8 +3015,9 @@ Output only final academic prose. Do not report the word count."""
2863
  display_name, len(assigned_tag_list), len(survived),
2864
  )
2865
  self._publish_preview(full_draft_en, display_name, force=True)
2866
- agent._extract_memory_from_text(en_raw, sec_title)
2867
- context_tail = " ".join(en_raw.split()[-200:])
 
2868
  actual_words = self._english_count_for_target(en_resolved)
2869
  if not bool(sec.get("exclude_from_word_budget")):
2870
  accumulated_deviation += actual_words - adjusted_target
@@ -2875,6 +3028,11 @@ Output only final academic prose. Do not report the word count."""
2875
  section_key = str(sec.get('section_id') or sec_title)
2876
  if self._section_is_complete(adaptive_structure, completed_sections, section_key):
2877
  frozen_section = self._extract_top_level_section(full_draft_en, sec_title)
 
 
 
 
 
2878
  self._enqueue_translation_unit(
2879
  translation_agent,
2880
  unit_key=section_key, unit_name=sec_title, source_text=frozen_section,
@@ -2905,6 +3063,8 @@ Output only final academic prose. Do not report the word count."""
2905
  "citation_unit_section_map": citation_unit_section_map,
2906
  "citation_required_claim_counts_by_unit": citation_required_claim_counts_by_unit,
2907
  "citation_validated_tags_by_unit": citation_validated_tags_by_unit,
 
 
2908
  "agent_state": agent.export_state(),
2909
  "data_metadata": data_metadata,
2910
  }
@@ -2920,30 +3080,35 @@ Output only final academic prose. Do not report the word count."""
2920
  section_order_for_fact = self._unique_top_level_sections(adaptive_structure)
2921
  if not section_order_for_fact:
2922
  raise RuntimeError("final_study_fact_section_plan_missing")
2923
- final_body_for_fact = self._extract_body_from_first_section(
2924
- full_draft_en, section_order_for_fact[0][1]
2925
- )
2926
- if mode == "asq":
2927
- fact_checked, fact_rows = agent.repair_qualitative_study_fact_contract(
2928
- final_body_for_fact, data_metadata, section_name="Final English body"
2929
- )
2930
- if fact_checked.strip() != final_body_for_fact.strip() or fact_rows:
2931
- raise RuntimeError(
2932
- "final_qualitative_study_fact_contract_requires_section_regeneration"
 
 
 
 
 
 
 
 
 
 
 
 
2933
  )
2934
- elif mode == "amj":
2935
- fact_checked, fact_rows = quant_engine.repair_quantitative_study_fact_contract(
2936
- final_body_for_fact, data_metadata, section_name="Final English body"
2937
  )
2938
- if fact_checked.strip() != final_body_for_fact.strip() or fact_rows:
2939
- raise RuntimeError(
2940
- "final_quantitative_study_fact_contract_requires_section_regeneration"
2941
- )
2942
- enum_checked, enum_rows = agent.repair_and_validate_enumeration_contract(
2943
- full_draft_en, section_name="Final English manuscript"
2944
- )
2945
- if enum_checked.strip() != full_draft_en.strip() or enum_rows:
2946
- raise RuntimeError("final_enumeration_contract_requires_section_regeneration")
2947
  # Validate the completed body while the Working Abstract is still in
2948
  # place. Final front matter must not be generated until all body
2949
  # sections, citation cleanup, and any Discussion rebalance are frozen.
@@ -3127,6 +3292,8 @@ Output only final academic prose. Do not report the word count."""
3127
  "citation_unit_section_map": citation_unit_section_map,
3128
  "citation_required_claim_counts_by_unit": citation_required_claim_counts_by_unit,
3129
  "citation_validated_tags_by_unit": citation_validated_tags_by_unit,
 
 
3130
  "quality_diagnostics": quality_diagnostics,
3131
  "english_finalized": True,
3132
  "translation_complete": False,
@@ -3178,6 +3345,26 @@ Output only final academic prose. Do not report the word count."""
3178
  'agent_state': agent.export_state(),
3179
  })
3180
  pct = min(96, 94 + int(index * 2 / max(1, len(final_units))))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3181
  self._checkpoint(
3182
  f'translation_{unit_key}', state, pct,
3183
  display_node=f'translation_final_gate:{index}:{len(final_units)}',
 
45
  }
46
  TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v8'
47
  FINAL_CITATION_LEDGER_VERSION = 'resolved-final-ledger-v1'
48
+ ENGLISH_SECTION_SEAL_VERSION = 'english-section-integrity-seal-v1'
49
 
50
 
51
  def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
 
930
  )
931
  return value, list(diagnostics or [])
932
 
933
+ @staticmethod
934
+ def _section_integrity_hash(value: str) -> str:
935
+ exact = str(value or "").replace("\r\n", "\n").replace("\r", "\n").strip()
936
+ return hashlib.sha256(exact.encode("utf-8")).hexdigest()
937
+
938
+ @classmethod
939
+ def _build_section_integrity_seal(cls, section_key: str, section_title: str, section_text: str) -> dict:
940
+ return {
941
+ "version": ENGLISH_SECTION_SEAL_VERSION,
942
+ "section_key": str(section_key or ""),
943
+ "section_title": str(section_title or ""),
944
+ "sha256": cls._section_integrity_hash(section_text),
945
+ }
946
+
947
+ @staticmethod
948
+ def _replace_top_level_section(manuscript: str, title: str, replacement: str) -> str:
949
+ value = str(manuscript or "")
950
+ pattern = re.compile(
951
+ rf'(?ms)^#\s+{re.escape(str(title))}\s*$\n.*?(?=^#\s+[^#].*?$|\Z)'
952
+ )
953
+ matches = list(pattern.finditer(value))
954
+ if len(matches) != 1:
955
+ raise RuntimeError(f"top_level_section_replace_count_invalid:{title}:{len(matches)}")
956
+ match = matches[0]
957
+ rendered = str(replacement or "").strip()
958
+ return (value[:match.start()] + rendered + "\n\n" + value[match.end():].lstrip("\n")).strip() + "\n\n"
959
+
960
+ @classmethod
961
+ def _validate_section_integrity_seal(cls, seal: dict, section_text: str, section_title: str) -> None:
962
+ record = dict(seal or {})
963
+ if record.get("version") != ENGLISH_SECTION_SEAL_VERSION:
964
+ raise RuntimeError(f"english_section_integrity_seal_version_invalid:{section_title}")
965
+ expected = str(record.get("sha256") or "")
966
+ actual = cls._section_integrity_hash(section_text)
967
+ if not expected or expected != actual:
968
+ raise RuntimeError(f"english_section_integrity_seal_mismatch:{section_title}")
969
+
970
  @staticmethod
971
  def _replace_keywords(manuscript: str, keywords) -> str:
972
  value = str(manuscript or '')
 
1482
  except PipelineCancelledError:
1483
  raise
1484
  except Exception as error:
1485
+ # Persist repair state for both intermediate and strict final gates.
1486
+ # The caller owns checkpoint authority and decides whether to stop.
1487
  code_method = getattr(agent, 'translation_error_code', None)
1488
  error_code = str(code_method(error) if callable(code_method) else 'TRANSLATION_PENDING')
1489
  updated_sections[unit_key] = {
 
1504
  'deferred_at': datetime.now(timezone.utc).isoformat(),
1505
  }
1506
  logging.warning(
1507
+ 'Translation repair pending | unit=%s final_gate=%s code=%s error=%s',
1508
+ unit_name, bool(final_gate), error_code, type(error).__name__,
1509
  )
1510
  self._translation_progress_sections = dict(updated_sections)
1511
  self._translation_progress_inflight.discard(str(unit_key))
 
1960
  state["english_finalized"] = False
1961
  state["translation_complete"] = False
1962
  state["package_validated"] = False
1963
+ if state.get("english_finalized") and state.get("english_section_seal_version") != ENGLISH_SECTION_SEAL_VERSION:
1964
+ # R2 compatibility: upgrade old completed English sections locally.
1965
+ # No writing-model call, new task, or new quota reservation is allowed.
1966
+ logging.warning("English Section Integrity Seal contract advanced; re-validating frozen completed sections locally.")
1967
+ state["english_finalized"] = False
1968
+ state["translation_complete"] = False
1969
+ state["package_validated"] = False
1970
 
1971
  user_lit_context = str(state.get("user_lit_context") or "")
1972
  user_data_md = str(state.get("user_data_md") or "")
 
2006
  )
2007
  self._checkpoint("evidence_ready", state, 6)
2008
 
2009
+ if mode == "amj":
2010
+ current_quant_ledger = dict(data_metadata.get("quantitative_evidence_ledger") or {})
2011
+ expected_quant_version = str(getattr(quant_engine, "QUANTITATIVE_EVIDENCE_LEDGER_VERSION", ""))
2012
+ if current_quant_ledger.get("version") != expected_quant_version:
2013
+ data_metadata["quantitative_evidence_ledger"] = quant_engine.build_quantitative_evidence_ledger(
2014
+ data_metadata, user_data_md
2015
+ )
2016
+ state["data_metadata"] = data_metadata
2017
+ logging.warning(
2018
+ "Quantitative Evidence Ledger contract advanced; rebuilt locally version=%s",
2019
+ expected_quant_version,
2020
+ )
2021
+ self._checkpoint("quantitative_evidence_ledger_upgraded", state, 6)
2022
+
2023
  self._qualitative_quote_sources = (
2024
  self._qualitative_quote_source_map(data_metadata) if mode == "asq" else {}
2025
  )
 
2286
  "citation_unit_section_map": citation_unit_section_map,
2287
  "citation_validated_tags_by_unit": {},
2288
  "citation_required_claim_counts_by_unit": {},
2289
+ "section_integrity_seals": {},
2290
+ "english_section_seal_version": ENGLISH_SECTION_SEAL_VERSION,
2291
  "research_logic_contract": research_logic_contract,
2292
  "working_abstract": working_abstract,
2293
  "working_keywords": working_keywords,
 
2332
  str(name): list(tags or [])
2333
  for name, tags in dict(state.get("citation_validated_tags_by_unit") or {}).items()
2334
  }
2335
+ section_integrity_seals = {
2336
+ str(key): dict(record or {})
2337
+ for key, record in dict(state.get("section_integrity_seals") or {}).items()
2338
+ if isinstance(record, dict)
2339
+ }
2340
  if not citation_minimums:
2341
  citation_minimums, citation_reserve_targets = self._adaptive_citation_requirements(
2342
  adaptive_structure, mode
 
2383
  if self._preview_target_text == '':
2384
  self._preview_target_text = str(state.get('full_draft_translated') or '')
2385
 
2386
+ # P0-R1.1-R2 compatibility migration: old checkpoints predate Section
2387
+ # Integrity Seals. Reconcile only already-completed English sections with
2388
+ # deterministic local contracts, seal them, and invalidate target cache
2389
+ # only when the exact English section changes. Never call the writer.
2390
+ seal_migration = False
2391
+ seal_text_changed = False
2392
+ for section_key, section_title in self._unique_top_level_sections(adaptive_structure):
2393
+ if not self._section_is_complete(adaptive_structure, completed_sections, section_key):
2394
+ continue
2395
+ section_text = self._extract_top_level_section(full_draft_en, section_title)
2396
+ if not section_text:
2397
+ continue
2398
+ existing_seal = dict(section_integrity_seals.get(section_key) or {})
2399
+ if (
2400
+ existing_seal.get("version") == ENGLISH_SECTION_SEAL_VERSION
2401
+ and existing_seal.get("sha256") == self._section_integrity_hash(section_text)
2402
+ ):
2403
+ continue
2404
+ migrated_text, enum_rows = agent.repair_and_validate_enumeration_contract(
2405
+ section_text, section_name=f"Resume sealed section: {section_title}"
2406
+ )
2407
+ migrated_text, fact_rows = self._repair_validate_study_fact_contract(
2408
+ mode, agent, quant_engine, migrated_text, data_metadata,
2409
+ section_name=f"Resume sealed section: {section_title}",
2410
+ )
2411
+ if migrated_text.strip() != section_text.strip():
2412
+ full_draft_en = self._replace_top_level_section(
2413
+ full_draft_en, section_title, migrated_text
2414
+ )
2415
+ translation_sections.pop(section_key, None)
2416
+ seal_text_changed = True
2417
+ quality_diagnostics.append({
2418
+ "scope":"english_section_seal_migration", "name":section_title,
2419
+ "status":"deterministic_repair", "count":len(enum_rows) + len(fact_rows),
2420
+ "examples":(list(enum_rows or []) + list(fact_rows or []))[:3],
2421
+ })
2422
+ section_text = self._extract_top_level_section(full_draft_en, section_title)
2423
+ section_integrity_seals[section_key] = self._build_section_integrity_seal(
2424
+ section_key, section_title, section_text
2425
+ )
2426
+ seal_migration = True
2427
+ if seal_migration:
2428
+ # Rebuild mutable manuscript memory only from sealed English text so
2429
+ # rejected/raw claims cannot propagate into later sections.
2430
+ for memory_key in ("hypotheses", "variables", "model_summary", "method_summary"):
2431
+ agent.global_memory[memory_key] = ""
2432
+ last_sealed = ""
2433
+ for section_key, section_title in self._unique_top_level_sections(adaptive_structure):
2434
+ if not self._section_is_complete(adaptive_structure, completed_sections, section_key):
2435
+ continue
2436
+ sealed_text = self._extract_top_level_section(full_draft_en, section_title)
2437
+ if sealed_text:
2438
+ agent._extract_memory_from_text(sealed_text, section_title)
2439
+ last_sealed = sealed_text
2440
+ if last_sealed:
2441
+ context_tail = " ".join(last_sealed.split()[-200:])
2442
+ state.update({
2443
+ "full_draft_en": full_draft_en,
2444
+ "translation_sections": translation_sections,
2445
+ "section_integrity_seals": section_integrity_seals,
2446
+ "english_section_seal_version": ENGLISH_SECTION_SEAL_VERSION,
2447
+ "context_tail": context_tail,
2448
+ "quality_diagnostics": quality_diagnostics,
2449
+ "agent_state": agent.export_state(),
2450
+ })
2451
+ if seal_text_changed:
2452
+ state["full_draft_translated"] = self._assemble_partial_translation(
2453
+ translation_sections, adaptive_structure, include_front=True
2454
+ )
2455
+ self._checkpoint("english_section_seal_migrated", state, 15)
2456
+
2457
  # Target-language work remains Executor-owned but no longer blocks the
2458
  # English writer. A dedicated single translation agent/worker keeps
2459
  # glossary order deterministic and avoids sharing mutable writer state.
 
2906
  en_resolved = recovered
2907
  used_after = set(agent.used_citations)
2908
  survived = used_after - used_before
 
 
 
 
 
 
 
 
 
 
 
 
 
2909
  if mode == "asq" and section_id == "findings":
2910
  en_resolved = self._render_planned_qualitative_quotes(
2911
  en_resolved,
 
2913
  data_metadata=data_metadata,
2914
  quote_invocation_plan=quote_invocation_plan,
2915
  )
2916
+
2917
+ # P0-R1.1-R2: every mutating deterministic operation must complete
2918
+ # before the Evidence Fact contract seals the English unit.
2919
+ en_resolved, enumeration_rows = agent.repair_and_validate_enumeration_contract(
2920
+ en_resolved, section_name=display_name
2921
+ )
2922
+ if enumeration_rows:
2923
+ logging.warning(
2924
+ "Structural enumeration repaired | unit=%s count=%s details=%s",
2925
+ display_name, len(enumeration_rows), enumeration_rows[:3],
2926
+ )
2927
+ quality_diagnostics.append({
2928
+ "scope":"structural_enumeration", "name":display_name,
2929
+ "status":"deterministic_repair", "count":len(enumeration_rows),
2930
+ "examples":enumeration_rows[:3],
2931
+ })
2932
+ en_resolved, duplicate_rows = self._remove_severe_cross_unit_duplicates(en_resolved, full_draft_en)
2933
+ if duplicate_rows:
2934
+ logging.warning("Severe cross-unit redundancy removed | unit=%s count=%s max_similarity=%s", display_name, len(duplicate_rows), max(row["similarity"] for row in duplicate_rows))
2935
+ quality_diagnostics.append({"scope":"manuscript_redundancy","name":display_name,"status":"bounded_duplicate_removed","count":len(duplicate_rows),"examples":duplicate_rows[:3]})
2936
+ en_resolved, sentence_echo_rows = agent.remove_severe_cross_unit_sentence_echoes(
2937
+ en_resolved, full_draft_en
2938
+ )
2939
+ if sentence_echo_rows:
2940
+ logging.warning(
2941
+ "Severe cross-unit sentence echo removed | unit=%s count=%s max_similarity=%s",
2942
+ display_name, len(sentence_echo_rows),
2943
+ max(row["similarity"] for row in sentence_echo_rows),
2944
+ )
2945
+ quality_diagnostics.append({
2946
+ "scope":"manuscript_sentence_echo", "name":display_name,
2947
+ "status":"bounded_duplicate_removed", "count":len(sentence_echo_rows),
2948
+ "examples":sentence_echo_rows[:3],
2949
+ })
2950
+
2951
+ # Evidence Fact repair/validation is intentionally LAST. Once this
2952
+ # passes, no later operation may mutate this English unit.
2953
  if mode == "asq":
2954
  en_resolved, study_fact_rows = agent.repair_qualitative_study_fact_contract(
2955
  en_resolved, data_metadata, section_name=display_name
 
2986
  quant_engine.validate_quantitative_study_fact_contract(
2987
  en_resolved, data_metadata, section_name=display_name
2988
  )
2989
+
2990
+ # Recompute citation survival/claim demand from the exact text that
2991
+ # will be sealed, not from an earlier pre-cleanup intermediate.
2992
+ if not is_quarantined and section_id in self._citation_eligible_section_ids(mode):
2993
+ final_survived = set()
2994
+ for tag in set(survived or set()):
2995
+ marker = str((agent.citation_map.get(tag) or {}).get("in_text") or "").strip()
2996
+ if marker and marker in en_resolved:
2997
+ final_survived.add(tag)
2998
+ survived = final_survived
2999
+ citation_required_claim_counts_by_unit[display_name] = agent.count_external_literature_claims(
3000
+ en_resolved, section_name=display_name
3001
  )
3002
+ if assigned_tag_list:
3003
+ citation_validated_tags_by_unit[display_name] = sorted(
3004
+ survived, key=agent._citation_tag_number
3005
+ )
3006
+ logging.info(
3007
+ "Top-level citation accumulation: unit=%s section=%s validated=%s external_claims=%s",
3008
+ display_name, citation_unit_section_map.get(display_name, section_id),
3009
+ len(survived), citation_required_claim_counts_by_unit[display_name],
 
 
 
 
 
 
 
 
 
3010
  )
 
 
 
 
 
3011
  full_draft_en += en_resolved + "\n\n"
3012
  if assigned_tag_list:
3013
  logging.info(
 
3015
  display_name, len(assigned_tag_list), len(survived),
3016
  )
3017
  self._publish_preview(full_draft_en, display_name, force=True)
3018
+ # Only sealed/reconciled prose may feed subsequent manuscript memory.
3019
+ agent._extract_memory_from_text(en_resolved, sec_title)
3020
+ context_tail = " ".join(en_resolved.split()[-200:])
3021
  actual_words = self._english_count_for_target(en_resolved)
3022
  if not bool(sec.get("exclude_from_word_budget")):
3023
  accumulated_deviation += actual_words - adjusted_target
 
3028
  section_key = str(sec.get('section_id') or sec_title)
3029
  if self._section_is_complete(adaptive_structure, completed_sections, section_key):
3030
  frozen_section = self._extract_top_level_section(full_draft_en, sec_title)
3031
+ if not frozen_section:
3032
+ raise RuntimeError(f"english_section_seal_source_missing:{sec_title}")
3033
+ section_integrity_seals[section_key] = self._build_section_integrity_seal(
3034
+ section_key, sec_title, frozen_section
3035
+ )
3036
  self._enqueue_translation_unit(
3037
  translation_agent,
3038
  unit_key=section_key, unit_name=sec_title, source_text=frozen_section,
 
3063
  "citation_unit_section_map": citation_unit_section_map,
3064
  "citation_required_claim_counts_by_unit": citation_required_claim_counts_by_unit,
3065
  "citation_validated_tags_by_unit": citation_validated_tags_by_unit,
3066
+ "section_integrity_seals": section_integrity_seals,
3067
+ "english_section_seal_version": ENGLISH_SECTION_SEAL_VERSION,
3068
  "agent_state": agent.export_state(),
3069
  "data_metadata": data_metadata,
3070
  }
 
3080
  section_order_for_fact = self._unique_top_level_sections(adaptive_structure)
3081
  if not section_order_for_fact:
3082
  raise RuntimeError("final_study_fact_section_plan_missing")
3083
+ # Final English validation is validation-only. Every mutable repair
3084
+ # was completed before the top-level section received its integrity
3085
+ # seal; finalization must never repair the whole manuscript again.
3086
+ for section_key, section_title in section_order_for_fact:
3087
+ sealed_section = self._extract_top_level_section(full_draft_en, section_title)
3088
+ if not sealed_section:
3089
+ raise RuntimeError(f"final_english_section_missing:{section_title}")
3090
+ seal = dict(section_integrity_seals.get(section_key) or {})
3091
+ self._validate_section_integrity_seal(seal, sealed_section, section_title)
3092
+ try:
3093
+ if mode == "asq":
3094
+ agent.validate_qualitative_study_fact_contract(
3095
+ sealed_section, data_metadata, section_name=section_title
3096
+ )
3097
+ elif mode == "amj":
3098
+ quant_engine.validate_quantitative_study_fact_contract(
3099
+ sealed_section, data_metadata, section_name=section_title
3100
+ )
3101
+ except Exception as exc:
3102
+ prefix = (
3103
+ "final_qualitative_study_fact_contract_failed"
3104
+ if mode == "asq" else "final_quantitative_study_fact_contract_failed"
3105
  )
3106
+ raise RuntimeError(f"{prefix}:{section_title}") from exc
3107
+ enum_checked, _enum_rows = agent.repair_and_validate_enumeration_contract(
3108
+ sealed_section, section_name=f"Final sealed section: {section_title}"
3109
  )
3110
+ if enum_checked.strip() != sealed_section.strip():
3111
+ raise RuntimeError(f"final_enumeration_contract_failed:{section_title}")
 
 
 
 
 
 
 
3112
  # Validate the completed body while the Working Abstract is still in
3113
  # place. Final front matter must not be generated until all body
3114
  # sections, citation cleanup, and any Discussion rebalance are frozen.
 
3292
  "citation_unit_section_map": citation_unit_section_map,
3293
  "citation_required_claim_counts_by_unit": citation_required_claim_counts_by_unit,
3294
  "citation_validated_tags_by_unit": citation_validated_tags_by_unit,
3295
+ "section_integrity_seals": section_integrity_seals,
3296
+ "english_section_seal_version": ENGLISH_SECTION_SEAL_VERSION,
3297
  "quality_diagnostics": quality_diagnostics,
3298
  "english_finalized": True,
3299
  "translation_complete": False,
 
3345
  'agent_state': agent.export_state(),
3346
  })
3347
  pct = min(96, 94 + int(index * 2 / max(1, len(final_units))))
3348
+ current_translation = dict(translation_sections.get(unit_key) or {})
3349
+ blocking = (
3350
+ current_translation.get('qa_status') not in {'passed', 'passed_with_warning'}
3351
+ or not current_translation.get('translated_text')
3352
+ )
3353
+ if blocking:
3354
+ state['translation_blocking_unit'] = str(unit_key)
3355
+ state['translation_blocking_unit_name'] = str(unit_name)
3356
+ state['translation_blocking_error_code'] = str(current_translation.get('error_code') or 'TRANSLATION_PENDING')
3357
+ self._checkpoint(
3358
+ f'translation_{unit_key}_repair_pending', state, pct,
3359
+ display_node=f'translation_repair_pending:{index}:{len(final_units)}',
3360
+ )
3361
+ # Stop at the first blocking unit. The durable claim state is
3362
+ # now checkpointed, so resume continues exactly here instead
3363
+ # of spending tokens on later units or repeating prior repair.
3364
+ raise RuntimeError('translation_final_gate_unresolved:' + str(unit_key))
3365
+ state.pop('translation_blocking_unit', None)
3366
+ state.pop('translation_blocking_unit_name', None)
3367
+ state.pop('translation_blocking_error_code', None)
3368
  self._checkpoint(
3369
  f'translation_{unit_key}', state, pct,
3370
  display_node=f'translation_final_gate:{index}:{len(final_units)}',
core/pipeline_agent.py CHANGED
@@ -3430,6 +3430,13 @@ INPUT:
3430
 
3431
  @classmethod
3432
  def _nearest_qual_source_family(cls, sentence, actor_start):
 
 
 
 
 
 
 
3433
  text = str(sentence or "")
3434
  markers = []
3435
  patterns = {
@@ -3439,22 +3446,75 @@ INPUT:
3439
  "log": r"\b(?:usage|interaction|system)\s+logs?\b|\blog\s+data\b",
3440
  "document": r"\bdocuments?\b|\barchival\s+(?:records?|materials?)\b|\brecords?\b",
3441
  }
 
3442
  for family, pattern in patterns.items():
3443
  for match in re.finditer(pattern, text, flags=re.I):
3444
- markers.append((abs(match.start() - int(actor_start)), family))
3445
  if not markers:
3446
  return ""
3447
- markers.sort(key=lambda row: row[0])
3448
- return markers[0][1]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3449
 
3450
  @classmethod
3451
  def repair_qualitative_study_fact_contract(cls, text, metadata, section_name=""):
3452
- """Remove/repair only high-confidence study-owned fact violations.
3453
 
3454
- The frozen Study Evidence Ledger owns actor/data-source relations and
3455
- whether trustworthiness/ethics/reflexivity procedures were actually
3456
- performed. External methodological best practice never authorizes a
3457
- study-owned procedure claim.
3458
  """
3459
  value = str(text or "")
3460
  facts = dict((metadata or {}).get("study_facts") or {})
@@ -3466,6 +3526,7 @@ INPUT:
3466
  }
3467
  allowed_by_actor = {}
3468
  canonical_relations = []
 
3469
  for row in relations:
3470
  role = re.sub(r"\s+", " ", str(row.get("actor_role") or "")).strip()
3471
  source = re.sub(r"\s+", " ", str(row.get("data_source") or "")).strip()
@@ -3475,80 +3536,142 @@ INPUT:
3475
  canonical_relations.append((role, source, row.get("count")))
3476
  for variant in cls._qual_actor_variants(role):
3477
  allowed_by_actor.setdefault(variant, set()).add(family)
 
3478
 
3479
- study_owned = re.compile(
3480
- r"\b(?:we|our\s+study|this\s+study|the\s+study|research\s+team|participants?|respondents?)\b|"
3481
- r"\b(?:was|were)\s+(?:conducted|performed|used|maintained|obtained|recorded|anonymized|anonymised)\b",
3482
- flags=re.I,
3483
- )
3484
  procedure_patterns = {
3485
  "trustworthiness": r"\bmember\s+check(?:ing|s)?\b|\bpeer\s+debrief(?:ing|ed)?\b|\baudit\s+trail\b|\bexternal\s+audit\b",
3486
  "ethics": r"\binformed\s+consent\b|\bethics?\s+approval\b|\binstitutional\s+review\s+board\b|\bIRB\b|\bapproval\s+(?:number|code)\b",
3487
  "reflexivity": r"\bfield\s+journal\b|\breflexive\s+journal\b|\bpositionality\s+(?:statement|memo)\b|\breflexive\s+memo(?:s)?\b",
3488
  }
 
 
 
 
 
 
 
3489
  spans = cls._sentence_spans(value)
3490
  replacements = []
3491
  diagnostics = []
3492
  for start, end, sentence in spans:
3493
  core = str(sentence or "")
3494
  lower = core.casefold()
3495
- if not core.strip():
3496
  continue
3497
  removed = False
3498
- if study_owned.search(core):
3499
- for category, pattern in procedure_patterns.items():
3500
- procedure_match = re.search(pattern, core, flags=re.I)
3501
- if not procedure_match:
3502
- continue
3503
- observed = procedure_match.group(0).casefold()
3504
- if "member check" in observed:
3505
- key = "member check"
3506
- elif "peer debrief" in observed:
3507
- key = "peer debrief"
3508
- elif "audit trail" in observed:
3509
- key = "audit trail"
3510
- elif "external audit" in observed:
3511
- key = "external audit"
3512
- elif "informed consent" in observed:
3513
- key = "informed consent"
3514
- elif "ethic" in observed and "approval" in observed:
3515
- key = "ethics approval"
3516
- elif "review board" in observed or observed == "irb":
3517
- key = "review board"
3518
- elif "approval" in observed:
3519
- key = "approval"
3520
- elif "field journal" in observed:
3521
- key = "field journal"
3522
- elif "reflexive journal" in observed:
3523
- key = "reflexive journal"
3524
- elif "positionality" in observed:
3525
- key = "positionality"
3526
- else:
3527
- key = "reflexive memo"
3528
- authorized = any(key in item.casefold() for item in procedure_lists[category])
3529
- if not authorized:
3530
- replacements.append((start, end, ""))
3531
- diagnostics.append({"type":"unsupported_study_procedure", "category":category, "procedure":key, "preview":core.strip()[:180]})
3532
- removed = True
3533
- break
3534
  if removed or not canonical_relations:
3535
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3536
  for actor_variant, allowed_families in allowed_by_actor.items():
3537
- for actor_match in re.finditer(rf"(?<![A-Za-z]){re.escape(actor_variant)}(?![A-Za-z])", lower, flags=re.I):
3538
- nearest = cls._nearest_qual_source_family(core, actor_match.start())
3539
- if nearest and nearest not in allowed_families:
3540
- relation_text = " and ".join(
3541
- f"{source} with {role}" + (f" (n={count})" if isinstance(count, int) else "")
3542
- for role, source, count in canonical_relations
3543
- )
3544
- replacement = f"Data collection comprised {relation_text}."
3545
- leading = core[: len(core) - len(core.lstrip())]
3546
- trailing = core[len(core.rstrip()):]
3547
- replacements.append((start, end, f"{leading}{replacement}{trailing}"))
3548
- diagnostics.append({"type":"actor_data_source_relation_repaired", "actor":actor_variant, "observed":nearest, "allowed":sorted(allowed_families)})
3549
- removed = True
3550
- break
3551
- if removed:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3552
  break
3553
  if replacements:
3554
  for start, end, replacement in sorted(replacements, reverse=True):
 
3430
 
3431
  @classmethod
3432
  def _nearest_qual_source_family(cls, sentence, actor_start):
3433
+ """Resolve the data source grammatically associated with one actor.
3434
+
3435
+ Prefer the last source phrase before the actor (``interviews with X``),
3436
+ then fall back to the nearest following source (``X was interviewed``).
3437
+ Absolute-distance matching can cross an ``and`` boundary and wrongly
3438
+ pair X with the next actor's source.
3439
+ """
3440
  text = str(sentence or "")
3441
  markers = []
3442
  patterns = {
 
3446
  "log": r"\b(?:usage|interaction|system)\s+logs?\b|\blog\s+data\b",
3447
  "document": r"\bdocuments?\b|\barchival\s+(?:records?|materials?)\b|\brecords?\b",
3448
  }
3449
+ position = int(actor_start)
3450
  for family, pattern in patterns.items():
3451
  for match in re.finditer(pattern, text, flags=re.I):
3452
+ markers.append((match.start(), match.end(), family))
3453
  if not markers:
3454
  return ""
3455
+ preceding = [row for row in markers if row[1] <= position]
3456
+ if preceding:
3457
+ start, end, family = max(preceding, key=lambda row: row[1])
3458
+ # A source more than one short clause away is unlikely to own actor.
3459
+ if position - end <= 120:
3460
+ return family
3461
+ following = [row for row in markers if row[0] >= position]
3462
+ if following:
3463
+ return min(following, key=lambda row: row[0] - position)[2]
3464
+ return ""
3465
+
3466
+ @staticmethod
3467
+ def _has_external_literature_marker(sentence):
3468
+ value = str(sentence or "")
3469
+ if re.search(r"\bCITE-\d+\b", value, flags=re.I):
3470
+ return True
3471
+ if re.search(
3472
+ r"\((?=[^()\n]*[A-Za-zÀ-ÖØ-öø-ÿ'’.-]+(?:\s+(?:&\s+[A-Za-zÀ-ÖØ-öø-ÿ'’.-]+|et\s+al\.))?\s*,\s*(?:19|20)\d{2}[a-z]?\b)[^()\n]+\)",
3473
+ value, flags=re.I,
3474
+ ):
3475
+ return True
3476
+ if re.search(r"\b[A-Z][A-Za-z'’.-]+\s+et\s+al\.\s*\((?:19|20)\d{2}[a-z]?\)", value):
3477
+ return True
3478
+ return False
3479
+
3480
+ @classmethod
3481
+ def _is_study_owned_qualitative_claim(cls, sentence, section_name=""):
3482
+ """Conservatively identify only this manuscript's empirical/procedural claims.
3483
+
3484
+ External literature, quoted participant speech, and generic methodological
3485
+ statements are immutable to the Study Evidence Ledger. False negatives are
3486
+ safer here than rewriting another study's facts.
3487
+ """
3488
+ value = str(sentence or "")
3489
+ stripped = value.strip()
3490
+ if not stripped or stripped.startswith(">"):
3491
+ return False
3492
+ if re.match(r'^["“‘\']', stripped) or re.search(r"\bQUOTE-\d+\b", value, flags=re.I):
3493
+ return False
3494
+ if cls._has_external_literature_marker(value):
3495
+ return False
3496
+ if re.search(r"(?i)\b(?:prior|previous|earlier|existing)\s+(?:research|stud(?:y|ies)|literature)\b", value):
3497
+ return False
3498
+ strong = re.compile(
3499
+ r"(?i)\b(?:we|our\s+study|this\s+study|the\s+present\s+study|the\s+study|"
3500
+ r"research\s+team|our\s+data|our\s+analysis|our\s+interviews?|our\s+focus\s+groups?|"
3501
+ r"our\s+observations?|data\s+collection|the\s+empirical\s+base|the\s+sample|"
3502
+ r"(?:interview|focus\s+group|observation)\s+participants?|"
3503
+ r"participants?\s+(?:in\s+this\s+study\s+)?(?:were|took|participated)|"
3504
+ r"respondents?\s+(?:in\s+this\s+study\s+)?(?:were|took|participated)|"
3505
+ r"interviews?\s+(?:were|was)\s+conducted|focus\s+groups?\s+(?:were|was)\s+conducted|"
3506
+ r"observations?\s+(?:were|was)\s+(?:conducted|recorded))\b|"
3507
+ r"\b(?:was|were)\s+(?:conducted|performed|used|maintained|obtained|recorded|anonymized|anonymised)\b"
3508
+ )
3509
+ return bool(strong.search(value))
3510
 
3511
  @classmethod
3512
  def repair_qualitative_study_fact_contract(cls, text, metadata, section_name=""):
3513
+ """Repair only high-confidence *study-owned* qualitative fact violations.
3514
 
3515
+ The frozen Study Evidence Ledger owns this study's actor/data-source
3516
+ relations and explicitly performed procedures. External literature and
3517
+ participant quotations are never rewritten by this deterministic layer.
 
3518
  """
3519
  value = str(text or "")
3520
  facts = dict((metadata or {}).get("study_facts") or {})
 
3526
  }
3527
  allowed_by_actor = {}
3528
  canonical_relations = []
3529
+ canonical_by_actor = {}
3530
  for row in relations:
3531
  role = re.sub(r"\s+", " ", str(row.get("actor_role") or "")).strip()
3532
  source = re.sub(r"\s+", " ", str(row.get("data_source") or "")).strip()
 
3536
  canonical_relations.append((role, source, row.get("count")))
3537
  for variant in cls._qual_actor_variants(role):
3538
  allowed_by_actor.setdefault(variant, set()).add(family)
3539
+ canonical_by_actor.setdefault(variant, []).append((role, source, family, row.get("count")))
3540
 
 
 
 
 
 
3541
  procedure_patterns = {
3542
  "trustworthiness": r"\bmember\s+check(?:ing|s)?\b|\bpeer\s+debrief(?:ing|ed)?\b|\baudit\s+trail\b|\bexternal\s+audit\b",
3543
  "ethics": r"\binformed\s+consent\b|\bethics?\s+approval\b|\binstitutional\s+review\s+board\b|\bIRB\b|\bapproval\s+(?:number|code)\b",
3544
  "reflexivity": r"\bfield\s+journal\b|\breflexive\s+journal\b|\bpositionality\s+(?:statement|memo)\b|\breflexive\s+memo(?:s)?\b",
3545
  }
3546
+ source_patterns = {
3547
+ "interview": r"\b(?:semi[- ]structured\s+)?interviews?\b|\binterviewed\b|\binterview\s+participants?\b",
3548
+ "focus_group": r"\bfocus\s+groups?\b",
3549
+ "observation": r"\b(?:field\s+)?observations?\b|\bobserved\b",
3550
+ "log": r"\b(?:usage|interaction|system)\s+logs?\b|\blog\s+data\b",
3551
+ "document": r"\bdocuments?\b|\barchival\s+(?:records?|materials?)\b|\brecords?\b",
3552
+ }
3553
  spans = cls._sentence_spans(value)
3554
  replacements = []
3555
  diagnostics = []
3556
  for start, end, sentence in spans:
3557
  core = str(sentence or "")
3558
  lower = core.casefold()
3559
+ if not core.strip() or not cls._is_study_owned_qualitative_claim(core, section_name):
3560
  continue
3561
  removed = False
3562
+ for category, pattern in procedure_patterns.items():
3563
+ procedure_match = re.search(pattern, core, flags=re.I)
3564
+ if not procedure_match:
3565
+ continue
3566
+ observed = procedure_match.group(0).casefold()
3567
+ if "member check" in observed:
3568
+ key = "member check"
3569
+ elif "peer debrief" in observed:
3570
+ key = "peer debrief"
3571
+ elif "audit trail" in observed:
3572
+ key = "audit trail"
3573
+ elif "external audit" in observed:
3574
+ key = "external audit"
3575
+ elif "informed consent" in observed:
3576
+ key = "informed consent"
3577
+ elif "ethic" in observed and "approval" in observed:
3578
+ key = "ethics approval"
3579
+ elif "review board" in observed or observed == "irb":
3580
+ key = "review board"
3581
+ elif "approval" in observed:
3582
+ key = "approval"
3583
+ elif "field journal" in observed:
3584
+ key = "field journal"
3585
+ elif "reflexive journal" in observed:
3586
+ key = "reflexive journal"
3587
+ elif "positionality" in observed:
3588
+ key = "positionality"
3589
+ else:
3590
+ key = "reflexive memo"
3591
+ authorized = any(key in item.casefold() for item in procedure_lists[category])
3592
+ if not authorized:
3593
+ replacements.append((start, end, ""))
3594
+ diagnostics.append({"type":"unsupported_study_procedure", "category":category, "procedure":key, "preview":core.strip()[:180]})
3595
+ removed = True
3596
+ break
 
3597
  if removed or not canonical_relations:
3598
  continue
3599
+
3600
+ # A pure participant-membership sentence can conflate two actor
3601
+ # groups under one source (e.g., "interview participants included
3602
+ # officers and farmers"). In that narrow case, render the frozen
3603
+ # relation set directly; this is safer than a phrase substitution
3604
+ # that would leave a grammatically or factually mixed sentence.
3605
+ participant_membership = bool(re.search(
3606
+ r"(?i)\b(?:interview|focus\s+group|observation)\s+participants?\b|"
3607
+ r"\bparticipants?\s+(?:included|comprised|consisted)\b", core
3608
+ ))
3609
+ present_roles = []
3610
+ for role, source, count in canonical_relations:
3611
+ if any(re.search(rf"(?<![A-Za-z]){re.escape(variant)}(?![A-Za-z])", lower, flags=re.I)
3612
+ for variant in cls._qual_actor_variants(role)):
3613
+ present_roles.append((role, source, count))
3614
+ if participant_membership and len(present_roles) >= 2:
3615
+ clauses = [
3616
+ f"{source} with {role}" + (f" (n={count})" if isinstance(count, int) else "")
3617
+ for role, source, count in present_roles
3618
+ ]
3619
+ canonical_sentence = "Data collection used " + " and ".join(clauses) + "."
3620
+ leading = core[: len(core) - len(core.lstrip())]
3621
+ trailing = core[len(core.rstrip()):]
3622
+ replacement = f"{leading}{canonical_sentence}{trailing}"
3623
+ if replacement != core:
3624
+ replacements.append((start, end, replacement))
3625
+ diagnostics.append({
3626
+ "type":"actor_data_source_relation_repaired",
3627
+ "actor":"multiple", "observed":"mixed_participant_source",
3628
+ "allowed":[cls._qual_source_family(source) for _role, source, _count in present_roles],
3629
+ })
3630
+ continue
3631
+
3632
+ # Repair only the mismatched source phrase nearest to the actor. This
3633
+ # preserves the sentence's substantive argument instead of replacing
3634
+ # the entire sentence with a mechanical data-collection template.
3635
  for actor_variant, allowed_families in allowed_by_actor.items():
3636
+ actor_matches = list(re.finditer(rf"(?<![A-Za-z]){re.escape(actor_variant)}(?![A-Za-z])", lower, flags=re.I))
3637
+ if not actor_matches:
3638
+ continue
3639
+ changed_core = core
3640
+ actor_changed = False
3641
+ for actor_match in actor_matches:
3642
+ nearest = cls._nearest_qual_source_family(changed_core, actor_match.start())
3643
+ if not nearest or nearest in allowed_families:
3644
+ continue
3645
+ preferred_rows = canonical_by_actor.get(actor_variant) or []
3646
+ preferred = next((row for row in preferred_rows if row[2] in allowed_families), None)
3647
+ if not preferred:
3648
+ continue
3649
+ canonical_source = preferred[1]
3650
+ pattern = source_patterns.get(nearest)
3651
+ if not pattern:
3652
+ continue
3653
+ source_matches = list(re.finditer(pattern, changed_core, flags=re.I))
3654
+ if not source_matches:
3655
+ continue
3656
+ source_match = min(source_matches, key=lambda m: abs(m.start() - actor_match.start()))
3657
+ observed_phrase = source_match.group(0)
3658
+ replacement_source = canonical_source
3659
+ if re.fullmatch(r"(?i)interviewed", observed_phrase.strip()):
3660
+ replacement_source = f"conducted {canonical_source} with"
3661
+ elif re.fullmatch(r"(?i)observed", observed_phrase.strip()):
3662
+ replacement_source = f"used {canonical_source} with"
3663
+ candidate = changed_core[:source_match.start()] + replacement_source + changed_core[source_match.end():]
3664
+ if candidate != changed_core:
3665
+ diagnostics.append({
3666
+ "type":"actor_data_source_relation_repaired", "actor":actor_variant,
3667
+ "observed":nearest, "allowed":sorted(allowed_families),
3668
+ "replacement":canonical_source,
3669
+ })
3670
+ changed_core = candidate
3671
+ actor_changed = True
3672
+ break
3673
+ if actor_changed and changed_core != core:
3674
+ replacements.append((start, end, changed_core))
3675
  break
3676
  if replacements:
3677
  for start, end, replacement in sorted(replacements, reverse=True):
core/quantitative_engine.py CHANGED
@@ -17,7 +17,7 @@ from utils.file_parser import extract_data_from_files
17
  class QuantitativeEngine:
18
  """Prepare one frozen quantitative evidence packet before manuscript writing."""
19
 
20
- QUANTITATIVE_EVIDENCE_LEDGER_VERSION = "quantitative-evidence-ledger-v1"
21
 
22
  @staticmethod
23
  def generate_psd_corr_matrix(n_constructs, max_corr=0.5):
@@ -344,14 +344,70 @@ Keywords: {keywords}"""
344
  spans.append((start, len(value), value[start:]))
345
  return spans
346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  @classmethod
348
  def build_quantitative_evidence_ledger(cls, metadata, evidence_text=""):
349
  """Freeze only high-confidence study-owned quantitative facts.
350
 
351
  System-generated evidence has typed metadata and therefore supports a
352
- strict contract. User-provided evidence is parsed conservatively: only
353
- an unambiguous sample size / Likert scale is promoted to deterministic
354
- authority; everything else remains owned by the uploaded evidence text.
 
355
  """
356
  meta = dict(metadata or {})
357
  source_type = str(meta.get("source_type") or "")
@@ -359,6 +415,7 @@ Keywords: {keywords}"""
359
  scale_points = meta.get("scale_points")
360
  strict = source_type == "system_generated"
361
  evidence = str(evidence_text or "")
 
362
  if not strict and evidence:
363
  sample_candidates = set()
364
  for pattern in (
@@ -371,6 +428,39 @@ Keywords: {keywords}"""
371
  scale_candidates = {int(x) for x in re.findall(r"(?i)\b([2-9]|1[0-1])[- ]point\s+Likert", evidence)}
372
  if len(scale_candidates) == 1:
373
  scale_points = next(iter(scale_candidates))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  constructs = dict(meta.get("constructs") or {}) if isinstance(meta.get("constructs"), dict) else {}
375
  results = dict(meta.get("association_results") or {}) if isinstance(meta.get("association_results"), dict) else {}
376
  return {
@@ -382,6 +472,7 @@ Keywords: {keywords}"""
382
  "study_design": str(meta.get("study_design") or "").strip(),
383
  "constructs": {k: str(v or "").strip() for k, v in constructs.items() if str(v or "").strip()},
384
  "association_results": results,
 
385
  "causal_identification": "none" if strict else "evidence_owned",
386
  "allowed_methods": [
387
  "reliability", "composite reliability", "average variance extracted",
@@ -398,7 +489,7 @@ Keywords: {keywords}"""
398
 
399
  @classmethod
400
  def repair_quantitative_study_fact_contract(cls, text, metadata, section_name=""):
401
- """Repair only deterministic AMJ study-fact drift owned by frozen evidence."""
402
  value = str(text or "")
403
  meta = dict(metadata or {})
404
  ledger = dict(meta.get("quantitative_evidence_ledger") or {})
@@ -410,20 +501,34 @@ Keywords: {keywords}"""
410
  strict = bool(ledger.get("strict"))
411
  constructs = dict(ledger.get("constructs") or {})
412
  results = dict(ledger.get("association_results") or {})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
 
414
- # Sample/scale claims are repaired only when the sentence is clearly
415
- # study-owned and not an external cited finding.
416
- spans = cls._sentence_spans(value)
417
- sample_scale_replacements = []
418
- for start, end, sentence in spans:
419
- has_citation = bool(re.search(r"\([^()]*?(?:19|20)\d{2}[a-z]?[^()]*?\)", sentence))
420
- study_owned = bool(re.search(
421
- r"(?i)\b(?:this\s+study|our\s+study|we|our\s+sample|the\s+sample|sample\s+(?:comprised|consisted|included)|respondents?|participants?)\b",
422
- sentence,
423
- ))
424
- if has_citation or not study_owned:
425
  continue
426
  revised = sentence
 
427
  if sample_size:
428
  sample_pattern = re.compile(r"(?i)(\b(?:sample(?:\s+size)?(?:\s+of)?|N)\s*(?:=|:)?\s*)(\d{2,6})\b")
429
  def sample_repl(match):
@@ -450,79 +555,36 @@ Keywords: {keywords}"""
450
  diagnostics.append({"type":"scale_points_repaired","observed":observed,"expected":int(scale_points)})
451
  return f"{int(scale_points)}-point {match.group(2)}"
452
  revised = scale_pattern.sub(scale_repl, revised)
453
- if revised != sentence:
454
- sample_scale_replacements.append((start, end, revised))
455
- for start, end, replacement in sorted(sample_scale_replacements, reverse=True):
456
- value = value[:start] + replacement + value[end:]
457
 
458
- # Explicit construct-role labels are deterministic and safe to correct.
459
- role_patterns = {
460
- "predictor": r"(?:predictor|independent\s+variable|predictor\s+construct)",
461
- "mediator": r"(?:mediator|mediating\s+variable|mediating\s+construct)",
462
- "outcome": r"(?:outcome|dependent\s+variable|outcome\s+construct)",
463
- }
464
- known_constructs = [str(v) for v in constructs.values() if str(v)]
465
- for role, role_pattern in role_patterns.items():
466
- expected = str(constructs.get(role) or "")
467
- if not expected:
468
- continue
469
- for candidate in known_constructs:
470
- if candidate.casefold() == expected.casefold():
471
  continue
472
- pattern = re.compile(rf"(?i)({role_pattern}\s*(?:was|is|:|=)?\s*){re.escape(candidate)}")
473
- if pattern.search(value):
474
- value, count = pattern.subn(lambda m: m.group(1) + expected, value)
 
 
475
  if count:
476
  diagnostics.append({"type":"construct_role_repaired","role":role,"observed":candidate,"expected":expected,"count":count})
477
 
478
- if strict:
479
- # Frozen system-generated evidence has no causal identification.
480
- replacements = (
481
- (r"(?i)\bhad\s+a\s+causal\s+effect\s+on\b", "was associated with"),
482
- (r"(?i)\bexerted\s+a\s+causal\s+effect\s+on\b", "was associated with"),
483
- (r"(?i)\bcausal\s+effect\s+on\b", "association with"),
484
- (r"(?i)\ban\s+causal\s+effect\b", "an association"),
485
- (r"(?i)\ba\s+causal\s+effect\b", "an association"),
486
- (r"(?i)\bthe\s+causal\s+effect\b", "the association"),
487
- (r"(?i)\bcausal\s+effects\b", "associations"),
488
- (r"(?i)\bcausal\s+effect\b", "association"),
489
- (r"(?i)\bcausal\s+mediation\b", "indirect association"),
490
- )
491
- for pattern, replacement in replacements:
492
- value, count = re.subn(pattern, replacement, value)
493
- if count:
494
- diagnostics.append({"type":"causal_language_repaired","replacement":replacement,"count":count})
495
-
496
- # Remove p-values attached to this study's frozen constructs because
497
- # no p-values are generated/frozen by this engine. External cited
498
- # literature claims are left untouched.
499
- construct_folded = [c.casefold() for c in known_constructs]
500
- spans = cls._sentence_spans(value)
501
- replacements_by_span = []
502
- forbidden = [str(x) for x in ledger.get("forbidden_unowned_methods") or []]
503
- section_sensitive = any(token in str(section_name or "").casefold() for token in ("method", "result", "abstract", "final", "analysis"))
504
- for start, end, sentence in spans:
505
- lower = sentence.casefold()
506
- has_construct = any(c in lower for c in construct_folded) if construct_folded else False
507
- has_citation = bool(re.search(r"\([^()]*?(?:19|20)\d{2}[a-z]?[^()]*?\)", sentence))
508
- revised = sentence
509
- if has_construct and not has_citation:
510
  revised, p_count = re.subn(r"(?i)\s*[,;]?\s*p\s*(?:=|<|>|≤|≥)\s*(?:0?\.\d+|\d+(?:\.\d+)?)", "", revised)
511
  if p_count:
512
  diagnostics.append({"type":"unfrozen_p_value_removed","count":p_count})
513
- if section_sensitive and not has_citation and re.search(r"(?i)\b(?:we|our\s+study|this\s+study|the\s+study|our\s+analysis|the\s+analysis)\b", sentence):
514
- bad = next((term for term in forbidden if re.search(rf"(?i)(?<![A-Za-z]){re.escape(term)}(?![A-Za-z])", sentence)), "")
515
  if bad:
516
- revised = ""
517
  diagnostics.append({"type":"unsupported_quantitative_method_removed","method":bad,"preview":sentence.strip()[:180]})
518
- if revised != sentence:
519
- replacements_by_span.append((start, end, revised))
520
- for start, end, replacement in sorted(replacements_by_span, reverse=True):
521
- value = value[:start] + replacement + value[end:]
522
-
523
- # High-confidence frozen numeric anchors.
524
- def replace_metric(pattern, expected, dtype="float", label="metric"):
525
- nonlocal value
526
  if expected is None:
527
  return
528
  expected_text = str(int(expected)) if dtype == "int" else f"{float(expected):.3f}".rstrip("0").rstrip(".")
@@ -537,61 +599,56 @@ Keywords: {keywords}"""
537
  return match.group(0)
538
  diagnostics.append({"type":"quantitative_metric_repaired","metric":label,"observed":observed,"expected":expected_text})
539
  return match.group(1) + expected_text
540
- value = regex.sub(repl, value)
 
 
 
 
 
541
 
542
- if results:
543
- replace_metric(r"(\bVIF\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", results.get("predictor_mediator_vif"), label="VIF")
544
- replace_metric(r"(\bbootstrap\s+(?:resamples?|iterations?)\s*(?:=|:)?\s*)(\d{2,6})", results.get("bootstrap_iterations"), dtype="int", label="bootstrap_iterations")
545
  for role, key in (("mediator", "r2_mediator"), ("outcome", "r2_outcome")):
546
  construct = str(constructs.get(role) or "")
547
  if construct and results.get(key) is not None:
548
  metric_pattern = rf"(R(?:²|\^2)\s*(?:for|of)?\s*{re.escape(construct)}\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)"
549
- replace_metric(metric_pattern, results.get(key), label=key)
550
 
551
- association_specs = [
 
 
552
  ("indirect", results.get("indirect"), [constructs.get("predictor"), constructs.get("mediator"), constructs.get("outcome")]),
553
  ("direct", results.get("direct"), [constructs.get("predictor"), constructs.get("outcome")]),
554
  ("a", results.get("a"), [constructs.get("predictor"), constructs.get("mediator")]),
555
  ("b", results.get("b"), [constructs.get("mediator"), constructs.get("outcome")]),
556
  ]
557
- sentences = cls._sentence_spans(value)
558
- metric_spans = []
559
- for start, end, sentence in sentences:
560
- lower = sentence.casefold()
561
- selected = None
562
- if "indirect" in lower and all(str(x or "").casefold() in lower for x in association_specs[0][2] if x):
563
- selected = association_specs[0]
564
- elif (
565
- f"controlling {str(constructs.get('mediator') or '').casefold()}" in lower
566
- and str(constructs.get("predictor") or "").casefold() in lower
567
- and str(constructs.get("outcome") or "").casefold() in lower
568
- ):
569
- selected = association_specs[1]
570
- elif (
571
- f"controlling {str(constructs.get('predictor') or '').casefold()}" in lower
572
- and str(constructs.get("mediator") or "").casefold() in lower
573
- and str(constructs.get("outcome") or "").casefold() in lower
574
- ):
575
- selected = association_specs[3]
576
- elif str(constructs.get("predictor") or "").casefold() in lower and str(constructs.get("mediator") or "").casefold() in lower and str(constructs.get("outcome") or "").casefold() not in lower:
577
- selected = association_specs[2]
578
- elif str(constructs.get("mediator") or "").casefold() in lower and str(constructs.get("outcome") or "").casefold() in lower and str(constructs.get("predictor") or "").casefold() not in lower:
579
- selected = association_specs[3]
580
- if selected and selected[1] is not None:
581
- expected = float(selected[1])
582
- def beta_repl(match):
583
- observed = float(match.group(2))
584
- if abs(observed - expected) < 0.0006:
585
- return match.group(0)
586
- expected_text = f"{expected:.3f}".rstrip("0").rstrip(".")
587
- diagnostics.append({"type":"quantitative_metric_repaired","metric":selected[0],"observed":match.group(2),"expected":expected_text})
588
- return match.group(1) + expected_text
589
- revised = re.sub(r"(?i)((?:β|standardized\s+estimate)\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", beta_repl, sentence)
590
- if revised != sentence:
591
- metric_spans.append((start, end, revised))
592
- for start, end, replacement in sorted(metric_spans, reverse=True):
593
- value = value[:start] + replacement + value[end:]
594
 
 
 
 
 
 
595
  value = re.sub(r"[ \t]+\n", "\n", value)
596
  value = re.sub(r"\n{3,}", "\n\n", value)
597
  value = re.sub(r" {2,}", " ", value)
 
17
  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):
 
344
  spans.append((start, len(value), value[start:]))
345
  return spans
346
 
347
+ @staticmethod
348
+ def _has_external_literature_marker(sentence):
349
+ value = str(sentence or "")
350
+ if re.search(r"\bCITE-\d+\b", value, flags=re.I):
351
+ return True
352
+ if re.search(
353
+ r"\((?=[^()\n]*[A-Za-zÀ-ÖØ-öø-ÿ'’.-]+(?:\s+(?:&\s+[A-Za-zÀ-ÖØ-öø-ÿ'’.-]+|et\s+al\.))?\s*,\s*(?:19|20)\d{2}[a-z]?\b)[^()\n]+\)",
354
+ value, flags=re.I,
355
+ ):
356
+ return True
357
+ if re.search(r"\b[A-Z][A-Za-z'’.-]+\s+et\s+al\.\s*\((?:19|20)\d{2}[a-z]?\)", value):
358
+ return True
359
+ return False
360
+
361
+ @classmethod
362
+ def _quantitative_sentence_ownership(cls, sentence, section_name="", known_constructs=None):
363
+ """Return external/study/result/unknown for deterministic AMJ repair.
364
+
365
+ Only study/result-owned prose may be rewritten. External literature is
366
+ immutable even when it reports the same constructs or statistics.
367
+ """
368
+ value = str(sentence or "")
369
+ stripped = value.strip()
370
+ if not stripped or stripped.startswith(">"):
371
+ return "external" if stripped else "unknown"
372
+ if cls._has_external_literature_marker(value):
373
+ return "external"
374
+ if re.search(r"(?i)\b(?:prior|previous|earlier|existing)\s+(?:research|stud(?:y|ies)|literature)\b", value):
375
+ return "external"
376
+ if re.search(r"(?i)\b(?:we|our\s+study|this\s+study|the\s+present\s+study|our\s+sample|our\s+analysis|our\s+results|the\s+sample|the\s+survey|respondents?|participants?)\b", value):
377
+ return "study"
378
+ section = str(section_name or "").casefold()
379
+ constructs = [str(x or "").casefold() for x in (known_constructs or []) if str(x or "").strip()]
380
+ has_construct = any(item in value.casefold() for item in constructs)
381
+ has_metric = bool(re.search(r"(?i)(?:\bVIF\b|\bbootstrap\b|R(?:²|\^2)|(?:β|standardized\s+estimate)\s*(?:=|:)|\bp\s*(?:=|<|>|≤|≥))", value))
382
+ has_role = bool(re.search(r"(?i)\b(?:predictor|mediator|outcome|independent\s+variable|dependent\s+variable)\b", value))
383
+ if any(token in section for token in ("result", "analysis", "abstract", "final")) and (has_construct or has_metric or has_role):
384
+ return "result"
385
+ if any(token in section for token in ("method", "measure")) and (has_construct or has_role or re.search(r"(?i)\b(?:Likert|sample|survey)\b", value)):
386
+ return "study"
387
+ return "unknown"
388
+
389
+ @staticmethod
390
+ def _unique_numeric(pattern, evidence, *, integer=False):
391
+ values = []
392
+ for raw in re.findall(pattern, str(evidence or ""), flags=re.I):
393
+ raw_value = raw[-1] if isinstance(raw, tuple) else raw
394
+ try:
395
+ value = int(float(raw_value)) if integer else float(raw_value)
396
+ except Exception:
397
+ continue
398
+ values.append(value)
399
+ unique = sorted(set(values))
400
+ return unique[0] if len(unique) == 1 else None
401
+
402
  @classmethod
403
  def build_quantitative_evidence_ledger(cls, metadata, evidence_text=""):
404
  """Freeze only high-confidence study-owned quantitative facts.
405
 
406
  System-generated evidence has typed metadata and therefore supports a
407
+ strict contract. User-provided evidence is parsed conservatively: only
408
+ uniquely labelled values are promoted to deterministic authority.
409
+ Ambiguous values remain owned by the uploaded evidence text and are not
410
+ guessed or globally substituted.
411
  """
412
  meta = dict(metadata or {})
413
  source_type = str(meta.get("source_type") or "")
 
415
  scale_points = meta.get("scale_points")
416
  strict = source_type == "system_generated"
417
  evidence = str(evidence_text or "")
418
+ uploaded_metric_anchors = {}
419
  if not strict and evidence:
420
  sample_candidates = set()
421
  for pattern in (
 
428
  scale_candidates = {int(x) for x in re.findall(r"(?i)\b([2-9]|1[0-1])[- ]point\s+Likert", evidence)}
429
  if len(scale_candidates) == 1:
430
  scale_points = next(iter(scale_candidates))
431
+
432
+ vif = cls._unique_numeric(r"(?i)\bVIF\s*(?:=|:)?\s*([-+]?\d+(?:\.\d+)?)", evidence)
433
+ bootstrap = cls._unique_numeric(r"(?i)\bbootstrap\s+(?:resamples?|iterations?)\s*(?:=|:)?\s*(\d{2,6})", evidence, integer=True)
434
+ if vif is not None:
435
+ uploaded_metric_anchors["VIF"] = vif
436
+ if bootstrap is not None:
437
+ uploaded_metric_anchors["bootstrap_iterations"] = int(bootstrap)
438
+
439
+ r2_by_label = {}
440
+ for match in re.finditer(
441
+ r"(?i)R(?:²|\^2)\s*(?:for|of)\s*([A-Za-z][A-Za-z0-9 _\-/]{1,80}?)\s*(?:=|:)\s*([-+]?\d+(?:\.\d+)?)",
442
+ evidence,
443
+ ):
444
+ label = re.sub(r"\s+", " ", match.group(1)).strip().casefold()
445
+ r2_by_label.setdefault(label, set()).add(float(match.group(2)))
446
+ uploaded_metric_anchors["r2_by_label"] = {
447
+ label: next(iter(values)) for label, values in r2_by_label.items() if len(values) == 1
448
+ }
449
+
450
+ beta_by_path = {}
451
+ for match in re.finditer(
452
+ r"(?i)([A-Za-z][A-Za-z0-9 _\-/]{1,60}?)\s*(?:→|->)\s*([A-Za-z][A-Za-z0-9 _\-/]{1,60}?)\s*[,;:]?\s*(?:β|standardized\s+estimate)\s*(?:=|:)\s*([-+]?\d+(?:\.\d+)?)",
453
+ evidence,
454
+ ):
455
+ key = (
456
+ re.sub(r"\s+", " ", match.group(1)).strip().casefold(),
457
+ re.sub(r"\s+", " ", match.group(2)).strip().casefold(),
458
+ )
459
+ beta_by_path.setdefault(key, set()).add(float(match.group(3)))
460
+ uploaded_metric_anchors["beta_by_path"] = {
461
+ " -> ".join(key): next(iter(values)) for key, values in beta_by_path.items() if len(values) == 1
462
+ }
463
+
464
  constructs = dict(meta.get("constructs") or {}) if isinstance(meta.get("constructs"), dict) else {}
465
  results = dict(meta.get("association_results") or {}) if isinstance(meta.get("association_results"), dict) else {}
466
  return {
 
472
  "study_design": str(meta.get("study_design") or "").strip(),
473
  "constructs": {k: str(v or "").strip() for k, v in constructs.items() if str(v or "").strip()},
474
  "association_results": results,
475
+ "uploaded_metric_anchors": uploaded_metric_anchors,
476
  "causal_identification": "none" if strict else "evidence_owned",
477
  "allowed_methods": [
478
  "reliability", "composite reliability", "average variance extracted",
 
489
 
490
  @classmethod
491
  def repair_quantitative_study_fact_contract(cls, text, metadata, section_name=""):
492
+ """Repair deterministic AMJ drift only in study/result-owned prose."""
493
  value = str(text or "")
494
  meta = dict(metadata or {})
495
  ledger = dict(meta.get("quantitative_evidence_ledger") or {})
 
501
  strict = bool(ledger.get("strict"))
502
  constructs = dict(ledger.get("constructs") or {})
503
  results = dict(ledger.get("association_results") or {})
504
+ uploaded = dict(ledger.get("uploaded_metric_anchors") or {})
505
+ known_constructs = [str(v) for v in constructs.values() if str(v)]
506
+ construct_folded = [c.casefold() for c in known_constructs]
507
+ forbidden = [str(x) for x in ledger.get("forbidden_unowned_methods") or []]
508
+ role_patterns = {
509
+ "predictor": r"(?:predictor|independent\s+variable|predictor\s+construct)",
510
+ "mediator": r"(?:mediator|mediating\s+variable|mediating\s+construct)",
511
+ "outcome": r"(?:outcome|dependent\s+variable|outcome\s+construct)",
512
+ }
513
+ causal_replacements = (
514
+ (r"(?i)\bhad\s+a\s+causal\s+effect\s+on\b", "was associated with"),
515
+ (r"(?i)\bexerted\s+a\s+causal\s+effect\s+on\b", "was associated with"),
516
+ (r"(?i)\bcausal\s+effect\s+on\b", "association with"),
517
+ (r"(?i)\ban\s+causal\s+effect\b", "an association"),
518
+ (r"(?i)\ba\s+causal\s+effect\b", "an association"),
519
+ (r"(?i)\bthe\s+causal\s+effect\b", "the association"),
520
+ (r"(?i)\bcausal\s+effects\b", "associations"),
521
+ (r"(?i)\bcausal\s+effect\b", "association"),
522
+ (r"(?i)\bcausal\s+mediation\b", "indirect association"),
523
+ )
524
 
525
+ replacements = []
526
+ for start, end, sentence in cls._sentence_spans(value):
527
+ ownership = cls._quantitative_sentence_ownership(sentence, section_name, known_constructs)
528
+ if ownership not in {"study", "result"}:
 
 
 
 
 
 
 
529
  continue
530
  revised = sentence
531
+
532
  if sample_size:
533
  sample_pattern = re.compile(r"(?i)(\b(?:sample(?:\s+size)?(?:\s+of)?|N)\s*(?:=|:)?\s*)(\d{2,6})\b")
534
  def sample_repl(match):
 
555
  diagnostics.append({"type":"scale_points_repaired","observed":observed,"expected":int(scale_points)})
556
  return f"{int(scale_points)}-point {match.group(2)}"
557
  revised = scale_pattern.sub(scale_repl, revised)
 
 
 
 
558
 
559
+ for role, role_pattern in role_patterns.items():
560
+ expected = str(constructs.get(role) or "")
561
+ if not expected:
 
 
 
 
 
 
 
 
 
 
562
  continue
563
+ for candidate in known_constructs:
564
+ if candidate.casefold() == expected.casefold():
565
+ continue
566
+ pattern = re.compile(rf"(?i)({role_pattern}\s*(?:was|is|:|=)?\s*){re.escape(candidate)}")
567
+ revised, count = pattern.subn(lambda m: m.group(1) + expected, revised)
568
  if count:
569
  diagnostics.append({"type":"construct_role_repaired","role":role,"observed":candidate,"expected":expected,"count":count})
570
 
571
+ if strict:
572
+ for pattern, replacement in causal_replacements:
573
+ revised, count = re.subn(pattern, replacement, revised)
574
+ if count:
575
+ diagnostics.append({"type":"causal_language_repaired","replacement":replacement,"count":count})
576
+ if any(c in revised.casefold() for c in construct_folded) if construct_folded else False:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
577
  revised, p_count = re.subn(r"(?i)\s*[,;]?\s*p\s*(?:=|<|>|≤|≥)\s*(?:0?\.\d+|\d+(?:\.\d+)?)", "", revised)
578
  if p_count:
579
  diagnostics.append({"type":"unfrozen_p_value_removed","count":p_count})
580
+ if ownership == "study":
581
+ bad = next((term for term in forbidden if re.search(rf"(?i)(?<![A-Za-z]){re.escape(term)}(?![A-Za-z])", revised)), "")
582
  if bad:
 
583
  diagnostics.append({"type":"unsupported_quantitative_method_removed","method":bad,"preview":sentence.strip()[:180]})
584
+ revised = ""
585
+
586
+ def replace_local_metric(pattern, expected, *, dtype="float", label="metric"):
587
+ nonlocal revised
 
 
 
 
588
  if expected is None:
589
  return
590
  expected_text = str(int(expected)) if dtype == "int" else f"{float(expected):.3f}".rstrip("0").rstrip(".")
 
599
  return match.group(0)
600
  diagnostics.append({"type":"quantitative_metric_repaired","metric":label,"observed":observed,"expected":expected_text})
601
  return match.group(1) + expected_text
602
+ revised = regex.sub(repl, revised)
603
+
604
+ vif_expected = results.get("predictor_mediator_vif") if strict else uploaded.get("VIF")
605
+ boot_expected = results.get("bootstrap_iterations") if strict else uploaded.get("bootstrap_iterations")
606
+ replace_local_metric(r"(\bVIF\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", vif_expected, label="VIF")
607
+ replace_local_metric(r"(\bbootstrap\s+(?:resamples?|iterations?)\s*(?:=|:)?\s*)(\d{2,6})", boot_expected, dtype="int", label="bootstrap_iterations")
608
 
609
+ if strict and results:
 
 
610
  for role, key in (("mediator", "r2_mediator"), ("outcome", "r2_outcome")):
611
  construct = str(constructs.get(role) or "")
612
  if construct and results.get(key) is not None:
613
  metric_pattern = rf"(R(?:²|\^2)\s*(?:for|of)?\s*{re.escape(construct)}\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)"
614
+ replace_local_metric(metric_pattern, results.get(key), label=key)
615
 
616
+ lower = revised.casefold()
617
+ selected = None
618
+ specs = [
619
  ("indirect", results.get("indirect"), [constructs.get("predictor"), constructs.get("mediator"), constructs.get("outcome")]),
620
  ("direct", results.get("direct"), [constructs.get("predictor"), constructs.get("outcome")]),
621
  ("a", results.get("a"), [constructs.get("predictor"), constructs.get("mediator")]),
622
  ("b", results.get("b"), [constructs.get("mediator"), constructs.get("outcome")]),
623
  ]
624
+ if "indirect" in lower and all(str(x or "").casefold() in lower for x in specs[0][2] if x):
625
+ selected = specs[0]
626
+ elif f"controlling {str(constructs.get('mediator') or '').casefold()}" in lower and str(constructs.get("predictor") or "").casefold() in lower and str(constructs.get("outcome") or "").casefold() in lower:
627
+ selected = specs[1]
628
+ elif f"controlling {str(constructs.get('predictor') or '').casefold()}" in lower and str(constructs.get("mediator") or "").casefold() in lower and str(constructs.get("outcome") or "").casefold() in lower:
629
+ selected = specs[3]
630
+ elif str(constructs.get("predictor") or "").casefold() in lower and str(constructs.get("mediator") or "").casefold() in lower and str(constructs.get("outcome") or "").casefold() not in lower:
631
+ selected = specs[2]
632
+ elif str(constructs.get("mediator") or "").casefold() in lower and str(constructs.get("outcome") or "").casefold() in lower and str(constructs.get("predictor") or "").casefold() not in lower:
633
+ selected = specs[3]
634
+ if selected and selected[1] is not None:
635
+ replace_local_metric(r"((?:β|standardized\s+estimate)\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", selected[1], label=selected[0])
636
+ elif not strict:
637
+ r2_map = dict(uploaded.get("r2_by_label") or {})
638
+ for label, expected in r2_map.items():
639
+ if label and label in revised.casefold():
640
+ replace_local_metric(rf"(R(?:²|\^2)\s*(?:for|of)\s*{re.escape(label)}\s*(?:=|:)\s*)([-+]?\d+(?:\.\d+)?)", expected, label=f"uploaded_R2:{label}")
641
+ beta_map = dict(uploaded.get("beta_by_path") or {})
642
+ for path, expected in beta_map.items():
643
+ left, _, right = path.partition(" -> ")
644
+ if left and right and left in revised.casefold() and right in revised.casefold():
645
+ replace_local_metric(r"((?:β|standardized\s+estimate)\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", expected, label=f"uploaded_beta:{path}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
 
647
+ if revised != sentence:
648
+ replacements.append((start, end, revised))
649
+
650
+ for start, end, replacement in sorted(replacements, reverse=True):
651
+ value = value[:start] + replacement + value[end:]
652
  value = re.sub(r"[ \t]+\n", "\n", value)
653
  value = re.sub(r"\n{3,}", "\n\n", value)
654
  value = re.sub(r" {2,}", " ", value)