S0-R1-C5.2 Frozen Citation Whitelist Four-Language Retrieval Anchor

#80
Files changed (2) hide show
  1. core/generation_runner.py +195 -41
  2. core/pipeline_agent.py +252 -45
core/generation_runner.py CHANGED
@@ -1047,6 +1047,36 @@ class GenerationRunner:
1047
  "or change the construct pairing to fit observed results. The contract is result-independent."
1048
  )
1049
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1050
  @classmethod
1051
  def _hypothesis_contract_violations(
1052
  cls, text: str, ledger: dict, *, section_name: str = "", require_all: bool = False
@@ -1081,45 +1111,43 @@ class GenerationRunner:
1081
  "reason": "Ordinal hypothesis aliases can silently reorder the canonical H1-Hn identity.",
1082
  "repair_instruction": "Refer to hypotheses only by their frozen H1-Hn identifiers.",
1083
  })
1084
- definition_scope = bool(re.search(r"Hypothesis Development|Integrated Research Model", str(section_name or ""), flags=re.I))
1085
- if definition_scope:
1086
- all_constructs = {
1087
- re.sub(r"\s+", " ", str(item.get(key) or "")).strip()
1088
- for item in items for key in ("predictor", "outcome")
1089
- if str(item.get(key) or "").strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1090
  }
1091
- for start, end, sentence in SSciPipelineAgent._sentence_spans(value):
1092
- ids = list(dict.fromkeys(
1093
- f"H{n}" for n in re.findall(r"\bH(\d{1,2})\b", sentence, flags=re.I)
1094
- ))
1095
- # Collective references such as "H1-H3 are summarized below"
1096
- # are not definitions and must not be mistaken for remapping.
1097
- if len(ids) != 1:
1098
- continue
1099
- hid = ids[0]
1100
- item = expected.get(hid)
1101
- if not item:
1102
- continue
1103
- predictor = re.sub(r"\s+", " ", str(item.get("predictor") or "")).strip()
1104
- outcome = re.sub(r"\s+", " ", str(item.get("outcome") or "")).strip()
1105
- lower_sentence = re.sub(r"\s+", " ", sentence).casefold()
1106
- mentioned_constructs = {
1107
- term for term in all_constructs if term.casefold() in lower_sentence
1108
- }
1109
- # Validate pairing only when the sentence actually names one or
1110
- # more constructs. A bare cross-reference ("H1 is summarized")
1111
- # remains legal and cannot silently redefine the hypothesis.
1112
- if mentioned_constructs and predictor and outcome and (
1113
- predictor.casefold() not in lower_sentence or outcome.casefold() not in lower_sentence
1114
- ):
1115
- rows.append({
1116
- "type": "hypothesis_contract_pairing_mismatch",
1117
- "id": hid,
1118
- "sentence": re.sub(r"\s+", " ", sentence).strip()[:420],
1119
- "expected": f"{predictor} -> {outcome}",
1120
- "reason": "A canonical hypothesis label was attached to a different construct pairing.",
1121
- "repair_instruction": f"Define {hid} only for {predictor} and {outcome}, using association language.",
1122
- })
1123
  if require_all:
1124
  missing = sorted(set(expected) - observed)
1125
  if missing:
@@ -2448,6 +2476,15 @@ class GenerationRunner:
2448
  )
2449
  state["data_metadata"] = data_metadata
2450
 
 
 
 
 
 
 
 
 
 
2451
  self._qualitative_quote_sources = (
2452
  self._qualitative_quote_source_map(data_metadata) if mode == "asq" else {}
2453
  )
@@ -2522,12 +2559,24 @@ class GenerationRunner:
2522
  agent.stream_generate_research_logic(
2523
  topic, keywords, synthesis_result, refs_text,
2524
  data_evidence=planning_data_md, mode=mode,
 
2525
  )
2526
  )
2527
  agent.record_prewrite_stage("research_logic", prewrite_started, prewrite_calls)
2528
  research_logic_contract = re.sub(
2529
  r"<think>.*?</think>", "", research_logic_response, flags=re.DOTALL
2530
  ).strip()
 
 
 
 
 
 
 
 
 
 
 
2531
  if not research_logic_contract:
2532
  raise RuntimeError("research_logic_contract_missing")
2533
  agent.global_memory["research_logic_contract"] = research_logic_contract
@@ -2536,8 +2585,13 @@ class GenerationRunner:
2536
  working_front = agent.generate_working_front_matter(
2537
  topic, keywords, research_logic_contract, synthesis_result,
2538
  data_evidence=planning_data_md, mode=mode,
 
2539
  )
2540
  working_abstract = str(working_front.get("abstract") or "").strip()
 
 
 
 
2541
  working_keywords = list(working_front.get("keywords") or [])
2542
  if not working_abstract or not working_keywords:
2543
  raise RuntimeError("working_front_matter_missing")
@@ -2545,6 +2599,10 @@ class GenerationRunner:
2545
  mode, agent, quant_engine, working_abstract, data_metadata,
2546
  section_name="Working Abstract",
2547
  )
 
 
 
 
2548
  if front_fact_rows:
2549
  feedback = self._section_retry_feedback(front_fact_rows)
2550
  logging.warning(
@@ -2554,12 +2612,21 @@ class GenerationRunner:
2554
  working_front = agent.generate_working_front_matter(
2555
  topic, keywords, research_logic_contract, synthesis_result,
2556
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
 
2557
  )
2558
  working_abstract = str(working_front.get("abstract") or "").strip()
 
 
 
 
2559
  working_keywords = list(working_front.get("keywords") or [])
2560
  _, remaining_front_rows = self._repair_validate_study_fact_contract(
2561
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
2562
  )
 
 
 
 
2563
  if remaining_front_rows:
2564
  raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining_front_rows[:3]}")
2565
  quality_diagnostics.append({
@@ -2757,6 +2824,18 @@ class GenerationRunner:
2757
  research_logic_contract = str(
2758
  state.get("research_logic_contract") or agent.global_memory.get("research_logic_contract") or ""
2759
  )
 
 
 
 
 
 
 
 
 
 
 
 
2760
  citation_plan = state.get("citation_plan") if isinstance(state.get("citation_plan"), dict) else {}
2761
  citation_minimums = dict(state.get("citation_minimums") or {})
2762
  citation_reserve_targets = dict(state.get("citation_reserve_targets") or {})
@@ -2798,20 +2877,37 @@ class GenerationRunner:
2798
  working_abstract = str(state.get("working_abstract") or "").strip()
2799
  working_keywords = list(state.get("working_keywords") or [])
2800
  if working_abstract and not state.get("english_finalized"):
 
 
 
 
2801
  _, resumed_front_rows = self._repair_validate_study_fact_contract(
2802
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
2803
  )
 
 
 
 
2804
  if resumed_front_rows:
2805
  feedback = self._section_retry_feedback(resumed_front_rows)
2806
  working_front = agent.generate_working_front_matter(
2807
  topic, keywords, research_logic_contract, synthesis_result,
2808
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
 
2809
  )
2810
  candidate_abstract = str(working_front.get("abstract") or "").strip()
 
 
 
 
2811
  candidate_keywords = list(working_front.get("keywords") or [])
2812
  _, remaining = self._repair_validate_study_fact_contract(
2813
  mode, agent, quant_engine, candidate_abstract, data_metadata, section_name="Working Abstract",
2814
  )
 
 
 
 
2815
  if remaining:
2816
  raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining[:3]}")
2817
  working_abstract = candidate_abstract
@@ -3010,25 +3106,43 @@ class GenerationRunner:
3010
  working_front = agent.generate_working_front_matter(
3011
  topic, keywords, research_logic_contract, synthesis_result,
3012
  data_evidence=planning_data_md, mode=mode,
 
3013
  )
3014
  working_abstract = str(working_front.get("abstract") or "").strip()
 
 
 
 
3015
  working_keywords = list(working_front.get("keywords") or [])
3016
  if not working_abstract or not working_keywords:
3017
  raise RuntimeError("working_front_matter_missing")
3018
  _, front_fact_rows = self._repair_validate_study_fact_contract(
3019
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
3020
  )
 
 
 
 
3021
  if front_fact_rows:
3022
  feedback = self._section_retry_feedback(front_fact_rows)
3023
  working_front = agent.generate_working_front_matter(
3024
  topic, keywords, research_logic_contract, synthesis_result,
3025
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
 
3026
  )
3027
  working_abstract = str(working_front.get("abstract") or "").strip()
 
 
 
 
3028
  working_keywords = list(working_front.get("keywords") or [])
3029
  _, remaining = self._repair_validate_study_fact_contract(
3030
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
3031
  )
 
 
 
 
3032
  if remaining:
3033
  raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining[:3]}")
