R14-TQ1-R4-R1: evidence-carrying semantic repair and durable claim recovery

#51
Files changed (2) hide show
  1. core/generation_runner.py +30 -14
  2. core/pipeline_agent.py +167 -18
core/generation_runner.py CHANGED
@@ -871,9 +871,9 @@ class GenerationRunner:
871
  discovery cannot see them. No semantic synonym merging is performed.
872
  """
873
  items = []
874
- seen = set()
875
 
876
- def add(value, kind):
877
  term = re.sub(r'\s+', ' ', str(value or '')).strip(' \t\r\n,;:.')
878
  if not term or len(term) < 3 or len(term) > 100:
879
  return
@@ -881,32 +881,37 @@ class GenerationRunner:
881
  if not words or len(words) > 10:
882
  return
883
  key = re.sub(r'[–—-]+', '-', term).casefold()
884
- if key in seen:
 
 
 
 
 
885
  return
886
- seen.add(key)
887
- items.append({'source': term, 'kind': str(kind or 'concept')})
888
 
889
  meta = dict(data_metadata or {})
890
  if str(mode or '') == 'amj':
891
  constructs = meta.get('constructs') if isinstance(meta.get('constructs'), dict) else {}
892
  for role in ('predictor', 'mediator', 'outcome'):
893
- add(constructs.get(role), f'quantitative_{role}')
894
  else:
895
  for theme in (meta.get('themes') or []):
896
  if not isinstance(theme, dict):
897
  continue
898
- add(theme.get('title'), 'qualitative_theme')
899
  for concept in (theme.get('concepts') or []):
900
- add(concept, 'qualitative_concept')
901
  for row in (meta.get('evidence') or []):
902
  if isinstance(row, dict):
903
- add(row.get('concept'), 'qualitative_concept')
904
 
905
  for task in (adaptive_structure or []):
906
  if not isinstance(task, dict):
907
  continue
908
  for concept in (task.get('concepts') or []):
909
- add(concept, 'outline_concept')
910
 
911
  # User keywords remain planning seeds, not canonical identities. Use
912
  # them only as a fallback when frozen empirical/qualitative metadata and
@@ -915,7 +920,7 @@ class GenerationRunner:
915
  for seed in re.split(r'[,;\n|]+', str(keywords or '')):
916
  seed = re.sub(r'\s+', ' ', seed).strip()
917
  if 1 <= len(seed.split()) <= 6:
918
- add(seed, 'keyword_fallback')
919
 
920
  # Capture explicit lower-case hyphenated academic expressions only when
921
  # they recur or carry a research-concept cue. This closes the
@@ -932,9 +937,9 @@ class GenerationRunner:
932
  for term in hyphenated:
933
  parts = set(term.casefold().split('-'))
934
  if counts.get(term.casefold(), 0) >= 2 or parts.intersection(concept_cues):
935
- add(term, 'logic_hyphenated_concept')
936
 
937
- return {'version': 'concept-ledger-v1', 'items': items}
938
 
939
  @staticmethod
940
  def _canonical_concept_writer_directive(ledger: dict) -> str:
@@ -1270,15 +1275,22 @@ class GenerationRunner:
1270
  'glossary': translation_glossary, 'section_name': unit_name, 'max_attempts': 2,
1271
  }
1272
  try:
1273
- parameters = inspect.signature(translate_method).parameters.values()
1274
  supports_stage_callback = any(
1275
  item.name == 'stage_callback' or item.kind == inspect.Parameter.VAR_KEYWORD
1276
  for item in parameters
1277
  )
 
 
 
 
1278
  except (TypeError, ValueError):
1279
  supports_stage_callback = False
 
1280
  if supports_stage_callback:
1281
  call_kwargs['stage_callback'] = publish_translation_stage
 
 
1282
  result = translate_method(translation_source, target_lang, **call_kwargs)
1283
  translated_text = str(result.get('text') or '').strip()
1284
  if pending_quote_tags or reused_quote_tags:
@@ -1314,6 +1326,8 @@ class GenerationRunner:
1314
  'fact_ledger_hash': fact_ledger_hash,
1315
  'translated_text': '',
1316
  'qa_status': 'repair_pending',
 
 
1317
  'error_code': error_code,
1318
  'attempts': int(existing.get('attempts') or 0) + 1,
1319
  'deferred_at': datetime.now(timezone.utc).isoformat(),
@@ -1344,6 +1358,8 @@ class GenerationRunner:
1344
  'qa_status': str(result.get('qa_status') or 'passed'),
1345
  'language_adapter': str(result.get('language_adapter') or ''),
1346
  'diagnostics': list(result.get('diagnostics') or [])[-20:],
 
 
1347
  'attempts': int(result.get('attempts') or 1),
1348
  'completed_at': datetime.now(timezone.utc).isoformat(),
1349
  }
 
871
  discovery cannot see them. No semantic synonym merging is performed.
872
  """
