S0-R1-C1 Citation Source-Role + Retry Evidence Ownership

#74
Files changed (2) hide show
  1. core/generation_runner.py +67 -18
  2. core/pipeline_agent.py +49 -7
core/generation_runner.py CHANGED
@@ -958,6 +958,24 @@ class GenerationRunner:
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."""
@@ -2327,8 +2345,9 @@ class GenerationRunner:
2327
  task_by_name.get(display_name, {}), research_logic_contract, synthesis_result
2328
  )
2329
  recovery_items = agent.select_contextual_citation_recovery_items(
2330
- [], section_name=display_name, section_context=section_context,
2331
- needed=shortfall, excluded_tags=planned_tags,
 
2332
  )
2333
  for item in recovery_items:
2334
  tag = str(item.get("tag") or "")
@@ -2355,8 +2374,8 @@ class GenerationRunner:
2355
  for name in section_units
2356
  )
2357
  jit_items = agent.fetch_contextual_jit_recovery_items(
2358
- topic=topic, section_name=section_id, section_context=section_context,
2359
- needed=deficit, excluded_tags=planned_tags,
2360
  )
2361
  planned_items = citation_plan.setdefault(primary_unit, [])
2362
  for item in jit_items:
@@ -2805,10 +2824,16 @@ class GenerationRunner:
2805
  word in sec_title.lower()
2806
  for word in ["abstract", "keyword", "conclusion", "references", "appendix"]
2807
  )
2808
- planned_items = list(citation_plan.get(display_name, []))
 
 
 
 
 
 
 
2809
  assigned_tag_list = [str(item.get("tag") or "") for item in planned_items if item.get("tag")]
2810
  claim_plan_text = agent.format_claim_evidence_plan(planned_items)
2811
- section_id = str(sec.get("section_id") or "")
2812
  section_role = str(
2813
  APP_CONFIG.get("section_reasoning_profiles", {}).get(mode, {}).get(section_id) or ""
2814
  )
@@ -2993,6 +3018,7 @@ Output only final academic prose. Do not report the word count."""
2993
  agent, citation_plan, display_name
2994
  )
2995
  attempt_feedback = ""
 
2996
  en_resolved = ""
2997
  survived = set()
2998
 