3034
  quality_diagnostics.append({
@@ -3414,6 +3528,16 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3414
  en_raw = re.sub(r"(?m)^(#{2,3})\s+[\d\.\s一二三四五六七八九十、]+\s*(.*)", r"\1 \2", en_raw)
3415
  en_raw = re.sub(r"(?m)^#{4,}\s+(.*)", r"**\1**", en_raw)
3416
  en_raw = re.sub(r"(?m)^#\s+(.+)$", r"## \1", en_raw)
 
 
 
 
 
 
 
 
 
 
3417
  if mode == "asq":
3418
  # Model prose has no authority over quote presence/count.
3419
  en_raw = self._strip_model_controlled_qualitative_quotes(en_raw, data_metadata)
@@ -3433,9 +3557,13 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3433
  # authoritative for ZH/RU/KK translation.
3434
  self._progress(pct, f"citation_audit:{current_index}:{total_steps}")
3435
  used_before = set(agent.used_citations)
 
 
 
 
3436
  en_resolved = agent.audit_and_resolve_citations(
3437
  prefix + en_raw, section_name=display_name, section_id=section_id,
3438
- quarantine=is_quarantined
3439
  )
3440
  # Capture immediately: later recovery may replace the agent's last-
3441
  # violation buffer. Only citation-owned rejected tags may enter the
@@ -3507,6 +3635,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3507
  missing_items, section_name=display_name, section_id=section_id,
3508
  section_context=en_resolved, needed=needed,
3509
  excluded_tags=used_after | retry_excluded_tags,
 
3510
  )
3511
  # Evidence retrieval is permitted only on the first writer
3512
  # attempt. Attempt 2 is bound to the original frozen packet.
@@ -3919,9 +4048,14 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3919
  # front matter deterministically. No body rebalance may occur afterward.
3920
  self._progress(92, "final_front_matter")
3921
  final_front = agent.generate_final_front_matter(
3922
- topic, keywords, body_only, data_evidence=user_data_md, mode=mode
 
3923
  )
3924
  final_abstract = str(final_front.get("abstract") or "").strip()
 
 
 
 
3925
  final_keywords = list(final_front.get("keywords") or [])
3926
  if not final_abstract or not final_keywords:
3927
  raise RuntimeError("final_front_matter_missing")
@@ -3929,6 +4063,10 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3929
  mode, agent, quant_engine, final_abstract, data_metadata,
3930
  section_name="Final Abstract",
3931
  )
 
 
 
 
3932
  if final_front_fact_rows:
3933
  feedback = self._section_retry_feedback(final_front_fact_rows)
3934
  logging.warning(
@@ -3938,9 +4076,13 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3938
  )
3939
  final_front = agent.generate_final_front_matter(
3940
  topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
3941
- validation_feedback=feedback,
3942
  )
3943
  final_abstract = str(final_front.get("abstract") or "").strip()
 
 
 
 
3944
  final_keywords = list(final_front.get("keywords") or [])
3945
  if not final_abstract or not final_keywords:
3946
  raise RuntimeError("final_front_matter_missing_after_retry")
@@ -3948,6 +4090,10 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3948
  mode, agent, quant_engine, final_abstract, data_metadata,
3949
  section_name="Final Abstract",
3950
  )
 
 
 
 
3951
  if remaining_final_fact_rows:
3952
  state["final_abstract_retry_exhausted"] = {
3953
  "attempts": 2,
@@ -3973,6 +4119,14 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3973
  state.pop("final_abstract_retry_exhausted", None)
3974
  full_draft_en = replace_abstract(full_draft_en, final_abstract)
3975
  full_draft_en = self._replace_keywords(full_draft_en, final_keywords)
 
 
 
 
 
 
 
 
3976
  body_only, _reference_tail = agent.validate_reference_tail(
3977
  full_draft_en, minimum_references=final_reference_count
3978
  )
 
1047
  "or change the construct pairing to fit observed results. The contract is result-independent."
1048
  )
1049
 
1050
+ @staticmethod
1051
+ def _hypothesis_memory_text(ledger: dict) -> str:
1052
+ """Render the sole AMJ H1-Hn memory from the frozen ledger."""
1053
+ lines = []
1054
+ for item in (dict(ledger or {}).get("items") or []):
1055
+ if not isinstance(item, dict):
1056
+ continue
1057
+ hid = str(item.get("hypothesis_id") or "").strip().upper()
1058
+ predictor = re.sub(r"\s+", " ", str(item.get("predictor") or "")).strip()
1059
+ outcome = re.sub(r"\s+", " ", str(item.get("outcome") or "")).strip()
1060
+ if hid and predictor and outcome:
1061
+ lines.append(f"{hid}: {predictor} is associated with {outcome}.")
1062
+ return "\n".join(lines)
1063
+
1064
+ @staticmethod
1065
+ def _normalize_hypothesis_ordinal_aliases(text: str, ledger: dict) -> str:
1066
+ """Map safe ordinal aliases to frozen H-identifiers without semantic rewrite."""
1067
+ value = str(text or "")
1068
+ items = [item for item in (dict(ledger or {}).get("items") or []) if isinstance(item, dict)]
1069
+ if not items:
1070
+ return value
1071
+ words = ["first", "second", "third", "fourth", "fifth", "sixth"]
1072
+ for index, item in enumerate(items[: len(words)]):
1073
+ hid = str(item.get("hypothesis_id") or "").strip().upper()
1074
+ if not hid:
1075
+ continue
1076
+ word = words[index]
1077
+ value = re.sub(rf"\b(?:the\s+)?{word}\s+hypothesis\b", hid, value, flags=re.I)
1078
+ return value
1079
+
1080
  @classmethod
1081
  def _hypothesis_contract_violations(
1082
  cls, text: str, ledger: dict, *, section_name: str = "", require_all: bool = False
 
1111
  "reason": "Ordinal hypothesis aliases can silently reorder the canonical H1-Hn identity.",
1112
  "repair_instruction": "Refer to hypotheses only by their frozen H1-Hn identifiers.",
1113
  })
1114
+ all_constructs = {
1115
+ re.sub(r"\s+", " ", str(item.get(key) or "")).strip()
1116
+ for item in items for key in ("predictor", "outcome")
1117
+ if str(item.get(key) or "").strip()
1118
+ }
1119
+ for start, end, sentence in SSciPipelineAgent._sentence_spans(value):
1120
+ ids = list(dict.fromkeys(
1121
+ f"H{n}" for n in re.findall(r"\bH(\d{1,2})\b", sentence, flags=re.I)
1122
+ ))
1123
+ # Collective references such as "H1-H3 are summarized below"
1124
+ # are not definitions and must not be mistaken for remapping.
1125
+ if len(ids) != 1:
1126
+ continue
1127
+ hid = ids[0]
1128
+ item = expected.get(hid)
1129
+ if not item:
1130
+ continue
1131
+ predictor = re.sub(r"\s+", " ", str(item.get("predictor") or "")).strip()
1132
+ outcome = re.sub(r"\s+", " ", str(item.get("outcome") or "")).strip()
1133
+ lower_sentence = re.sub(r"\s+", " ", sentence).casefold()
1134
+ mentioned_constructs = {
1135
+ term for term in all_constructs if term.casefold() in lower_sentence
1136
  }
1137
+ # Pairing identity is global, not limited to the theory-definition
1138
+ # sections. Require at least two named constructs so a bare reference
1139
+ # such as "H1 was supported" cannot be misclassified as remapping.
1140
+ if len(mentioned_constructs) >= 2 and predictor and outcome and (
1141
+ predictor.casefold() not in lower_sentence or outcome.casefold() not in lower_sentence
1142
+ ):
1143
+ rows.append({
1144
+ "type": "hypothesis_contract_pairing_mismatch",
1145
+ "id": hid,
1146
+ "sentence": re.sub(r"\s+", " ", sentence).strip()[:420],
1147
+ "expected": f"{predictor} -> {outcome}",
1148
+ "reason": "A canonical hypothesis label was attached to a different construct pairing.",
1149
+ "repair_instruction": f"Define {hid} only for {predictor} and {outcome}, using association language.",
1150
+ })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1151
  if require_all:
1152
  missing = sorted(set(expected) - observed)
1153
  if missing:
 
2476
  )
2477
  state["data_metadata"] = data_metadata
2478
 
