P0-R1.1-R2-S0-R1 Deployment Source Correction

#73
core/generation_runner.py CHANGED
@@ -7,6 +7,7 @@ connections. Checkpoints are task-scoped, immutable-by-sequence JSON snapshots.
7
  """
8
  from __future__ import annotations
9
 
 
10
  import hashlib
11
  import inspect
12
  import json
@@ -912,24 +913,121 @@ class GenerationRunner:
912
  def _repair_validate_study_fact_contract(
913
  mode: str, agent, quant_engine, text: str, data_metadata: dict, *, section_name: str
914
  ):
 
915
  value = str(text or '').strip()
916
  diagnostics = []
917
  if str(mode or '') == 'asq':
918
- value, diagnostics = agent.repair_qualitative_study_fact_contract(
919
- value, data_metadata, section_name=section_name
920
- )
921
- agent.validate_qualitative_study_fact_contract(
922
  value, data_metadata, section_name=section_name
923
  )
924
  elif str(mode or '') == 'amj':
925
- value, diagnostics = quant_engine.repair_quantitative_study_fact_contract(
926
- value, data_metadata, section_name=section_name
927
- )
928
- quant_engine.validate_quantitative_study_fact_contract(
929
  value, data_metadata, section_name=section_name
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()
@@ -957,6 +1055,16 @@ class GenerationRunner:
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 {})
@@ -1862,13 +1970,14 @@ class GenerationRunner:
1862
  old_tokens = cls._paragraph_token_set(old)
1863
  union = tokens | old_tokens
1864
  similarity = len(tokens & old_tokens) / max(1, len(union))
1865
- if similarity < 0.90:
 
1866
  continue
1867
  old_citations = set(re.findall(r'\[CITE-\d+\]', old, flags=re.I))
1868
  old_numerics = set(re.findall(r'(?<![A-Za-z])[-+]?\d+(?:\.\d+)?%?(?![A-Za-z])', old))
1869
  # Never delete a paragraph carrying different evidence/facts.
1870
  if citations == old_citations and numerics == old_numerics:
1871
- duplicate = (similarity, old[:180])
1872
  break
1873
  if duplicate:
1874
  diagnostics.append({"similarity": round(duplicate[0], 4), "removed_preview": value[:180]})
@@ -2113,23 +2222,33 @@ class GenerationRunner:
2113
  working_keywords = list(working_front.get("keywords") or [])
2114
  if not working_abstract or not working_keywords:
2115
  raise RuntimeError("working_front_matter_missing")
2116
- working_abstract, front_fact_rows = self._repair_validate_study_fact_contract(
2117
  mode, agent, quant_engine, working_abstract, data_metadata,
2118
  section_name="Working Abstract",
2119
  )
2120
- if not working_abstract:
2121
- raise RuntimeError("working_front_matter_empty_after_study_fact_contract")
2122
  if front_fact_rows:
 
2123
  logging.warning(
2124
- "Working Abstract study-fact repair | mode=%s count=%s types=%s",
2125
- mode, len(front_fact_rows),
2126
- sorted({str(row.get("type") or "") for row in front_fact_rows}),
2127
  )
 
 
 
 
 
 
 
 
 
 
 
2128
  quality_diagnostics.append({
2129
  "scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
2130
- "status":"deterministic_repair", "count":len(front_fact_rows),
2131
- "examples":front_fact_rows[:3],
2132
  })
 
 
2133
  agent.global_memory["working_abstract"] = working_abstract
2134
 
2135
  self._progress(14, "outline_planning")
@@ -2359,36 +2478,48 @@ class GenerationRunner:
2359
  working_abstract = str(state.get("working_abstract") or "").strip()
2360
  working_keywords = list(state.get("working_keywords") or [])
2361
  if working_abstract and not state.get("english_finalized"):
2362
- checked_working_abstract, resumed_front_rows = self._repair_validate_study_fact_contract(
2363
- mode, agent, quant_engine, working_abstract, data_metadata,
2364
- section_name="Working Abstract",
2365
- )
2366
- if not checked_working_abstract:
2367
- raise RuntimeError("working_front_matter_empty_after_study_fact_contract")
2368
- if checked_working_abstract != working_abstract or resumed_front_rows:
2369
- working_abstract = checked_working_abstract
 
 
 
 
 
 
 
 
 
 
2370
  state["working_abstract"] = working_abstract
 
2371
  if str(state.get("full_draft_en") or "").strip():
2372
  state["full_draft_en"] = replace_abstract(str(state.get("full_draft_en") or ""), working_abstract)
 
2373
  full_draft_en = str(state["full_draft_en"] or "")
2374
  quality_diagnostics.append({
2375
  "scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
2376
- "status":"resume_deterministic_repair", "count":len(resumed_front_rows),
2377
- "examples":resumed_front_rows[:3],
2378
  })
2379
  state["quality_diagnostics"] = quality_diagnostics
2380
- self._checkpoint("working_front_matter_fact_reconciled", state, 15)
2381
  if working_abstract:
2382
  agent.global_memory["working_abstract"] = working_abstract
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
@@ -2401,32 +2532,55 @@ class GenerationRunner:
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 = ""
@@ -2440,6 +2594,7 @@ class GenerationRunner:
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,
@@ -2519,23 +2674,28 @@ class GenerationRunner:
2519
  working_keywords = list(working_front.get("keywords") or [])
2520
  if not working_abstract or not working_keywords:
2521
  raise RuntimeError("working_front_matter_missing")
2522
- working_abstract, front_fact_rows = self._repair_validate_study_fact_contract(
2523
- mode, agent, quant_engine, working_abstract, data_metadata,
2524
- section_name="Working Abstract",
2525
  )
2526
- if not working_abstract:
2527
- raise RuntimeError("working_front_matter_empty_after_study_fact_contract")
2528
  if front_fact_rows:
2529
- logging.warning(
2530
- "Working Abstract resume study-fact repair | mode=%s count=%s types=%s",
2531
- mode, len(front_fact_rows),
2532
- sorted({str(row.get("type") or "") for row in front_fact_rows}),
2533
- )
 
 
 
 
 
 
 
2534
  quality_diagnostics.append({
2535
  "scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
2536
- "status":"deterministic_repair", "count":len(front_fact_rows),
2537
- "examples":front_fact_rows[:3],
2538
  })
 
 
2539
  agent.global_memory["working_abstract"] = working_abstract
2540
  full_draft_en = replace_abstract(full_draft_en, working_abstract)
2541
  full_draft_en = self._replace_keywords(full_draft_en, working_keywords)
@@ -2817,175 +2977,291 @@ Output only final academic prose. Do not report the word count."""
2817
  prefix += f"# {sec_title}\n\n"
2818
  if sec.get("subsection_first") and subsection:
2819
  prefix += f"## {subsection}\n\n"
2820
- if mode == "asq" and section_id == "appendix":
2821
- # R6A-R4: Appendix is a deterministic reader-facing projection
2822
- # of the Evidence Ledger. It is never regenerated by the LLM.
2823
- en_raw = qual_engine.build_appendix_evidence_md(data_metadata)
2824
- else:
2825
- en_raw = self._stream_text(
2826
- agent.stream_deepseek(
2827
- sys_prompt, user_prompt, max_tokens=7000,
2828
- model_role="writing", thinking=False, allow_fallback=False,
2829
- ),
2830
- preview_prefix=full_draft_en + prefix,
2831
- preview_stage=display_name,
2832
- )
2833
- en_raw = re.sub(r"<think>.*?</think>", "", en_raw, flags=re.S).strip()
2834
- en_raw = re.sub(rf"(?im)^#\s*{re.escape(sec_title)}\s*\n+", "", en_raw).strip()
2835
- if subsection:
2836
- en_raw = re.sub(rf"(?im)^##\s*{re.escape(subsection)}\s*\n+", "", en_raw).strip()
2837
- en_raw = re.sub(r"(?m)^(#{2,3})\s+[\d\.\s一二三四五六七八九十、]+\s*(.*)", r"\1 \2", en_raw)
2838
- en_raw = re.sub(r"(?m)^#{4,}\s+(.*)", r"**\1**", en_raw)
2839
- en_raw = re.sub(r"(?m)^#\s+(.+)$", r"## \1", en_raw)
2840
- en_raw = agent.critique_and_repair_section(
2841
- en_raw, section_id=section_id, section_name=display_name, mode=mode,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2842
  evidence_contract=evidence_packet,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2843
  )
2844
- en_raw = agent.polish_academic_section(en_raw, display_name, abstract=False, mode=mode)
2845
- if mode == "asq":
2846
- # Model prose has no authority over quote presence/count.
2847
- en_raw = self._strip_model_controlled_qualitative_quotes(en_raw, data_metadata)
2848
- allow_deterministic_citation_reinforcement = not (
2849
- mode == "asq" and section_id in {"methods", "discussion"}
2850
- )
2851
- if not is_quarantined and assigned_tag_list and allow_deterministic_citation_reinforcement:
2852
- en_raw = agent.reinforce_claim_specific_citation_coverage(
2853
- en_raw, assigned_tag_list, section_name=display_name
2854
- )
2855
- self._progress(pct, f"citation_audit:{current_index}:{total_steps}")
2856
- used_before = set(agent.used_citations)
2857
- en_resolved = agent.audit_and_resolve_citations(
2858
- prefix + en_raw, section_name=display_name, quarantine=is_quarantined
2859
- )
2860
- used_after = set(agent.used_citations)
2861
- survived = used_after - used_before
2862
- if assigned_tag_list and not is_quarantined:
2863
- required_survival = min(
2864
- len(assigned_tag_list),
2865
- max(1, int(math.ceil(len(assigned_tag_list) * CITATION_SEMANTIC_SURVIVAL_RATIO))),
2866
- )
2867
- if len(survived) < required_survival:
2868
- missing_items = [
2869
- item for item in planned_items
2870
- if str(item.get("tag") or "") not in used_after
2871
- ]
2872
- needed = max(0, required_survival - len(survived))
2873
- recovery_items = agent.select_contextual_citation_recovery_items(
2874
- missing_items, section_name=display_name, section_context=en_resolved,
2875
- needed=needed, excluded_tags=used_after,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2876
  )
2877
- if len(recovery_items) < needed:
2878
- recovery_items.extend(
2879
- agent.fetch_contextual_jit_recovery_items(
2880
- topic=topic, section_name=display_name, section_context=en_resolved,
2881
- needed=needed - len(recovery_items),
2882
- excluded_tags=used_after | {str(item.get("tag") or "") for item in recovery_items},
 
 
 
 
 
 
 
 
 
 
 
 
 
2883
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2884
  )
2885
- planned_tags_for_unit = {str(item.get("tag") or "") for item in planned_items}
2886
- for recovery_item in recovery_items[:needed]:
2887
- tag = str(recovery_item.get("tag") or "")
2888
- if tag and tag not in planned_tags_for_unit:
2889
- planned_items.append(recovery_item)
2890
- planned_tags_for_unit.add(tag)
2891
- citation_plan[display_name] = planned_items
2892
- # R14-TQ2-PQ1-R1: recovery may attach evidence only to
2893
- # an already-written claim. It is never authorized to create a
2894
- # supplemental paragraph from a source merely to satisfy citation
2895
- # survival. If no existing claim can be validated, leave prose
2896
- # unchanged. Evidence coverage may later be reported as limited,
2897
- # while citation correctness itself remains fail-closed.
2898
- self._progress(pct, f"citation_recovery:{current_index}:{total_steps}")
2899
- recovered = agent.recover_citations_on_existing_claims(
2900
  en_resolved,
2901
- recovery_items[:needed],
2902
- section_name=display_name,
2903
- needed=needed,
2904
  )
2905
- if recovered != en_resolved:
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,
2912
- task=sec,
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
2956
  )
2957
- if study_fact_rows:
2958
  logging.warning(
2959
- "Qualitative Study Evidence Ledger repair | unit=%s count=%s types=%s",
2960
- display_name, len(study_fact_rows),
2961
- sorted({str(row.get("type") or "") for row in study_fact_rows}),
2962
  )
2963
- quality_diagnostics.append({
2964
- "scope":"qualitative_study_fact_integrity", "name":display_name,
2965
- "status":"deterministic_repair", "count":len(study_fact_rows),
2966
- "examples":study_fact_rows[:3],
2967
  })
2968
- agent.validate_qualitative_study_fact_contract(
2969
- en_resolved, data_metadata, section_name=display_name
2970
- )
2971
- elif mode == "amj":
2972
- en_resolved, study_fact_rows = quant_engine.repair_quantitative_study_fact_contract(
2973
- en_resolved, data_metadata, section_name=display_name
2974
  )