873
  items = []
874
+ index_by_key = {}
875
 
876
+ def add(value, kind, policy='FLEXIBLE_TERM'):
877
  term = re.sub(r'\s+', ' ', str(value or '')).strip(' \t\r\n,;:.')
878
  if not term or len(term) < 3 or len(term) > 100:
879
  return
 
881
  if not words or len(words) > 10:
882
  return
883
  key = re.sub(r'[–—-]+', '-', term).casefold()
884
+ normalized_policy = 'STRICT_CONSTRUCT' if str(policy or '').upper() == 'STRICT_CONSTRUCT' else 'FLEXIBLE_TERM'
885
+ if key in index_by_key:
886
+ row = items[index_by_key[key]]
887
+ if normalized_policy == 'STRICT_CONSTRUCT' and row.get('policy') != 'STRICT_CONSTRUCT':
888
+ row['policy'] = 'STRICT_CONSTRUCT'
889
+ row['kind'] = str(kind or row.get('kind') or 'concept')
890
  return
891
+ index_by_key[key] = len(items)
892
+ items.append({'source': term, 'kind': str(kind or 'concept'), 'policy': normalized_policy})
893
 
894
  meta = dict(data_metadata or {})
895
  if str(mode or '') == 'amj':
896
  constructs = meta.get('constructs') if isinstance(meta.get('constructs'), dict) else {}
897
  for role in ('predictor', 'mediator', 'outcome'):
898
+ add(constructs.get(role), f'quantitative_{role}', 'STRICT_CONSTRUCT')
899
  else:
900
  for theme in (meta.get('themes') or []):
901
  if not isinstance(theme, dict):
902
  continue
903
+ add(theme.get('title'), 'qualitative_theme', 'STRICT_CONSTRUCT')
904
  for concept in (theme.get('concepts') or []):
905
+ add(concept, 'qualitative_concept', 'STRICT_CONSTRUCT')
906
  for row in (meta.get('evidence') or []):
907
  if isinstance(row, dict):
908
+ add(row.get('concept'), 'qualitative_concept', 'STRICT_CONSTRUCT')
909
 
910
  for task in (adaptive_structure or []):
911
  if not isinstance(task, dict):
912
  continue
913
  for concept in (task.get('concepts') or []):
914
+ add(concept, 'outline_concept', 'FLEXIBLE_TERM')
915
 
916
  # User keywords remain planning seeds, not canonical identities. Use
917
  # them only as a fallback when frozen empirical/qualitative metadata and
 
920
  for seed in re.split(r'[,;\n|]+', str(keywords or '')):
921
  seed = re.sub(r'\s+', ' ', seed).strip()
922
  if 1 <= len(seed.split()) <= 6:
923
+ add(seed, 'keyword_fallback', 'FLEXIBLE_TERM')
924
 
925
  # Capture explicit lower-case hyphenated academic expressions only when
926
  # they recur or carry a research-concept cue. This closes the
 
937
  for term in hyphenated:
938
  parts = set(term.casefold().split('-'))
939
  if counts.get(term.casefold(), 0) >= 2 or parts.intersection(concept_cues):
940
+ add(term, 'logic_hyphenated_concept', 'FLEXIBLE_TERM')
941
 
942
+ return {'version': 'concept-ledger-v2', 'items': items}
943
 
944
  @staticmethod
945
  def _canonical_concept_writer_directive(ledger: dict) -> str:
 