2479
+ hypothesis_ledger = dict(data_metadata.get("hypothesis_ledger") or {}) if mode == "amj" else {}
2480
+ hypothesis_contract = self._hypothesis_contract_directive(hypothesis_ledger)
2481
+ if hypothesis_ledger:
2482
+ # C5.1 single authority: legacy prose extraction may not redefine AMJ
2483
+ # H1-Hn once the result-independent ledger exists. Downstream memory is
2484
+ # populated deterministically from that ledger and survives checkpoints.
2485
+ agent.global_memory["hypotheses"] = self._hypothesis_memory_text(hypothesis_ledger)
2486
+ agent.global_memory["hypothesis_contract_locked"] = True
2487
+
2488
  self._qualitative_quote_sources = (
2489
  self._qualitative_quote_source_map(data_metadata) if mode == "asq" else {}
2490
  )
 
2559
  agent.stream_generate_research_logic(
2560
  topic, keywords, synthesis_result, refs_text,
2561
  data_evidence=planning_data_md, mode=mode,
2562
+ hypothesis_contract=hypothesis_contract,
2563
  )
2564
  )
2565
  agent.record_prewrite_stage("research_logic", prewrite_started, prewrite_calls)
2566
  research_logic_contract = re.sub(
2567
  r"<think>.*?</think>", "", research_logic_response, flags=re.DOTALL
2568
  ).strip()
2569
+ if hypothesis_ledger:
2570
+ research_logic_contract = self._normalize_hypothesis_ordinal_aliases(
2571
+ research_logic_contract, hypothesis_ledger
2572
+ )
2573
+ logic_hypothesis_rows = self._hypothesis_contract_violations(
2574
+ research_logic_contract, hypothesis_ledger, section_name="Research Logic Contract"
2575
+ )
2576
+ if logic_hypothesis_rows:
2577
+ raise RuntimeError(
2578
+ f"research_logic_hypothesis_contract_invalid:{logic_hypothesis_rows[:3]}"
2579
+ )
2580
  if not research_logic_contract:
2581
  raise RuntimeError("research_logic_contract_missing")
2582
  agent.global_memory["research_logic_contract"] = research_logic_contract
 
2585
  working_front = agent.generate_working_front_matter(
2586
  topic, keywords, research_logic_contract, synthesis_result,
2587
  data_evidence=planning_data_md, mode=mode,
2588
+ hypothesis_contract=hypothesis_contract,
2589
  )
2590
  working_abstract = str(working_front.get("abstract") or "").strip()
2591
+ if hypothesis_ledger:
2592
+ working_abstract = self._normalize_hypothesis_ordinal_aliases(
2593
+ working_abstract, hypothesis_ledger
2594
+ )
2595
  working_keywords = list(working_front.get("keywords") or [])
2596
  if not working_abstract or not working_keywords:
2597
  raise RuntimeError("working_front_matter_missing")
 
2599
  mode, agent, quant_engine, working_abstract, data_metadata,
2600
  section_name="Working Abstract",
2601
  )
2602
+ if hypothesis_ledger:
2603
+ front_fact_rows.extend(self._hypothesis_contract_violations(
2604
+ working_abstract, hypothesis_ledger, section_name="Working Abstract"
2605
+ ))
2606
  if front_fact_rows:
2607
  feedback = self._section_retry_feedback(front_fact_rows)
2608
  logging.warning(
 
2612
  working_front = agent.generate_working_front_matter(
2613
  topic, keywords, research_logic_contract, synthesis_result,
2614
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
2615
+ hypothesis_contract=hypothesis_contract,
2616
  )
2617
  working_abstract = str(working_front.get("abstract") or "").strip()
2618
+ if hypothesis_ledger:
2619
+ working_abstract = self._normalize_hypothesis_ordinal_aliases(
2620
+ working_abstract, hypothesis_ledger
2621
+ )
2622
  working_keywords = list(working_front.get("keywords") or [])
2623
  _, remaining_front_rows = self._repair_validate_study_fact_contract(
2624
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
2625
  )
2626
+ if hypothesis_ledger:
2627
+ remaining_front_rows.extend(self._hypothesis_contract_violations(
2628
+ working_abstract, hypothesis_ledger, section_name="Working Abstract"
2629
+ ))
2630
  if remaining_front_rows:
2631
  raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining_front_rows[:3]}")
2632
  quality_diagnostics.append({
 
2824
  research_logic_contract = str(
2825
  state.get("research_logic_contract") or agent.global_memory.get("research_logic_contract") or ""
2826
  )
2827
+ if hypothesis_ledger and research_logic_contract:
2828
+ research_logic_contract = self._normalize_hypothesis_ordinal_aliases(
2829
+ research_logic_contract, hypothesis_ledger
2830
+ )
2831
+ resumed_logic_rows = self._hypothesis_contract_violations(
2832
+ research_logic_contract, hypothesis_ledger, section_name="Research Logic Contract"
2833
+ )
2834
+ if resumed_logic_rows:
2835
+ raise RuntimeError(
2836
+ f"research_logic_hypothesis_contract_invalid_on_resume:{resumed_logic_rows[:3]}"
2837
+ )
2838
+ agent.global_memory["research_logic_contract"] = research_logic_contract
2839
  citation_plan = state.get("citation_plan") if isinstance(state.get("citation_plan"), dict) else {}
2840
  citation_minimums = dict(state.get("citation_minimums") or {})
2841
  citation_reserve_targets = dict(state.get("citation_reserve_targets") or {})
 
2877
  working_abstract = str(state.get("working_abstract") or "").strip()
2878
  working_keywords = list(state.get("working_keywords") or [])
2879
  if working_abstract and not state.get("english_finalized"):
2880
+ if hypothesis_ledger:
2881
+ working_abstract = self._normalize_hypothesis_ordinal_aliases(
2882
+ working_abstract, hypothesis_ledger
2883
+ )
2884
  _, resumed_front_rows = self._repair_validate_study_fact_contract(
2885
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
2886
  )
2887
+ if hypothesis_ledger:
2888
+ resumed_front_rows.extend(self._hypothesis_contract_violations(
2889
+ working_abstract, hypothesis_ledger, section_name="Working Abstract"
2890
+ ))
2891
  if resumed_front_rows:
2892
  feedback = self._section_retry_feedback(resumed_front_rows)
2893
  working_front = agent.generate_working_front_matter(
2894
  topic, keywords, research_logic_contract, synthesis_result,
2895
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
2896
+ hypothesis_contract=hypothesis_contract,
2897
  )
2898
  candidate_abstract = str(working_front.get("abstract") or "").strip()
2899
+ if hypothesis_ledger:
2900
+ candidate_abstract = self._normalize_hypothesis_ordinal_aliases(
2901
+ candidate_abstract, hypothesis_ledger
2902
+ )
2903
  candidate_keywords = list(working_front.get("keywords") or [])
2904
  _, remaining = self._repair_validate_study_fact_contract(
2905
  mode, agent, quant_engine, candidate_abstract, data_metadata, section_name="Working Abstract",
2906
  )
2907
+ if hypothesis_ledger:
2908
+ remaining.extend(self._hypothesis_contract_violations(
2909
+ candidate_abstract, hypothesis_ledger, section_name="Working Abstract"
2910
+ ))
2911
  if remaining:
2912
  raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining[:3]}")
2913
  working_abstract = candidate_abstract
 
3106
  working_front = agent.generate_working_front_matter(
3107
  topic, keywords, research_logic_contract, synthesis_result,
3108
  data_evidence=planning_data_md, mode=mode,
3109
+ hypothesis_contract=hypothesis_contract,
3110
  )
3111
  working_abstract = str(working_front.get("abstract") or "").strip()
3112
+ if hypothesis_ledger:
3113
+ working_abstract = self._normalize_hypothesis_ordinal_aliases(
3114
+ working_abstract, hypothesis_ledger
3115
+ )
3116
  working_keywords = list(working_front.get("keywords") or [])
3117
  if not working_abstract or not working_keywords:
3118
  raise RuntimeError("working_front_matter_missing")
3119
  _, front_fact_rows = self._repair_validate_study_fact_contract(
3120
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
3121
  )
3122
+ if hypothesis_ledger:
3123
+ front_fact_rows.extend(self._hypothesis_contract_violations(
3124
+ working_abstract, hypothesis_ledger, section_name="Working Abstract"
3125
+ ))
3126
  if front_fact_rows:
3127
  feedback = self._section_retry_feedback(front_fact_rows)
3128
  working_front = agent.generate_working_front_matter(
3129
  topic, keywords, research_logic_contract, synthesis_result,
3130
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
3131
+ hypothesis_contract=hypothesis_contract,
3132
  )
3133
  working_abstract = str(working_front.get("abstract") or "").strip()
3134
+ if hypothesis_ledger:
3135
+ working_abstract = self._normalize_hypothesis_ordinal_aliases(
3136
+ working_abstract, hypothesis_ledger
3137
+ )
3138
  working_keywords = list(working_front.get("keywords") or [])
3139
  _, remaining = self._repair_validate_study_fact_contract(
3140
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
3141
  )
3142
+ if hypothesis_ledger:
3143
+ remaining.extend(self._hypothesis_contract_violations(
3144
+ working_abstract, hypothesis_ledger, section_name="Working Abstract"
3145
+ ))
3146
  if remaining:
3147
  raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining[:3]}")