2975
- if study_fact_rows:
2976
  logging.warning(
2977
- "Quantitative Evidence Ledger repair | unit=%s count=%s types=%s",
2978
- display_name, len(study_fact_rows),
2979
- sorted({str(row.get("type") or "") for row in study_fact_rows}),
2980
  )
2981
- quality_diagnostics.append({
2982
- "scope":"quantitative_study_fact_integrity", "name":display_name,
2983
- "status":"deterministic_repair", "count":len(study_fact_rows),
2984
- "examples":study_fact_rows[:3],
2985
  })
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.
@@ -3216,23 +3492,52 @@ Output only final academic prose. Do not report the word count."""
3216
  final_keywords = list(final_front.get("keywords") or [])
3217
  if not final_abstract or not final_keywords:
3218
  raise RuntimeError("final_front_matter_missing")
3219
- final_abstract, final_front_fact_rows = self._repair_validate_study_fact_contract(
3220
  mode, agent, quant_engine, final_abstract, data_metadata,
3221
  section_name="Final Abstract",
3222
  )
3223
- if not final_abstract:
3224
- raise RuntimeError("final_abstract_empty_after_study_fact_contract")
3225
  if final_front_fact_rows:
 
3226
  logging.warning(
3227
- "Final Abstract study-fact repair | mode=%s count=%s types=%s",
3228
  mode, len(final_front_fact_rows),
3229
- sorted({str(row.get("type") or "") for row in final_front_fact_rows}),
3230
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3231
  quality_diagnostics.append({
3232
  "scope":"final_abstract_study_fact_integrity", "name":"Final Abstract",
3233
- "status":"deterministic_repair", "count":len(final_front_fact_rows),
3234
  "examples":final_front_fact_rows[:3],
3235
  })
 
3236
  full_draft_en = replace_abstract(full_draft_en, final_abstract)
3237
  full_draft_en = self._replace_keywords(full_draft_en, final_keywords)
3238
  body_only, _reference_tail = agent.validate_reference_tail(
 
7
  """
8
  from __future__ import annotations
9
 
10
+ import copy
11
  import hashlib
12
  import inspect
13
  import json
 
913
  def _repair_validate_study_fact_contract(
914
  mode: str, agent, quant_engine, text: str, data_metadata: dict, *, section_name: str
915
  ):
916
+ """S0-R1 compatibility gateway: inspect only and never mutate prose."""
917
  value = str(text or '').strip()
918
  diagnostics = []
919
  if str(mode or '') == 'asq':
920
+ diagnostics = agent.inspect_qualitative_study_fact_contract(
 
 
 
921
  value, data_metadata, section_name=section_name
922
  )
923
  elif str(mode or '') == 'amj':
924
+ diagnostics = quant_engine.inspect_quantitative_study_fact_contract(
 
 
 
925
  value, data_metadata, section_name=section_name
926
  )
927
  return value, list(diagnostics or [])
928
 
929
+ @staticmethod
930
+ def _study_fact_violations(mode: str, agent, quant_engine, text: str, data_metadata: dict, *, section_name: str):
931
+ if str(mode or '') == 'asq':
932
+ return list(agent.inspect_qualitative_study_fact_contract(text, data_metadata, section_name=section_name) or [])
933
+ if str(mode or '') == 'amj':
934
+ return list(quant_engine.inspect_quantitative_study_fact_contract(text, data_metadata, section_name=section_name) or [])
935
+ return []
936
+
937
+ @staticmethod
938
+ def _section_retry_feedback(violations, *, limit=8) -> str:
939
+ rows = []
940
+ for item in list(violations or [])[:max(1, int(limit))]:
941
+ if not isinstance(item, dict):
942
+ rows.append(str(item)[:500])
943
+ continue
944
+ kind = str(item.get('type') or 'validation_issue')
945
+ detail = str(item.get('repair_instruction') or item.get('reason') or item.get('preview') or '')
946
+ actor = str(item.get('actor') or '')
947
+ expected = item.get('expected')
948
+ observed = item.get('observed')
949
+ parts = [kind]
950
+ if actor:
951
+ parts.append(f'actor={actor}')
952
+ if observed not in (None, ''):
953
+ parts.append(f'observed={observed}')
954
+ if expected not in (None, ''):
955
+ parts.append(f'expected={expected}')
956
+ if detail:
957
+ parts.append(detail[:360])
958
+ rows.append(' | '.join(parts))
959
+ return '\n'.join(f'- {row}' for row in rows)
960
+
961
+ @staticmethod
962
+ def _snapshot_section_citation_state(agent, citation_plan: dict, unit_name: str) -> dict:
963
+ """Snapshot only Section-owned citation state so a rejected attempt cannot leak forward."""
964
+ return {
965
+ "used_citations": set(getattr(agent, "used_citations", set()) or set()),
966
+ "citation_audit_log": copy.deepcopy(list(getattr(agent, "citation_audit_log", []) or [])),
967
+ "citation_map": copy.deepcopy(dict(getattr(agent, "citation_map", {}) or {})),
968
+ "planned_items": copy.deepcopy(list(dict(citation_plan or {}).get(unit_name, []) or [])),
969
+ }
970
+
971
+ @staticmethod
972
+ def _restore_section_citation_state(agent, citation_plan: dict, unit_name: str, snapshot: dict) -> None:
973
+ """Restore citation ownership after an invalid Section attempt without rewinding API counters."""
974
+ snap = dict(snapshot or {})
975
+ lock = getattr(agent, "lock", None)
976
+ if lock is None:
977
+ agent.used_citations = set(snap.get("used_citations") or set())
978
+ agent.citation_audit_log = copy.deepcopy(list(snap.get("citation_audit_log") or []))
979
+ agent.citation_map = copy.deepcopy(dict(snap.get("citation_map") or {}))
980
+ else:
981
+ with lock:
982
+ agent.used_citations = set(snap.get("used_citations") or set())
983
+ agent.citation_audit_log = copy.deepcopy(list(snap.get("citation_audit_log") or []))
984
+ agent.citation_map = copy.deepcopy(dict(snap.get("citation_map") or {}))
985
+ citation_plan[unit_name] = copy.deepcopy(list(snap.get("planned_items") or []))
986
+ setattr(agent, "_last_citation_semantic_violations", [])
987
+
988
+ @classmethod
989
+ def _section_validation_bundle(
990
+ cls, mode: str, agent, quant_engine, text: str, data_metadata: dict, *,
991
+ section_id: str, section_name: str, evidence_contract: str = "", include_critic: bool = True,
992
+ citation_violations=None,
993
+ ) -> list[dict]:
994
+ """Collect typed S0-R1 validation feedback without mutating manuscript prose."""
995
+ value = str(text or "")
996
+ violations = []
997
+ if include_critic:
998
+ verdict = agent.critique_section(
999
+ value, section_id=section_id, section_name=section_name, mode=mode,
1000
+ evidence_contract=evidence_contract,
1001
+ )
1002
+ if isinstance(verdict, dict) and verdict.get("pass") is False:
1003
+ violations.append({
1004
+ "type": "top_journal_critic",
1005
+ "reason": str(verdict.get("critical_issue") or "")[:800],
1006
+ "repair_instruction": str(verdict.get("repair_instruction") or "")[:1200],
1007
+ })
1008
+ violations.extend(list(agent.inspect_authorial_ai_meta(value) or []))
1009
+ violations.extend(cls._study_fact_violations(
1010
+ mode, agent, quant_engine, value, data_metadata, section_name=section_name
1011
+ ))
1012
+ violations.extend(copy.deepcopy(list(citation_violations or [])))
1013
+ return violations
1014
+
1015
+ def _checkpoint_section_retry_exhausted(
1016
+ self, state: dict, *, section_id: str, section_name: str, violations, pct: int, attempts: int = 2
1017
+ ) -> None:
1018
+ typed = copy.deepcopy(list(violations or []))
1019
+ state["section_retry_exhausted"] = {
1020
+ "section_id": str(section_id or ""),
1021
+ "section_name": str(section_name or ""),
1022
+ "attempts": max(1, int(attempts or 1)),
1023
+ "violations": typed[:12],
1024
+ "semantic_integrity_corruption": False,
1025
+ }
1026
+ self._checkpoint(
1027
+ "section_retry_exhausted", state, pct,
1028
+ display_node=f"section_retry_exhausted:{section_name}",
1029
+ )
1030
+
1031
  @staticmethod
1032
  def _section_integrity_hash(value: str) -> str:
1033
  exact = str(value or "").replace("\r\n", "\n").replace("\r", "\n").strip()
 
1055
  rendered = str(replacement or "").strip()
1056
  return (value[:match.start()] + rendered + "\n\n" + value[match.end():].lstrip("\n")).strip() + "\n\n"
1057
 
1058
+ @staticmethod
1059
+ def _remove_top_level_section(manuscript: str, title: str) -> str:
1060
+ value = str(manuscript or "")
1061
+ pattern = re.compile(rf'(?ms)^#\s+{re.escape(str(title))}\s*$\n.*?(?=^#\s+[^#].*?$|\Z)')
1062
+ matches = list(pattern.finditer(value))
1063
+ if len(matches) != 1:
1064
+ raise RuntimeError(f"top_level_section_remove_count_invalid:{title}:{len(matches)}")
1065
+ match = matches[0]
1066
+ return (value[:match.start()] + value[match.end():].lstrip("\n")).strip() + "\n\n"
1067
+
1068
  @classmethod
1069
  def _validate_section_integrity_seal(cls, seal: dict, section_text: str, section_title: str) -> None:
1070
  record = dict(seal or {})
 
1970
  old_tokens = cls._paragraph_token_set(old)
1971
  union = tokens | old_tokens
1972
  similarity = len(tokens & old_tokens) / max(1, len(union))
1973
+ normalized_same = re.sub(r"\W+", " ", value).strip().casefold() == re.sub(r"\W+", " ", old).strip().casefold()
1974
+ if not normalized_same:
1975
  continue
1976
  old_citations = set(re.findall(r'\[CITE-\d+\]', old, flags=re.I))
1977
  old_numerics = set(re.findall(r'(?<![A-Za-z])[-+]?\d+(?:\.\d+)?%?(?![A-Za-z])', old))
1978
  # Never delete a paragraph carrying different evidence/facts.
1979
  if citations == old_citations and numerics == old_numerics:
1980
+ duplicate = (1.0, old[:180])
1981
  break
1982
  if duplicate:
1983
  diagnostics.append({"similarity": round(duplicate[0], 4), "removed_preview": value[:180]})
 
2222
  working_keywords = list(working_front.get("keywords") or [])
2223
  if not working_abstract or not working_keywords:
2224
  raise RuntimeError("working_front_matter_missing")
2225
+ _, front_fact_rows = self._repair_validate_study_fact_contract(
2226
  mode, agent, quant_engine, working_abstract, data_metadata,
2227
  section_name="Working Abstract",
2228
  )
 
 
2229
  if front_fact_rows:
2230
+ feedback = self._section_retry_feedback(front_fact_rows)
2231
  logging.warning(
2232
+ "Working Abstract fact validation retry | mode=%s count=%s types=%s",
2233
+ mode, len(front_fact_rows), sorted({str(row.get("type") or "") for row in front_fact_rows}),
 
2234
  )
2235
+ working_front = agent.generate_working_front_matter(
2236
+ topic, keywords, research_logic_contract, synthesis_result,
2237
+ data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
2238
+ )
2239
+ working_abstract = str(working_front.get("abstract") or "").strip()
2240
+ working_keywords = list(working_front.get("keywords") or [])
2241
+ _, remaining_front_rows = self._repair_validate_study_fact_contract(
2242
+ mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
2243
+ )
2244
+ if remaining_front_rows:
2245
+ raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining_front_rows[:3]}")
2246
  quality_diagnostics.append({
2247
  "scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
2248
+ "status":"section_retry_resolved", "count":len(front_fact_rows), "examples":front_fact_rows[:3],
 
2249
  })
2250
+ if not working_abstract:
2251
+ raise RuntimeError("working_front_matter_empty_after_validation")
2252
  agent.global_memory["working_abstract"] = working_abstract
2253
 
2254
  self._progress(14, "outline_planning")
 
2478
  working_abstract = str(state.get("working_abstract") or "").strip()
2479
  working_keywords = list(state.get("working_keywords") or [])
2480
  if working_abstract and not state.get("english_finalized"):
2481
+ _, resumed_front_rows = self._repair_validate_study_fact_contract(
2482
+ mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
2483
+ )
2484
+ if resumed_front_rows:
2485
+ feedback = self._section_retry_feedback(resumed_front_rows)
2486
+ working_front = agent.generate_working_front_matter(
2487
+ topic, keywords, research_logic_contract, synthesis_result,
2488
+ data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
2489
+ )
2490
+ candidate_abstract = str(working_front.get("abstract") or "").strip()
2491
+ candidate_keywords = list(working_front.get("keywords") or [])
2492
+ _, remaining = self._repair_validate_study_fact_contract(
2493
+ mode, agent, quant_engine, candidate_abstract, data_metadata, section_name="Working Abstract",
2494
+ )
2495
+ if remaining:
2496
+ raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining[:3]}")
2497
+ working_abstract = candidate_abstract
2498
+ working_keywords = candidate_keywords or working_keywords
2499
  state["working_abstract"] = working_abstract