1275
  'glossary': translation_glossary, 'section_name': unit_name, 'max_attempts': 2,
1276
  }
1277
  try:
1278
+ parameters = list(inspect.signature(translate_method).parameters.values())
1279
  supports_stage_callback = any(
1280
  item.name == 'stage_callback' or item.kind == inspect.Parameter.VAR_KEYWORD
1281
  for item in parameters
1282
  )
1283
+ supports_claim_states = any(
1284
+ item.name == 'claim_states' or item.kind == inspect.Parameter.VAR_KEYWORD
1285
+ for item in parameters
1286
+ )
1287
  except (TypeError, ValueError):
1288
  supports_stage_callback = False
1289
+ supports_claim_states = False
1290
  if supports_stage_callback:
1291
  call_kwargs['stage_callback'] = publish_translation_stage
1292
+ if supports_claim_states and isinstance(existing.get('claim_states'), dict):
1293
+ call_kwargs['claim_states'] = dict(existing.get('claim_states') or {})
1294
  result = translate_method(translation_source, target_lang, **call_kwargs)
1295
  translated_text = str(result.get('text') or '').strip()
1296
  if pending_quote_tags or reused_quote_tags:
 
1326
  'fact_ledger_hash': fact_ledger_hash,
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(),
 
1358
  'qa_status': str(result.get('qa_status') or 'passed'),
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
  }
core/pipeline_agent.py CHANGED
@@ -4767,13 +4767,26 @@ Output only the repaired section."""
4767
  mapping = dict(existing or {})
4768
  rows = (dict(concept_ledger or {}).get('items') or []) if isinstance(concept_ledger, dict) else []
4769
  sources = []
 
 
4770
  for row in rows:
4771
  if not isinstance(row, dict):
4772
  continue
4773
  term = re.sub(r'\s+', ' ', str(row.get('source') or '')).strip()
4774
- if term and term not in sources:
4775
- sources.append(term)
4776
- self._canonical_concept_sources = set(sources)
 
 
 
 
 
 
 
 
 
 
 
4777
  if not sources:
4778
  return mapping
4779
 
@@ -5002,6 +5015,14 @@ SECTION: {section_name}
5002
  'UNCERTAINTY_DRIFT','TERMINOLOGY','GRAMMAR','LANGUAGE_CONTAMINATION',
5003
  'CITATION_ATTACHMENT','ACRONYM_ATTACHMENT','SEMANTIC_DRIFT',
5004
  }
 
 
 
 
 
 
 
 
5005
  claim_rows = []
5006
  glossary_value = dict(glossary or {})
5007
  for block_id in expected_ids:
@@ -5040,8 +5061,8 @@ SECTION: {section_name}
5040
  Review EACH claim independently. Do not rewrite and do not provide free-form explanations. Check semantic equivalence and language quality: omissions/additions, negation, uncertainty, association-versus-causation strength, terminology, grammar, language contamination, citation attachment, and acronym attachment.
5041
  LANGUAGE ADAPTER: {profile['adapter_id']}; morphology={profile['morphology']}. {profile['term_policy']} {profile['contamination_policy']}
5042
  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.
5043
- Return valid JSON only in this exact form: {{"items":[{{"id":"SP-P000001-C000001","ok":true,"codes":[]}}]}}.
5044
- If ok=false, codes MUST contain only values from allowed_issue_codes. Never return numbered prose, examples, quotes, or corrective wording. Return every supplied id exactly once."""
5045
  response = self.call_deepseek(
5046
  system_prompt, json.dumps(payload, ensure_ascii=False),
5047
  max_tokens=max(1200, min(3600, 650 + 260 * len(rows))),
@@ -5062,6 +5083,29 @@ If ok=false, codes MUST contain only values from allowed_issue_codes. Never retu
5062
  meta = row_lookup[row_id]
5063
  raw_codes = [str(code or '').strip().upper() for code in (returned_row.get('codes') or [])]
5064
  codes = [code for code in raw_codes if code in allowed_codes]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5065
  model_contamination = 'LANGUAGE_CONTAMINATION' in codes
5066
  lexical = self._translation_lexical_purity_evidence(
5067
  meta['english'], meta['target'], target_lang, relevant_glossary
@@ -5473,12 +5517,19 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
5473
  ]
5474
  if lexical_tokens:
5475
  evidence_payload['detected_unprotected_english_tokens'] = lexical_tokens[:8]
 
 
 
 
 
 
5476
  effective_prompt = system_prompt + (
5477
  '\nCLAIM QUALITY RETRY. Repair only this one claim. The only permitted repair signals are the following bounded issue codes: '
5478
  + json.dumps(codes[:5], ensure_ascii=False)
5479
  + '. Use the bounded detector evidence below only to repair the supplied claim; do not invent additional defects. '
5480
  'The current target claim is context, not authority: the frozen English source remains authoritative. '
5481
  '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. '
 
5482
  '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.'
5483
  + '\n<CLAIM_REPAIR_EVIDENCE>\n'
5484
  + json.dumps(evidence_payload, ensure_ascii=False)
@@ -5553,6 +5604,65 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
5553
  _order, segments = self._parse_translation_claim_segments(restored, [claim_id])
5554
  return segments[claim_id]
5555
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5556
  def _adjudicate_persistent_translation_issues(
5557
  self, sources, translations, issues, target_lang, glossary=None, section_name=''
5558
  ):
@@ -5740,7 +5850,7 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
5740
  )
5741
  return {}
5742
 
5743
- def translate_frozen_section(self, section_text, target_lang, glossary=None, section_name='', max_attempts=2, stage_callback=None):
5744
  """Translate one frozen English unit and publish only after all QA passes."""
5745
  value = str(section_text or '').strip()
5746
  if not value:
@@ -5769,8 +5879,15 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
5769
  + '\nUse this frozen terminology glossary consistently. Never remap an existing term:\n'
5770
  + json.dumps(frozen_glossary, ensure_ascii=False)
5771
  )
 
 
 
 
 