3148
  quality_diagnostics.append({
 
3528
  en_raw = re.sub(r"(?m)^(#{2,3})\s+[\d\.\s一二三四五六七八九十、]+\s*(.*)", r"\1 \2", en_raw)
3529
  en_raw = re.sub(r"(?m)^#{4,}\s+(.*)", r"**\1**", en_raw)
3530
  en_raw = re.sub(r"(?m)^#\s+(.+)$", r"## \1", en_raw)
3531
+ if mode == "amj" and hypothesis_ledger:
3532
+ normalized_hypothesis_text = self._normalize_hypothesis_ordinal_aliases(
3533
+ en_raw, hypothesis_ledger
3534
+ )
3535
+ if normalized_hypothesis_text != en_raw:
3536
+ logging.info(
3537
+ "Deterministic hypothesis ordinal alias normalization | unit=%s",
3538
+ display_name,
3539
+ )
3540
+ en_raw = normalized_hypothesis_text
3541
  if mode == "asq":
3542
  # Model prose has no authority over quote presence/count.
3543
  en_raw = self._strip_model_controlled_qualitative_quotes(en_raw, data_metadata)
 
3557
  # authoritative for ZH/RU/KK translation.
3558
  self._progress(pct, f"citation_audit:{current_index}:{total_steps}")
3559
  used_before = set(agent.used_citations)
3560
+ allowed_section_citation_tags = (
3561
+ set(attempt_assigned_tag_list) - set(retry_excluded_tags)
3562
+ if not is_quarantined else set()
3563
+ )
3564
  en_resolved = agent.audit_and_resolve_citations(
3565
  prefix + en_raw, section_name=display_name, section_id=section_id,
3566
+ quarantine=is_quarantined, allowed_tags=allowed_section_citation_tags
3567
  )
3568
  # Capture immediately: later recovery may replace the agent's last-
3569
  # violation buffer. Only citation-owned rejected tags may enter the
 
3635
  missing_items, section_name=display_name, section_id=section_id,
3636
  section_context=en_resolved, needed=needed,
3637
  excluded_tags=used_after | retry_excluded_tags,
3638
+ allowed_tags=set(attempt_assigned_tag_list),
3639
  )
3640
  # Evidence retrieval is permitted only on the first writer
3641
  # attempt. Attempt 2 is bound to the original frozen packet.
 
4048
  # front matter deterministically. No body rebalance may occur afterward.
4049
  self._progress(92, "final_front_matter")
4050
  final_front = agent.generate_final_front_matter(
4051
+ topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
4052
+ hypothesis_contract=hypothesis_contract,
4053
  )
4054
  final_abstract = str(final_front.get("abstract") or "").strip()
4055
+ if hypothesis_ledger:
4056
+ final_abstract = self._normalize_hypothesis_ordinal_aliases(
4057
+ final_abstract, hypothesis_ledger
4058
+ )
4059
  final_keywords = list(final_front.get("keywords") or [])
4060
  if not final_abstract or not final_keywords:
4061
  raise RuntimeError("final_front_matter_missing")
 
4063
  mode, agent, quant_engine, final_abstract, data_metadata,
4064
  section_name="Final Abstract",
4065
  )
4066
+ if hypothesis_ledger:
4067
+ final_front_fact_rows.extend(self._hypothesis_contract_violations(
4068
+ final_abstract, hypothesis_ledger, section_name="Final Abstract"
4069
+ ))
4070
  if final_front_fact_rows:
4071
  feedback = self._section_retry_feedback(final_front_fact_rows)
4072
  logging.warning(
 
4076
  )
4077
  final_front = agent.generate_final_front_matter(
4078
  topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
4079
+ validation_feedback=feedback, hypothesis_contract=hypothesis_contract,
4080
  )
4081
  final_abstract = str(final_front.get("abstract") or "").strip()
4082
+ if hypothesis_ledger:
4083
+ final_abstract = self._normalize_hypothesis_ordinal_aliases(
4084
+ final_abstract, hypothesis_ledger
4085
+ )
4086
  final_keywords = list(final_front.get("keywords") or [])
4087
  if not final_abstract or not final_keywords:
4088
  raise RuntimeError("final_front_matter_missing_after_retry")
 
4090
  mode, agent, quant_engine, final_abstract, data_metadata,
4091
  section_name="Final Abstract",
4092
  )
4093
+ if hypothesis_ledger:
4094
+ remaining_final_fact_rows.extend(self._hypothesis_contract_violations(
4095
+ final_abstract, hypothesis_ledger, section_name="Final Abstract"
4096
+ ))
4097
  if remaining_final_fact_rows:
4098
  state["final_abstract_retry_exhausted"] = {
4099
  "attempts": 2,
 
4119
  state.pop("final_abstract_retry_exhausted", None)
4120
  full_draft_en = replace_abstract(full_draft_en, final_abstract)
4121
  full_draft_en = self._replace_keywords(full_draft_en, final_keywords)
4122
+ if mode == "amj" and hypothesis_ledger:
4123
+ post_front_hypothesis_rows = self._hypothesis_contract_violations(
4124
+ full_draft_en, hypothesis_ledger, section_name="Full Manuscript", require_all=True
4125
+ )
4126
+ if post_front_hypothesis_rows:
4127
+ raise RuntimeError(
4128
+ f"final_hypothesis_contract_failed_after_front_matter:{post_front_hypothesis_rows[:3]}"
4129
+ )
4130
  body_only, _reference_tail = agent.validate_reference_tail(
4131
  full_draft_en, minimum_references=final_reference_count
4132
  )
core/pipeline_agent.py CHANGED
@@ -135,6 +135,10 @@ class SSciPipelineAgent:
135
  self.s2_fused = False
136
  self.oa_fused = False
137
  self.citation_map = {}
 
 
 
 
138
  self.used_citations = set()
139
  self.citation_audit_log = []
140
  # R3.2.8.6: translation validators emit hierarchical assurance evidence.
@@ -195,7 +199,7 @@ class SSciPipelineAgent:
195
  self.global_memory = {
196
  "hypotheses": "", "variables": "", "model_summary": "",
197
  "method_summary": "", "research_logic_contract": "",
198
- "core_golden_metadata": []
199
  }
200
 
201
  try:
@@ -311,6 +315,7 @@ class SSciPipelineAgent:
311
  "s2_calls": self.s2_calls,
312
  "source_metrics": self.get_source_metrics(),
313
  "citation_map": self.citation_map,
 
314
  "used_citations": list(self.used_citations),
315
  "citation_audit_log": list(self.citation_audit_log),
316
  "translation_assurance_log": list(self.translation_assurance_log),
@@ -334,6 +339,10 @@ class SSciPipelineAgent:
334
  if isinstance(loaded_metrics.get(source), dict):
335
  self.source_metrics[source].update(loaded_metrics[source])
336
  self.citation_map = state_dict.get("citation_map", {})
 
 
 
 
337
  self.used_citations = set(state_dict.get("used_citations", []))
338
  self.citation_audit_log = list(state_dict.get("citation_audit_log", []))
339
  self.translation_assurance_log = list(state_dict.get("translation_assurance_log", []))
@@ -456,7 +465,10 @@ class SSciPipelineAgent:
456
 
457
  def _extract_memory_from_text(self, text, section_name):
458
  lower_sec = section_name.lower()
459
- if any(kw in lower_sec for kw in ["introduction", "literature", "hypothesis", "framing"]):
 
 
 
460
  hypo_matches = re.findall(r'(H\d+[:\s].*?)(?=\n|$)', text, re.IGNORECASE)
461
  if hypo_matches:
462
  self.global_memory["hypotheses"] = "\n".join(hypo_matches[:10])
@@ -1274,6 +1286,7 @@ class SSciPipelineAgent:
1274
  refs_text,
1275
  data_evidence="",
1276
  mode="amj",
 
1277
  ):
1278
  """Generate a provisional research-logic contract without prewriting results."""
1279
  mode = 'asq' if str(mode).lower() == 'asq' else 'amj'
@@ -1296,6 +1309,10 @@ Literature Synthesis:
1296
 
1297
  {evidence_block}
1298
  {mode_directive}
 
 
 
 