2500
+ state["working_keywords"] = working_keywords
2501
  if str(state.get("full_draft_en") or "").strip():
2502
  state["full_draft_en"] = replace_abstract(str(state.get("full_draft_en") or ""), working_abstract)
2503
+ state["full_draft_en"] = self._replace_keywords(state["full_draft_en"], working_keywords)
2504
  full_draft_en = str(state["full_draft_en"] or "")
2505
  quality_diagnostics.append({
2506
  "scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
2507
+ "status":"resume_section_retry_resolved", "count":len(resumed_front_rows), "examples":resumed_front_rows[:3],
 
2508
  })
2509
  state["quality_diagnostics"] = quality_diagnostics
2510
+ self._checkpoint("working_front_matter_fact_retry_resolved", state, 15)
2511
  if working_abstract:
2512
  agent.global_memory["working_abstract"] = working_abstract
2513
  if self._preview_target_text == '':
2514
  self._preview_target_text = str(state.get('full_draft_translated') or '')
2515
 
2516
+ # S0-R1 compatibility migration: old completed English is never silently
2517
+ # repaired. Unsealed sections are inspected only. Valid text receives a
2518
+ # seal; invalid text is removed from the completed set so only that owning
2519
+ # top-level section is regenerated by the normal Section Writer.
2520
  seal_migration = False
2521
  seal_text_changed = False
2522
+ retry_section_keys = set()
2523
  for section_key, section_title in self._unique_top_level_sections(adaptive_structure):
2524
  if not self._section_is_complete(adaptive_structure, completed_sections, section_key):
2525
  continue
 
2532
  and existing_seal.get("sha256") == self._section_integrity_hash(section_text)
2533
  ):
2534
  continue
2535
+ violations = self._study_fact_violations(
2536
+ mode, agent, quant_engine, section_text, data_metadata,
 
 
 
2537
  section_name=f"Resume sealed section: {section_title}",
2538
  )
2539
+ try:
2540
+ agent.validate_enumeration_contract(
2541
+ section_text, section_name=f"Resume sealed section: {section_title}"
2542
  )
2543
+ except Exception as exc:
2544
+ violations.append({
2545
+ "type":"enumeration_contract_invalid",
2546
+ "reason":str(exc)[:500], "preview":section_title,
2547
+ })
2548
+ ai_meta_rows = list(agent.inspect_authorial_ai_meta(section_text) or [])
2549
+ violations.extend(ai_meta_rows)
2550
+ if violations:
2551
+ retry_section_keys.add(str(section_key))
2552
  translation_sections.pop(section_key, None)
2553
+ section_integrity_seals.pop(section_key, None)
2554
  quality_diagnostics.append({
2555
  "scope":"english_section_seal_migration", "name":section_title,
2556
+ "status":"local_section_retry_required", "count":len(violations),
2557
+ "examples":violations[:3],
2558
  })
2559
+ continue
2560
  section_integrity_seals[section_key] = self._build_section_integrity_seal(
2561
  section_key, section_title, section_text
2562
  )
2563
  seal_migration = True
2564
+
2565
+ if retry_section_keys:
2566
+ # Remove only invalid unsealed top-level sections and their completed
2567
+ # unit markers. Other completed English stays byte-stable.
2568
+ for section_key, section_title in self._unique_top_level_sections(adaptive_structure):
2569
+ if str(section_key) not in retry_section_keys:
2570
+ continue
2571
+ if self._extract_top_level_section(full_draft_en, section_title):
2572
+ full_draft_en = self._remove_top_level_section(full_draft_en, section_title)
2573
+ seal_text_changed = True
2574
+ relevant_names = {
2575
+ str(task.get('display_name') or '')
2576
+ for task in adaptive_structure
2577
+ if str(task.get('section_id') or task.get('section') or '') == str(section_key)
2578
+ }
2579
+ completed_sections = [name for name in completed_sections if name not in relevant_names]
2580
+ seal_migration = True
2581
+
2582
  if seal_migration:
2583
+ # Rebuild mutable manuscript memory only from still-completed sealed English text.
 
2584
  for memory_key in ("hypotheses", "variables", "model_summary", "method_summary"):
2585
  agent.global_memory[memory_key] = ""
2586
  last_sealed = ""
 
2594
  if last_sealed:
2595
  context_tail = " ".join(last_sealed.split()[-200:])
