R14-TQ1-R4-R2: typed defect evidence and non-convergent claim recovery

#52
Files changed (2) hide show
  1. core/generation_runner.py +2 -2
  2. core/pipeline_agent.py +280 -38
core/generation_runner.py CHANGED
@@ -1327,7 +1327,7 @@ class GenerationRunner:
1327
  'translated_text': '',
1328
  'qa_status': 'repair_pending',
1329
  'claim_states': dict(getattr(agent, '_last_translation_claim_states', {}) or {}),
1330
- 'claim_state_version': 'translation-claim-state-v1',
1331
  'error_code': error_code,
1332
  'attempts': int(existing.get('attempts') or 0) + 1,
1333
  'deferred_at': datetime.now(timezone.utc).isoformat(),
@@ -1359,7 +1359,7 @@ class GenerationRunner:
1359
  'language_adapter': str(result.get('language_adapter') or ''),
1360
  'diagnostics': list(result.get('diagnostics') or [])[-20:],
1361
  'claim_states': dict(result.get('claim_states') or {}),
1362
- 'claim_state_version': str(result.get('claim_state_version') or 'translation-claim-state-v1'),
1363
  'attempts': int(result.get('attempts') or 1),
1364
  'completed_at': datetime.now(timezone.utc).isoformat(),
1365
  }
 
1327
  'translated_text': '',
1328
  'qa_status': 'repair_pending',
1329
  'claim_states': dict(getattr(agent, '_last_translation_claim_states', {}) or {}),
1330
+ 'claim_state_version': 'translation-claim-state-v2',
1331
  'error_code': error_code,
1332
  'attempts': int(existing.get('attempts') or 0) + 1,
1333
  'deferred_at': datetime.now(timezone.utc).isoformat(),
 
1359
  'language_adapter': str(result.get('language_adapter') or ''),
1360
  'diagnostics': list(result.get('diagnostics') or [])[-20:],
1361
  'claim_states': dict(result.get('claim_states') or {}),
1362
+ 'claim_state_version': str(result.get('claim_state_version') or 'translation-claim-state-v2'),
1363
  'attempts': int(result.get('attempts') or 1),
1364
  'completed_at': datetime.now(timezone.utc).isoformat(),
1365
  }
core/pipeline_agent.py CHANGED
@@ -11,6 +11,7 @@ import tiktoken
11
  import sys
12
  import logging
13
  import hashlib
 
14
  import concurrent.futures
15
  from collections import Counter
16
  from datetime import datetime, timezone
@@ -5021,6 +5022,10 @@ SECTION: {section_name}
5021
  'CONDITION_SCOPE_CHANGED','COMPARISON_DIRECTION_CHANGED','TEMPORAL_SCOPE_CHANGED',
5022
  'CLAIM_MISSING','CLAIM_ADDED','MEANING_CHANGED',
5023
  }
 
 
 
 
5024
  if not isinstance(getattr(self, '_translation_claim_qa_evidence', None), dict):
5025
  self._translation_claim_qa_evidence = {}
5026
  claim_rows = []
@@ -5062,7 +5067,7 @@ Review EACH claim independently. Do not rewrite and do not provide free-form exp
5062
  LANGUAGE ADAPTER: {profile['adapter_id']}; morphology={profile['morphology']}. {profile['term_policy']} {profile['contamination_policy']}
5063
  For Russian, do not flag a terminology error merely because a translated term is inflected for grammatical case/number/gender. Russian LANGUAGE_CONTAMINATION includes sustained unprotected English prose and ordinary untranslated English lexemes; never use it for protected acronyms, AI/LLM/SEM, model or tool names, variable names, citations, canonical glossary terms, or source-matched proper names/titles. For Chinese, ordinary untranslated English lexical words are contamination while protected technical objects remain legal. For Kazakh, do not flag a terminology error merely because productive suffixes attach to the correct term stem. Kazakh LANGUAGE_CONTAMINATION includes ordinary untranslated English lexemes and clearly Russian lexical/syntactic intrusions not justified by shared international terminology; Cyrillic script or absence of Kazakh-specific letters alone is never sufficient evidence.
5064
  Return valid JSON only in this exact form: {{"items":[{{"id":"SP-P000001-C000001","ok":true,"codes":[],"evidence_codes":[]}}]}}.
