S0-R1-C5.5-R6-R3 Cross-Mode Four-Language Repair Entitlement Closure

#91
Files changed (2) hide show
  1. core/generation_runner.py +54 -19
  2. core/pipeline_agent.py +42 -6
core/generation_runner.py CHANGED
@@ -1082,7 +1082,10 @@ class GenerationRunner:
1082
  lineages = {cls._section_violation_lineage(row) for row in (rows or [])}
1083
  exhausted = {name for name in lineages if int(lineage_attempts.get(name, 0)) >= 1}
1084
  fresh = lineages - exhausted
1085
- return bool(fresh) and not exhausted and not hard_cap_reached, exhausted, fresh
 
 
 
1086
 
1087
  @classmethod
1088
  def _partition_section_violations(cls, rows, *, section_name: str = "", mode: str = "", exhausted: bool = False):
@@ -1097,6 +1100,30 @@ class GenerationRunner:
1097
  blocking.append(row)
1098
  return blocking, advisory
1099
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1100
  @staticmethod
1101
  def _record_section_advisories(quality_notes: list, rows, *, section_name: str = "") -> None:
1102
  """Append reader-facing, nonfatal notes without exposing control vocabulary."""
@@ -3920,23 +3947,27 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3920
  may_retry, exhausted_lineages, fresh_lineages = self._section_retry_decision(
3921
  preview_blocking, lineage_attempts, hard_cap_reached=hard_cap_reached
3922
  )
3923
- exhausted = hard_cap_reached or bool(exhausted_lineages)
3924
- blocking_rows, advisory_rows = self._partition_section_violations(
3925
- phase1_validation_rows, section_name=display_name, mode=mode, exhausted=exhausted
3926
- )
3927
- if blocking_rows and may_retry:
3928
  for lineage in fresh_lineages:
3929
  lineage_attempts[lineage] = int(lineage_attempts.get(lineage, 0)) + 1
3930
- attempt_feedback = self._section_retry_feedback(blocking_rows)
3931
  logging.warning(
3932
- "Unified Phase-1 Section lineage retry | unit=%s attempt=%s lineages=%s count=%s types=%s",
3933
- display_name, section_attempt, sorted(fresh_lineages), len(blocking_rows),
3934
- sorted({str(row.get("type") or "") for row in blocking_rows if isinstance(row, dict)}),
3935
  )
3936
  self._restore_section_citation_state(
3937
  agent, citation_plan, display_name, frozen_citation_state
3938
  )
3939
  continue
 
 
 
 
3940
  if advisory_rows:
3941
  self._record_section_advisories(
3942
  quality_notes, advisory_rows, section_name=display_name
@@ -4104,23 +4135,27 @@ The previous attempt for this same manuscript unit was rejected by validation. R
4104
  may_retry, exhausted_lineages, fresh_lineages = self._section_retry_decision(
4105
  preview_blocking, lineage_attempts, hard_cap_reached=hard_cap_reached
4106
  )
4107
- exhausted = hard_cap_reached or bool(exhausted_lineages)
4108
- blocking_rows, advisory_rows = self._partition_section_violations(
4109
- post_validation_rows, section_name=display_name, mode=mode, exhausted=exhausted
4110
- )
4111
- if blocking_rows and may_retry:
4112
  for lineage in fresh_lineages:
4113
  lineage_attempts[lineage] = int(lineage_attempts.get(lineage, 0)) + 1
4114
- attempt_feedback = self._section_retry_feedback(blocking_rows)
4115
  logging.warning(
4116
- "Post-normalization Section lineage retry | unit=%s attempt=%s lineages=%s count=%s types=%s",
4117
- display_name, section_attempt, sorted(fresh_lineages), len(blocking_rows),
4118
- sorted({str(row.get("type") or "") for row in blocking_rows if isinstance(row, dict)}),
4119
  )
4120
  self._restore_section_citation_state(
4121
  agent, citation_plan, display_name, frozen_citation_state
4122
  )
4123
  continue
 
 
 
 
4124
  if advisory_rows:
4125
  self._record_section_advisories(
4126
  quality_notes, advisory_rows, section_name=display_name
 
1082
  lineages = {cls._section_violation_lineage(row) for row in (rows or [])}
1083
  exhausted = {name for name in lineages if int(lineage_attempts.get(name, 0)) >= 1}
1084
  fresh = lineages - exhausted
1085
+ # R6-R3 fairness closure: an exhausted lineage may not steal the bounded
1086
+ # repair entitlement of a genuinely fresh lineage discovered in the same
1087
+ # validation round. The Section hard cap remains the global stop.
1088
+ return bool(fresh) and not hard_cap_reached, exhausted, fresh
1089
 
1090
  @classmethod
1091
  def _partition_section_violations(cls, rows, *, section_name: str = "", mode: str = "", exhausted: bool = False):
 
1100
  blocking.append(row)
1101
  return blocking, advisory
1102
 
1103
+ @classmethod
1104
+ def _partition_section_violations_by_lineage(
1105
+ cls, rows, lineage_attempts, *, section_name: str = "", mode: str = "",
1106
+ hard_cap_reached: bool = False,
1107
+ ):
1108
+ """Classify each defect using only its own repair-exhaustion state.
1109
+
1110
+ R6-R3 prevents a previously exhausted sibling lineage from globally
1111
+ changing the severity of a newly discovered defect. When the Section
1112
+ hard cap is reached every lineage is evaluated as exhausted.
1113
+ """
1114
+ blocking, advisory = [], []
1115
+ for row in list(rows or []):
1116
+ lineage = cls._section_violation_lineage(row)
1117
+ lineage_exhausted = bool(hard_cap_reached) or int(lineage_attempts.get(lineage, 0)) >= 1
1118
+ severity = cls._section_violation_severity(
1119
+ row, section_name=section_name, mode=mode, exhausted=lineage_exhausted
1120
+ )
1121
+ if severity == "ADVISORY":
1122
+ advisory.append(row)
1123
+ else:
1124
+ blocking.append(row)
1125
+ return blocking, advisory
1126
+
1127
  @staticmethod
1128
  def _record_section_advisories(quality_notes: list, rows, *, section_name: str = "") -> None:
1129
  """Append reader-facing, nonfatal notes without exposing control vocabulary."""
 
3947
  may_retry, exhausted_lineages, fresh_lineages = self._section_retry_decision(
3948
  preview_blocking, lineage_attempts, hard_cap_reached=hard_cap_reached
3949
  )
3950
+ if may_retry:
3951
+ retry_rows = [
3952
+ row for row in preview_blocking
3953
+ if self._section_violation_lineage(row) in fresh_lineages
3954
+ ]
3955
  for lineage in fresh_lineages:
3956
  lineage_attempts[lineage] = int(lineage_attempts.get(lineage, 0)) + 1
3957
+ attempt_feedback = self._section_retry_feedback(retry_rows)
3958
  logging.warning(
3959
+ "Unified Phase-1 Section lineage retry | unit=%s attempt=%s lineages=%s exhausted_siblings=%s count=%s types=%s",
3960
+ display_name, section_attempt, sorted(fresh_lineages), sorted(exhausted_lineages), len(retry_rows),
3961
+ sorted({str(row.get("type") or "") for row in retry_rows if isinstance(row, dict)}),
3962
  )
3963
  self._restore_section_citation_state(
3964
  agent, citation_plan, display_name, frozen_citation_state
3965
  )
3966
  continue
3967
+ blocking_rows, advisory_rows = self._partition_section_violations_by_lineage(
3968
+ phase1_validation_rows, lineage_attempts, section_name=display_name, mode=mode,
3969
+ hard_cap_reached=hard_cap_reached
3970
+ )
3971
  if advisory_rows:
3972
  self._record_section_advisories(
3973
  quality_notes, advisory_rows, section_name=display_name
 
4135
  may_retry, exhausted_lineages, fresh_lineages = self._section_retry_decision(
4136
  preview_blocking, lineage_attempts, hard_cap_reached=hard_cap_reached
4137
  )
4138
+ if may_retry:
4139
+ retry_rows = [
4140
+ row for row in preview_blocking
4141
+ if self._section_violation_lineage(row) in fresh_lineages
4142
+ ]
4143
  for lineage in fresh_lineages:
4144
  lineage_attempts[lineage] = int(lineage_attempts.get(lineage, 0)) + 1
4145
+ attempt_feedback = self._section_retry_feedback(retry_rows)
4146
  logging.warning(
4147
+ "Post-normalization Section lineage retry | unit=%s attempt=%s lineages=%s exhausted_siblings=%s count=%s types=%s",
4148
+ display_name, section_attempt, sorted(fresh_lineages), sorted(exhausted_lineages), len(retry_rows),
4149
+ sorted({str(row.get("type") or "") for row in retry_rows if isinstance(row, dict)}),
4150
  )
4151
  self._restore_section_citation_state(
4152
  agent, citation_plan, display_name, frozen_citation_state
4153
  )
4154
  continue
4155
+ blocking_rows, advisory_rows = self._partition_section_violations_by_lineage(
4156
+ post_validation_rows, lineage_attempts, section_name=display_name, mode=mode,
4157
+ hard_cap_reached=hard_cap_reached
4158
+ )
4159
  if advisory_rows:
4160
  self._record_section_advisories(
4161
  quality_notes, advisory_rows, section_name=display_name
core/pipeline_agent.py CHANGED
@@ -8219,6 +8219,20 @@ Each code must be one of: {sorted(allowed)}. Return one or more specific dimensi
8219
  'last_evidence_signature': str(meta.get('last_evidence_signature') or ''),
8220
  'last_strategy': str(meta.get('last_strategy') or ''),
8221
  'nonconvergent_state': str(meta.get('nonconvergent_state') or ''),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8222
  }
8223
  return states
8224
 
@@ -8736,7 +8750,8 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
8736
  current_target_claim = segments[claim_id]
8737
  evidence_key = (block_id, claim_id)
8738
  repair_evidence = dict(self._translation_claim_qa_evidence.get(evidence_key) or {})
8739
- code_set = {str(code or '').strip().upper() for code in (codes or [])}
 
8740
  meta = self._translation_claim_repair_meta.setdefault(evidence_key, {
8741
  'repair_attempt_count': 0, 'last_issue_codes': [], 'last_evidence_signature': '',
8742
  'last_strategy': '', 'nonconvergent_state': '',
@@ -8744,27 +8759,48 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
8744
  })
8745
  prior_attempts = int(meta.get('repair_attempt_count') or 0)
8746
  prior_codes = set(meta.get('last_issue_codes') or [])
8747
- families = self._translation_defect_family(code_set)
8748
  family_counts = dict(meta.get('family_attempt_counts') or {})
8749
  family_strategies = dict(meta.get('family_last_strategy') or {})
8750
  exhausted_families = {
8751
- family for family in families
8752
  if int(family_counts.get(family, 0)) >= self.MAX_TRANSLATION_CLAIM_REPAIR_ATTEMPTS
8753
  }
 
8754
  claim_hard_cap = prior_attempts >= self.MAX_TRANSLATION_CLAIM_TOTAL_REPAIRS
8755
- if exhausted_families or claim_hard_cap:
8756
  meta['nonconvergent_state'] = 'exhausted_family:' + ','.join(sorted(exhausted_families)) if exhausted_families else 'exhausted_after_claim_hard_cap'
8757
  carried_exhausted.setdefault(block_id, {})[claim_id] = list(codes)
8758
  logging.info(
8759
  'Translation claim defect-family entitlement exhausted: section=%s id=SP-P%s-%s attempts=%s families=%s',
8760
- section_name or 'section', block_id, claim_id, prior_attempts, sorted(exhausted_families or set(families)),
8761
  )
8762
  self._record_translation_assurance(
8763
  'CLAIM_REPAIR_EXHAUSTED', target_lang=target_lang,
8764
  block_id=f'SP-P{block_id}-{claim_id}', severity='critical', decision='ADJUDICATE',
8765
- detail={'attempts': prior_attempts, 'families': '|'.join(sorted(exhausted_families or set(families)))},
8766
  )
8767
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8768
  if prior_attempts == 0 and repair_cycle > 1:
8769
  logging.info(
8770
  'Translation late-detected claim receives own repair entitlement: section=%s id=SP-P%s-%s cycle=%s',
 
8219
  'last_evidence_signature': str(meta.get('last_evidence_signature') or ''),
8220
  'last_strategy': str(meta.get('last_strategy') or ''),
8221
  'nonconvergent_state': str(meta.get('nonconvergent_state') or ''),
8222
+ # R6-R3: persist defect-family ownership across durable resume.
8223
+ # Without these fields a restarted RU/KK/ZH claim can collapse
8224
+ # back to legacy claim-level history and either revive an
8225
+ # exhausted family or deny a genuinely fresh family its turn.
8226
+ 'family_attempt_counts': {
8227
+ str(family): int(count or 0)
8228
+ for family, count in dict(meta.get('family_attempt_counts') or {}).items()
8229
+ if str(family or '').strip()
8230
+ },
8231
+ 'family_last_strategy': {
8232
+ str(family): str(strategy or '').strip().upper()
8233
+ for family, strategy in dict(meta.get('family_last_strategy') or {}).items()
8234
+ if str(family or '').strip()
8235
+ },
8236
  }
8237
  return states
8238
 
 
8750
  current_target_claim = segments[claim_id]
8751
  evidence_key = (block_id, claim_id)
8752
  repair_evidence = dict(self._translation_claim_qa_evidence.get(evidence_key) or {})
8753
+ observed_code_set = {str(code or '').strip().upper() for code in (codes or [])}
8754
+ code_set = set(observed_code_set)
8755
  meta = self._translation_claim_repair_meta.setdefault(evidence_key, {
8756
  'repair_attempt_count': 0, 'last_issue_codes': [], 'last_evidence_signature': '',
8757
  'last_strategy': '', 'nonconvergent_state': '',
 
8759
  })
8760
  prior_attempts = int(meta.get('repair_attempt_count') or 0)
8761
  prior_codes = set(meta.get('last_issue_codes') or [])
8762
+ observed_families = self._translation_defect_family(code_set)
8763
  family_counts = dict(meta.get('family_attempt_counts') or {})
8764
  family_strategies = dict(meta.get('family_last_strategy') or {})
8765
  exhausted_families = {
8766
+ family for family in observed_families
8767
  if int(family_counts.get(family, 0)) >= self.MAX_TRANSLATION_CLAIM_REPAIR_ATTEMPTS
8768
  }
8769
+ eligible_families = set(observed_families) - exhausted_families
8770
  claim_hard_cap = prior_attempts >= self.MAX_TRANSLATION_CLAIM_TOTAL_REPAIRS
8771
+ if claim_hard_cap or not eligible_families:
8772
  meta['nonconvergent_state'] = 'exhausted_family:' + ','.join(sorted(exhausted_families)) if exhausted_families else 'exhausted_after_claim_hard_cap'
8773
  carried_exhausted.setdefault(block_id, {})[claim_id] = list(codes)
8774
  logging.info(
8775
  'Translation claim defect-family entitlement exhausted: section=%s id=SP-P%s-%s attempts=%s families=%s',
8776
+ section_name or 'section', block_id, claim_id, prior_attempts, sorted(exhausted_families or set(observed_families)),
8777
  )
8778
  self._record_translation_assurance(
8779
  'CLAIM_REPAIR_EXHAUSTED', target_lang=target_lang,
8780
  block_id=f'SP-P{block_id}-{claim_id}', severity='critical', decision='ADJUDICATE',
8781
+ detail={'attempts': prior_attempts, 'families': '|'.join(sorted(exhausted_families or set(observed_families)))},
8782
  )
8783
  continue
8784
+ if exhausted_families:
8785
+ # R6-R3 fairness closure: do not let an exhausted sibling
8786
+ # family consume or block the entitlement of a still-eligible
8787
+ # family. Repair only codes owned by eligible families; the
8788
+ # exhausted defect remains subject to later adjudication if it
8789
+ # survives the fresh-family repair.
8790
+ active_codes = [
8791
+ str(code or '').strip().upper() for code in (codes or [])
8792
+ if set(self._translation_defect_family([code])) & eligible_families
8793
+ ]
8794
+ codes = list(dict.fromkeys(active_codes))
8795
+ code_set = set(codes)
8796
+ families = self._translation_defect_family(code_set)
8797
+ logging.info(
8798
+ 'Translation mixed exhausted+eligible families: section=%s id=SP-P%s-%s exhausted=%s eligible=%s repair_codes=%s',
8799
+ section_name or 'section', block_id, claim_id, sorted(exhausted_families),
8800
+ sorted(eligible_families), sorted(code_set),
8801
+ )
8802
+ else:
8803
+ families = observed_families
8804
  if prior_attempts == 0 and repair_cycle > 1:
8805
  logging.info(
8806
  'Translation late-detected claim receives own repair entitlement: section=%s id=SP-P%s-%s cycle=%s',