@@ -3004,15 +3030,32 @@ Output only final academic prose. Do not report the word count."""
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:
@@ -3082,9 +3125,9 @@ The previous attempt for this same manuscript unit was rejected by validation. R
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)
@@ -3099,6 +3142,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
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",
@@ -3127,10 +3171,10 @@ The previous attempt for this same manuscript unit was rejected by validation. R
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 = [
@@ -3139,17 +3183,21 @@ The previous attempt for this same manuscript unit was rejected by validation. R
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}
@@ -3260,6 +3308,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3260
  )
3261
 
3262
  quality_diagnostics.extend(attempt_diagnostics)
 
3263
  state.pop("section_retry_exhausted", None)
3264
  break
3265
 
 
958
  rows.append(' | '.join(parts))
959
  return '\n'.join(f'- {row}' for row in rows)
960
 
961
+ @staticmethod
962
+ def _citation_violation_tags(rows) -> set:
963
+ """Extract only citation tags owned by the rejected claim sentence.
964
+
965
+ S0-R1-C1 uses this set as a bounded retry exclusion. Reasons may mention
966
+ comparison sources, so only explicit tag fields and rejected sentences are
967
+ inspected; this avoids excluding unrelated frozen evidence.
968
+ """
969
+ tags = set()
970
+ for row in rows or []:
971
+ if not isinstance(row, dict):
972
+ continue
973
+ owned = ' '.join(str(row.get(key) or '') for key in ('tag', 'citation', 'sentence'))
974
+ for raw in re.findall(r'\[?CITE-\d+\]?', owned, flags=re.I):
975
+ token = raw.upper().strip('[]')
976
+ tags.add(f'[{token}]')
977
+ return tags
978
+
979
  @staticmethod
980
  def _snapshot_section_citation_state(agent, citation_plan: dict, unit_name: str) -> dict:
981
  """Snapshot only Section-owned citation state so a rejected attempt cannot leak forward."""
 
2345
  task_by_name.get(display_name, {}), research_logic_contract, synthesis_result
2346
  )
2347
  recovery_items = agent.select_contextual_citation_recovery_items(
2348
+ [], section_name=display_name,
2349
+ section_id=str(task_by_name.get(display_name, {}).get("section_id") or ""),
2350
+ section_context=section_context, needed=shortfall, excluded_tags=planned_tags,
2351
  )
2352
  for item in recovery_items:
2353
  tag = str(item.get("tag") or "")
 
2374
  for name in section_units
2375
  )
2376
  jit_items = agent.fetch_contextual_jit_recovery_items(
2377
+ topic=topic, section_name=section_id, section_id=section_id,
2378
+ section_context=section_context, needed=deficit, excluded_tags=planned_tags,
2379
  )
2380
  planned_items = citation_plan.setdefault(primary_unit, [])
2381
  for item in jit_items:
 
2824
  word in sec_title.lower()
2825
  for word in ["abstract", "keyword", "conclusion", "references", "appendix"]
2826
  )
2827
+ section_id = str(sec.get("section_id") or "")
2828
+ planned_items = [
2829
+ item for item in list(citation_plan.get(display_name, []))
2830
+ if agent.citation_source_role_eligible(
2831
+ str(item.get("tag") or ""), section_id=section_id, section_name=display_name
2832
+ )
2833
+ ]
2834
+ citation_plan[display_name] = copy.deepcopy(planned_items)
2835
  assigned_tag_list = [str(item.get("tag") or "") for item in planned_items if item.get("tag")]
2836
  claim_plan_text = agent.format_claim_evidence_plan(planned_items)
 
2837
  section_role = str(
2838
  APP_CONFIG.get("section_reasoning_profiles", {}).get(mode, {}).get(section_id) or ""
2839
  )
 
3018
  agent, citation_plan, display_name
3019
  )
3020
  attempt_feedback = ""
3021
+ retry_excluded_tags = set()
3022
  en_resolved = ""
3023
  survived = set()
3024
 
 
3030
  self._restore_section_citation_state(
3031
  agent, citation_plan, display_name, frozen_citation_state
3032
  )
3033
+ planned_items = [
3034
+ item for item in copy.deepcopy(list(citation_plan.get(display_name, []) or []))
3035
+ if str(item.get("tag") or "") not in retry_excluded_tags
3036
+ and agent.citation_source_role_eligible(
3037
+ str(item.get("tag") or ""), section_id=section_id, section_name=display_name
3038
+ )
3039
+ ]
3040
  citation_plan[display_name] = planned_items
3041
+ attempt_assigned_tag_list = [
3042
+ str(item.get("tag") or "") for item in planned_items if item.get("tag")
3043
+ ]
3044
  attempt_diagnostics = []
3045
  candidate_user_prompt = user_prompt
3046
+ if retry_excluded_tags:
3047
+ retry_claim_plan = agent.format_claim_evidence_plan(planned_items)
3048
+ candidate_user_prompt = re.sub(
3049
+ r"<CLAIM_EVIDENCE_PLAN>.*?</CLAIM_EVIDENCE_PLAN>",
3050
+ f"<CLAIM_EVIDENCE_PLAN>\n{retry_claim_plan}\n</CLAIM_EVIDENCE_PLAN>",
3051
+ candidate_user_prompt, flags=re.S,
3052
+ )
3053
  if attempt_feedback:
3054
+ excluded_text = ", ".join(sorted(retry_excluded_tags, key=agent._citation_tag_number)) or "None"
3055
  candidate_user_prompt += f"""
3056
 
3057
  <SECTION_RETRY_VALIDATION_FEEDBACK>
3058
+ The previous attempt for this same manuscript unit was rejected by validation. Rewrite ONLY this same unit once. Keep the identical frozen evidence universe, research logic, and section role. Do not fetch, invent, or broaden evidence. Citation tags rejected for this claim context are ineligible on the retry and must not be reused or deterministically reinserted: {excluded_text}. Use another already-frozen eligible source only when it directly supports the rewritten claim, or narrow/qualify the claim so no unsupported citation is needed. Resolve every material issue below without changing the task or manuscript scope:
3059
  {attempt_feedback}