5772
  attempts = max(1, int(max_attempts or 1))
5773
  last_error = None
 
 
5774
 
5775
  def emit_stage(stage):
5776
  if not callable(stage_callback):
@@ -5788,17 +5905,33 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
5788
  emit_stage('translating')
5789
  translations = {}
5790
  try:
5791
- for batch in batches:
5792
- translations.update(self._translate_marked_batch_with_recovery(batch, system_prompt, target_lang))
5793
- source_blocks = {
5794
- block_id: source
5795
- for batch in batches
5796
- for block_id, source in batch
5797
- }
5798
- emit_stage('qa')
5799
- quality_issues = self._audit_translation_blocks_with_retry(
5800
- source_blocks, translations, target_lang, frozen_glossary,
5801
- section_name=section_name or 'frozen section', max_attempts=2,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5802
  )
5803
  local_round = 0
5804
  while quality_issues and local_round < 2:
@@ -5829,7 +5962,15 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
5829
  claim_order = list(source_claims)
5830
  _order, segments = self._parse_translation_claim_segments(translations[block_id], claim_order)
5831
  current_target_claim = segments[claim_id]
5832
- repair_evidence = {}
 
 
 
 
 
 
 
 
5833
  if 'LANGUAGE_CONTAMINATION' in {str(code or '').strip().upper() for code in (codes or [])}:
5834
  lexical = self._translation_lexical_purity_evidence(
5835
  source_claims[claim_id], current_target_claim, target_lang, frozen_glossary
@@ -5871,12 +6012,18 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
5871
  {block_id: translations[block_id] for block_id in sorted(touched_blocks)},
5872
  target_lang, frozen_glossary, section_name=section_name or 'frozen section', max_attempts=2,
5873
  )
 
 
 
5874
  if quality_issues:
5875
  emit_stage('qa')
5876
  quality_issues = self._adjudicate_persistent_translation_issues(
5877
  source_blocks, translations, quality_issues, target_lang, frozen_glossary,
5878
  section_name=section_name or 'frozen section',
5879
  )
 
 
 
5880
  if quality_issues:
5881
  detail = '; '.join(
5882
  f'SP-P{block_id}-{claim_id}: {",".join(codes)}'
@@ -5913,6 +6060,8 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
5913
  'fact_ledger_hash': self.translation_fact_ledger_hash(value),
5914
  'language_adapter': profile['adapter_id'],
5915
  'diagnostics': diagnostics[-20:],
 
 
5916
  }