2596
  state.update({
2597
+ "completed_sections": completed_sections,
2598
  "full_draft_en": full_draft_en,
2599
  "translation_sections": translation_sections,
2600
  "section_integrity_seals": section_integrity_seals,
 
2674
  working_keywords = list(working_front.get("keywords") or [])
2675
  if not working_abstract or not working_keywords:
2676
  raise RuntimeError("working_front_matter_missing")
2677
+ _, front_fact_rows = self._repair_validate_study_fact_contract(
2678
+ mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
 
2679
  )
 
 
2680
  if front_fact_rows:
2681
+ feedback = self._section_retry_feedback(front_fact_rows)
2682
+ working_front = agent.generate_working_front_matter(
2683
+ topic, keywords, research_logic_contract, synthesis_result,
2684
+ data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
2685
+ )
2686
+ working_abstract = str(working_front.get("abstract") or "").strip()
2687
+ working_keywords = list(working_front.get("keywords") or [])
2688
+ _, remaining = self._repair_validate_study_fact_contract(
2689
+ mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
2690
+ )
2691
+ if remaining:
2692
+ raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining[:3]}")
2693
  quality_diagnostics.append({
2694
  "scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
2695
+ "status":"section_retry_resolved", "count":len(front_fact_rows), "examples":front_fact_rows[:3],
 
2696
  })
2697
+ if not working_abstract:
2698
+ raise RuntimeError("working_front_matter_empty_after_validation")
2699
  agent.global_memory["working_abstract"] = working_abstract
2700
  full_draft_en = replace_abstract(full_draft_en, working_abstract)
2701
  full_draft_en = self._replace_keywords(full_draft_en, working_keywords)
 
2977
  prefix += f"# {sec_title}\n\n"
2978
  if sec.get("subsection_first") and subsection:
2979
  prefix += f"## {subsection}\n\n"
2980
+ prior_retry_exhausted = state.get("section_retry_exhausted")
2981
+ if (
2982
+ isinstance(prior_retry_exhausted, dict)
2983
+ and str(prior_retry_exhausted.get("section_name") or "") == display_name
2984
+ ):
2985
+ # S0-R1 deliberately does not invent an executor/network retry state.
2986
+ # A content-invalid Section that exhausted its one local retry remains
2987
+ # durable and fail-closed until a separately approved lifecycle change.
2988
+ raise RuntimeError(f"section_retry_exhausted_requires_new_lifecycle:{display_name}")
2989
+
2990
+ deterministic_appendix = mode == "asq" and section_id == "appendix"
2991
+ max_section_attempts = 1 if deterministic_appendix else 2
2992
+ frozen_citation_state = self._snapshot_section_citation_state(
2993
+ agent, citation_plan, display_name
2994
+ )
2995
+ attempt_feedback = ""
2996
+ en_resolved = ""
2997
+ survived = set()
2998
+
2999
+ for section_attempt in range(1, max_section_attempts + 1):
3000
+ # Every attempt starts from exactly the same evidence/citation packet.
3001
+ # Rejected attempts may not leak used tags, JIT references, or audit
3002
+ # decisions into the retry or any later Section.
3003
+ if section_attempt > 1:
3004
+ self._restore_section_citation_state(
3005
+ agent, citation_plan, display_name, frozen_citation_state
3006
+ )
3007
+ planned_items = copy.deepcopy(list(citation_plan.get(display_name, []) or []))
3008
+ citation_plan[display_name] = planned_items
3009
+ attempt_diagnostics = []
3010
+ candidate_user_prompt = user_prompt
3011
+ if attempt_feedback:
3012
+ candidate_user_prompt += f"""
3013
+
3014
+ <SECTION_RETRY_VALIDATION_FEEDBACK>
3015
+ The previous attempt for this same manuscript unit was rejected by validation. Rewrite ONLY this same unit once. Keep the identical frozen evidence packet, research logic, section role, and citation plan. Do not fetch, invent, or broaden evidence. Resolve every material issue below without changing the task or manuscript scope:
3016
+ {attempt_feedback}
3017
+ </SECTION_RETRY_VALIDATION_FEEDBACK>"""
3018
+ if agent._count_tokens(sys_prompt) + agent._count_tokens(candidate_user_prompt) > 60000:
3019
+ candidate_user_prompt = agent._truncate_context(
3020
+ candidate_user_prompt, 60000 - agent._count_tokens(sys_prompt)
3021
+ )
3022
+
3023
+ if deterministic_appendix:
3024
+ # R6A-R4: Appendix is a deterministic reader-facing projection
3025
+ # of the Evidence Ledger. It is never regenerated by the LLM.
3026
+ en_raw = qual_engine.build_appendix_evidence_md(data_metadata)
3027
+ else:
3028
+ en_raw = self._stream_text(
3029
+ agent.stream_deepseek(
3030
+ sys_prompt, candidate_user_prompt, max_tokens=7000,
3031
+ model_role="writing", thinking=False, allow_fallback=False,
3032
+ ),
3033
+ preview_prefix=full_draft_en + prefix,
3034
+ preview_stage=(display_name if section_attempt == 1 else f"{display_name}:retry"),
3035
+ )
3036
+ en_raw = re.sub(r"<think>.*?</think>", "", en_raw, flags=re.S).strip()
3037
+ en_raw = re.sub(rf"(?im)^#\s*{re.escape(sec_title)}\s*\n+", "", en_raw).strip()
3038
+ if subsection:
3039
+ en_raw = re.sub(rf"(?im)^##\s*{re.escape(subsection)}\s*\n+", "", en_raw).strip()
3040
+ en_raw = re.sub(r"(?m)^(#{2,3})\s+[\d\.\s一二三四五六七八九十、]+\s*(.*)", r"\1 \2", en_raw)
3041
+ en_raw = re.sub(r"(?m)^#{4,}\s+(.*)", r"**\1**", en_raw)
3042
+ en_raw = re.sub(r"(?m)^#\s+(.+)$", r"## \1", en_raw)
3043
+ if mode == "asq":
3044
+ # Model prose has no authority over quote presence/count.
3045
+ en_raw = self._strip_model_controlled_qualitative_quotes(en_raw, data_metadata)
3046
+
3047
+ # S0-R1 phase 1: feedback-only critic + pure semantic validators.
3048
+ pre_validation_rows = self._section_validation_bundle(
3049
+ mode, agent, quant_engine, prefix + en_raw, data_metadata,
3050
+ section_id=section_id, section_name=display_name,
3051
  evidence_contract=evidence_packet,
3052
+ include_critic=not deterministic_appendix,
3053
+ )
3054
+ if pre_validation_rows:
3055
+ if section_attempt < max_section_attempts:
3056
+ attempt_feedback = self._section_retry_feedback(pre_validation_rows)
3057
+ logging.warning(
3058
+ "Section validation retry | unit=%s attempt=%s count=%s types=%s",
3059
+ display_name, section_attempt, len(pre_validation_rows),
3060
+ sorted({str(row.get("type") or "") for row in pre_validation_rows if isinstance(row, dict)}),
3061
+ )
3062
+ self._restore_section_citation_state(
3063
+ agent, citation_plan, display_name, frozen_citation_state
3064
+ )
3065
+ continue
3066
+ self._restore_section_citation_state(
3067
+ agent, citation_plan, display_name, frozen_citation_state
3068
+ )
3069
+ state.update({
3070
+ "citation_plan": citation_plan,
3071
+ "agent_state": agent.export_state(),
3072
+ "quality_diagnostics": quality_diagnostics,
3073
+ })
3074
+ self._checkpoint_section_retry_exhausted(
3075
+ state, section_id=section_id, section_name=display_name,
3076
+ violations=pre_validation_rows, pct=pct, attempts=max_section_attempts,
3077
+ )
3078
+ raise RuntimeError(
3079
+ f"section_content_invalid_after_retry:{display_name}:{pre_validation_rows[:3]}"
3080
+ )
3081
+
3082
+ allow_deterministic_citation_reinforcement = not (
3083
+ mode == "asq" and section_id in {"methods", "discussion"}
3084
  )
3085
+ if not is_quarantined and assigned_tag_list and allow_deterministic_citation_reinforcement:
3086
+ en_raw = agent.reinforce_claim_specific_citation_coverage(
3087
+ en_raw, assigned_tag_list, section_name=display_name
3088
+ )
3089
+ self._progress(pct, f"citation_audit:{current_index}:{total_steps}")
3090
+ used_before = set(agent.used_citations)
3091
+ en_resolved = agent.audit_and_resolve_citations(
3092
+ prefix + en_raw, section_name=display_name, quarantine=is_quarantined
3093
+ )
3094
+ # Capture immediately: citation recovery may later replace the
3095
+ # agent's last-violation buffer. These violations belong to the
3096
+ # writer attempt and require the one bounded same-Section retry.
3097
+ citation_semantic_rows = copy.deepcopy(
3098
+ list(getattr(agent, "_last_citation_semantic_violations", []) or [])
3099
+ )
3100
+ if citation_semantic_rows:
3101
+ if section_attempt < max_section_attempts:
3102
+ attempt_feedback = self._section_retry_feedback(citation_semantic_rows)
3103
+ logging.warning(
3104
+ "Citation semantic Section retry | unit=%s attempt=%s count=%s types=%s",
3105
+ display_name, section_attempt, len(citation_semantic_rows),
3106
+ sorted({str(row.get("type") or "") for row in citation_semantic_rows if isinstance(row, dict)}),
3107
+ )
3108
+ self._restore_section_citation_state(
3109
+ agent, citation_plan, display_name, frozen_citation_state
3110
+ )
3111
+ continue
3112
+ self._restore_section_citation_state(
3113
+ agent, citation_plan, display_name, frozen_citation_state
3114
+ )
3115
+ state.update({
3116
+ "citation_plan": citation_plan,
3117
+ "agent_state": agent.export_state(),
3118
+ "quality_diagnostics": quality_diagnostics,
3119
+ })
3120
+ self._checkpoint_section_retry_exhausted(
3121
+ state, section_id=section_id, section_name=display_name,
3122
+ violations=citation_semantic_rows, pct=pct, attempts=max_section_attempts,
3123
+ )
3124
+ raise RuntimeError(
3125
+ f"section_citation_invalid_after_retry:{display_name}:{citation_semantic_rows[:3]}"
3126
+ )
3127
+
3128
+ used_after = set(agent.used_citations)
3129
+ survived = used_after - used_before
3130
+ if assigned_tag_list and not is_quarantined:
3131
+ required_survival = min(
3132
+ len(assigned_tag_list),
3133
+ max(1, int(math.ceil(len(assigned_tag_list) * CITATION_SEMANTIC_SURVIVAL_RATIO))),
3134
  )
3135
+ if len(survived) < required_survival:
3136
+ missing_items = [
3137
+ item for item in planned_items
3138
+ if str(item.get("tag") or "") not in used_after
3139
+ ]
3140
+ needed = max(0, required_survival - len(survived))
3141
+ recovery_items = agent.select_contextual_citation_recovery_items(
3142
+ missing_items, section_name=display_name, section_context=en_resolved,
3143
+ needed=needed, excluded_tags=used_after,
3144
+ )
3145
+ # Evidence retrieval is permitted only on the first writer
3146
+ # attempt. Attempt 2 is bound to the original frozen packet.
3147
+ if section_attempt == 1 and len(recovery_items) < needed:
3148
+ recovery_items.extend(
3149
+ agent.fetch_contextual_jit_recovery_items(
3150
+ topic=topic, section_name=display_name, section_context=en_resolved,
3151
+ needed=needed - len(recovery_items),
3152
+ excluded_tags=used_after | {str(item.get("tag") or "") for item in recovery_items},
3153
+ )
3154
  )
3155
+ planned_tags_for_unit = {str(item.get("tag") or "") for item in planned_items}
3156
+ for recovery_item in recovery_items[:needed]:
3157
+ tag = str(recovery_item.get("tag") or "")
3158
+ if tag and tag not in planned_tags_for_unit:
3159
+ planned_items.append(recovery_item)
3160
+ planned_tags_for_unit.add(tag)
3161
+ citation_plan[display_name] = planned_items
3162
+ # Recovery may attach evidence only to an already-written
3163
+ # claim. It never creates supplemental prose.
3164
+ self._progress(pct, f"citation_recovery:{current_index}:{total_steps}")
3165
+ recovered = agent.recover_citations_on_existing_claims(
3166
+ en_resolved,
3167
+ recovery_items[:needed],
3168
+ section_name=display_name,
3169
+ needed=needed,
3170
  )
3171
+ if recovered != en_resolved:
3172
+ en_resolved = recovered
3173
+ used_after = set(agent.used_citations)
3174
+ survived = used_after - used_before
3175
+
3176
+ if mode == "asq" and section_id == "findings":
3177
+ en_resolved = self._render_planned_qualitative_quotes(
 
 
 
 
 
 
 
 
3178
  en_resolved,
3179
+ task=sec,
3180
+ data_metadata=data_metadata,
3181
+ quote_invocation_plan=quote_invocation_plan,
3182
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3183
 
3184
+ # S0-R1 phase 2: only local/idempotent structural normalizers may
3185
+ # mutate the candidate before the final pure validation bundle.
3186
+ en_resolved, enumeration_rows = agent.repair_and_validate_enumeration_contract(
3187
+ en_resolved, section_name=display_name
 
3188
  )
3189
+ if enumeration_rows:
3190
  logging.warning(
3191
+ "Structural enumeration repaired | unit=%s count=%s details=%s",
3192
+ display_name, len(enumeration_rows), enumeration_rows[:3],
 
3193
  )
3194
+ attempt_diagnostics.append({
3195
+ "scope":"structural_enumeration", "name":display_name,
3196
+ "status":"deterministic_repair", "count":len(enumeration_rows),
3197
+ "examples":enumeration_rows[:3],
3198
  })
3199
+ en_resolved, duplicate_rows = self._remove_severe_cross_unit_duplicates(
3200
+ en_resolved, full_draft_en
 
 
 
 
3201
  )
3202
+ if duplicate_rows:
3203
  logging.warning(
3204
+ "Exact cross-unit redundancy removed | unit=%s count=%s",
3205
+ display_name, len(duplicate_rows),
 
3206
  )
3207
+ attempt_diagnostics.append({
3208
+ "scope":"manuscript_redundancy", "name":display_name,
3209
+ "status":"exact_duplicate_removed", "count":len(duplicate_rows),
3210
+ "examples":duplicate_rows[:3],
3211
  })
3212
+ en_resolved, sentence_echo_rows = agent.remove_severe_cross_unit_sentence_echoes(
3213
+ en_resolved, full_draft_en
3214
  )
3215
+ if sentence_echo_rows:
3216
+ logging.warning(
3217
+ "Exact cross-unit sentence echo removed | unit=%s count=%s",
3218
+ display_name, len(sentence_echo_rows),
3219
+ )
3220
+ attempt_diagnostics.append({
3221
+ "scope":"manuscript_sentence_echo", "name":display_name,
3222
+ "status":"exact_duplicate_removed", "count":len(sentence_echo_rows),
3223
+ "examples":sentence_echo_rows[:3],
3224
+ })
3225
+
3226
+ # Final Section validation is pure. Once it passes, the exact bytes
3227
+ # are eligible for the Section Integrity Seal and may never be
3228
+ # semantically rewritten by a later validator.
3229
+ post_validation_rows = self._section_validation_bundle(
3230
+ mode, agent, quant_engine, en_resolved, data_metadata,
3231
+ section_id=section_id, section_name=display_name,
3232
+ evidence_contract=evidence_packet, include_critic=False,
3233
+ )
3234
+ if post_validation_rows:
3235
+ if section_attempt < max_section_attempts:
3236
+ attempt_feedback = self._section_retry_feedback(post_validation_rows)
3237
+ logging.warning(
3238
+ "Post-normalization Section retry | unit=%s attempt=%s count=%s types=%s",
3239
+ display_name, section_attempt, len(post_validation_rows),
3240
+ sorted({str(row.get("type") or "") for row in post_validation_rows if isinstance(row, dict)}),
3241
+ )
3242
+ self._restore_section_citation_state(
3243
+ agent, citation_plan, display_name, frozen_citation_state
3244
+ )
3245
+ continue
3246
+ self._restore_section_citation_state(
3247
+ agent, citation_plan, display_name, frozen_citation_state
3248
+ )
3249
+ state.update({
3250
+ "citation_plan": citation_plan,
3251
+ "agent_state": agent.export_state(),
3252
+ "quality_diagnostics": quality_diagnostics,
3253
+ })
3254
+ self._checkpoint_section_retry_exhausted(
3255
+ state, section_id=section_id, section_name=display_name,
3256
+ violations=post_validation_rows, pct=pct, attempts=max_section_attempts,
3257
+ )
3258
+ raise RuntimeError(
3259
+ f"section_content_invalid_after_retry:{display_name}:{post_validation_rows[:3]}"
3260
+ )
3261
+
3262
+ quality_diagnostics.extend(attempt_diagnostics)
3263
+ state.pop("section_retry_exhausted", None)
3264
+ break
3265
 
3266
  # Recompute citation survival/claim demand from the exact text that
3267
  # will be sealed, not from an earlier pre-cleanup intermediate.
 
3492
  final_keywords = list(final_front.get("keywords") or [])
3493
  if not final_abstract or not final_keywords:
3494
  raise RuntimeError("final_front_matter_missing")
3495
+ final_front_fact_rows = self._study_fact_violations(
3496
  mode, agent, quant_engine, final_abstract, data_metadata,
3497
  section_name="Final Abstract",
3498
  )
 
 
3499
  if final_front_fact_rows:
3500
+ feedback = self._section_retry_feedback(final_front_fact_rows)
3501
  logging.warning(
3502
+ "Final Abstract pure-validation retry | mode=%s count=%s types=%s",
3503
  mode, len(final_front_fact_rows),
3504
+ sorted({str(row.get("type") or "") for row in final_front_fact_rows if isinstance(row, dict)}),
3505
+ )
3506
+ final_front = agent.generate_final_front_matter(
3507
+ topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
3508
+ validation_feedback=feedback,
3509
+ )
3510
+ final_abstract = str(final_front.get("abstract") or "").strip()
3511
+ final_keywords = list(final_front.get("keywords") or [])
3512
+ if not final_abstract or not final_keywords:
3513
+ raise RuntimeError("final_front_matter_missing_after_retry")
3514
+ remaining_final_fact_rows = self._study_fact_violations(
3515
+ mode, agent, quant_engine, final_abstract, data_metadata,
3516
+ section_name="Final Abstract",
3517
+ )
3518
+ if remaining_final_fact_rows:
3519
+ state["final_abstract_retry_exhausted"] = {
3520
+ "attempts": 2,
3521
+ "violations": copy.deepcopy(remaining_final_fact_rows[:12]),
3522
+ "semantic_integrity_corruption": False,
3523
+ }
3524
+ state.update({
3525
+ "agent_state": agent.export_state(),
3526
+ "quality_diagnostics": quality_diagnostics,
3527
+ })
3528
+ self._checkpoint(
3529
+ "final_abstract_retry_exhausted", state, 92,
3530
+ display_node="final_abstract_retry_exhausted",
3531
+ )
3532
+ raise RuntimeError(
3533
+ f"final_abstract_invalid_after_retry:{remaining_final_fact_rows[:3]}"
3534
+ )
3535
  quality_diagnostics.append({
3536
  "scope":"final_abstract_study_fact_integrity", "name":"Final Abstract",
3537
+ "status":"front_matter_retry_resolved", "count":len(final_front_fact_rows),
3538
  "examples":final_front_fact_rows[:3],
3539
  })
3540
+ state.pop("final_abstract_retry_exhausted", None)
3541
  full_draft_en = replace_abstract(full_draft_en, final_abstract)
3542
  full_draft_en = self._replace_keywords(full_draft_en, final_keywords)
3543
  body_only, _reference_tail = agent.validate_reference_tail(
core/pipeline_agent.py CHANGED
@@ -1468,7 +1468,7 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1468
  raise DeepSeekRequestError(f'{phase} front matter validation failed: {last_error}')
1469
 
1470
  def generate_working_front_matter(
1471
- self, topic, keywords, research_logic_contract, synthesis_result, data_evidence='', mode='amj'
1472
  ):
1473
  """Create a provisional visible abstract/keyword anchor without prewriting results."""
1474
  evidence = self._truncate_context(str(data_evidence or ''), 3500)
@@ -1488,7 +1488,10 @@ USER KEYWORD SEEDS: {keywords}
1488
  </LITERATURE_SYNTHESIS>
1489
  <FROZEN_RESEARCH_EVIDENCE>
1490
  {evidence}
1491
- </FROZEN_RESEARCH_EVIDENCE>"""
 
 
 
1492
  def validate_working(parsed):
1493
  abstract = self._validate_working_abstract(parsed.get('abstract'))
1494
  working_keywords = self._front_matter_keywords(parsed.get('keywords'))
@@ -1499,7 +1502,7 @@ USER KEYWORD SEEDS: {keywords}
1499
  prompt, phase='Working', max_tokens=1100, validator=validate_working,
1500
  )
1501
 
1502
- def generate_final_front_matter(self, topic, keywords, manuscript, data_evidence='', mode='amj'):
1503
  """Generate final Abstract + Keywords only from the frozen completed body/evidence."""
1504
  evidence = self._truncate_context(str(data_evidence or ''), 5000)
1505
  body = self._abstract_manuscript_context(manuscript, 18000)
@@ -1514,11 +1517,17 @@ USER KEYWORD SEEDS: {keywords}
1514
  </FROZEN_RESEARCH_EVIDENCE>
1515
  <COMPLETED_FROZEN_MANUSCRIPT>
1516
  {body}
1517
- </COMPLETED_FROZEN_MANUSCRIPT>"""
 
 
 
1518
  def validate_final(parsed):
1519
  abstract = re.sub(r'(?im)^#+\s*abstract\s*', '', str(parsed.get('abstract') or '')).strip()
1520
  if self._front_matter_has_citation(abstract):
1521
  raise DeepSeekRequestError('Final Abstract must not contain citations.')
 
 
 
1522
  final_keywords = self._front_matter_keywords(parsed.get('keywords'))
1523
  return {'abstract': abstract, 'keywords': final_keywords}
1524
 
@@ -1527,9 +1536,8 @@ USER KEYWORD SEEDS: {keywords}
1527
  prompt, phase='Final', max_tokens=1200, validator=validate_final,
1528
  )
1529
  abstract = structured['abstract']
1530
- abstract = self.polish_academic_section(abstract, 'Abstract', abstract=True, mode=mode)
1531
  if self._front_matter_has_citation(abstract):
1532
- raise DeepSeekRequestError('Final Abstract revision introduced a citation.')
1533
  max_words = max(80, int(APP_CONFIG.get('manuscript_quality', {}).get('abstract_max_words', 200)))
1534
  if not abstract:
1535
  raise DeepSeekRequestError('Final Abstract is empty after validation.')
@@ -3667,6 +3675,115 @@ INPUT:
3667
  )
3668
  return bool(strong.search(value))
3669
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3670
  @classmethod
3671
  def repair_qualitative_study_fact_contract(cls, text, metadata, section_name=""):
3672
  """Repair only high-confidence *study-owned* qualitative fact violations.
@@ -3842,9 +3959,9 @@ INPUT:
3842
 
3843
  @classmethod
3844
  def validate_qualitative_study_fact_contract(cls, text, metadata, section_name=""):
3845
- repaired, diagnostics = cls.repair_qualitative_study_fact_contract(text, metadata, section_name=section_name)
3846
- if repaired.strip() != str(text or "").strip():
3847
- raise RuntimeError(f"qualitative_study_fact_contract_unrepaired:{section_name}:{diagnostics[:3]}")
3848
  return True
3849
 
3850
  @classmethod
@@ -3954,8 +4071,8 @@ INPUT:
3954
  union = tokens | old_tokens
3955
  similarity = len(tokens & old_tokens) / max(1, len(union))
3956
  normalized_same = re.sub(r"\W+", " ", core).strip().casefold() == re.sub(r"\W+", " ", old).strip().casefold()
3957
- if normalized_same or similarity >= 0.96:
3958
- duplicate = (1.0 if normalized_same else similarity, old[:180])
3959
  break
3960
  if duplicate:
3961
  replacements.append((start, end, ""))
@@ -3970,19 +4087,36 @@ INPUT:
3970
  value = re.sub(r" {2,}", " ", value)
3971
  return value.strip(), diagnostics
3972
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3973
  def semantic_audit_claim_specific_citations(
3974
  self,
3975
  text,
3976
  section_name="",
3977
  ):
3978
- """Audit cited sentences against retrieved evidence at sentence scope.
3979
 
3980
- The audit may split a sentence when different sources support different
3981
- claims, or keep one source when several sources support the same claim.
3982
- It may not invent facts, numbers, quotations, or citation tags. A cited
3983
- claim that cannot be supported is removed from the manuscript and logged;
3984
- one unsupported sentence must not discard an otherwise valid paper.
3985
  """
 
3986
  if not text:
3987
  return text
3988
 
@@ -4158,83 +4292,62 @@ Return exactly:
4158
  status = str(item.get("status", "")).strip().lower()
4159
  revised = str(item.get("revised_text", "")).strip()
4160
  if status not in {
4161
- "ok",
4162
- "split",
4163
- "uncited_common_knowledge",
4164
- "unsupported",
4165
  }:
4166
  raise DeepSeekRequestError(
4167
  f"Invalid citation audit status for {record['id']}: {status!r}"
4168
  )
4169
- if status == "unsupported":
4170
- reason = str(item.get("reason", ""))[:500]
4171
- unsupported.append(
4172
- {
4173
- "id": record["id"],
4174
- "sentence": record["original"][:500],
4175
- "reason": reason,
4176
- }
4177
- )
4178
- # An unsupported externally verifiable claim cannot remain in
4179
- # the manuscript with an arbitrary citation. Remove only this
4180
- # sentence and retain an auditable record of the decision.
4181
- replacements[record["id"]] = ""
4182
- self._append_citation_audit(
4183
- section_name,
4184
- "remove_unsupported_claim",
4185
- record["original"],
4186
- "",
4187
- reason or "No candidate evidence directly supported the claim.",
4188
- )
4189
- continue
4190
- if not revised:
4191
- raise DeepSeekRequestError(
4192
- f"Citation semantic audit returned empty text for {record['id']}"
4193
- )
4194
  revised_tags = {
4195
  f"[{tag.upper()}]"
4196
  for tag in re.findall(r"CITE-\d+", revised, flags=re.IGNORECASE)
4197
  }
4198
- if status in {"ok", "split"} and not revised_tags:
4199
- raise DeepSeekRequestError(
4200
- f"Citation semantic audit removed all citation tags for {record['id']} "
4201
- f"while status={status}."
4202
- )
4203
  disallowed = revised_tags - set(record["allowed_tags"])
4204
  if disallowed:
4205
  raise DeepSeekRequestError(
4206
  f"Citation semantic audit invented tag(s) for {record['id']}: "
4207
  f"{sorted(disallowed)}"
4208
  )
4209
- if self._numeric_tokens(record["original"]) != self._numeric_tokens(revised):
4210
- raise DeepSeekRequestError(
4211
- f"Citation semantic audit changed numerical content for {record['id']}"
 
 
 
 
 
 
 
 
 
 
4212
  )
4213
- missing_quotes = [
4214
- quote for quote in self._quoted_passages(record["original"])
4215
- if quote not in revised
4216
- ]
4217
- if missing_quotes:
4218
- raise DeepSeekRequestError(
4219
- f"Citation semantic audit changed quoted evidence for {record['id']}"
 
 
 
 
4220
  )
4221
- sentence_violations = self.find_multi_citation_violations(revised)
4222
- if sentence_violations:
 
 
 
4223
  raise DeepSeekRequestError(
4224
- f"Citation semantic audit left multiple citations for {record['id']}: "
4225
- f"{sentence_violations[:2]}"
4226
  )
4227
- replacements[record["id"]] = self._preserve_sentence_boundary_whitespace(
4228
- record["original"],
4229
- revised,
4230
- )
4231
 
4232
  if unsupported:
4233
  logging.warning(
4234
- "Citation audit removed unsupported claims: section=%s count=%s examples=%s",
4235
- section_name,
4236
- len(unsupported),
4237
- json.dumps(unsupported[:3], ensure_ascii=False),
4238
  )
4239
 
4240
  rebuilt = []
@@ -4247,14 +4360,6 @@ Return exactly:
4247
  audited = "".join(rebuilt)
4248
  audited = re.sub(r"[ \t]+\n", "\n", audited)
4249
  audited = re.sub(r"\n{3,}", "\n\n", audited).strip()
4250
- if unsupported:
4251
- repaired = self._repair_local_discourse_after_citation_removal(text, audited, section_name)
4252
- if repaired != audited:
4253
- self._append_citation_audit(
4254
- section_name, "repair_local_discourse_framing", audited, repaired,
4255
- "Renumbered surviving ordinal/enumeration framing after unsupported claim removal; no external claim added.",
4256
- )
4257
- audited = repaired
4258
  violations = self.find_multi_citation_violations(audited)
4259
  if violations:
4260
  raise DeepSeekRequestError(
@@ -4290,13 +4395,9 @@ Return exactly:
4290
  return coverage, overlap
4291
 
4292
  def _local_citation_recovery(self, text, section_name='', retain_lexical=True):
4293
- """Conservative deterministic recovery for technical audit failures.
4294
-
4295
- This routine never keeps the first citation merely because it appeared
4296
- first. A source is retained only when lexical evidence is sufficiently
4297
- strong; otherwise the externally verifiable sentence is removed and the
4298
- decision is written to the citation audit log.
4299
- """
4300
  rebuilt = []
4301
  cursor = 0
4302
  for start, end, sentence in self._sentence_spans(text):
@@ -4306,79 +4407,38 @@ Return exactly:
4306
  unique_tags = list(dict.fromkeys(tags))
4307
  valid = [tag for tag in unique_tags if tag in self.citation_map]
4308
  revised = sentence
4309
-
4310
  if unique_tags and not valid:
4311
- revised = re.sub(r'\s*[\[(]?CITE-\d+[\])]?\s*', ' ', sentence, flags=re.I)
4312
- revised = re.sub(r'\s+', ' ', revised).strip()
4313
- self._append_citation_audit(
4314
- section_name,
4315
- 'remove_unknown_tag',
4316
- original,
4317
- revised,
4318
- 'No valid citation tag was available.',
4319
- )
4320
  elif len(valid) == 1:
4321
  selected_tag = valid[0]
4322
  coverage, overlap = self._citation_support_score(sentence, selected_tag)
4323
  if retain_lexical and overlap >= 2 and coverage >= 0.12:
4324
- self._append_citation_audit(
4325
- section_name,
4326
- 'retain_lexically_supported_source',
4327
- original,
4328
- sentence,
4329
- f'Lexical support overlap={overlap}, coverage={coverage:.3f}.',
4330
- )
4331
  else:
4332
- revised = ''
4333
- self._append_citation_audit(
4334
- section_name,
4335
- 'remove_unverified_claim',
4336
- original,
4337
- '',
4338
- f'The single candidate did not meet the conservative support threshold; overlap={overlap}, coverage={coverage:.3f}.',
4339
- )
4340
  elif len(valid) > 1:
4341
  scored = sorted(
4342
- (
4343
- (*self._citation_support_score(sentence, tag), -self._citation_tag_number(tag), tag)
4344
- for tag in valid
4345
- ),
4346
  reverse=True,
4347
  )
4348
  coverage, overlap, _, selected_tag = scored[0]
4349
  if retain_lexical and overlap >= 2 and coverage >= 0.12:
4350
- revised = re.sub(
4351
- r'\s*[\[(]?CITE-\d+(?:\s*[,;]\s*CITE-\d+)*[\])]?\s*',
4352
- ' ',
4353
- sentence,
4354
- flags=re.I,
4355
- )
4356
- revised = re.sub(r'\s+', ' ', revised).strip()
4357
- punctuation = revised[-1] if revised and revised[-1] in '.!?。!?' else ''
4358
- if punctuation:
4359
- revised = revised[:-1].rstrip()
4360
- revised = f"{revised} {selected_tag}{punctuation}"
4361
- self._append_citation_audit(
4362
- section_name,
4363
- 'select_evidence_supported_source',
4364
- original,
4365
- revised,
4366
- f'Lexical support overlap={overlap}, coverage={coverage:.3f}.',
4367
- )
4368
  else:
4369
- revised = ''
4370
- self._append_citation_audit(
4371
- section_name,
4372
- 'remove_unverified_claim',
4373
- original,
4374
- '',
4375
- f'No candidate met the conservative support threshold; best overlap={overlap}, coverage={coverage:.3f}.',
4376
- )
4377
- rebuilt.append(
4378
- self._preserve_sentence_boundary_whitespace(original, revised)
4379
- if revised
4380
- else ""
4381
- )
4382
  cursor = end
4383
  rebuilt.append(str(text)[cursor:])
4384
  recovered = ''.join(rebuilt)
@@ -4461,6 +4521,7 @@ Return exactly:
4461
  return revised
4462
 
4463
  def audit_and_resolve_citations(self, text, section_name='', quarantine=False):
 
4464
  value = str(text or '')
4465
  if quarantine:
4466
  return self.resolve_citations(value, quarantine=True)
@@ -4505,6 +4566,21 @@ Return exactly:
4505
  )
4506
  return self.resolve_citations(value, quarantine=False)
4507
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4508
  @staticmethod
4509
  def _remove_ai_meta_traces(text):
4510
  """Bounded deterministic repair for high-confidence authorial meta only.
@@ -4638,13 +4714,13 @@ TEXT:
4638
  return value
4639
  return revised.strip()
4640
 
4641
- def critique_and_repair_section(self, text, *, section_id, section_name, mode, evidence_contract=''):
4642
- """Independent top-journal critic with bounded local repair."""
4643
  value = str(text or '').strip()
4644
  mode = 'asq' if str(mode).lower() == 'asq' else 'amj'
4645
  critical_sections = ({'introduction', 'theory', 'discussion'} if mode == 'amj' else {'introduction', 'theoretical_framing', 'findings', 'discussion'})
4646
  if str(section_id or '') not in critical_sections or len(value.split()) < 120:
4647
- return value
4648
  dimensions = APP_CONFIG.get('top_journal_critic_profiles', {}).get(mode, [])
4649
  profile = APP_CONFIG.get('section_reasoning_profiles', {}).get(mode, {}).get(str(section_id or ''), '')
4650
  prompt = f"""Assess ONLY this manuscript unit as an independent top-journal critic. Return valid JSON only:
@@ -4660,35 +4736,30 @@ TEXT:
4660
  )
4661
  parsed = self._extract_json_from_text(raw)
4662
  if not isinstance(parsed, dict) or parsed.get('pass') is not False:
4663
- return value
4664
- instruction = re.sub(r'\s+', ' ', str(parsed.get('repair_instruction') or parsed.get('critical_issue') or '')).strip()
 
4665
  if not instruction:
4666
- return value
4667
- repair_prompt = f"""Repair only the material issue identified by an independent reviewer.
4668
- Do not change or remove any number, citation tag, quotation/QUOTE tag, hypothesis label, variable abbreviation, heading, or empirical conclusion direction. Do not add a new statistic, source, quotation, setting fact, sample fact, or method. Preserve the approximate length.
4669
- MODE: {mode}
4670
- SECTION: {section_name}
4671
- REVIEWER INSTRUCTION: {instruction}
4672
- EVIDENCE CONTRACT: {evidence_contract[:4500]}
4673
- TEXT:
4674
- {value}
4675
- Output only the repaired section."""
4676
- revised = self.call_deepseek(
4677
- 'You are a meticulous top-journal academic editor.', repair_prompt,
4678
- max_tokens=max(1600, min(8000, self._count_tokens(value) + 1000)), temp=0.1,
4679
- model_role='writing', thinking=False, allow_fallback=False,
4680
- ).strip()
4681
- invariants = [
4682
- (self._numeric_tokens(value), self._numeric_tokens(revised)),
4683
- (Counter(re.findall(r'CITE-\d+', value, re.I)), Counter(re.findall(r'CITE-\d+', revised, re.I))),
4684
- (Counter(re.findall(r'QUOTE-\d+', value, re.I)), Counter(re.findall(r'QUOTE-\d+', revised, re.I))),
4685
- (Counter(re.findall(r'\bH\d+[A-Za-z]?\b', value)), Counter(re.findall(r'\bH\d+[A-Za-z]?\b', revised))),
4686
- ]
4687
- if any(left != right for left, right in invariants):
4688
- logging.warning('Top-journal local repair rejected because protected content changed: %s', section_name)
4689
- return value
4690
- logging.info('Top-journal local repair accepted: section=%s issue=%s', section_name, instruction[:180])
4691
- return self._remove_ai_meta_traces(revised)
4692
 
4693
  def _abstract_manuscript_context(self, manuscript, max_tokens=18000):
4694
  value = str(manuscript or '').strip()
 
1468
  raise DeepSeekRequestError(f'{phase} front matter validation failed: {last_error}')
1469
 
1470
  def generate_working_front_matter(
1471
+ self, topic, keywords, research_logic_contract, synthesis_result, data_evidence='', mode='amj', validation_feedback=''
1472
  ):
1473
  """Create a provisional visible abstract/keyword anchor without prewriting results."""
1474
  evidence = self._truncate_context(str(data_evidence or ''), 3500)
 
1488
  </LITERATURE_SYNTHESIS>
1489
  <FROZEN_RESEARCH_EVIDENCE>
1490
  {evidence}
1491
+ </FROZEN_RESEARCH_EVIDENCE>
1492
+ <LOCAL_VALIDATION_FEEDBACK>
1493
+ {str(validation_feedback or '')[:2500]}
1494
+ </LOCAL_VALIDATION_FEEDBACK>"""
1495
  def validate_working(parsed):
1496
  abstract = self._validate_working_abstract(parsed.get('abstract'))
1497
  working_keywords = self._front_matter_keywords(parsed.get('keywords'))
 
1502
  prompt, phase='Working', max_tokens=1100, validator=validate_working,
1503
  )