5065
- If ok=false, codes MUST contain only values from allowed_issue_codes. For SEMANTIC_DRIFT, NEGATION_DRIFT, CAUSALITY_STRENGTH_DRIFT, UNCERTAINTY_DRIFT, OMITTED_CLAIM, or ADDED_CLAIM, evidence_codes should identify the affected semantic dimension using only: {sorted(allowed_semantic_evidence)}. Use the narrowest supported evidence; do not invent an error dimension. Never return numbered prose, examples, quotes, or corrective wording. Never return replacement text. Return every supplied id exactly once."""
5066
  response = self.call_deepseek(
5067
  system_prompt, json.dumps(payload, ensure_ascii=False),
5068
  max_tokens=max(1200, min(3600, 650 + 260 * len(rows))),
@@ -5083,10 +5088,12 @@ If ok=false, codes MUST contain only values from allowed_issue_codes. For SEMANT
5083
  meta = row_lookup[row_id]
5084
  raw_codes = [str(code or '').strip().upper() for code in (returned_row.get('codes') or [])]
5085
  codes = [code for code in raw_codes if code in allowed_codes]
5086
- evidence_codes = [
5087
  str(code or '').strip().upper() for code in (returned_row.get('evidence_codes') or [])
5088
- if str(code or '').strip().upper() in allowed_semantic_evidence
5089
  ]
 
 
5090
  semantic_code_set = {
5091
  'SEMANTIC_DRIFT','NEGATION_DRIFT','CAUSALITY_STRENGTH_DRIFT',
5092
  'UNCERTAINTY_DRIFT','OMITTED_CLAIM','ADDED_CLAIM',
@@ -5103,9 +5110,19 @@ If ok=false, codes MUST contain only values from allowed_issue_codes. For SEMANT
5103
  fallback = derived.get(issue_code)
5104
  if fallback and fallback not in evidence_codes:
5105
  evidence_codes.append(fallback)
5106
- self._translation_claim_qa_evidence[(meta['block_id'], meta['claim_id'])] = {
5107
- 'semantic_delta_codes': evidence_codes[:6],
5108
- }
 
 
 
 
 
 
 
 
 
 
5109
  model_contamination = 'LANGUAGE_CONTAMINATION' in codes
5110
  lexical = self._translation_lexical_purity_evidence(
5111
  meta['english'], meta['target'], target_lang, relevant_glossary
@@ -5497,20 +5514,78 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
5497
  restored[block_id] = recovered[block_id]
5498
  return restored
5499
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5500
  def _translate_single_claim_with_recovery(
5501
  self, block_id, claim_id, source_claim, system_prompt, target_lang, issue_codes,
5502
- current_target_claim=None, repair_evidence=None,
5503
  ):
5504
- """Regenerate one failed claim using bounded QA codes plus detector evidence."""
5505
  protected_claim, mapping = self._protect_translation_tokens(source_claim, claim_id=claim_id)
5506
  marked_source = f'[[SP-P{block_id}]]\n[[SP-{claim_id}]]\n{protected_claim}'
5507
  safe_manifest = self._model_safe_translation_manifest(mapping)
5508
  codes = [str(code or '').strip().upper() for code in (issue_codes or []) if str(code or '').strip()]
5509
- evidence_payload = {
5510
- 'issue_codes': codes[:5],
5511
- 'current_target_claim': str(current_target_claim or '').strip(),
5512
- }
5513
  supplied_evidence = dict(repair_evidence or {})
 
 
 
 
 
 
5514
  lexical_tokens = [
5515
  str(token or '').strip() for token in (supplied_evidence.get('detected_unprotected_english_tokens') or [])
5516
  if str(token or '').strip()
@@ -5523,13 +5598,25 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
5523
  ]
5524
  if semantic_delta_codes:
5525
  evidence_payload['semantic_delta_codes'] = semantic_delta_codes[:6]
5526
- effective_prompt = system_prompt + (
5527
- '\nCLAIM QUALITY RETRY. Repair only this one claim. The only permitted repair signals are the following bounded issue codes: '
 
 
 
 
 
 
 
 
 
 
 
5528
  + json.dumps(codes[:5], ensure_ascii=False)
5529
  + '. Use the bounded detector evidence below only to repair the supplied claim; do not invent additional defects. '
5530
- 'The current target claim is context, not authority: the frozen English source remains authoritative. '
5531
- 'If detected_unprotected_english_tokens is non-empty, every listed token is deterministic lexical-purity evidence and MUST be translated or removed from ordinary target-language prose; do not preserve it unless it is represented by a protected SP-T anchor. '
5532
- 'If semantic_delta_codes is non-empty, repair ONLY those evidenced semantic dimensions against the frozen English source and preserve all unaffected actor, relation, object, polarity, modality, condition, comparison, temporal scope, and evidentiary strength. Do not freely paraphrase a claim that only needs a bounded semantic correction. '
 
5533
  'Preserve every SP-P, SP-C, and SP-T marker byte-for-byte. Do not add claims or factual content. Return only the marked translated claim.'
5534
  + '\n<CLAIM_REPAIR_EVIDENCE>\n'
5535
  + json.dumps(evidence_payload, ensure_ascii=False)
@@ -5605,10 +5692,13 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
5605
  return segments[claim_id]
5606
 
5607
  @classmethod
5608
- def _snapshot_translation_claim_states(cls, source_blocks, translations, pending_issues=None, repair_evidence=None):
5609
- """Freeze claim-level target text so a deferred section never needs wholesale retranslation."""
 
 
5610
  pending = dict(pending_issues or {})
5611
  evidence_map = dict(repair_evidence or {})
 
5612
  states = {}
5613
  for block_id, source_block in dict(source_blocks or {}).items():
5614
  if block_id not in dict(translations or {}):
@@ -5625,6 +5715,7 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
5625
  if not target_text:
5626
  continue
5627
  codes = list((pending.get(block_id) or {}).get(claim_id) or [])
 
5628
  states[f'SP-P{block_id}-{claim_id}'] = {
5629
  'block_id': str(block_id),
5630
  'claim_id': str(claim_id),
@@ -5633,6 +5724,11 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
5633
  'qa_status': 'repair_pending' if codes else 'passed',
5634
  'issue_codes': [str(code or '').strip().upper() for code in codes if str(code or '').strip()][:5],
5635
  'repair_evidence': dict(evidence_map.get((block_id, claim_id)) or {}),
 
 
 
 
 
5636
  }
5637
  return states
5638
 
@@ -5704,17 +5800,22 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
5704
  lexical = self._translation_lexical_purity_evidence(
5705
  source_claims[claim_id], target_claims[claim_id], target_lang, glossary
5706
  )
5707
- deterministic = bool(lexical.get('confirmed'))
 
 
5708
  if profile.get('language_code') == 'ru' and not deterministic:
5709
  evidence = self._russian_contamination_evidence(
5710
  source_claims[claim_id], target_claims[claim_id], glossary
5711
  )
5712
  deterministic = bool(evidence.get('confirmed'))
 
 
5713
  if deterministic:
5714
  rows.append({
5715
  'id': f'SP-P{block_id}-{claim_id}', 'block_id': block_id,
5716
  'claim_id': claim_id, 'code': code, 'english': source_claims[claim_id],
5717
  'target': target_claims[claim_id], 'deterministic_confirmed': True,
 
5718
  })
5719
  continue
5720
  rows.append({
@@ -5731,7 +5832,10 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
5731
  self._record_translation_assurance(
5732
  'QA_ADJUDICATION_CONFIRMED', target_lang=target_lang, block_id=row['id'],
5733
  severity='critical', decision='HOLD_FINAL',
5734
- detail={'issue_code': row['code'], 'evidence_code': 'UNTRANSLATED_PROSE'},
 
 
 
5735
  )
5736
 
5737
  if not model_rows:
@@ -5888,6 +5992,21 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
5888
  last_error = None
5889
  self._last_translation_claim_states = {}
5890
  self._translation_claim_qa_evidence = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5891
 
5892
  def emit_stage(stage):
5893
  if not callable(stage_callback):
@@ -5931,7 +6050,8 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
5931
  section_name=section_name or 'frozen section', max_attempts=2,
5932
  )
5933
  self._last_translation_claim_states = self._snapshot_translation_claim_states(
5934
- source_blocks, translations, quality_issues, self._translation_claim_qa_evidence
 
5935
  )
5936
  local_round = 0
5937
  while quality_issues and local_round < 2:
@@ -5951,6 +6071,7 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
5951
  )
5952
  emit_stage('repairing')
5953
  touched_blocks = set()
 
5954
  for block_id, claim_id, codes in failed_pairs:
5955
  source_claims = {
5956
  item['claim_id']: item['text'] for item in self._translation_claim_units(source_blocks[block_id])
@@ -5962,16 +6083,56 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
5962
  claim_order = list(source_claims)
5963
  _order, segments = self._parse_translation_claim_segments(translations[block_id], claim_order)
5964
  current_target_claim = segments[claim_id]
5965
- repair_evidence = dict(
5966
- self._translation_claim_qa_evidence.get((block_id, claim_id)) or {}
5967
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5968
  if repair_evidence.get('semantic_delta_codes'):
5969
  self._record_translation_assurance(
5970
  'SEMANTIC_REPAIR_EVIDENCE', target_lang=target_lang,
5971
  block_id=f'SP-P{block_id}-{claim_id}', severity='repair', decision='REPAIR_LOCAL',
5972
  detail={'deltas': '|'.join(repair_evidence.get('semantic_delta_codes') or [])[:180]},
5973
  )
5974
- if 'LANGUAGE_CONTAMINATION' in {str(code or '').strip().upper() for code in (codes or [])}:
 
 
 
 
 
 
 
 
 
 
 
5975
  lexical = self._translation_lexical_purity_evidence(
5976
  source_claims[claim_id], current_target_claim, target_lang, frozen_glossary
5977
  )
@@ -5985,11 +6146,70 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
5985
  'sample': ','.join(lexical.get('tokens') or [])[:120],
5986
  },
5987
  )
5988
- repaired_claim = self._translate_single_claim_with_recovery(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5989
  block_id, claim_id, source_claims[claim_id], system_prompt, target_lang, codes,
5990
- current_target_claim=current_target_claim, repair_evidence=repair_evidence,
5991
  )
5992
- if 'LANGUAGE_CONTAMINATION' in {str(code or '').strip().upper() for code in (codes or [])}:
 
 
 
 
 
5993
  post_repair_lexical = self._translation_lexical_purity_evidence(
5994
  source_claims[claim_id], repaired_claim, target_lang, frozen_glossary
5995
  )
@@ -5997,7 +6217,7 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
5997
  'LEXICAL_PURITY_REPAIR_RESIDUAL' if post_repair_lexical.get('confirmed') else 'LEXICAL_PURITY_REPAIR_CLEARED',
5998
  target_lang=target_lang, block_id=f'SP-P{block_id}-{claim_id}',
5999
  severity='repair' if post_repair_lexical.get('confirmed') else 'diagnostic',
6000
- decision='REPAIR_LOCAL' if post_repair_lexical.get('confirmed') else 'PASS_WITH_WARNING',
6001
  detail={
6002
  'token_count': int(post_repair_lexical.get('token_count') or 0),
6003
  'sample': ','.join(post_repair_lexical.get('tokens') or [])[:120],
@@ -6007,14 +6227,35 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
6007
  translations[block_id] = self._compose_translation_claim_segments(claim_order, segments)
6008
  touched_blocks.add(block_id)
6009
  emit_stage('qa')
6010
- quality_issues = self._audit_translation_blocks_with_retry(
6011
- {block_id: source_blocks[block_id] for block_id in sorted(touched_blocks)},
6012
- {block_id: translations[block_id] for block_id in sorted(touched_blocks)},
6013
- target_lang, frozen_glossary, section_name=section_name or 'frozen section', max_attempts=2,
6014
- )
 
 
 
 
 
 
6015
  self._last_translation_claim_states = self._snapshot_translation_claim_states(
6016
- source_blocks, translations, quality_issues, self._translation_claim_qa_evidence
 
6017
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6018
  if quality_issues:
6019
  emit_stage('qa')
6020
  quality_issues = self._adjudicate_persistent_translation_issues(
@@ -6022,7 +6263,8 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
6022
  section_name=section_name or 'frozen section',
6023
  )
6024
  self._last_translation_claim_states = self._snapshot_translation_claim_states(
6025
- source_blocks, translations, quality_issues, self._translation_claim_qa_evidence
 
6026
  )
6027
  if quality_issues:
6028
  detail = '; '.join(
@@ -6061,7 +6303,7 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
6061
  'language_adapter': profile['adapter_id'],
6062
  'diagnostics': diagnostics[-20:],
6063
  'claim_states': dict(self._last_translation_claim_states),
6064
- 'claim_state_version': 'translation-claim-state-v1',
6065
  }
6066
  except PipelineCancelledError:
6067
  raise
 
11
  import sys
12
  import logging
13
  import hashlib
14
+ import inspect
15
  import concurrent.futures
16
  from collections import Counter
17
  from datetime import datetime, timezone
 
5022
  'CONDITION_SCOPE_CHANGED','COMPARISON_DIRECTION_CHANGED','TEMPORAL_SCOPE_CHANGED',
5023
  'CLAIM_MISSING','CLAIM_ADDED','MEANING_CHANGED',
5024
  }
5025
+ allowed_grammar_evidence = {
5026
+ 'CASE_GOVERNMENT','AGREEMENT','MORPHOLOGY','VERB_FORM','WORD_ORDER',
5027
+ 'SYNTAX_STRUCTURE','PUNCTUATION','SYNTACTIC_COMPLETENESS','GRAMMAR_UNSPECIFIED',
5028
+ }
5029
  if not isinstance(getattr(self, '_translation_claim_qa_evidence', None), dict):
5030
  self._translation_claim_qa_evidence = {}
5031
  claim_rows = []
 
5067
  LANGUAGE ADAPTER: {profile['adapter_id']}; morphology={profile['morphology']}. {profile['term_policy']} {profile['contamination_policy']}
5068
  For Russian, do not flag a terminology error merely because a translated term is inflected for grammatical case/number/gender. Russian LANGUAGE_CONTAMINATION includes sustained unprotected English prose and ordinary untranslated English lexemes; never use it for protected acronyms, AI/LLM/SEM, model or tool names, variable names, citations, canonical glossary terms, or source-matched proper names/titles. For Chinese, ordinary untranslated English lexical words are contamination while protected technical objects remain legal. For Kazakh, do not flag a terminology error merely because productive suffixes attach to the correct term stem. Kazakh LANGUAGE_CONTAMINATION includes ordinary untranslated English lexemes and clearly Russian lexical/syntactic intrusions not justified by shared international terminology; Cyrillic script or absence of Kazakh-specific letters alone is never sufficient evidence.
5069
  Return valid JSON only in this exact form: {{"items":[{{"id":"SP-P000001-C000001","ok":true,"codes":[],"evidence_codes":[]}}]}}.
5070
+ If ok=false, codes MUST contain only values from allowed_issue_codes. For SEMANTIC_DRIFT, NEGATION_DRIFT, CAUSALITY_STRENGTH_DRIFT, UNCERTAINTY_DRIFT, OMITTED_CLAIM, or ADDED_CLAIM, evidence_codes should identify the affected semantic dimension using only: {sorted(allowed_semantic_evidence)}. For GRAMMAR, evidence_codes should identify the narrow grammatical defect using only: {sorted(allowed_grammar_evidence)}. Use the narrowest supported evidence; use MEANING_CHANGED or GRAMMAR_UNSPECIFIED only when the narrower dimension cannot be identified from the pair. Do not invent an error dimension. Never return numbered prose, examples, quotes, or corrective wording. Never return replacement text. Return every supplied id exactly once."""
5071
  response = self.call_deepseek(
5072
  system_prompt, json.dumps(payload, ensure_ascii=False),
5073
  max_tokens=max(1200, min(3600, 650 + 260 * len(rows))),
 
5088
  meta = row_lookup[row_id]
5089
  raw_codes = [str(code or '').strip().upper() for code in (returned_row.get('codes') or [])]
5090
  codes = [code for code in raw_codes if code in allowed_codes]
5091
+ raw_evidence_codes = [
5092
  str(code or '').strip().upper() for code in (returned_row.get('evidence_codes') or [])
5093
+ if str(code or '').strip()
5094
  ]
5095
+ evidence_codes = [code for code in raw_evidence_codes if code in allowed_semantic_evidence]
5096
+ grammar_evidence_codes = [code for code in raw_evidence_codes if code in allowed_grammar_evidence]
5097
  semantic_code_set = {
5098
  'SEMANTIC_DRIFT','NEGATION_DRIFT','CAUSALITY_STRENGTH_DRIFT',
5099
  'UNCERTAINTY_DRIFT','OMITTED_CLAIM','ADDED_CLAIM',
 
5110
  fallback = derived.get(issue_code)
5111
  if fallback and fallback not in evidence_codes:
5112
  evidence_codes.append(fallback)
5113
+ if 'SEMANTIC_DRIFT' in codes and not evidence_codes:
5114
+ evidence_codes.append('MEANING_CHANGED')
5115
+ evidence_row = self._translation_claim_qa_evidence.setdefault(
5116
+ (meta['block_id'], meta['claim_id']), {}
5117
+ )
5118
+ evidence_row['semantic_delta_codes'] = evidence_codes[:6]
5119
+ if 'GRAMMAR' in codes:
5120
+ if not grammar_evidence_codes:
5121
+ grammar_evidence_codes = ['GRAMMAR_UNSPECIFIED']
5122
+ evidence_row = self._translation_claim_qa_evidence.setdefault(
5123
+ (meta['block_id'], meta['claim_id']), {}
5124
+ )
5125
+ evidence_row['grammar_evidence_codes'] = grammar_evidence_codes[:4]
5126
  model_contamination = 'LANGUAGE_CONTAMINATION' in codes
5127
  lexical = self._translation_lexical_purity_evidence(
5128
  meta['english'], meta['target'], target_lang, relevant_glossary
 
5514
  restored[block_id] = recovered[block_id]
5515
  return restored
5516
 
5517
+ @staticmethod
5518
+ def _translation_repair_evidence_signature(issue_codes, repair_evidence=None):
5519
+ """Stable signature for one claim defect so repeated strategies cannot loop across Final Gate."""
5520
+ evidence = dict(repair_evidence or {})
5521
+ bounded = {
5522
+ 'issue_codes': sorted({str(code or '').strip().upper() for code in (issue_codes or []) if str(code or '').strip()}),
5523
+ 'semantic_delta_codes': sorted({str(code or '').strip().upper() for code in (evidence.get('semantic_delta_codes') or []) if str(code or '').strip()}),
5524
+ 'grammar_evidence_codes': sorted({str(code or '').strip().upper() for code in (evidence.get('grammar_evidence_codes') or []) if str(code or '').strip()}),
5525
+ 'detected_unprotected_english_tokens': sorted({str(token or '').strip().casefold() for token in (evidence.get('detected_unprotected_english_tokens') or []) if str(token or '').strip()}),
5526
+ }
5527
+ payload = json.dumps(bounded, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
5528
+ return hashlib.sha256(payload.encode('utf-8')).hexdigest()
5529
+
5530
+ def _decompose_translation_semantic_delta(
5531
+ self, source_claim, current_target_claim, target_lang, issue_codes=None, section_name=''
5532
+ ):
5533
+ """Verification-only decomposition of generic MEANING_CHANGED into repairable semantic dimensions."""
5534
+ allowed = {
5535
+ 'ACTOR_CHANGED','RELATION_CHANGED','OBJECT_CHANGED','POLARITY_CHANGED',
5536
+ 'CAUSALITY_STRENGTH_CHANGED','MODALITY_UNCERTAINTY_CHANGED',
5537
+ 'CONDITION_SCOPE_CHANGED','COMPARISON_DIRECTION_CHANGED','TEMPORAL_SCOPE_CHANGED',
5538
+ 'CLAIM_MISSING','CLAIM_ADDED','UNRESOLVED_SEMANTIC_DELTA',
5539
+ }
5540
+ payload = {
5541
+ 'target_language': target_lang,
5542
+ 'section': str(section_name or 'section'),
5543
+ 'issue_codes': [str(code or '').strip().upper() for code in (issue_codes or []) if str(code or '').strip()][:5],
5544
+ 'english': str(source_claim or '').strip(),
5545
+ 'target': str(current_target_claim or '').strip(),
5546
+ }
5547
+ prompt = f"""You are a bilingual semantic-delta classifier for academic translation into {target_lang}.
5548
+ Verification only. Do not rewrite, suggest wording, explain, or return replacement text. The pair has already been flagged with a generic semantic problem. Identify ONLY the narrow semantic dimensions that differ between the frozen English source and the current target claim.
5549
+ Return valid JSON only: {{"delta_codes":["RELATION_CHANGED"]}}.
5550
+ Each code must be one of: {sorted(allowed)}. Return one or more specific dimensions when supported. Return only UNRESOLVED_SEMANTIC_DELTA when the pair supports a meaning difference but the changed dimension cannot be narrowed safely."""
5551
+ try:
5552
+ response = self.call_deepseek(
5553
+ prompt, json.dumps(payload, ensure_ascii=False), max_tokens=900, temp=0.0,
5554
+ model_role='reasoning', thinking=False, allow_fallback=False,
5555
+ )
5556
+ parsed = self._extract_json_from_text(response)
5557
+ raw = parsed.get('delta_codes', []) if isinstance(parsed, dict) else []
5558
+ codes = [str(code or '').strip().upper() for code in raw if str(code or '').strip().upper() in allowed]
5559
+ codes = list(dict.fromkeys(codes))[:6]
5560
+ specific = [code for code in codes if code != 'UNRESOLVED_SEMANTIC_DELTA']
5561
+ if specific:
5562
+ return specific
5563
+ except PipelineCancelledError:
5564
+ raise
5565
+ except Exception as error:
5566
+ self._record_translation_assurance(
5567
+ 'SEMANTIC_DECOMPOSITION_UNCERTAIN', target_lang=target_lang,
5568
+ block_id=section_name or 'claim', severity='diagnostic', decision='SOURCE_REGENERATE',
5569
+ detail={'error_type': type(error).__name__},
5570
+ )
5571
+ return ['UNRESOLVED_SEMANTIC_DELTA']
5572
+
5573
  def _translate_single_claim_with_recovery(
5574
  self, block_id, claim_id, source_claim, system_prompt, target_lang, issue_codes,
5575
+ current_target_claim=None, repair_evidence=None, repair_strategy='LOCAL_EDIT',
5576
  ):
5577
+ """Repair one failed claim with bounded evidence; escalate to source-only regeneration when needed."""
5578
  protected_claim, mapping = self._protect_translation_tokens(source_claim, claim_id=claim_id)
5579
  marked_source = f'[[SP-P{block_id}]]\n[[SP-{claim_id}]]\n{protected_claim}'
5580
  safe_manifest = self._model_safe_translation_manifest(mapping)
5581
  codes = [str(code or '').strip().upper() for code in (issue_codes or []) if str(code or '').strip()]
 
 
 
 
5582
  supplied_evidence = dict(repair_evidence or {})
5583
+ strategy = str(repair_strategy or 'LOCAL_EDIT').strip().upper()
5584
+ if strategy not in {'LOCAL_EDIT', 'SOURCE_REGENERATE'}:
5585
+ strategy = 'LOCAL_EDIT'
5586
+ evidence_payload = {'issue_codes': codes[:5], 'repair_strategy': strategy}
5587
+ if strategy == 'LOCAL_EDIT':
5588
+ evidence_payload['current_target_claim'] = str(current_target_claim or '').strip()
5589
  lexical_tokens = [
5590
  str(token or '').strip() for token in (supplied_evidence.get('detected_unprotected_english_tokens') or [])
5591
  if str(token or '').strip()
 
5598
  ]
5599
  if semantic_delta_codes:
5600
  evidence_payload['semantic_delta_codes'] = semantic_delta_codes[:6]
5601
+ grammar_evidence_codes = [
5602
+ str(code or '').strip().upper() for code in (supplied_evidence.get('grammar_evidence_codes') or [])
5603
+ if str(code or '').strip()
5604
+ ]
5605
+ if grammar_evidence_codes:
5606
+ evidence_payload['grammar_evidence_codes'] = grammar_evidence_codes[:4]
5607
+ retry_mode = (
5608
+ '\nCLAIM SOURCE REGENERATION. The previous target wording has failed bounded repair and MUST NOT be used as drafting context. Generate a fresh translation of only this one claim directly from the frozen marked English source. '
5609
+ if strategy == 'SOURCE_REGENERATE' else
5610
+ '\nCLAIM QUALITY RETRY. Repair only this one claim. '
5611
+ )
5612
+ effective_prompt = system_prompt + retry_mode + (
5613
+ 'The only permitted repair signals are the following bounded issue codes: '
5614
  + json.dumps(codes[:5], ensure_ascii=False)
5615
  + '. Use the bounded detector evidence below only to repair the supplied claim; do not invent additional defects. '
5616
+ 'For LOCAL_EDIT, the current target claim is context, not authority; for SOURCE_REGENERATE it is intentionally absent. The frozen English source is always authoritative. '
5617
+ 'If detected_unprotected_english_tokens is non-empty, every listed token is deterministic lexical-purity evidence from a prior target and MUST NOT survive as ordinary untranslated target-language prose unless represented by a protected SP-T anchor. '
5618
+ 'If semantic_delta_codes is non-empty, enforce ONLY those evidenced semantic dimensions against the frozen English source and preserve all unaffected actor, relation, object, polarity, modality, condition, comparison, temporal scope, and evidentiary strength. UNRESOLVED_SEMANTIC_DELTA is a signal to regenerate faithfully from source, not an instruction to guess a specific correction. '
5619
+ 'If grammar_evidence_codes is non-empty, repair or avoid only those bounded grammatical defect classes; GRAMMAR_UNSPECIFIED requires a fresh natural target-language rendering rather than blind micro-editing. '
5620
  'Preserve every SP-P, SP-C, and SP-T marker byte-for-byte. Do not add claims or factual content. Return only the marked translated claim.'
5621
  + '\n<CLAIM_REPAIR_EVIDENCE>\n'
5622
  + json.dumps(evidence_payload, ensure_ascii=False)
 
5692
  return segments[claim_id]
5693
 
5694
  @classmethod
5695
+ def _snapshot_translation_claim_states(
5696
+ cls, source_blocks, translations, pending_issues=None, repair_evidence=None, repair_meta=None
5697
+ ):
5698
+ """Freeze claim text plus bounded repair history so Final Gate cannot restart a failed strategy."""
5699
  pending = dict(pending_issues or {})
5700
  evidence_map = dict(repair_evidence or {})
5701
+ meta_map = dict(repair_meta or {})
5702
  states = {}
5703
  for block_id, source_block in dict(source_blocks or {}).items():
5704
  if block_id not in dict(translations or {}):
 
5715
  if not target_text:
5716
  continue
5717
  codes = list((pending.get(block_id) or {}).get(claim_id) or [])
5718
+ meta = dict(meta_map.get((block_id, claim_id)) or {})
5719
  states[f'SP-P{block_id}-{claim_id}'] = {
5720
  'block_id': str(block_id),
5721
  'claim_id': str(claim_id),
 
5724
  'qa_status': 'repair_pending' if codes else 'passed',
5725
  'issue_codes': [str(code or '').strip().upper() for code in codes if str(code or '').strip()][:5],
5726
  'repair_evidence': dict(evidence_map.get((block_id, claim_id)) or {}),
5727
+ 'repair_attempt_count': int(meta.get('repair_attempt_count') or 0),
5728
+ 'last_issue_codes': [str(code or '').strip().upper() for code in (meta.get('last_issue_codes') or []) if str(code or '').strip()][:5],
5729
+ 'last_evidence_signature': str(meta.get('last_evidence_signature') or ''),
5730
+ 'last_strategy': str(meta.get('last_strategy') or ''),
5731
+ 'nonconvergent_state': str(meta.get('nonconvergent_state') or ''),
5732
  }
5733
  return states
5734
 
 
5800
  lexical = self._translation_lexical_purity_evidence(
5801
  source_claims[claim_id], target_claims[claim_id], target_lang, glossary
5802
  )
5803
+ lexical_confirmed = bool(lexical.get('confirmed'))
5804
+ deterministic = lexical_confirmed
5805
+ deterministic_evidence_code = 'UNTRANSLATED_LEXEME' if lexical_confirmed else ''
5806
  if profile.get('language_code') == 'ru' and not deterministic:
5807
  evidence = self._russian_contamination_evidence(
5808
  source_claims[claim_id], target_claims[claim_id], glossary
5809
  )
5810
  deterministic = bool(evidence.get('confirmed'))
5811
+ if deterministic:
5812
+ deterministic_evidence_code = 'UNTRANSLATED_PROSE'
5813
  if deterministic:
5814
  rows.append({
5815
  'id': f'SP-P{block_id}-{claim_id}', 'block_id': block_id,
5816
  'claim_id': claim_id, 'code': code, 'english': source_claims[claim_id],
5817
  'target': target_claims[claim_id], 'deterministic_confirmed': True,
5818
+ 'deterministic_evidence_code': deterministic_evidence_code,
5819
  })
5820
  continue
5821
  rows.append({
 
5832
  self._record_translation_assurance(
5833
  'QA_ADJUDICATION_CONFIRMED', target_lang=target_lang, block_id=row['id'],
5834
  severity='critical', decision='HOLD_FINAL',
5835
+ detail={
5836
+ 'issue_code': row['code'],
5837
+ 'evidence_code': str(row.get('deterministic_evidence_code') or 'UNTRANSLATED_PROSE'),
5838
+ },
5839
  )
5840
 
5841
  if not model_rows:
 
5992
  last_error = None
5993
  self._last_translation_claim_states = {}
5994
  self._translation_claim_qa_evidence = {}
5995
+ self._translation_claim_repair_meta = {}
5996
+ for row in dict(claim_states or {}).values():
5997
+ if not isinstance(row, dict):
5998
+ continue
5999
+ block_key = str(row.get('block_id') or '')
6000
+ claim_key = str(row.get('claim_id') or '')
6001
+ if not block_key or not claim_key:
6002
+ continue
6003
+ self._translation_claim_repair_meta[(block_key, claim_key)] = {
6004
+ 'repair_attempt_count': int(row.get('repair_attempt_count') or 0),
6005
+ 'last_issue_codes': [str(code or '').strip().upper() for code in (row.get('last_issue_codes') or []) if str(code or '').strip()][:5],
6006
+ 'last_evidence_signature': str(row.get('last_evidence_signature') or ''),
6007
+ 'last_strategy': str(row.get('last_strategy') or '').strip().upper(),
6008
+ 'nonconvergent_state': str(row.get('nonconvergent_state') or ''),
6009
+ }
6010
 
6011
  def emit_stage(stage):
6012
  if not callable(stage_callback):
 
6050
  section_name=section_name or 'frozen section', max_attempts=2,
6051
  )
6052
  self._last_translation_claim_states = self._snapshot_translation_claim_states(
6053
+ source_blocks, translations, quality_issues, self._translation_claim_qa_evidence,
6054
+ self._translation_claim_repair_meta,
6055
  )
6056
  local_round = 0
6057
  while quality_issues and local_round < 2:
 
6071
  )
6072
  emit_stage('repairing')
6073
  touched_blocks = set()
6074
+ carried_exhausted = {}
6075
  for block_id, claim_id, codes in failed_pairs:
6076
  source_claims = {
6077
  item['claim_id']: item['text'] for item in self._translation_claim_units(source_blocks[block_id])
 
6083
  claim_order = list(source_claims)
6084
  _order, segments = self._parse_translation_claim_segments(translations[block_id], claim_order)
6085
  current_target_claim = segments[claim_id]
6086
+ evidence_key = (block_id, claim_id)
6087
+ repair_evidence = dict(self._translation_claim_qa_evidence.get(evidence_key) or {})
6088
+ code_set = {str(code or '').strip().upper() for code in (codes or [])}
6089
+
6090
+ semantic_deltas = [
6091
+ str(code or '').strip().upper() for code in (repair_evidence.get('semantic_delta_codes') or [])
6092
+ if str(code or '').strip()
6093
+ ]
6094
+ semantic_issue = bool(code_set & {
6095
+ 'SEMANTIC_DRIFT','NEGATION_DRIFT','CAUSALITY_STRENGTH_DRIFT',
6096
+ 'UNCERTAINTY_DRIFT','OMITTED_CLAIM','ADDED_CLAIM',
6097
+ })
6098
+ derived_semantic = {
6099
+ 'NEGATION_DRIFT': 'POLARITY_CHANGED',
6100
+ 'CAUSALITY_STRENGTH_DRIFT': 'CAUSALITY_STRENGTH_CHANGED',
6101
+ 'UNCERTAINTY_DRIFT': 'MODALITY_UNCERTAINTY_CHANGED',
6102
+ 'OMITTED_CLAIM': 'CLAIM_MISSING',
6103
+ 'ADDED_CLAIM': 'CLAIM_ADDED',
6104
+ }
6105
+ for issue_code in code_set:
6106
+ fallback = derived_semantic.get(issue_code)
6107
+ if fallback and fallback not in semantic_deltas:
6108
+ semantic_deltas.append(fallback)
6109
+ if semantic_deltas:
6110
+ repair_evidence['semantic_delta_codes'] = list(dict.fromkeys(semantic_deltas))[:6]
6111
+ specific_semantic = [code for code in semantic_deltas if code != 'MEANING_CHANGED']
6112
+ if semantic_issue and (not specific_semantic or 'MEANING_CHANGED' in semantic_deltas):
6113
+ specific_semantic = self._decompose_translation_semantic_delta(
6114
+ source_claims[claim_id], current_target_claim, target_lang,
6115
+ issue_codes=codes, section_name=f'SP-P{block_id}-{claim_id}',
6116
+ )
6117
+ repair_evidence['semantic_delta_codes'] = list(specific_semantic)[:6]
6118
  if repair_evidence.get('semantic_delta_codes'):
6119
  self._record_translation_assurance(
6120
  'SEMANTIC_REPAIR_EVIDENCE', target_lang=target_lang,
6121
  block_id=f'SP-P{block_id}-{claim_id}', severity='repair', decision='REPAIR_LOCAL',
6122
  detail={'deltas': '|'.join(repair_evidence.get('semantic_delta_codes') or [])[:180]},
6123
  )
6124
+ if 'GRAMMAR' in code_set:
6125
+ grammar_codes = [
6126
+ str(code or '').strip().upper() for code in (repair_evidence.get('grammar_evidence_codes') or [])
6127
+ if str(code or '').strip()
6128
+ ] or ['GRAMMAR_UNSPECIFIED']
6129
+ repair_evidence['grammar_evidence_codes'] = grammar_codes[:4]
6130
+ self._record_translation_assurance(
6131
+ 'GRAMMAR_REPAIR_EVIDENCE', target_lang=target_lang,
6132
+ block_id=f'SP-P{block_id}-{claim_id}', severity='repair', decision='REPAIR_LOCAL',
6133
+ detail={'classes': '|'.join(grammar_codes)[:180]},
6134
+ )
6135
+ if 'LANGUAGE_CONTAMINATION' in code_set:
6136
  lexical = self._translation_lexical_purity_evidence(
6137
  source_claims[claim_id], current_target_claim, target_lang, frozen_glossary
6138
  )
 
6146
  'sample': ','.join(lexical.get('tokens') or [])[:120],
6147
  },
6148
  )
6149
+ self._translation_claim_qa_evidence[evidence_key] = dict(repair_evidence)
6150
+ signature = self._translation_repair_evidence_signature(codes, repair_evidence)
6151
+ meta = self._translation_claim_repair_meta.setdefault(evidence_key, {
6152
+ 'repair_attempt_count': 0, 'last_issue_codes': [], 'last_evidence_signature': '',
6153
+ 'last_strategy': '', 'nonconvergent_state': '',
6154
+ })
6155
+ prior_codes = set(meta.get('last_issue_codes') or [])
6156
+ same_defect = (
6157
+ str(meta.get('last_evidence_signature') or '') == signature
6158
+ and bool(prior_codes & code_set)
6159
+ )
6160
+ if (
6161
+ str(meta.get('nonconvergent_state') or '').startswith('exhausted')
6162
+ and bool(prior_codes & code_set)
6163
+ ) or (same_defect and str(meta.get('last_strategy') or '').upper() == 'SOURCE_REGENERATE'):
6164
+ meta['nonconvergent_state'] = 'exhausted_after_source_regenerate'
6165
+ carried_exhausted.setdefault(block_id, {})[claim_id] = list(codes)
6166
+ self._record_translation_assurance(
6167
+ 'CLAIM_REPAIR_EXHAUSTED', target_lang=target_lang,
6168
+ block_id=f'SP-P{block_id}-{claim_id}', severity='critical', decision='ADJUDICATE',
6169
+ detail={'attempts': int(meta.get('repair_attempt_count') or 0), 'strategy': 'SOURCE_REGENERATE'},
6170
+ )
6171
+ continue
6172
+
6173
+ force_source_regenerate = (
6174
+ 'UNRESOLVED_SEMANTIC_DELTA' in set(repair_evidence.get('semantic_delta_codes') or [])
6175
+ or 'GRAMMAR_UNSPECIFIED' in set(repair_evidence.get('grammar_evidence_codes') or [])
6176
+ )
6177
+ strategy = 'SOURCE_REGENERATE' if (
6178
+ force_source_regenerate
6179
+ or (same_defect and str(meta.get('last_strategy') or '').upper() == 'LOCAL_EDIT')
6180
+ ) else 'LOCAL_EDIT'
6181
+ if strategy == 'SOURCE_REGENERATE':
6182
+ self._record_translation_assurance(
6183
+ 'CLAIM_REPAIR_ESCALATED', target_lang=target_lang,
6184
+ block_id=f'SP-P{block_id}-{claim_id}', severity='repair', decision='SOURCE_REGENERATE',
6185
+ detail={'reason': 'nonconvergent_or_unresolved_evidence'},
6186
+ )
6187
+ self._translation_claim_qa_evidence[evidence_key] = dict(repair_evidence)
6188
+ repair_method = self._translate_single_claim_with_recovery
6189
+ repair_kwargs = {
6190
+ 'current_target_claim': current_target_claim,
6191
+ 'repair_evidence': repair_evidence,
6192
+ }
6193
+ try:
6194
+ repair_parameters = list(inspect.signature(repair_method).parameters.values())
6195
+ supports_strategy = any(
6196
+ item.name == 'repair_strategy' or item.kind == inspect.Parameter.VAR_KEYWORD
6197
+ for item in repair_parameters
6198
+ )
6199
+ except (TypeError, ValueError):
6200
+ supports_strategy = False
6201
+ if supports_strategy:
6202
+ repair_kwargs['repair_strategy'] = strategy
6203
+ repaired_claim = repair_method(
6204
  block_id, claim_id, source_claims[claim_id], system_prompt, target_lang, codes,
6205
+ **repair_kwargs,
6206
  )
6207
+ meta['repair_attempt_count'] = int(meta.get('repair_attempt_count') or 0) + 1
6208
+ meta['last_issue_codes'] = sorted(code_set)[:5]
6209
+ meta['last_evidence_signature'] = signature
6210
+ meta['last_strategy'] = strategy
6211
+ meta['nonconvergent_state'] = 'source_regenerated' if strategy == 'SOURCE_REGENERATE' else ''
6212
+ if 'LANGUAGE_CONTAMINATION' in code_set:
6213
  post_repair_lexical = self._translation_lexical_purity_evidence(
6214
  source_claims[claim_id], repaired_claim, target_lang, frozen_glossary
6215
  )
 
6217
  'LEXICAL_PURITY_REPAIR_RESIDUAL' if post_repair_lexical.get('confirmed') else 'LEXICAL_PURITY_REPAIR_CLEARED',
6218
  target_lang=target_lang, block_id=f'SP-P{block_id}-{claim_id}',
6219
  severity='repair' if post_repair_lexical.get('confirmed') else 'diagnostic',
6220
+ decision='SOURCE_REGENERATE' if post_repair_lexical.get('confirmed') and strategy == 'SOURCE_REGENERATE' else ('REPAIR_LOCAL' if post_repair_lexical.get('confirmed') else 'PASS_WITH_WARNING'),
6221
  detail={
6222
  'token_count': int(post_repair_lexical.get('token_count') or 0),
6223
  'sample': ','.join(post_repair_lexical.get('tokens') or [])[:120],
 
6227
  translations[block_id] = self._compose_translation_claim_segments(claim_order, segments)
6228
  touched_blocks.add(block_id)
6229
  emit_stage('qa')
6230
+ refreshed = {}
6231
+ if touched_blocks:
6232
+ refreshed = self._audit_translation_blocks_with_retry(
6233
+ {block_id: source_blocks[block_id] for block_id in sorted(touched_blocks)},
6234
+ {block_id: translations[block_id] for block_id in sorted(touched_blocks)},
6235
+ target_lang, frozen_glossary, section_name=section_name or 'frozen section', max_attempts=2,
6236
+ )
6237
+ for block_id, claim_map in carried_exhausted.items():
6238
+ for claim_id, codes in claim_map.items():
6239
+ refreshed.setdefault(block_id, {})[claim_id] = list(codes)
6240
+ quality_issues = refreshed
6241
  self._last_translation_claim_states = self._snapshot_translation_claim_states(
6242
+ source_blocks, translations, quality_issues, self._translation_claim_qa_evidence,
6243
+ self._translation_claim_repair_meta,
6244
  )
6245
+ if not touched_blocks and carried_exhausted:
6246
+ break
6247
+
6248
+ if quality_issues:
6249
+ for block_id, claim_map in quality_issues.items():
6250
+ for claim_id, codes in (claim_map or {}).items():
6251
+ meta = self._translation_claim_repair_meta.get((block_id, claim_id))
6252
+ if not isinstance(meta, dict):
6253
+ continue
6254
+ if str(meta.get('last_strategy') or '').upper() == 'SOURCE_REGENERATE':
6255
+ prior = set(meta.get('last_issue_codes') or [])
6256
+ current = {str(code or '').strip().upper() for code in (codes or [])}
6257
+ if prior & current:
6258
+ meta['nonconvergent_state'] = 'exhausted_after_source_regenerate'
6259
  if quality_issues:
6260
  emit_stage('qa')
6261
  quality_issues = self._adjudicate_persistent_translation_issues(
 
6263
  section_name=section_name or 'frozen section',
6264
  )
6265
  self._last_translation_claim_states = self._snapshot_translation_claim_states(
6266
+ source_blocks, translations, quality_issues, self._translation_claim_qa_evidence,
6267
+ self._translation_claim_repair_meta,
6268
  )
6269
  if quality_issues:
6270
  detail = '; '.join(
 
6303
  'language_adapter': profile['adapter_id'],
6304
  'diagnostics': diagnostics[-20:],
6305
  'claim_states': dict(self._last_translation_claim_states),
6306
+ 'claim_state_version': 'translation-claim-state-v2',
6307
  }
6308
  except PipelineCancelledError:
6309
  raise