3060
  </SECTION_RETRY_VALIDATION_FEEDBACK>"""
3061
  if agent._count_tokens(sys_prompt) + agent._count_tokens(candidate_user_prompt) > 60000:
 
3125
  allow_deterministic_citation_reinforcement = not (
3126
  mode == "asq" and section_id in {"methods", "discussion"}
3127
  )
3128
+ if not is_quarantined and attempt_assigned_tag_list and allow_deterministic_citation_reinforcement:
3129
  en_raw = agent.reinforce_claim_specific_citation_coverage(
3130
+ en_raw, attempt_assigned_tag_list, section_name=display_name, section_id=section_id
3131
  )
3132
  self._progress(pct, f"citation_audit:{current_index}:{total_steps}")
3133
  used_before = set(agent.used_citations)
 
3142
  )
3143
  if citation_semantic_rows:
3144
  if section_attempt < max_section_attempts:
3145
+ retry_excluded_tags.update(self._citation_violation_tags(citation_semantic_rows))
3146
  attempt_feedback = self._section_retry_feedback(citation_semantic_rows)
3147
  logging.warning(
3148
  "Citation semantic Section retry | unit=%s attempt=%s count=%s types=%s",
 
3171
 
3172
  used_after = set(agent.used_citations)
3173
  survived = used_after - used_before
3174
+ if attempt_assigned_tag_list and not is_quarantined:
3175
  required_survival = min(
3176
+ len(attempt_assigned_tag_list),
3177
+ max(1, int(math.ceil(len(attempt_assigned_tag_list) * CITATION_SEMANTIC_SURVIVAL_RATIO))),
3178
  )
3179
  if len(survived) < required_survival:
3180
  missing_items = [
 
3183
  ]
3184
  needed = max(0, required_survival - len(survived))
3185
  recovery_items = agent.select_contextual_citation_recovery_items(
3186
+ missing_items, section_name=display_name, section_id=section_id,
3187
+ section_context=en_resolved, needed=needed,
3188
+ excluded_tags=used_after | retry_excluded_tags,
3189
  )
3190
  # Evidence retrieval is permitted only on the first writer
3191
  # attempt. Attempt 2 is bound to the original frozen packet.
3192
  if section_attempt == 1 and len(recovery_items) < needed:
3193
  recovery_items.extend(
3194
  agent.fetch_contextual_jit_recovery_items(
3195
+ topic=topic, section_name=display_name, section_id=section_id,
3196
+ section_context=en_resolved, needed=needed - len(recovery_items),
3197
+ excluded_tags=(
3198
+ used_after | retry_excluded_tags
3199
+ | {str(item.get("tag") or "") for item in recovery_items}
3200
+ ),
3201
  )
3202
  )
3203
  planned_tags_for_unit = {str(item.get("tag") or "") for item in planned_items}
 
3308
  )
3309
 
3310
  quality_diagnostics.extend(attempt_diagnostics)
3311
+ assigned_tag_list = list(attempt_assigned_tag_list)
3312
  state.pop("section_retry_exhausted", None)
3313
  break
3314
 
core/pipeline_agent.py CHANGED
@@ -2744,8 +2744,9 @@ INPUT:
2744
  if len(self._citation_terms(claim) & self._citation_terms(evidence)) < 2:
2745
  continue
2746
  section_meta = next((item for item in sections if item['name'] == section), {})
2747
- lanes = set((self.citation_map.get(tag) or {}).get('evidence_lanes') or [])
2748
- if 'method_authority' in lanes and str(section_meta.get('section_id') or '') not in {'methods', 'methodology'}:
 
2749
  logging.info('Citation planner rejected METHOD_AUTHORITY outside methods: section=%s tag=%s', section, tag)
2750
  continue
2751
  section_context = ' '.join([
@@ -2816,6 +2817,28 @@ INPUT:
2816
  distinctive = overlap_terms - cls._CITATION_RECOVERY_GENERIC_TERMS
2817
  return len(overlap_terms), len(distinctive)
2818
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2819
  @classmethod
2820
  def _citation_recovery_item_relevant(cls, item, section_name='', section_context=''):
2821
  if not str(section_context or '').strip():
@@ -2851,7 +2874,7 @@ INPUT:
2851
  return ranked[0][3]
2852
 
2853
  def select_contextual_citation_recovery_items(
2854
- self, items, *, section_name='', section_context='', needed=1, excluded_tags=None,
2855
  ):
2856
  """Select only recovery citations that are actually about the current unit.
2857
 
@@ -2872,6 +2895,10 @@ INPUT:
2872
  tag = str(item.get('tag') or '').strip()