1504
 
1505
+ def generate_final_front_matter(self, topic, keywords, manuscript, data_evidence='', mode='amj', validation_feedback=''):
1506
  """Generate final Abstract + Keywords only from the frozen completed body/evidence."""
1507
  evidence = self._truncate_context(str(data_evidence or ''), 5000)
1508
  body = self._abstract_manuscript_context(manuscript, 18000)
 
1517
  </FROZEN_RESEARCH_EVIDENCE>
1518
  <COMPLETED_FROZEN_MANUSCRIPT>
1519
  {body}
1520
+ </COMPLETED_FROZEN_MANUSCRIPT>
1521
+ <LOCAL_VALIDATION_FEEDBACK>
1522
+ {str(validation_feedback or '')[:2500]}
1523
+ </LOCAL_VALIDATION_FEEDBACK>"""
1524
  def validate_final(parsed):
1525
  abstract = re.sub(r'(?im)^#+\s*abstract\s*', '', str(parsed.get('abstract') or '')).strip()
1526
  if self._front_matter_has_citation(abstract):
1527
  raise DeepSeekRequestError('Final Abstract must not contain citations.')
1528
+ meta_findings = self.inspect_authorial_ai_meta(abstract)
1529
+ if meta_findings:
1530
+ raise DeepSeekRequestError('Final Abstract contains high-confidence authorial AI-writing meta-commentary.')
1531
  final_keywords = self._front_matter_keywords(parsed.get('keywords'))
1532
  return {'abstract': abstract, 'keywords': final_keywords}
1533
 
 
1536
  prompt, phase='Final', max_tokens=1200, validator=validate_final,
1537
  )
1538
  abstract = structured['abstract']
 
1539
  if self._front_matter_has_citation(abstract):
1540
+ raise DeepSeekRequestError('Final Abstract contains a citation after structured validation.')
1541
  max_words = max(80, int(APP_CONFIG.get('manuscript_quality', {}).get('abstract_max_words', 200)))
1542
  if not abstract:
1543
  raise DeepSeekRequestError('Final Abstract is empty after validation.')
 
3675
  )
3676
  return bool(strong.search(value))
3677
 
3678
+ @classmethod
3679
+ def inspect_qualitative_study_fact_contract(cls, text, metadata, section_name=""):
3680
+ """Pure ASQ Study Evidence inspection. Never mutates manuscript prose.
3681
+
3682
+ S0-R1 deliberately prefers false negatives to destructive false positives.
3683
+ Actor/source violations are reported only when the sentence contains an
3684
+ explicit grammatical relation (for example ``interviews with farmers`` or
3685
+ ``farmers were interviewed``). Mere proximity is not evidence ownership.
3686
+ External literature and participant quotations are excluded.
3687
+ """
3688
+ value = str(text or "")
3689
+ facts = dict((metadata or {}).get("study_facts") or {})
3690
+ relations = [row for row in (facts.get("data_source_roles") or []) if isinstance(row, dict)]
3691
+ allowed_by_actor = {}
3692
+ for row in relations:
3693
+ role = re.sub(r"\s+", " ", str(row.get("actor_role") or "")).strip()
3694
+ family = cls._qual_source_family(row.get("data_source"))
3695
+ if not role or not family:
3696
+ continue
3697
+ for variant in cls._qual_actor_variants(role):
3698
+ allowed_by_actor.setdefault(variant, set()).add(family)
3699
+
3700
+ procedure_lists = {
3701
+ "trustworthiness": [str(x or "").strip().casefold() for x in (facts.get("trustworthiness_procedures") or []) if str(x or "").strip()],
3702
+ "ethics": [str(x or "").strip().casefold() for x in (facts.get("ethics_procedures") or []) if str(x or "").strip()],
3703
+ "reflexivity": [str(x or "").strip().casefold() for x in (facts.get("reflexivity_procedures") or []) if str(x or "").strip()],
3704
+ }
3705
+ procedure_patterns = {
3706
+ "trustworthiness": r"\bmember\s+check(?:ing|s)?\b|\bpeer\s+debrief(?:ing|ed)?\b|\baudit\s+trail\b|\bexternal\s+audit\b",
3707
+ "ethics": r"\binformed\s+consent\b|\bethics?\s+approval\b|\binstitutional\s+review\s+board\b|\bIRB\b|\bapproval\s+(?:number|code)\b",
3708
+ "reflexivity": r"\bfield\s+journal\b|\breflexive\s+journal\b|\bpositionality\s+(?:statement|memo)\b|\breflexive\s+memo(?:s)?\b",
3709
+ }
3710
+ source_patterns = {
3711
+ "interview": r"(?:semi[- ]structured\s+)?interviews?|interviewed",
3712
+ "focus_group": r"focus\s+groups?",
3713
+ "observation": r"(?:field\s+)?observations?|observed",
3714
+ "log": r"(?:usage|interaction|system)\s+logs?|log\s+data",
3715
+ "document": r"documents?|archival\s+(?:records?|materials?)|records?",
3716
+ }
3717
+ source_union = "(?:" + "|".join(source_patterns.values()) + ")"
3718
+
3719
+ def procedure_key(raw):
3720
+ observed = str(raw or "").casefold()
3721
+ if "member check" in observed: return "member check"
3722
+ if "peer debrief" in observed: return "peer debrief"
3723
+ if "audit trail" in observed: return "audit trail"
3724
+ if "external audit" in observed: return "external audit"
3725
+ if "informed consent" in observed: return "informed consent"
3726
+ if "ethic" in observed and "approval" in observed: return "ethics approval"
3727
+ if "review board" in observed or observed.strip() == "irb": return "review board"
3728
+ if "approval" in observed: return "approval"
3729
+ if "field journal" in observed: return "field journal"
3730
+ if "reflexive journal" in observed: return "reflexive journal"
3731
+ if "positionality" in observed: return "positionality"
3732
+ return "reflexive memo"
3733
+
3734
+ violations = []
3735
+ seen = set()
3736
+ for _start, _end, sentence in cls._sentence_spans(value):
3737
+ core = str(sentence or "")
3738
+ if not core.strip() or not cls._is_study_owned_qualitative_claim(core, section_name):
3739
+ continue
3740
+
3741
+ # Check every explicit procedure mention; do not stop after the first.
3742
+ for category, pattern in procedure_patterns.items():
3743
+ for match in re.finditer(pattern, core, flags=re.I):
3744
+ key = procedure_key(match.group(0))
3745
+ authorized = any(key in item for item in procedure_lists[category])
3746
+ if authorized:
3747
+ continue
3748
+ sig = ("procedure", category, key, match.start())
3749
+ if sig in seen:
3750
+ continue
3751
+ seen.add(sig)
3752
+ violations.append({
3753
+ "type":"unsupported_study_procedure", "category":category,
3754
+ "procedure":key, "preview":core.strip()[:180],
3755
+ })
3756
+
3757
+ # Explicit actor/source ownership only. These patterns intentionally
3758
+ # do not use nearest-distance heuristics.
3759
+ for actor_variant, allowed_families in allowed_by_actor.items():
3760
+ actor = re.escape(actor_variant)
3761
+ patterns = [
3762
+ rf"(?P<source>{source_union})\s+(?:with|of|from|among|involving)\s+(?:the\s+)?(?P<actor>{actor})(?![A-Za-z])",
3763
+ rf"(?P<source>interviewed|observed)\s+(?:the\s+)?(?P<actor>{actor})(?![A-Za-z])",
3764
+ rf"(?P<actor>(?<![A-Za-z]){actor}(?![A-Za-z]))\s+(?:were|was|are|is)\s+(?P<source>interviewed|observed)\b",
3765
+ rf"(?P<actor>(?<![A-Za-z]){actor}(?![A-Za-z]))\s+(?:participated|took\s+part)\s+in\s+(?P<source>{source_union})",
3766
+ rf"(?P<actor>(?<![A-Za-z]){actor}(?![A-Za-z]))\s+(?:provided|submitted|generated)\s+(?P<source>{source_union})",
3767
+ rf"(?P<source>interview|focus\s+group|observation)\s+participants?[^.!?\n]{{0,120}}(?P<actor>(?<![A-Za-z]){actor}(?![A-Za-z]))",
3768
+ ]
3769
+ for pattern in patterns:
3770
+ for match in re.finditer(pattern, core, flags=re.I):
3771
+ raw_source = match.group("source")
3772
+ family = cls._qual_source_family(raw_source)
3773
+ if not family or family in allowed_families:
3774
+ continue
3775
+ sig = ("relation", actor_variant, family, match.start(), match.end())
3776
+ if sig in seen:
3777
+ continue
3778
+ seen.add(sig)
3779
+ violations.append({
3780
+ "type":"actor_data_source_relation_mismatch",
3781
+ "actor":actor_variant, "observed":family,
3782
+ "allowed":sorted(allowed_families),
3783
+ "preview":core.strip()[:180],
3784
+ })
3785
+ return violations
3786
+
3787
  @classmethod
3788
  def repair_qualitative_study_fact_contract(cls, text, metadata, section_name=""):
3789
  """Repair only high-confidence *study-owned* qualitative fact violations.
 