5917
  except PipelineCancelledError:
5918
  raise
 
4767
  mapping = dict(existing or {})
4768
  rows = (dict(concept_ledger or {}).get('items') or []) if isinstance(concept_ledger, dict) else []
4769
  sources = []
4770
+ strict_sources = []
4771
+ flexible_sources = []
4772
  for row in rows:
4773
  if not isinstance(row, dict):
4774
  continue
4775
  term = re.sub(r'\s+', ' ', str(row.get('source') or '')).strip()
4776
+ if not term or term in sources:
4777
+ continue
4778
+ sources.append(term)
4779
+ policy = str(row.get('policy') or 'STRICT_CONSTRUCT').strip().upper()
4780
+ if policy == 'FLEXIBLE_TERM':
4781
+ flexible_sources.append(term)
4782
+ else:
4783
+ strict_sources.append(term)
4784
+ # Only true research constructs receive deterministic canonical-identity
4785
+ # enforcement. Descriptive academic terms still receive a preferred
4786
+ # glossary translation but may vary naturally with target-language
4787
+ # grammar or equivalent wording; semantic QA remains authoritative.
4788
+ self._canonical_concept_sources = set(strict_sources)
4789
+ self._canonical_flexible_sources = set(flexible_sources)
4790
  if not sources:
4791
  return mapping
4792
 
 
5015
  'UNCERTAINTY_DRIFT','TERMINOLOGY','GRAMMAR','LANGUAGE_CONTAMINATION',
5016
  'CITATION_ATTACHMENT','ACRONYM_ATTACHMENT','SEMANTIC_DRIFT',
5017
  }
5018
+ allowed_semantic_evidence = {
5019
+ 'ACTOR_CHANGED','RELATION_CHANGED','OBJECT_CHANGED','POLARITY_CHANGED',
5020
+ 'CAUSALITY_STRENGTH_CHANGED','MODALITY_UNCERTAINTY_CHANGED',
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 = []
5027
  glossary_value = dict(glossary or {})
5028
  for block_id in expected_ids:
 
5061
  Review EACH claim independently. Do not rewrite and do not provide free-form explanations. Check semantic equivalence and language quality: omissions/additions, negation, uncertainty, association-versus-causation strength, terminology, grammar, language contamination, citation attachment, and acronym attachment.
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
  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',
5093
+ }
5094
+ if any(code in semantic_code_set for code in codes):
5095
+ derived = {
5096
+ 'NEGATION_DRIFT': 'POLARITY_CHANGED',
5097
+ 'CAUSALITY_STRENGTH_DRIFT': 'CAUSALITY_STRENGTH_CHANGED',
5098
+ 'UNCERTAINTY_DRIFT': 'MODALITY_UNCERTAINTY_CHANGED',
5099
+ 'OMITTED_CLAIM': 'CLAIM_MISSING',
5100
+ 'ADDED_CLAIM': 'CLAIM_ADDED',
5101
+ }
5102
+ for issue_code in codes:
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
 
5517
  ]
5518
  if lexical_tokens:
5519
  evidence_payload['detected_unprotected_english_tokens'] = lexical_tokens[:8]
5520
+ semantic_delta_codes = [
5521
+ str(code or '').strip().upper() for code in (supplied_evidence.get('semantic_delta_codes') or [])
5522
+ if str(code or '').strip()
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)
 