1299
  Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It must guide later sections without fixing an expected result or conclusion. Do not use citations in the contract."""
1300
  def _stream_direct_research_logic():
1301
  # R4-A: research logic is a concise planning contract. A reasoning-first
@@ -1469,7 +1486,7 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1469
  raise DeepSeekRequestError(f'{phase} front matter validation failed: {last_error}')
1470
 
1471
  def generate_working_front_matter(
1472
- self, topic, keywords, research_logic_contract, synthesis_result, data_evidence='', mode='amj', validation_feedback=''
1473
  ):
1474
  """Create a provisional visible abstract/keyword anchor without prewriting results."""
1475
  evidence = self._truncate_context(str(data_evidence or ''), 3500)
@@ -1490,6 +1507,10 @@ USER KEYWORD SEEDS: {keywords}
1490
  <FROZEN_RESEARCH_EVIDENCE>
1491
  {evidence}
1492
  </FROZEN_RESEARCH_EVIDENCE>
 
 
 
 
1493
  <LOCAL_VALIDATION_FEEDBACK>
1494
  {str(validation_feedback or '')[:2500]}
1495
  </LOCAL_VALIDATION_FEEDBACK>"""
@@ -1503,7 +1524,7 @@ USER KEYWORD SEEDS: {keywords}
1503
  prompt, phase='Working', max_tokens=1100, validator=validate_working,
1504
  )
1505
 
1506
- def generate_final_front_matter(self, topic, keywords, manuscript, data_evidence='', mode='amj', validation_feedback=''):
1507
  """Generate final Abstract + Keywords only from the frozen completed body/evidence."""
1508
  evidence = self._truncate_context(str(data_evidence or ''), 5000)
1509
  body = self._abstract_manuscript_context(manuscript, 18000)
@@ -1516,6 +1537,10 @@ USER KEYWORD SEEDS: {keywords}
1516
  <FROZEN_RESEARCH_EVIDENCE>
1517
  {evidence}
1518
  </FROZEN_RESEARCH_EVIDENCE>
 
 
 
 
1519
  <COMPLETED_FROZEN_MANUSCRIPT>
1520
  {body}
1521
  </COMPLETED_FROZEN_MANUSCRIPT>
@@ -2450,6 +2475,7 @@ Return ONLY a valid JSON array of strings."""
2450
  """
2451
  with self.lock:
2452
  self.citation_map.clear()
 
2453
  self.used_citations.clear()
2454
  self.citation_audit_log.clear()
2455
  for source in ('s2', 'oa'):
@@ -2529,6 +2555,9 @@ Return ONLY a valid JSON array of strings."""
2529
  all_used_terms.add(term)
2530
  if not normalized_terms:
2531
  continue
 
 
 
2532
 
2533
  run_wave(normalized_terms, second_page=False)
2534
  if unique_preview_count() >= min_required:
@@ -2585,7 +2614,7 @@ Return ONLY a valid JSON array of strings."""
2585
  return int(match.group()) if match else 10**9
2586
 
2587
  @staticmethod
2588
- def _citation_terms(value):
2589
  stopwords = {
2590
  "about", "after", "also", "among", "and", "are", "because",
2591
  "been", "before", "between", "both", "can", "could", "does",
@@ -2596,7 +2625,11 @@ Return ONLY a valid JSON array of strings."""
2596
  "with", "within", "would",
2597
  }
2598
  words = re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,}", str(value).lower())
2599
- return {word for word in words if word not in stopwords}
 
 
 
 
2600
 
2601
  def build_claim_evidence_plan(self, tasks, topic, synthesis_result, mode='amj', target_unique=60):
2602
  """Build a bounded citation-first plan without one oversized model call.
@@ -2810,6 +2843,17 @@ INPUT:
2810
  'use','uses','used','using','user','users','approach','approaches','framework','frameworks',
2811
  })
2812
 
 
 
 
 
 
 
 
 
 
 
 
2813
  @classmethod
2814
  def _citation_recovery_overlap(cls, section_name, section_context, claim, evidence):
2815
  anchor_terms = cls._citation_terms(f"{section_name} {section_context}")
@@ -2876,6 +2920,7 @@ INPUT:
2876
 
2877
  def select_contextual_citation_recovery_items(
2878
  self, items, *, section_name='', section_id='', section_context='', needed=1, excluded_tags=None,
 
2879
  ):
2880
  """Select only recovery citations that are actually about the current unit.
2881
 
@@ -2888,6 +2933,9 @@ INPUT:
2888
  if needed <= 0:
2889
  return []
2890
  excluded = {str(tag or '').strip() for tag in (excluded_tags or set()) if str(tag or '').strip()}
 
 
 
2891
  ranked = []
2892
  seen = set()
2893
  for item in items or []:
@@ -2896,6 +2944,8 @@ INPUT:
2896
  tag = str(item.get('tag') or '').strip()
2897
  if not tag or tag in excluded or tag in seen:
2898
  continue
 
 
2899
  if not self.citation_source_role_eligible(
2900
  tag, section_id=section_id, section_name=section_name
2901
  ):
@@ -2917,6 +2967,8 @@ INPUT:
2917
  for tag, meta in self.citation_map.items():
2918
  if tag in used:
2919
  continue
 
 
2920
  if not self.citation_source_role_eligible(
2921
  tag, metadata=meta, section_id=section_id, section_name=section_name
2922
  ):
@@ -2941,30 +2993,80 @@ INPUT:
2941
  return selected
2942
 
2943
  def _deterministic_contextual_jit_query(self, topic, section_name, section_context):
2944
- """Build one bounded section-aware recovery query without a model call."""
2945
- generic = set(self._CITATION_RECOVERY_GENERIC_TERMS)
2946
- context_terms = [
2947
- term for term in sorted(self._citation_terms(section_context))
2948
- if term not in generic and len(term) >= 3
2949
- ]
2950
- topic_terms = [
2951
- term for term in sorted(self._citation_terms(topic))
2952
- if term not in generic and len(term) >= 3
2953
- ]
2954
- section_terms = [
2955
- term for term in re.findall(r"[A-Za-z][A-Za-z-]{2,}", str(section_name or '').lower())
2956
- if term not in generic
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2957
  ]
 
 
 
 
 
 
 
 
 
2958
  selected = []
2959
- # Context is authoritative because JIT exists to repair one current unit,
2960
- # not to broaden the manuscript into another application domain.
2961
- for term in context_terms + section_terms + topic_terms:
2962
- normalized = self._normalize_search_query(term)
2963
- if normalized and normalized not in selected:
2964
- selected.append(normalized)
 
 
 
 
 
 
 
 
2965
  if len(selected) >= 5:
2966
  break
2967
- return ' '.join(selected[:5]) if len(selected) >= 3 else ''
 
 
 
 
 
 
 
 
 
 
 
 
2968
 
2969
  def fetch_contextual_jit_recovery_items(
2970
  self, *, topic, section_name, section_context, section_id='', needed=1, excluded_tags=None,
@@ -3248,6 +3350,15 @@ INPUT:
3248
  supports_section_id = True
3249
  if supports_section_id:
3250
  audit_kwargs['section_id'] = section_id
 
 
 
 
 
 
 
 
 
3251
  audited = audit_method(proposed, **audit_kwargs)
3252
  except PipelineCancelledError:
3253
  raise
@@ -3294,7 +3405,7 @@ INPUT:
3294
  return ''
3295
 
3296
  def _candidate_citation_tags(
3297
- self, sentence, required_tags, limit, *, section_id='', section_name=''
3298
  ):
3299
  """Return source-role-eligible semantic-audit candidates for one claim.
3300
 
@@ -3304,9 +3415,14 @@ INPUT:
3304
  them. Lexical overlap only ranks eligible candidates; it never authorizes
3305
  attachment by itself.
3306
  """
 
 
 
3307
  valid_required = [
3308
  tag for tag in required_tags
3309
- if tag in self.citation_map and self.citation_source_role_eligible(
 
 
3310
  tag, section_id=section_id, section_name=section_name
3311
  )
3312
  ]
@@ -3315,6 +3431,8 @@ INPUT:
3315
  for tag, metadata in self.citation_map.items():
3316
  if tag in valid_required:
3317
  continue
 
 
3318
  if not self.citation_source_role_eligible(
3319
  tag, metadata=metadata, section_id=section_id, section_name=section_name
3320
  ):
@@ -4170,11 +4288,68 @@ INPUT:
4170
  core = f"{core} {tag}{punctuation}".strip()
4171
  return f"{leading}{core}{trailing}"
4172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4173
  def semantic_audit_claim_specific_citations(
4174
  self,
4175
  text,
4176
  section_name="",
4177
  section_id="",
 
4178
  ):
4179
  """Audit citation support while granting the audit TAG-ONLY mutation authority.