3959
 
3960
  @classmethod
3961
  def validate_qualitative_study_fact_contract(cls, text, metadata, section_name=""):
3962
+ violations = cls.inspect_qualitative_study_fact_contract(text, metadata, section_name=section_name)
3963
+ if violations:
3964
+ raise RuntimeError(f"qualitative_study_fact_contract_invalid:{section_name}:{violations[:3]}")
3965
  return True
3966
 
3967
  @classmethod
 
4071
  union = tokens | old_tokens
4072
  similarity = len(tokens & old_tokens) / max(1, len(union))
4073
  normalized_same = re.sub(r"\W+", " ", core).strip().casefold() == re.sub(r"\W+", " ", old).strip().casefold()
4074
+ if normalized_same:
4075
+ duplicate = (1.0, old[:180])
4076
  break
4077
  if duplicate:
4078
  replacements.append((start, end, ""))
 
4087
  value = re.sub(r" {2,}", " ", value)
4088
  return value.strip(), diagnostics
4089
 
4090
+ @classmethod
4091
+ def _citation_tag_only_rewrite(cls, original, selected_tag=None):
4092
+ """Change citation markup only; preserve academic claim wording."""
4093
+ source = str(original or "")
4094
+ leading = source[: len(source) - len(source.lstrip())]
4095
+ trailing = source[len(source.rstrip()):]
4096
+ core = source.strip()
4097
+ core = re.sub(r"\s*[\[(]?CITE-\d+[\])]?\s*", " ", core, flags=re.I)
4098
+ core = re.sub(r"[ \t]{2,}", " ", core).strip()
4099
+ if selected_tag:
4100
+ tag = str(selected_tag).strip()
4101
+ punctuation = core[-1] if core and core[-1] in '.!?。!?' else ''
4102
+ if punctuation:
4103
+ core = core[:-1].rstrip()
4104
+ core = f"{core} {tag}{punctuation}".strip()
4105
+ return f"{leading}{core}{trailing}"
4106
+
4107
  def semantic_audit_claim_specific_citations(
4108
  self,
4109
  text,
4110
  section_name="",
4111
  ):