2873
  if not tag or tag in excluded or tag in seen:
2874
  continue
 
 
 
 
2875
  if not self._citation_recovery_item_relevant(item, section_name, section_context):
2876
  continue
2877
  overlap, distinctive = self._citation_recovery_overlap(
@@ -2889,6 +2916,10 @@ INPUT:
2889
  for tag, meta in self.citation_map.items():
2890
  if tag in used:
2891
  continue
 
 
 
 
2892
  evidence = re.sub(r'\s+', ' ', str(meta.get('evidence') or '')).strip()
2893
  claim = self._best_contextual_evidence_claim(
2894
  evidence, section_name=section_name, section_context=section_context
@@ -2935,7 +2966,7 @@ INPUT:
2935
  return ' '.join(selected[:5]) if len(selected) >= 3 else ''
2936
 
2937
  def fetch_contextual_jit_recovery_items(
2938
- self, *, topic, section_name, section_context, needed=1, excluded_tags=None,
2939
  ):
2940
  """Bounded last-resort JIT top-up for a genuine section-specific citation gap.
2941
 
@@ -2995,7 +3026,7 @@ INPUT:
2995
  tag = f"[CITE-{len(self.citation_map)+1:02d}]"
2996
  if tag in excluded:
2997
  continue
2998
- self.citation_map[tag] = {
2999
  'in_text': res.get('in_text', ''),
3000
  'ref': res.get('ref', ''),
3001
  'id': keys[0] if keys else ('title_year', res.get('title'), res.get('year')),
@@ -3005,7 +3036,13 @@ INPUT:
3005
  'year': res.get('year', ''),
3006
  'evidence': evidence,
3007
  'sources': [res.get('source', 'jit')],
 
3008
  }
 
 
 
 
 
3009
  selected.append({'tag': tag, 'claim': claim, 'evidence': evidence[:900]})
3010
  if len(selected) >= needed:
3011
  break
@@ -4445,7 +4482,7 @@ Return exactly:
4445
  recovered = re.sub(r'[ \t]+\n', '\n', recovered)
4446
  return re.sub(r'\n{3,}', '\n\n', recovered).strip()
4447
 
4448
- def reinforce_claim_specific_citation_coverage(self, text, assigned_tags, section_name=''):
4449
  """Add assigned tags only to citation-free claims with lexical evidence overlap.
4450
 
4451
  This pass is deterministic and makes no additional model call. It never
@@ -4455,7 +4492,12 @@ Return exactly:
4455
  final authority and may still reject an unsupported placement.
4456
  """
4457
  value = str(text or '').strip()
4458
- valid_tags = [tag for tag in dict.fromkeys(assigned_tags or []) if tag in self.citation_map]
 
 
 
 
 
4459
  if not value or not valid_tags:
4460
  return value
4461
  existing = {f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', value, flags=re.I)}
 
2744
  if len(self._citation_terms(claim) & self._citation_terms(evidence)) < 2:
2745
  continue
2746
  section_meta = next((item for item in sections if item['name'] == section), {})
2747
+ if not self.citation_source_role_eligible(
2748
+ tag, section_id=str(section_meta.get('section_id') or ''), section_name=section
2749
+ ):
2750
  logging.info('Citation planner rejected METHOD_AUTHORITY outside methods: section=%s tag=%s', section, tag)
2751
  continue
2752
  section_context = ' '.join([
 
2817
  distinctive = overlap_terms - cls._CITATION_RECOVERY_GENERIC_TERMS
2818
  return len(overlap_terms), len(distinctive)
2819
 
2820
+ @staticmethod
2821
+ def _citation_method_section(section_id='', section_name=''):
2822
+ sid = str(section_id or '').strip().lower()
2823
+ if sid in {'methods', 'methodology'}:
2824
+ return True
2825
+ name = re.sub(r'[^a-z]+', ' ', str(section_name or '').lower()).strip()
2826
+ return bool(re.search(r'\b(?:methods?|methodology)\b', name))
2827
+
2828
+ def citation_source_role_eligible(self, tag='', metadata=None, *, section_id='', section_name=''):
2829
+ """Return whether a retrieved source role is eligible for one manuscript section.
2830
+
2831
+ S0-R1-C1 keeps role ownership deterministic across every citation path.
2832
+ In particular, METHOD_AUTHORITY evidence may support methodological rationale
2833
+ only in Methods/Methodology; reserve/recovery/reinforcement paths may not
2834
+ reintroduce it into substantive sections after the planner rejected it.
2835
+ """
2836
+ meta = dict(metadata or self.citation_map.get(str(tag or '').strip()) or {})
2837
+ lanes = {str(value or '').strip().lower() for value in (meta.get('evidence_lanes') or [])}
2838
+ if 'method_authority' in lanes and not self._citation_method_section(section_id, section_name):
2839
+ return False
2840
+ return True
2841
+
2842
  @classmethod
2843
  def _citation_recovery_item_relevant(cls, item, section_name='', section_context=''):
2844
  if not str(section_context or '').strip():
 
2874
  return ranked[0][3]
2875
 
2876
  def select_contextual_citation_recovery_items(
2877
+ self, items, *, section_name='', section_id='', section_context='', needed=1, excluded_tags=None,
2878
  ):
2879
  """Select only recovery citations that are actually about the current unit.
2880
 
 
2895
  tag = str(item.get('tag') or '').strip()
2896
  if not tag or tag in excluded or tag in seen:
2897
  continue
2898
+ if not self.citation_source_role_eligible(
2899
+ tag, section_id=section_id, section_name=section_name
2900
+ ):
2901
+ continue
2902
  if not self._citation_recovery_item_relevant(item, section_name, section_context):
2903
  continue
2904
  overlap, distinctive = self._citation_recovery_overlap(
 
2916
  for tag, meta in self.citation_map.items():
2917
  if tag in used:
2918
  continue
2919
+ if not self.citation_source_role_eligible(
2920
+ tag, metadata=meta, section_id=section_id, section_name=section_name
2921
+ ):
2922
+ continue
2923
  evidence = re.sub(r'\s+', ' ', str(meta.get('evidence') or '')).strip()
2924
  claim = self._best_contextual_evidence_claim(
2925
  evidence, section_name=section_name, section_context=section_context
 
2966
  return ' '.join(selected[:5]) if len(selected) >= 3 else ''
2967
 
2968
  def fetch_contextual_jit_recovery_items(
2969
+ self, *, topic, section_name, section_context, section_id='', needed=1, excluded_tags=None,
2970
  ):
2971
  """Bounded last-resort JIT top-up for a genuine section-specific citation gap.
2972
 
 
3026
  tag = f"[CITE-{len(self.citation_map)+1:02d}]"
3027
  if tag in excluded:
3028
  continue
3029
+ metadata = {
3030
  'in_text': res.get('in_text', ''),
3031
  'ref': res.get('ref', ''),
3032
  'id': keys[0] if keys else ('title_year', res.get('title'), res.get('year')),
 
3036
  'year': res.get('year', ''),
3037
  'evidence': evidence,
3038
  'sources': [res.get('source', 'jit')],
3039
+ 'evidence_lanes': ['contextual_jit'],
3040
  }
3041
+ if not self.citation_source_role_eligible(
3042
+ tag, metadata=metadata, section_id=section_id, section_name=section_name
3043
+ ):
3044
+ continue
3045
+ self.citation_map[tag] = metadata
3046
  selected.append({'tag': tag, 'claim': claim, 'evidence': evidence[:900]})
3047
  if len(selected) >= needed:
3048
  break
 
4482
  recovered = re.sub(r'[ \t]+\n', '\n', recovered)
4483
  return re.sub(r'\n{3,}', '\n\n', recovered).strip()
4484
 
4485
+ def reinforce_claim_specific_citation_coverage(self, text, assigned_tags, section_name='', section_id=''):
4486
  """Add assigned tags only to citation-free claims with lexical evidence overlap.
4487
 
4488
  This pass is deterministic and makes no additional model call. It never
 
4492
  final authority and may still reject an unsupported placement.
4493
  """
4494
  value = str(text or '').strip()
4495
+ valid_tags = [
4496
+ tag for tag in dict.fromkeys(assigned_tags or [])
4497
+ if tag in self.citation_map and self.citation_source_role_eligible(
4498
+ tag, section_id=section_id, section_name=section_name
4499
+ )
4500
+ ]
4501
  if not value or not valid_tags:
4502
  return value
4503
  existing = {f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', value, flags=re.I)}