4180
 
@@ -4218,7 +4393,7 @@ INPUT:
4218
  sentence,
4219
  required_tags,
4220
  candidate_limit,
4221
- section_id=section_id, section_name=section_name,
4222
  )
4223
  evidence = []
4224
  for tag in candidates:
@@ -4391,23 +4566,30 @@ Return exactly:
4391
  )
4392
  continue
4393
  if status == "split":
4394
- # S0-R1-C2 decision-consistency postcondition. The audit
4395
- # contract permits split only when different sources are
4396
- # actually required. If the auditor itself returns exactly
4397
- # one eligible tag, that is evidence that one source supports
4398
- # the complete claim; keep the original sentence and bind that
4399
- # one tag instead of consuming the Section retry.
4400
  if len(revised_tags) == 1:
4401
  selected = next(iter(revised_tags))
4402
- replacements[record["id"]] = self._citation_tag_only_rewrite(
4403
- record["original"], selected
4404
- )
4405
- self._append_citation_audit(
4406
- section_name, "normalize_inconsistent_split_to_single_source",
4407
- record["original"], replacements[record["id"]],
4408
- reason or "Audit returned split with only one eligible supporting source.",
4409
  )
4410
- continue
 
 
 
 
 
 
 
 
 
 
4411
  violation = {
4412
  "type":"citation_claim_split_required", "id":record["id"],
4413
  "sentence":record["original"][:500], "reason":reason,
@@ -4610,7 +4792,9 @@ Return exactly:
4610
  )
4611
  return revised
4612
 
4613
- def audit_and_resolve_citations(self, text, section_name='', section_id='', quarantine=False):
 
 
4614
  self._last_citation_semantic_violations = []
4615
  value = str(text or '')
4616
  if quarantine:
@@ -4620,12 +4804,31 @@ Return exactly:
4620
  for tag in unknown:
4621
  value = re.sub(rf'\s*\[?{re.escape(tag.strip("[]"))}\]?\s*', ' ', value, flags=re.I)
4622
  self._append_citation_audit(section_name, 'remove_unknown_tags', text, value, ','.join(unknown))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4623
  has_citations = bool(re.search(r'CITE-\d+', value, re.I))
4624
  violations = self.find_multi_citation_violations(value)
4625
  if has_citations and self.citation_semantic_audit.get('enabled', True):
4626
  try:
4627
  value = self.semantic_audit_claim_specific_citations(
4628
- value, section_name=section_name, section_id=section_id
4629
  )
4630
  except PipelineCancelledError:
4631
  raise
@@ -4650,6 +4853,10 @@ Return exactly:
4650
  value = self._local_citation_recovery(value, section_name, retain_lexical=False)
4651
  elif violations:
4652
  value = self._local_citation_recovery(value, section_name)
 
 
 
 
4653
  if self.find_multi_citation_violations(value):
4654
  value = self._local_citation_recovery(value, section_name)
4655
  if self.find_multi_citation_violations(value):
 
135
  self.s2_fused = False
136
  self.oa_fused = False
137
  self.citation_map = {}
138
+ # C5.2: English retrieval anchors are captured from the actual initial
139
+ # literature-search queries so non-English user topics can reuse the same
140
+ # semantic topic vocabulary during bounded JIT recovery.
141
+ self.retrieval_query_anchors = []
142
  self.used_citations = set()
143
  self.citation_audit_log = []
144
  # R3.2.8.6: translation validators emit hierarchical assurance evidence.
 
199
  self.global_memory = {
200
  "hypotheses": "", "variables": "", "model_summary": "",
201
  "method_summary": "", "research_logic_contract": "",
202
+ "core_golden_metadata": [], "hypothesis_contract_locked": False,
203
  }
204
 
205
  try:
 
315
  "s2_calls": self.s2_calls,
316
  "source_metrics": self.get_source_metrics(),
317
  "citation_map": self.citation_map,
318
+ "retrieval_query_anchors": list(getattr(self, "retrieval_query_anchors", [])),
319
  "used_citations": list(self.used_citations),
320
  "citation_audit_log": list(self.citation_audit_log),
321
  "translation_assurance_log": list(self.translation_assurance_log),
 
339
  if isinstance(loaded_metrics.get(source), dict):
340
  self.source_metrics[source].update(loaded_metrics[source])
341
  self.citation_map = state_dict.get("citation_map", {})
342
+ self.retrieval_query_anchors = [
343
+ str(value or '').strip() for value in state_dict.get("retrieval_query_anchors", [])
344
+ if str(value or '').strip()
345
+ ]
346
  self.used_citations = set(state_dict.get("used_citations", []))
347
  self.citation_audit_log = list(state_dict.get("citation_audit_log", []))
348
  self.translation_assurance_log = list(state_dict.get("translation_assurance_log", []))
 
465
 
466
  def _extract_memory_from_text(self, text, section_name):
467
  lower_sec = section_name.lower()
468
+ if (
469
+ not bool(self.global_memory.get("hypothesis_contract_locked"))
470
+ and any(kw in lower_sec for kw in ["introduction", "literature", "hypothesis", "framing"])
471
+ ):
472
  hypo_matches = re.findall(r'(H\d+[:\s].*?)(?=\n|$)', text, re.IGNORECASE)
473
  if hypo_matches:
474
  self.global_memory["hypotheses"] = "\n".join(hypo_matches[:10])
 
1286
  refs_text,
1287
  data_evidence="",
1288
  mode="amj",
1289
+ hypothesis_contract="",
1290
  ):
1291
  """Generate a provisional research-logic contract without prewriting results."""
1292
  mode = 'asq' if str(mode).lower() == 'asq' else 'amj'
 
1309
 
1310
  {evidence_block}
1311
  {mode_directive}
1312
+ <IMMUTABLE_HYPOTHESIS_CONTRACT>
1313
+ {str(hypothesis_contract or '')}
1314
+ </IMMUTABLE_HYPOTHESIS_CONTRACT>
1315
+ If the immutable hypothesis contract is non-empty, it is the sole authority for H-identifiers and construct pairings. Do not redefine, reorder, or infer an alternative H1-Hn architecture.
1316
  Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It must guide later sections without fixing an expected result or conclusion. Do not use citations in the contract."""
1317
  def _stream_direct_research_logic():
1318
  # R4-A: research logic is a concise planning contract. A reasoning-first
 
1486
  raise DeepSeekRequestError(f'{phase} front matter validation failed: {last_error}')
1487
 
1488
  def generate_working_front_matter(
1489
+ self, topic, keywords, research_logic_contract, synthesis_result, data_evidence='', mode='amj', validation_feedback='', hypothesis_contract=''
1490
  ):
1491
  """Create a provisional visible abstract/keyword anchor without prewriting results."""
1492
  evidence = self._truncate_context(str(data_evidence or ''), 3500)
 
1507
  <FROZEN_RESEARCH_EVIDENCE>
1508
  {evidence}
1509
  </FROZEN_RESEARCH_EVIDENCE>
1510
+ <IMMUTABLE_HYPOTHESIS_CONTRACT>
1511
+ {str(hypothesis_contract or '')}
1512
+ </IMMUTABLE_HYPOTHESIS_CONTRACT>
1513
+ If the immutable hypothesis contract is non-empty, preserve its H-identifiers and construct pairings exactly; do not invent or reorder hypotheses.
1514
  <LOCAL_VALIDATION_FEEDBACK>
1515
  {str(validation_feedback or '')[:2500]}
1516
  </LOCAL_VALIDATION_FEEDBACK>"""
 
1524
  prompt, phase='Working', max_tokens=1100, validator=validate_working,
1525
  )
1526
 
1527
+ def generate_final_front_matter(self, topic, keywords, manuscript, data_evidence='', mode='amj', validation_feedback='', hypothesis_contract=''):
1528
  """Generate final Abstract + Keywords only from the frozen completed body/evidence."""
1529
  evidence = self._truncate_context(str(data_evidence or ''), 5000)
1530
  body = self._abstract_manuscript_context(manuscript, 18000)
 
1537
  <FROZEN_RESEARCH_EVIDENCE>
1538
  {evidence}
1539
  </FROZEN_RESEARCH_EVIDENCE>
1540
+ <IMMUTABLE_HYPOTHESIS_CONTRACT>
1541
+ {str(hypothesis_contract or '')}
1542
+ </IMMUTABLE_HYPOTHESIS_CONTRACT>
1543
+ If the immutable hypothesis contract is non-empty, preserve its H-identifiers and construct pairings exactly; do not invent or reorder hypotheses.
1544
  <COMPLETED_FROZEN_MANUSCRIPT>