4112
+ """Audit citation support while granting the audit TAG-ONLY mutation authority.
4113
 
4114
+ S0-R1 forbids citation QA from rewriting or deleting academic claims.
4115
+ Unsupported or split-required claims are returned as typed section
4116
+ violations for the owning Section Writer retry; supported decisions may
4117
+ only select/remove citation tags.
 
4118
  """
4119
+ self._last_citation_semantic_violations = []
4120
  if not text:
4121
  return text
4122
 
 
4292
  status = str(item.get("status", "")).strip().lower()
4293
  revised = str(item.get("revised_text", "")).strip()
4294
  if status not in {
4295
+ "ok", "split", "uncited_common_knowledge", "unsupported",
 
 
 
4296
  }:
4297
  raise DeepSeekRequestError(
4298
  f"Invalid citation audit status for {record['id']}: {status!r}"
4299
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4300
  revised_tags = {
4301
  f"[{tag.upper()}]"
4302
  for tag in re.findall(r"CITE-\d+", revised, flags=re.IGNORECASE)
4303
  }
 
 
 
 
 
4304
  disallowed = revised_tags - set(record["allowed_tags"])
4305
  if disallowed:
4306
  raise DeepSeekRequestError(
4307
  f"Citation semantic audit invented tag(s) for {record['id']}: "
4308
  f"{sorted(disallowed)}"
4309
  )
4310
+ reason = str(item.get("reason", ""))[:500]
4311
+ if status == "unsupported":
4312
+ violation = {
4313
+ "type":"citation_unsupported_claim", "id":record["id"],
4314
+ "sentence":record["original"][:500], "reason":reason,
4315
+ }
4316
+ unsupported.append(violation)
4317
+ self._last_citation_semantic_violations.append(violation)
4318
+ replacements[record["id"]] = self._citation_tag_only_rewrite(record["original"], None)
4319
+ self._append_citation_audit(
4320
+ section_name, "flag_unsupported_claim_for_section_retry",
4321
+ record["original"], replacements[record["id"]],
4322
+ reason or "No candidate evidence directly supported the claim.",
4323
  )
4324
+ continue
4325
+ if status == "split":
4326
+ violation = {
4327
+ "type":"citation_claim_split_required", "id":record["id"],
4328
+ "sentence":record["original"][:500], "reason":reason,
4329
+ }
4330
+ self._last_citation_semantic_violations.append(violation)
4331
+ replacements[record["id"]] = self._citation_tag_only_rewrite(record["original"], None)
4332
+ self._append_citation_audit(
4333
+ section_name, "flag_claim_split_for_section_retry",
4334
+ record["original"], replacements[record["id"]], reason,
4335
  )
4336
+ continue
4337
+ if status == "uncited_common_knowledge":
4338
+ replacements[record["id"]] = self._citation_tag_only_rewrite(record["original"], None)
4339
+ continue
4340
+ if len(revised_tags) != 1:
4341
  raise DeepSeekRequestError(
4342
+ f"Citation semantic audit must select exactly one citation tag for {record['id']} while status=ok."
 
4343
  )
4344
+ selected = next(iter(revised_tags))
4345
+ replacements[record["id"]] = self._citation_tag_only_rewrite(record["original"], selected)
 
 
4346
 
4347
  if unsupported:
4348
  logging.warning(
4349
+ "Citation audit flagged unsupported claims for section retry: section=%s count=%s examples=%s",
4350
+ section_name, len(unsupported), json.dumps(unsupported[:3], ensure_ascii=False),
 
 
4351
  )
4352
 
4353
  rebuilt = []
 
4360
  audited = "".join(rebuilt)
4361
  audited = re.sub(r"[ \t]+\n", "\n", audited)
4362
  audited = re.sub(r"\n{3,}", "\n\n", audited).strip()
 
 
 
 
 
 
 
 
4363
  violations = self.find_multi_citation_violations(audited)
4364
  if violations:
4365
  raise DeepSeekRequestError(
 
4395
  return coverage, overlap
4396
 
4397
  def _local_citation_recovery(self, text, section_name='', retain_lexical=True):
4398
+ """Conservative TAG-ONLY recovery for citation audit technical failures."""
4399
+ if not hasattr(self, '_last_citation_semantic_violations'):
4400
+ self._last_citation_semantic_violations = []
 
 
 
 
4401
  rebuilt = []
4402
  cursor = 0
4403
  for start, end, sentence in self._sentence_spans(text):
 
4407
  unique_tags = list(dict.fromkeys(tags))
4408
  valid = [tag for tag in unique_tags if tag in self.citation_map]
4409
  revised = sentence
 
4410
  if unique_tags and not valid:
4411
+ revised = self._citation_tag_only_rewrite(original, None)
4412
+ self._append_citation_audit(section_name, 'remove_unknown_tag', original, revised, 'No valid citation tag was available.')
 
 
 
 
 
 
 
4413
  elif len(valid) == 1:
4414
  selected_tag = valid[0]
4415
  coverage, overlap = self._citation_support_score(sentence, selected_tag)
4416
  if retain_lexical and overlap >= 2 and coverage >= 0.12:
4417
+ revised = self._citation_tag_only_rewrite(original, selected_tag)
 
 
 
 
 
 
4418
  else:
4419
+ revised = self._citation_tag_only_rewrite(original, None)
4420
+ self._last_citation_semantic_violations.append({
4421
+ 'type':'citation_support_unverified', 'sentence':str(original).strip()[:500],
4422
+ 'reason':f'No verified citation support; overlap={overlap}, coverage={coverage:.3f}.',
4423
+ })
4424
+ self._append_citation_audit(section_name, 'flag_unverified_claim_for_section_retry', original, revised, f'overlap={overlap}, coverage={coverage:.3f}')
 
 
4425
  elif len(valid) > 1:
4426
  scored = sorted(
4427
+ ((*self._citation_support_score(sentence, tag), -self._citation_tag_number(tag), tag) for tag in valid),
 
 
 
4428
  reverse=True,
4429
  )
4430
  coverage, overlap, _, selected_tag = scored[0]
4431
  if retain_lexical and overlap >= 2 and coverage >= 0.12:
4432
+ revised = self._citation_tag_only_rewrite(original, selected_tag)
4433
+ self._append_citation_audit(section_name, 'select_evidence_supported_source', original, revised, f'overlap={overlap}, coverage={coverage:.3f}')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4434
  else:
4435
+ revised = self._citation_tag_only_rewrite(original, None)
4436
+ self._last_citation_semantic_violations.append({
4437
+ 'type':'citation_support_unverified', 'sentence':str(original).strip()[:500],
4438
+ 'reason':f'No verified candidate support; best overlap={overlap}, coverage={coverage:.3f}.',
4439
+ })
4440
+ self._append_citation_audit(section_name, 'flag_unverified_claim_for_section_retry', original, revised, f'best overlap={overlap}, coverage={coverage:.3f}')
4441
+ rebuilt.append(revised)
 
 
 
 
 
 
4442
  cursor = end
4443
  rebuilt.append(str(text)[cursor:])
4444
  recovered = ''.join(rebuilt)
 
4521
  return revised
4522
 
4523
  def audit_and_resolve_citations(self, text, section_name='', quarantine=False):
4524
+ self._last_citation_semantic_violations = []
4525
  value = str(text or '')
4526
  if quarantine:
4527
  return self.resolve_citations(value, quarantine=True)
 
4566
  )
4567
  return self.resolve_citations(value, quarantine=False)
4568
 
4569
+ @staticmethod
4570
+ def inspect_authorial_ai_meta(text):
4571
+ """Return typed high-confidence authorial AI-meta findings without editing text."""
4572
+ findings = find_authorial_ai_meta_spans(str(text or ""))
4573
+ output = []
4574
+ for finding in findings:
4575
+ output.append({
4576
+ "type":"authorial_ai_meta_trace",
4577
+ "code":str(finding.get("code") or finding.get("type") or "AI_META"),
4578
+ "preview":str(finding.get("text") or finding.get("match") or "")[:180],
4579
+ "start":int(finding.get("start") or 0),
4580
+ "end":int(finding.get("end") or 0),
4581
+ })
4582
+ return output
4583
+
4584
  @staticmethod
4585
  def _remove_ai_meta_traces(text):
4586
  """Bounded deterministic repair for high-confidence authorial meta only.
 
4714
  return value
4715
  return revised.strip()
4716
 
4717
+ def critique_section(self, text, *, section_id, section_name, mode, evidence_contract=''):
4718
+ """Independent top-journal critic that returns feedback only; never rewrites prose."""
4719
  value = str(text or '').strip()
4720
  mode = 'asq' if str(mode).lower() == 'asq' else 'amj'
4721
  critical_sections = ({'introduction', 'theory', 'discussion'} if mode == 'amj' else {'introduction', 'theoretical_framing', 'findings', 'discussion'})
4722
  if str(section_id or '') not in critical_sections or len(value.split()) < 120:
4723
+ return {'pass': True, 'critical_issue': '', 'repair_instruction': ''}
4724
  dimensions = APP_CONFIG.get('top_journal_critic_profiles', {}).get(mode, [])
4725
  profile = APP_CONFIG.get('section_reasoning_profiles', {}).get(mode, {}).get(str(section_id or ''), '')
4726
  prompt = f"""Assess ONLY this manuscript unit as an independent top-journal critic. Return valid JSON only:
 
4736
  )
4737
  parsed = self._extract_json_from_text(raw)
4738
  if not isinstance(parsed, dict) or parsed.get('pass') is not False:
4739
+ return {'pass': True, 'critical_issue': '', 'repair_instruction': ''}
4740
+ issue = re.sub(r'\s+', ' ', str(parsed.get('critical_issue') or '')).strip()
4741
+ instruction = re.sub(r'\s+', ' ', str(parsed.get('repair_instruction') or issue)).strip()
4742
  if not instruction:
4743
+ return {'pass': True, 'critical_issue': '', 'repair_instruction': ''}
4744
+ return {'pass': False, 'critical_issue': issue[:800], 'repair_instruction': instruction[:1200]}
4745
+
4746
+ def critique_and_repair_section(self, text, *, section_id, section_name, mode, evidence_contract=''):
4747
+ """Legacy compatibility wrapper. Active S0-R1 path uses critique_section().
4748
+
4749
+ The wrapper intentionally preserves manuscript bytes and only records the
4750
+ critic verdict. It no longer owns a second writing-model rewrite.
4751
+ """
4752
+ value = str(text or '').strip()
4753
+ verdict = self.critique_section(
4754
+ value, section_id=section_id, section_name=section_name, mode=mode,
4755
+ evidence_contract=evidence_contract,
4756
+ )
4757
+ if verdict.get('pass') is False:
4758
+ logging.warning(
4759
+ 'Top-journal critic material issue (no direct rewrite): section=%s issue=%s',
4760
+ section_name, str(verdict.get('repair_instruction') or verdict.get('critical_issue') or '')[:180],
4761
+ )
4762
+ return value
 
 
 
 
 
 
4763
 
4764
  def _abstract_manuscript_context(self, manuscript, max_tokens=18000):
4765
  value = str(manuscript or '').strip()
core/quantitative_engine.py CHANGED
@@ -328,7 +328,13 @@ Keywords: {keywords}"""
328
  if char == ".":
329
  prev = value[index - 1] if index > 0 else ""
330
  nxt = value[index + 1] if index + 1 < len(value) else ""
331
- decimal = prev.isdigit() and nxt.isdigit()
 
 
 
 
 
 
332
  prefix = value[max(0, index - 12): index + 1].lower()
333
  abbreviation = any(prefix.endswith(item) for item in abbreviations)
334
  boundary = not decimal and not abbreviation
@@ -487,6 +493,136 @@ Keywords: {keywords}"""
487
  ] if strict else [],
488
  }
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."""
@@ -656,9 +792,9 @@ Keywords: {keywords}"""
656
 
657
  @classmethod
658
  def validate_quantitative_study_fact_contract(cls, text, metadata, section_name=""):
659
- repaired, diagnostics = cls.repair_quantitative_study_fact_contract(text, metadata, section_name=section_name)
660
- if repaired.strip() != str(text or "").strip():
661
- raise RuntimeError(f"quantitative_study_fact_contract_unrepaired:{section_name}:{diagnostics[:3]}")
662
  return True
663
 
664
  def resolve_empirical_data(
 
328
  if char == ".":
329
  prev = value[index - 1] if index > 0 else ""
330
  nxt = value[index + 1] if index + 1 < len(value) else ""
331
+ # Keep both ordinary decimals (0.31) and leading-decimal
332
+ # statistical notation (p < .05) inside one sentence.
333
+ leading_decimal = bool(
334
+ nxt.isdigit()
335
+ and re.search(r"(?:[<>=≤≥]|\b(?:p|r|β))\s*$", value[max(0, index - 12):index], flags=re.I)
336
+ )
337
+ decimal = (prev.isdigit() and nxt.isdigit()) or leading_decimal
338
  prefix = value[max(0, index - 12): index + 1].lower()
339
  abbreviation = any(prefix.endswith(item) for item in abbreviations)
340
  boundary = not decimal and not abbreviation
 
493
  ] if strict else [],
494
  }
495
 
496
+ @classmethod
497
+ def inspect_quantitative_study_fact_contract(cls, text, metadata, section_name=""):
498
+ """Pure AMJ quantitative Evidence Ledger inspection; never rewrites prose."""
499
+ value = str(text or "")
500
+ meta = dict(metadata or {})
501
+ ledger = dict(meta.get("quantitative_evidence_ledger") or {})
502
+ if not ledger:
503
+ ledger = cls.build_quantitative_evidence_ledger(meta)
504
+ sample_size = ledger.get("sample_size")
505
+ scale_points = ledger.get("scale_points")
506
+ strict = bool(ledger.get("strict"))
507
+ constructs = dict(ledger.get("constructs") or {})
508
+ results = dict(ledger.get("association_results") or {})
509
+ uploaded = dict(ledger.get("uploaded_metric_anchors") or {})
510
+ known_constructs = [str(v) for v in constructs.values() if str(v)]
511
+ construct_folded = [c.casefold() for c in known_constructs]
512
+ forbidden = [str(x) for x in ledger.get("forbidden_unowned_methods") or []]
513
+ role_patterns = {
514
+ "predictor": r"(?:predictor|independent\s+variable|predictor\s+construct)",
515
+ "mediator": r"(?:mediator|mediating\s+variable|mediating\s+construct)",
516
+ "outcome": r"(?:outcome|dependent\s+variable|outcome\s+construct)",
517
+ }
518
+ causal_patterns = (
519
+ r"(?i)\bhad\s+a\s+causal\s+effect\s+on\b",
520
+ r"(?i)\bexerted\s+a\s+causal\s+effect\s+on\b",
521
+ r"(?i)\bcausal\s+effect\s+on\b",
522
+ r"(?i)\b(?:an|a|the)\s+causal\s+effect\b",
523
+ r"(?i)\bcausal\s+effects\b",
524
+ r"(?i)\bcausal\s+effect\b",
525
+ r"(?i)\bcausal\s+mediation\b",
526
+ )
527
+ violations = []
528
+
529
+ def add_metric_violations(sentence, pattern, expected, *, dtype="float", label="metric"):
530
+ if expected is None:
531
+ return
532
+ regex = re.compile(pattern, re.I)
533
+ for match in regex.finditer(sentence):
534
+ observed = match.group(2)
535
+ try:
536
+ same = int(float(observed)) == int(expected) if dtype == "int" else abs(float(observed) - float(expected)) < 0.0006
537
+ except Exception:
538
+ same = False
539
+ if not same:
540
+ expected_text = str(int(expected)) if dtype == "int" else f"{float(expected):.3f}".rstrip("0").rstrip(".")
541
+ violations.append({
542
+ "type":"quantitative_metric_mismatch", "metric":label,
543
+ "observed":observed, "expected":expected_text,
544
+ "preview":str(sentence).strip()[:180],
545
+ })
546
+
547
+ for _start, _end, sentence in cls._sentence_spans(value):
548
+ ownership = cls._quantitative_sentence_ownership(sentence, section_name, known_constructs)
549
+ if ownership not in {"study", "result"}:
550
+ continue
551
+ if sample_size:
552
+ for match in re.finditer(r"(?i)(\b(?:sample(?:\s+size)?(?:\s+of)?|N)\s*(?:=|:)?\s*)(\d{2,6})\b", sentence):
553
+ observed = int(match.group(2))
554
+ if observed != int(sample_size):
555
+ violations.append({"type":"sample_size_mismatch","observed":observed,"expected":int(sample_size),"preview":sentence.strip()[:180]})
556
+ for match in re.finditer(r"(?i)\b(\d{2,6})\s+(respondents|participants|observations|cases)\b", sentence):
557
+ observed = int(match.group(1))
558
+ if observed != int(sample_size):
559
+ violations.append({"type":"sample_count_mismatch","observed":observed,"expected":int(sample_size),"preview":sentence.strip()[:180]})
560
+ if scale_points:
561
+ for match in re.finditer(r"(?i)\b([2-9]|1[0-1])[- ]point\s+(Likert(?:-type)?\s+scale)", sentence):
562
+ observed = int(match.group(1))
563
+ if observed != int(scale_points):
564
+ violations.append({"type":"scale_points_mismatch","observed":observed,"expected":int(scale_points),"preview":sentence.strip()[:180]})
565
+ for role, role_pattern in role_patterns.items():
566
+ expected = str(constructs.get(role) or "")
567
+ if not expected:
568
+ continue
569
+ for candidate in known_constructs:
570
+ if candidate.casefold() == expected.casefold():
571
+ continue
572
+ pattern = re.compile(rf"(?i)({role_pattern}\s*(?:was|is|:|=)?\s*){re.escape(candidate)}")
573
+ if pattern.search(sentence):
574
+ violations.append({"type":"construct_role_mismatch","role":role,"observed":candidate,"expected":expected,"preview":sentence.strip()[:180]})
575
+ if strict:
576
+ for pattern in causal_patterns:
577
+ if re.search(pattern, sentence):
578
+ violations.append({"type":"causal_language_violation","preview":sentence.strip()[:180]})
579
+ break
580
+ if (any(c in sentence.casefold() for c in construct_folded) if construct_folded else False) and re.search(r"(?i)\bp\s*(?:=|<|>|≤|≥)\s*(?:0?\.\d+|\d+(?:\.\d+)?)", sentence):
581
+ violations.append({"type":"unfrozen_p_value","preview":sentence.strip()[:180]})
582
+ if ownership == "study":
583
+ bad = next((term for term in forbidden if re.search(rf"(?i)(?<![A-Za-z]){re.escape(term)}(?![A-Za-z])", sentence)), "")
584
+ if bad:
585
+ violations.append({"type":"unsupported_quantitative_method","method":bad,"preview":sentence.strip()[:180]})
586
+
587
+ vif_expected = results.get("predictor_mediator_vif") if strict else uploaded.get("VIF")
588
+ boot_expected = results.get("bootstrap_iterations") if strict else uploaded.get("bootstrap_iterations")
589
+ add_metric_violations(sentence, r"(\bVIF\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", vif_expected, label="VIF")
590
+ add_metric_violations(sentence, r"(\bbootstrap\s+(?:resamples?|iterations?)\s*(?:=|:)?\s*)(\d{2,6})", boot_expected, dtype="int", label="bootstrap_iterations")
591
+ if strict and results:
592
+ for role, key in (("mediator", "r2_mediator"), ("outcome", "r2_outcome")):
593
+ construct = str(constructs.get(role) or "")
594
+ if construct and results.get(key) is not None:
595
+ add_metric_violations(sentence, rf"(R(?:²|\^2)\s*(?:for|of)?\s*{re.escape(construct)}\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", results.get(key), label=key)
596
+ lower = sentence.casefold()
597
+ selected = None
598
+ specs = [
599
+ ("indirect", results.get("indirect"), [constructs.get("predictor"), constructs.get("mediator"), constructs.get("outcome")]),
600
+ ("direct", results.get("direct"), [constructs.get("predictor"), constructs.get("outcome")]),
601
+ ("a", results.get("a"), [constructs.get("predictor"), constructs.get("mediator")]),
602
+ ("b", results.get("b"), [constructs.get("mediator"), constructs.get("outcome")]),
603
+ ]
604
+ if "indirect" in lower and all(str(x or "").casefold() in lower for x in specs[0][2] if x):
605
+ selected = specs[0]
606
+ 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:
607
+ selected = specs[1]
608
+ 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:
609
+ selected = specs[3]
610
+ 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:
611
+ selected = specs[2]
612
+ 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:
613
+ selected = specs[3]
614
+ if selected and selected[1] is not None:
615
+ add_metric_violations(sentence, r"((?:β|standardized\s+estimate)\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", selected[1], label=selected[0])
616
+ elif not strict:
617
+ for label, expected in dict(uploaded.get("r2_by_label") or {}).items():
618
+ if label and label in sentence.casefold():
619
+ add_metric_violations(sentence, rf"(R(?:²|\^2)\s*(?:for|of)\s*{re.escape(label)}\s*(?:=|:)\s*)([-+]?\d+(?:\.\d+)?)", expected, label=f"uploaded_R2:{label}")
620
+ for path, expected in dict(uploaded.get("beta_by_path") or {}).items():
621
+ left, _, right = path.partition(" -> ")
622
+ if left and right and left in sentence.casefold() and right in sentence.casefold():
623
+ add_metric_violations(sentence, r"((?:β|standardized\s+estimate)\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", expected, label=f"uploaded_beta:{path}")
624
+ return violations
625
+
626
  @classmethod
627
  def repair_quantitative_study_fact_contract(cls, text, metadata, section_name=""):
628
  """Repair deterministic AMJ drift only in study/result-owned prose."""
 
792
 
793
  @classmethod
794
  def validate_quantitative_study_fact_contract(cls, text, metadata, section_name=""):
795
+ violations = cls.inspect_quantitative_study_fact_contract(text, metadata, section_name=section_name)
796
+ if violations:
797
+ raise RuntimeError(f"quantitative_study_fact_contract_invalid:{section_name}:{violations[:3]}")
798
  return True
799
 
800
  def resolve_empirical_data(