5604
  _order, segments = self._parse_translation_claim_segments(restored, [claim_id])
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 {}):
5615
+ continue
5616
+ source_claims = cls._translation_claim_units(source_block)
5617
+ claim_order = [item['claim_id'] for item in source_claims]
5618
+ try:
5619
+ _order, target_claims = cls._parse_translation_claim_segments(translations[block_id], claim_order)
5620
+ except Exception:
5621
+ continue
5622
+ for item in source_claims:
5623
+ claim_id = item['claim_id']
5624
+ target_text = str(target_claims.get(claim_id) or '').strip()
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),
5631
+ 'source_hash': hashlib.sha256(str(item['text']).encode('utf-8')).hexdigest(),
5632
+ 'target_text': target_text,
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
+
5639
+ @classmethod
5640
+ def _restore_translation_claim_states(cls, source_blocks, claim_states):
5641
+ """Reassemble marked blocks only when every frozen source claim has a hash-matched durable target."""
5642
+ states = dict(claim_states or {})
5643
+ if not states:
5644
+ return {}, {}, False
5645
+ translations = {}
5646
+ pending = {}
5647
+ for block_id, source_block in dict(source_blocks or {}).items():
5648
+ source_claims = cls._translation_claim_units(source_block)
5649
+ order = [item['claim_id'] for item in source_claims]
5650
+ segments = {}
5651
+ for item in source_claims:
5652
+ claim_id = item['claim_id']
5653
+ key = f'SP-P{block_id}-{claim_id}'
5654
+ row = states.get(key)
5655
+ expected_hash = hashlib.sha256(str(item['text']).encode('utf-8')).hexdigest()
5656
+ if not isinstance(row, dict) or row.get('source_hash') != expected_hash or not str(row.get('target_text') or '').strip():
5657
+ return {}, {}, False
5658
+ segments[claim_id] = str(row.get('target_text') or '').strip()
5659
+ if str(row.get('qa_status') or '') == 'repair_pending':
5660
+ codes = [str(code or '').strip().upper() for code in (row.get('issue_codes') or []) if str(code or '').strip()]
5661
+ if codes:
5662
+ pending.setdefault(str(block_id), {})[str(claim_id)] = codes[:5]
5663
+ translations[str(block_id)] = cls._compose_translation_claim_segments(order, segments)
5664
+ return translations, pending, True
5665
+
5666
  def _adjudicate_persistent_translation_issues(
5667
  self, sources, translations, issues, target_lang, glossary=None, section_name=''
5668
  ):
 
5850
  )
5851
  return {}
5852
 
5853
+ def translate_frozen_section(self, section_text, target_lang, glossary=None, section_name='', max_attempts=2, stage_callback=None, claim_states=None):
5854
  """Translate one frozen English unit and publish only after all QA passes."""
5855
  value = str(section_text or '').strip()
5856
  if not value:
 
5879
  + '\nUse this frozen terminology glossary consistently. Never remap an existing term:\n'
5880
  + json.dumps(frozen_glossary, ensure_ascii=False)
5881
  )
5882
+ source_blocks = {
5883
+ block_id: source
5884
+ for batch in batches
5885
+ for block_id, source in batch
5886
+ }
5887
  attempts = max(1, int(max_attempts or 1))
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):
 
5905
  emit_stage('translating')
5906
  translations = {}
5907
  try:
5908
+ translations, quality_issues, resumed_claims = self._restore_translation_claim_states(
5909
+ source_blocks, claim_states
5910
+ )
5911
+ if resumed_claims:
5912
+ for row in dict(claim_states or {}).values():
5913
+ if not isinstance(row, dict):
5914
+ continue
5915
+ block_key = str(row.get('block_id') or '')
5916
+ claim_key = str(row.get('claim_id') or '')
5917
+ evidence = dict(row.get('repair_evidence') or {})
5918
+ if block_key and claim_key and evidence:
5919
+ self._translation_claim_qa_evidence[(block_key, claim_key)] = evidence
5920
+ logging.info(
5921
+ 'Translation claim-level durable resume | section=%s claims=%s pending=%s',
5922
+ section_name or 'section', len(dict(claim_states or {})),
5923
+ sum(len(v or {}) for v in quality_issues.values()),
5924
+ )
5925
+ else:
5926
+ for batch in batches:
5927
+ translations.update(self._translate_marked_batch_with_recovery(batch, system_prompt, target_lang))
5928
+ emit_stage('qa')
5929
+ quality_issues = self._audit_translation_blocks_with_retry(
5930
+ source_blocks, translations, target_lang, frozen_glossary,
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:
 
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
 
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(
6021
  source_blocks, translations, quality_issues, target_lang, frozen_glossary,
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(
6029
  f'SP-P{block_id}-{claim_id}: {",".join(codes)}'
 
6060
  'fact_ledger_hash': self.translation_fact_ledger_hash(value),
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