1545
  {body}
1546
  </COMPLETED_FROZEN_MANUSCRIPT>
 
2475
  """
2476
  with self.lock:
2477
  self.citation_map.clear()
2478
+ self.retrieval_query_anchors.clear()
2479
  self.used_citations.clear()
2480
  self.citation_audit_log.clear()
2481
  for source in ('s2', 'oa'):
 
2555
  all_used_terms.add(term)
2556
  if not normalized_terms:
2557
  continue
2558
+ for term in normalized_terms:
2559
+ if term not in self.retrieval_query_anchors:
2560
+ self.retrieval_query_anchors.append(term)
2561
 
2562
  run_wave(normalized_terms, second_page=False)
2563
  if unique_preview_count() >= min_required:
 
2614
  return int(match.group()) if match else 10**9
2615
 
2616
  @staticmethod
2617
+ def _citation_term_sequence(value):
2618
  stopwords = {
2619
  "about", "after", "also", "among", "and", "are", "because",
2620
  "been", "before", "between", "both", "can", "could", "does",
 
2625
  "with", "within", "would",
2626
  }
2627
  words = re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,}", str(value).lower())
2628
+ return [word for word in words if word not in stopwords]
2629
+
2630
+ @classmethod
2631
+ def _citation_terms(cls, value):
2632
+ return set(cls._citation_term_sequence(value))
2633
 
2634
  def build_claim_evidence_plan(self, tasks, topic, synthesis_result, mode='amj', target_unique=60):
2635
  """Build a bounded citation-first plan without one oversized model call.
 
2843
  'use','uses','used','using','user','users','approach','approaches','framework','frameworks',
2844
  })
2845
 
2846
+ _CITATION_RECOVERY_QUERY_NOISE_TERMS = frozenset({
2847
+ 'academic','academics','accept','accepted','acceptance','acceptable','access','accessed',
2848
+ 'account','accounts','absence','absent','abandoned','accomplish','accomplished','achieve',
2849
+ 'achieved','achieves','across','accordingly','additional','aim','aims','allow','allows',
2850
+ 'available','based','basis','consider','considered','current','different','evidence',
2851
+ 'existing','finding','findings','important','indicate','indicates','paper','prior','process',
2852
+ 'processes','provide','provides','related','relationship','relationships','relevant','report',
2853
+ 'reports','show','shows','specific','support','supports','introduction','discussion',
2854
+ 'background','theoretical','framing','methodology','setting','results','hypothesis','hypotheses',
2855
+ })
2856
+
2857
  @classmethod
2858
  def _citation_recovery_overlap(cls, section_name, section_context, claim, evidence):
2859
  anchor_terms = cls._citation_terms(f"{section_name} {section_context}")
 
2920
 
2921
  def select_contextual_citation_recovery_items(
2922
  self, items, *, section_name='', section_id='', section_context='', needed=1, excluded_tags=None,
2923
+ allowed_tags=None,
2924
  ):
2925
  """Select only recovery citations that are actually about the current unit.
2926
 
 
2933
  if needed <= 0:
2934
  return []
2935
  excluded = {str(tag or '').strip() for tag in (excluded_tags or set()) if str(tag or '').strip()}
2936
+ allowed = None if allowed_tags is None else {
2937
+ str(tag or '').strip() for tag in allowed_tags if str(tag or '').strip()
2938
+ }
2939
  ranked = []
2940
  seen = set()
2941
  for item in items or []:
 
2944
  tag = str(item.get('tag') or '').strip()
2945
  if not tag or tag in excluded or tag in seen:
2946
  continue
2947
+ if allowed is not None and tag not in allowed:
2948
+ continue
2949
  if not self.citation_source_role_eligible(
2950
  tag, section_id=section_id, section_name=section_name
2951
  ):
 
2967
  for tag, meta in self.citation_map.items():
2968
  if tag in used:
2969
  continue
2970
+ if allowed is not None and tag not in allowed:
2971
+ continue
2972
  if not self.citation_source_role_eligible(
2973
  tag, metadata=meta, section_id=section_id, section_name=section_name
2974
  ):
 
2993
  return selected
2994
 
2995
  def _deterministic_contextual_jit_query(self, topic, section_name, section_context):
2996
+ """Build one bounded topic/construct-first recovery query without a model call.
2997
+
2998
+ C5.1 deliberately preserves semantic/source order. The previous code
2999
+ converted the manuscript context to a set and then alphabetically sorted
3000
+ it, which produced queries such as ``academic accept access ...``. Topic
3001
+ terms now anchor the query, while context terms are ranked by occurrence
3002
+ frequency with first appearance as the deterministic tie-breaker.
3003
+ """
3004
+ noise = set(self._CITATION_RECOVERY_GENERIC_TERMS) | set(self._CITATION_RECOVERY_QUERY_NOISE_TERMS)
3005
+
3006
+ def clean_sequence(value):
3007
+ output = []
3008
+ for term in self._citation_term_sequence(value):
3009
+ if len(term) < 3 or term in noise or term in output:
3010
+ continue
3011
+ output.append(term)
3012
+ return output
3013
+
3014
+ topic_terms = clean_sequence(topic)
3015
+ retrieval_anchor_terms = []
3016
+ for query in getattr(self, 'retrieval_query_anchors', []):
3017
+ for term in clean_sequence(query):
3018
+ if term not in retrieval_anchor_terms:
3019
+ retrieval_anchor_terms.append(term)
3020
+ # C5.2: Chinese/Russian/Kazakh topic text may contain no ASCII tokens.
3021
+ # Reuse the English librarian queries that actually seeded the frozen
3022
+ # literature pool; if those anchors are unavailable, fail closed rather
3023
+ # than falling back to generic section prose vocabulary.
3024
+ use_retrieval_anchor = len(topic_terms) < 2
3025
+ effective_topic_terms = topic_terms if not use_retrieval_anchor else retrieval_anchor_terms
3026
+ section_terms = clean_sequence(section_name)
3027
+ raw_context = [
3028
+ term for term in self._citation_term_sequence(section_context)
3029
+ if len(term) >= 3 and term not in noise
3030
  ]
3031
+ counts = Counter(raw_context)
3032
+ first_index = {}
3033
+ for index, term in enumerate(raw_context):
3034
+ first_index.setdefault(term, index)
3035
+ context_terms = sorted(
3036
+ counts,
3037
+ key=lambda term: (-counts[term], first_index[term]),
3038
+ )
3039
+
3040
  selected = []
3041
+ # A recovery query should remain anchored in the manuscript topic rather
3042
+ # than drifting toward alphabetically early prose vocabulary.
3043
+ topic_target = min(5 if use_retrieval_anchor else 3, len(effective_topic_terms))
3044
+ for term in effective_topic_terms[:topic_target]:
3045
+ if term not in selected:
3046
+ selected.append(term)
3047
+ tail_terms = (
3048
+ effective_topic_terms[topic_target:] + section_terms + context_terms
3049
+ if use_retrieval_anchor
3050
+ else section_terms + context_terms + effective_topic_terms[topic_target:]
3051
+ )
3052
+ for term in tail_terms:
3053
+ if term not in selected:
3054
+ selected.append(term)
3055
  if len(selected) >= 5:
3056
  break
3057
+
3058
+ # When two or more topic/construct-specific terms are available, at
3059
+ # least two must survive into the bounded query. Otherwise skip JIT
3060
+ # instead of spending retrieval budget on a semantically weak query.
3061
+ if len(effective_topic_terms) < 2:
3062
+ return ''
3063
+ if len(set(selected) & set(effective_topic_terms)) < 2:
3064
+ return ''
3065
+ if len(selected) < 3:
3066
+ return ''
3067
+ if any(term in noise for term in selected):
3068
+ return ''
3069
+ return ' '.join(selected[:5])
3070
 
3071
  def fetch_contextual_jit_recovery_items(
3072
  self, *, topic, section_name, section_context, section_id='', needed=1, excluded_tags=None,
 
3350
  supports_section_id = True
3351
  if supports_section_id:
3352
  audit_kwargs['section_id'] = section_id
3353
+ try:
3354
+ supports_allowed_tags = any(
3355
+ item.name == 'allowed_tags' or item.kind == inspect.Parameter.VAR_KEYWORD
3356
+ for item in parameters
3357
+ )
3358
+ except Exception:
3359
+ supports_allowed_tags = True
3360
+ if supports_allowed_tags:
3361
+ audit_kwargs['allowed_tags'] = expected_tags
3362
  audited = audit_method(proposed, **audit_kwargs)
3363
  except PipelineCancelledError:
3364
  raise
 
3405
  return ''
3406
 
3407
  def _candidate_citation_tags(
3408
+ self, sentence, required_tags, limit, *, section_id='', section_name='', allowed_tags=None
3409
  ):
3410
  """Return source-role-eligible semantic-audit candidates for one claim.
3411
 
 
3415
  them. Lexical overlap only ranks eligible candidates; it never authorizes
3416
  attachment by itself.
3417
  """
3418
+ allowed = None if allowed_tags is None else {
3419
+ str(tag or '').strip() for tag in allowed_tags if str(tag or '').strip()
3420
+ }
3421
  valid_required = [
3422
  tag for tag in required_tags
3423
+ if tag in self.citation_map
3424
+ and (allowed is None or tag in allowed)
3425
+ and self.citation_source_role_eligible(
3426
  tag, section_id=section_id, section_name=section_name
3427
  )
3428
  ]
 
3431
  for tag, metadata in self.citation_map.items():
3432
  if tag in valid_required:
3433
  continue
3434
+ if allowed is not None and tag not in allowed:
3435
+ continue
3436
  if not self.citation_source_role_eligible(
3437
  tag, metadata=metadata, section_id=section_id, section_name=section_name
3438
  ):
 
4288
  core = f"{core} {tag}{punctuation}".strip()
4289
  return f"{leading}{core}{trailing}"
4290
 
4291
+ def _adjudicate_single_source_split(self, *, sentence, selected_tag, evidence, split_reason, section_name=''):
4292
+ """Decide whether one selected source supports the complete compound claim.
4293
+
4294
+ This is a bounded decision-only semantic check. It has no prose rewrite,
4295
+ citation invention, or retrieval authority. Any malformed/failed decision
4296
+ fails closed as partial support so the existing same-Section retry owns the
4297
+ correction.
4298
+ """
4299
+ selected_evidence = ''
4300
+ selected_reference = ''
4301
+ for row in evidence or []:
4302
+ if str(row.get('tag') or '').strip() == str(selected_tag or '').strip():
4303
+ selected_evidence = str(row.get('evidence') or '').strip()
4304
+ selected_reference = str(row.get('reference') or '').strip()
4305
+ break
4306
+ payload = {
4307
+ 'section': str(section_name or ''),
4308
+ 'sentence': str(sentence or ''),
4309
+ 'selected_tag': str(selected_tag or ''),
4310
+ 'selected_reference': selected_reference,
4311
+ 'selected_evidence': selected_evidence,
4312
+ 'original_split_reason': str(split_reason or '')[:700],
4313
+ }
4314
+ system_prompt = (
4315
+ "You are a strict citation-support adjudicator. Decide only whether the supplied single source directly supports "
4316
+ "the complete original sentence, including every material clause. Do not rewrite prose, add citations, infer from title alone, "
4317
+ "or use outside knowledge. Return JSON only: "
4318
+ '{"decision":"complete_support|partial_or_unsupported","reason":"brief evidence-grounded reason"}.'
4319
+ )
4320
+ try:
4321
+ response = self.call_deepseek(
4322
+ system_prompt,
4323
+ json.dumps(payload, ensure_ascii=False),
4324
+ max_tokens=700,
4325
+ temp=0.0,
4326
+ model_role='reasoning',
4327
+ thinking=False,
4328
+ allow_fallback=False,
4329
+ )
4330
+ parsed = self._extract_json_from_text(response)
4331
+ if isinstance(parsed, dict):
4332
+ decision = str(parsed.get('decision') or '').strip().lower()
4333
+ reason = str(parsed.get('reason') or '').strip()
4334
+ if decision == 'complete_support':
4335
+ return True, reason
4336
+ if decision == 'partial_or_unsupported':
4337
+ return False, reason
4338
+ except PipelineCancelledError:
4339
+ raise
4340
+ except Exception as exc:
4341
+ logging.warning(
4342
+ 'Citation split adjudication failed closed: section=%s error=%s',
4343
+ section_name, type(exc).__name__,
4344
+ )
4345
+ return False, 'Single-source split adjudication did not confirm complete support.'
4346
+
4347
  def semantic_audit_claim_specific_citations(
4348
  self,
4349
  text,
4350
  section_name="",
4351
  section_id="",
4352
+ allowed_tags=None,
4353
  ):
4354
  """Audit citation support while granting the audit TAG-ONLY mutation authority.
4355
 
 
4393
  sentence,
4394
  required_tags,
4395
  candidate_limit,
4396
+ section_id=section_id, section_name=section_name, allowed_tags=allowed_tags,
4397
  )
4398
  evidence = []
4399
  for tag in candidates:
 
4566
  )
4567
  continue
4568
  if status == "split":
4569
+ # C5.1: ``split + one tag`` is an inconsistent audit decision,
4570
+ # not proof of complete support. Re-adjudicate the same
4571
+ # sentence/source pair once, without rewrite/retrieval authority.
4572
+ # This preserves C2's legitimate full-support recovery while
4573
+ # preventing a partially unsupported clause from inheriting an
4574
+ # overbroad sentence-final citation.
4575
  if len(revised_tags) == 1:
4576
  selected = next(iter(revised_tags))
4577
+ complete_support, adjudication_reason = self._adjudicate_single_source_split(
4578
+ sentence=record["original"], selected_tag=selected,
4579
+ evidence=record.get("evidence") or [], split_reason=reason,
4580
+ section_name=section_name,
 
 
 
4581
  )
4582
+ if complete_support:
4583
+ replacements[record["id"]] = self._citation_tag_only_rewrite(
4584
+ record["original"], selected
4585
+ )
4586
+ self._append_citation_audit(
4587
+ section_name, "normalize_inconsistent_split_to_single_source",
4588
+ record["original"], replacements[record["id"]],
4589
+ adjudication_reason or reason or "Decision-only adjudication confirmed complete support.",
4590
+ )
4591
+ continue
4592
+ reason = adjudication_reason or reason
4593
  violation = {
4594
  "type":"citation_claim_split_required", "id":record["id"],
4595
  "sentence":record["original"][:500], "reason":reason,
 
4792
  )
4793
  return revised
4794
 
4795
+ def audit_and_resolve_citations(
4796
+ self, text, section_name='', section_id='', quarantine=False, allowed_tags=None
4797
+ ):
4798
  self._last_citation_semantic_violations = []
4799
  value = str(text or '')
4800
  if quarantine:
 
4804
  for tag in unknown:
4805
  value = re.sub(rf'\s*\[?{re.escape(tag.strip("[]"))}\]?\s*', ' ', value, flags=re.I)
4806
  self._append_citation_audit(section_name, 'remove_unknown_tags', text, value, ','.join(unknown))
4807
+ allowed = None if allowed_tags is None else {
4808
+ str(tag or '').strip() for tag in allowed_tags if str(tag or '').strip()
4809
+ }
4810
+ outside_rows = []
4811
+ if allowed is not None:
4812
+ present = {f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', value, re.I)}
4813
+ outside = sorted(present - allowed, key=self._citation_tag_number)
4814
+ for tag in outside:
4815
+ outside_rows.append({
4816
+ 'type': 'citation_outside_frozen_section_whitelist',
4817
+ 'tag': tag,
4818
+ 'sentence': tag,
4819
+ 'reason': 'Citation tag is outside the frozen evidence whitelist for this manuscript unit.',
4820
+ 'repair_instruction': 'Use only citation tags frozen for this manuscript unit, or narrow the claim so no unsupported citation is needed.',
4821
+ })
4822
+ value = re.sub(rf'\s*\[?{re.escape(tag.strip("[]"))}\]?\s*', ' ', value, flags=re.I)
4823
+ self._append_citation_audit(
4824
+ section_name, 'reject_outside_frozen_section_whitelist', text, value, tag
4825
+ )
4826
  has_citations = bool(re.search(r'CITE-\d+', value, re.I))
4827
  violations = self.find_multi_citation_violations(value)
4828
  if has_citations and self.citation_semantic_audit.get('enabled', True):
4829
  try:
4830
  value = self.semantic_audit_claim_specific_citations(
4831
+ value, section_name=section_name, section_id=section_id, allowed_tags=allowed
4832
  )
4833
  except PipelineCancelledError:
4834
  raise
 
4853
  value = self._local_citation_recovery(value, section_name, retain_lexical=False)
4854
  elif violations:
4855
  value = self._local_citation_recovery(value, section_name)
4856
+ if outside_rows:
4857
+ self._last_citation_semantic_violations = list(
4858
+ self._last_citation_semantic_violations or []
4859
+ ) + outside_rows
4860
  if self.find_multi_citation_violations(value):
4861
  value = self._local_citation_recovery(value, section_name)
4862
  if self.find_multi_citation_violations(value):