S0-R1-C5.5-R6-R4 Canonical Concept + Protected Anchor Translation Closure

#92
core/generation_runner.py CHANGED
@@ -2168,13 +2168,28 @@ class GenerationRunner:
2168
  if not callable(translate_cells):
2169
  raise RuntimeError('Typed qualitative Appendix cell translation entry point is unavailable.')
2170
  try:
2171
- result = translate_cells(
2172
- qual_engine.appendix_translation_cells(manifest), target_lang,
2173
- glossary=translation_glossary, max_attempts=2,
2174
- claim_states=(existing.get('appendix_cell_claim_states') if isinstance(existing.get('appendix_cell_claim_states'), dict) else None),
2175
  )
2176
- cell_translations = dict((result or {}).get('translations') or {})
2177
- updated_glossary = dict((result or {}).get('glossary') or translation_glossary or {})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2178
  translated_text = qual_engine.bind_target_language_appendix(
2179
  manifest, target_lang, cell_translations, quote_map
2180
  )
@@ -2222,6 +2237,7 @@ class GenerationRunner:
2222
  'qualitative_appendix_manifest_hash': manifest_hash,
2223
  'appendix_cell_contract_version': str((result or {}).get('appendix_cell_contract_version') or ''),
2224
  'appendix_cell_claim_states': dict((result or {}).get('claim_states') or {}),
 
2225
  'updated_at': datetime.now(timezone.utc).isoformat(),
2226
  }
2227
  return sections, updated_glossary
 
2168
  if not callable(translate_cells):
2169
  raise RuntimeError('Typed qualitative Appendix cell translation entry point is unavailable.')
2170
  try:
2171
+ prebound_cells, model_cells = qual_engine.appendix_translation_plan(
2172
+ manifest, translation_glossary
 
 
2173
  )
2174
+ if model_cells:
2175
+ result = translate_cells(
2176
+ model_cells, target_lang,
2177
+ glossary=translation_glossary, max_attempts=2,
2178
+ claim_states=(existing.get('appendix_cell_claim_states') if isinstance(existing.get('appendix_cell_claim_states'), dict) else None),
2179
+ )
2180
+ model_translations = dict((result or {}).get('translations') or {})
2181
+ updated_glossary = dict((result or {}).get('glossary') or translation_glossary or {})
2182
+ else:
2183
+ result = {
2184
+ 'qa_status': 'passed', 'attempts': 1, 'claim_states': {},
2185
+ 'appendix_cell_contract_version': 'typed-qualitative-appendix-cells-v2',
2186
+ }
2187
+ model_translations = {}
2188
+ updated_glossary = dict(translation_glossary or {})
2189
+ cell_translations = {**dict(prebound_cells), **dict(model_translations)}
2190
+ expected_cell_ids = set(qual_engine.appendix_translation_cells(manifest))
2191
+ if set(cell_translations) != expected_cell_ids:
2192
+ raise RuntimeError('qualitative_appendix_target_cell_binding_incomplete')
2193
  translated_text = qual_engine.bind_target_language_appendix(
2194
  manifest, target_lang, cell_translations, quote_map
2195
  )
 
2237
  'qualitative_appendix_manifest_hash': manifest_hash,
2238
  'appendix_cell_contract_version': str((result or {}).get('appendix_cell_contract_version') or ''),
2239
  'appendix_cell_claim_states': dict((result or {}).get('claim_states') or {}),
2240
+ 'appendix_prebound_label_count': len(prebound_cells),
2241
  'updated_at': datetime.now(timezone.utc).isoformat(),
2242
  }
2243
  return sections, updated_glossary
core/pipeline_agent.py CHANGED
@@ -74,7 +74,7 @@ class SSciPipelineAgent:
74
  MAX_TRANSLATION_CLAIM_REPAIR_ATTEMPTS = 2
75
  MAX_TRANSLATION_CLAIM_TOTAL_REPAIRS = 6
76
  PROTECTED_ENTITY_LEDGER_VERSION = 'protected-entity-ledger-v2'
77
- CANONICAL_GLOSSARY_QUALITY_VERSION = 'canonical-glossary-quality-v1'
78
  SEMANTIC_CLAIM_FRAME_VERSION = 'smartpaper-core-semantic-claim-frame-v1'
79
  CITATION_CLAIM_FRAME_VERSION = 'smartpaper-citation-claim-frame-v1'
80
 
@@ -6026,7 +6026,10 @@ TEXT:
6026
  }
6027
 
6028
  @classmethod
6029
- def _canonical_glossary_violations(cls, source, target, target_lang, glossary=None, canonical_sources=None):
 
 
 
6030
  """Return canonical concept terms whose frozen target identity disappeared.
6031
 
6032
  Chinese uses compact exact identity. Russian/Kazakh use conservative
@@ -6039,16 +6042,37 @@ TEXT:
6039
  language_code = profile.get('language_code')
6040
  violations = []
6041
  canonical = {str(item or '').strip().casefold() for item in (canonical_sources or []) if str(item or '').strip()}
 
 
 
 
 
6042
  for source_term, target_term in dict(glossary or {}).items():
6043
  source_term = str(source_term or '').strip()
6044
  target_term = str(target_term or '').strip()
6045
  if canonical and source_term.casefold() not in canonical:
6046
  continue
 
 
 
 
 
 
 
 
 
 
6047
  if not source_term or not target_term or source_term.casefold() not in source_value.casefold():
6048
  continue
6049
  # Pure technical/acronym identities are protected elsewhere.
 
 
 
 
 
 
6050
  compact = re.sub(r'[^A-Za-z0-9]', '', target_term)
6051
- if compact and ' ' not in target_term and (
6052
  compact.isupper() or any(ch.isdigit() for ch in compact)
6053
  or (len(compact) > 2 and any(ch.isupper() for ch in compact[1:]))
6054
  ):
@@ -6076,6 +6100,30 @@ TEXT:
6076
  violations.append(source_term)
6077
  return list(dict.fromkeys(violations))[:8]
6078
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6079
  @classmethod
6080
  def _compile_translation_ir(cls, source):
6081
  """Compile a private, language-neutral translation intermediate representation."""
@@ -7049,6 +7097,7 @@ TEXT:
7049
  sources = []
7050
  strict_sources = []
7051
  flexible_sources = []
 
7052
  for row in rows:
7053
  if not isinstance(row, dict):
7054
  continue
@@ -7056,6 +7105,7 @@ TEXT:
7056
  if not term or term in sources:
7057
  continue
7058
  sources.append(term)
 
7059
  policy = str(row.get('policy') or 'STRICT_CONSTRUCT').strip().upper()
7060
  if policy == 'FLEXIBLE_TERM':
7061
  flexible_sources.append(term)
@@ -7067,6 +7117,7 @@ TEXT:
7067
  # grammar or equivalent wording; semantic QA remains authoritative.
7068
  self._canonical_concept_sources = set(strict_sources)
7069
  self._canonical_flexible_sources = set(flexible_sources)
 
7070
  if not sources:
7071
  return mapping
7072
 
@@ -7155,6 +7206,7 @@ TEXT:
7155
  f"Independently review strict research-construct translations for {target_lang}. {language_rule} "
7156
  "Judge semantic identity and natural academic terminology, not stylistic preference. "
7157
  "Reject duplicated/malformed wording, concept narrowing/broadening, or a target that changes the construct. "
 
7158
  "If the candidate is sound, return it unchanged with accepted=true. If it is materially flawed, return exactly one corrected canonical target with accepted=false. "
7159
  "Do not merge distinct constructs. Return JSON only as "
7160
  '{"terms":[{"source":"...","candidate_target":"...","accepted":true,"target":"..."}]}'
@@ -7183,7 +7235,10 @@ TEXT:
7183
  accepted_raw = item.get('accepted')
7184
  accepted = accepted_raw is True or str(accepted_raw).strip().casefold() == 'true'
7185
  reviewed_target = re.sub(r'\s+', ' ', str(item.get('target') or '')).strip()
7186
- suspicious = self._canonical_target_surface_suspicious(candidate, target_lang)
 
 
 
7187
  if accepted and not suspicious:
7188
  # An acceptance cannot silently remap the frozen candidate.
7189
  if reviewed_target and reviewed_target != candidate:
@@ -7195,7 +7250,10 @@ TEXT:
7195
  )
7196
  if reviewed_target.casefold() == source.casefold() and re.search(r'[a-z]{4,}', source):
7197
  raise DeepSeekRequestError(f'Canonical concept remained untranslated after quality review: {source[:80]}')
7198
- if self._canonical_target_surface_suspicious(reviewed_target, target_lang):
 
 
 
7199
  raise DeepSeekRequestError(f'Canonical concept correction remains malformed: {source[:80]}')
7200
  refined[source] = reviewed_target
7201
  self._record_translation_assurance(
@@ -7523,6 +7581,8 @@ If ok=false, codes MUST contain only values from allowed_issue_codes. For SEMANT
7523
  canonical_violations = self._canonical_glossary_violations(
7524
  meta['english'], meta['target'], target_lang, relevant_glossary,
7525
  canonical_sources=getattr(self, '_canonical_concept_sources', set()),
 
 
7526
  )
7527
  if canonical_violations:
7528
  if 'TERMINOLOGY' not in codes:
@@ -8580,10 +8640,21 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
8580
  section_name='Qualitative Appendix semantic cells', max_attempts=max_attempts,
8581
  stage_callback=stage_callback, claim_states=claim_states,
8582
  )
8583
- body_units = [
8584
- unit['text'] for unit in self._translation_body_units(result.get('text') or '')
8585
- if unit.get('kind') == 'body'
8586
- ]
 
 
 
 
 
 
 
 
 
 
 
8587
  if len(body_units) != len(ordered):
8588
  raise DeepSeekRequestError(
8589
  'Qualitative Appendix cell identity mismatch after translation: '
@@ -8601,7 +8672,7 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
8601
  **dict(result or {}),
8602
  'translations': translations,
8603
  'cell_ids': list(cell_objects.keys()),
8604
- 'appendix_cell_contract_version': 'typed-qualitative-appendix-cells-v1',
8605
  }
8606
 
8607
  def translate_frozen_section(self, section_text, target_lang, glossary=None, section_name='', max_attempts=2, stage_callback=None, claim_states=None):
@@ -9018,6 +9089,7 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
9018
  # Rebuild after any localized repairs, then apply deterministic
9019
  # whole-unit integrity/language checks before publication.
9020
  output_units = []
 
9021
  body_index = 0
9022
  for unit in units:
9023
  if unit['kind'] == 'heading':
@@ -9027,7 +9099,10 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
9027
  output_units.append('#' * int(unit['level']) + ' ' + translated_heading)
9028
  else:
9029
  body_index += 1
9030
- output_units.append(self._strip_translation_claim_markers(translations[f'{body_index:06d}']))
 
 
 
9031
  output = '\n\n'.join(output_units).strip()
9032
  self.validate_translation_alignment(value, output)
9033
  self._validate_translation_language_quality(output, target_lang)
@@ -9045,6 +9120,10 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
9045
  'diagnostics': diagnostics[-20:],
9046
  'claim_states': dict(self._last_translation_claim_states),
9047
  'claim_state_version': 'translation-claim-state-v2',
 
 
 
 
9048
  }
9049
  except PipelineCancelledError:
9050
  raise
 
74
  MAX_TRANSLATION_CLAIM_REPAIR_ATTEMPTS = 2
75
  MAX_TRANSLATION_CLAIM_TOTAL_REPAIRS = 6
76
  PROTECTED_ENTITY_LEDGER_VERSION = 'protected-entity-ledger-v2'
77
+ CANONICAL_GLOSSARY_QUALITY_VERSION = 'canonical-glossary-quality-v2'
78
  SEMANTIC_CLAIM_FRAME_VERSION = 'smartpaper-core-semantic-claim-frame-v1'
79
  CITATION_CLAIM_FRAME_VERSION = 'smartpaper-citation-claim-frame-v1'
80
 
 
6026
  }
6027
 
6028
  @classmethod
6029
+ def _canonical_glossary_violations(
6030
+ cls, source, target, target_lang, glossary=None, canonical_sources=None,
6031
+ canonical_kinds=None, section_name='',
6032
+ ):
6033
  """Return canonical concept terms whose frozen target identity disappeared.
6034
 
6035
  Chinese uses compact exact identity. Russian/Kazakh use conservative
 
6042
  language_code = profile.get('language_code')
6043
  violations = []
6044
  canonical = {str(item or '').strip().casefold() for item in (canonical_sources or []) if str(item or '').strip()}
6045
+ kinds = {
6046
+ str(key or '').strip().casefold(): str(value or '').strip().casefold()
6047
+ for key, value in dict(canonical_kinds or {}).items() if str(key or '').strip()
6048
+ }
6049
+ label_context = 'qualitative appendix semantic cells' in str(section_name or '').casefold()
6050
  for source_term, target_term in dict(glossary or {}).items():
6051
  source_term = str(source_term or '').strip()
6052
  target_term = str(target_term or '').strip()
6053
  if canonical and source_term.casefold() not in canonical:
6054
  continue
6055
+ # Qualitative theme/concept names are exact reader-facing labels in
6056
+ # the deterministic Appendix, but ordinary prose may realize the
6057
+ # same semantic identity with natural grammar. Requiring an exact
6058
+ # frozen phrase inside every ZH/RU/KK sentence created a false
6059
+ # terminology loop (especially for morphologically rich RU/KK and
6060
+ # compact Chinese paraphrase). Bilingual semantic QA remains the
6061
+ # prose authority; label identity stays exact in the Appendix binder.
6062
+ concept_kind = kinds.get(source_term.casefold(), '')
6063
+ if concept_kind in {'qualitative_theme', 'qualitative_concept'} and not label_context:
6064
+ continue
6065
  if not source_term or not target_term or source_term.casefold() not in source_value.casefold():
6066
  continue
6067
  # Pure technical/acronym identities are protected elsewhere.
6068
+ # Skip deterministic canonical checking only when the *entire*
6069
+ # target is a pure technical/acronym identity. A localized label
6070
+ # containing an embedded acronym (e.g. ``AI推荐...``) is still a
6071
+ # conceptual phrase and must not be mistaken for the bare ``AI``
6072
+ # token merely because stripping non-ASCII characters leaves AI.
6073
+ pure_ascii = bool(re.fullmatch(r'[A-Za-z0-9+.#-]+', target_term))
6074
  compact = re.sub(r'[^A-Za-z0-9]', '', target_term)
6075
+ if pure_ascii and compact and ' ' not in target_term and (
6076
  compact.isupper() or any(ch.isdigit() for ch in compact)
6077
  or (len(compact) > 2 and any(ch.isupper() for ch in compact[1:]))
6078
  ):
 
6100
  violations.append(source_term)
6101
  return list(dict.fromkeys(violations))[:8]
6102
 
6103
+ @classmethod
6104
+ def _canonical_target_preserves_source_anchors(cls, source, target):
6105
+ """Require exact-render source anchors to survive inside a canonical label.
6106
+
6107
+ Canonical labels and the protected-anchor compiler must never issue
6108
+ contradictory instructions. Dates/word-quantities are intentionally
6109
+ excluded because their render policy is localized/canonicalized; exact
6110
+ acronyms, names, hypotheses, IDs, formulas, statistics and numbers remain
6111
+ byte-stable under the existing translation contract.
6112
+ """
6113
+ _protected, mapping = cls._protect_translation_tokens(str(source or ''))
6114
+ target_value = str(target or '')
6115
+ required = Counter()
6116
+ for manifest in dict(mapping or {}).values():
6117
+ if not isinstance(manifest, dict):
6118
+ continue
6119
+ if str(manifest.get('render_policy') or '') != 'exact':
6120
+ continue
6121
+ surface = str(manifest.get('source') or '')
6122
+ if not surface or surface == '|':
6123
+ continue
6124
+ required[surface] += 1
6125
+ return all(target_value.count(surface) >= count for surface, count in required.items())
6126
+
6127
  @classmethod
6128
  def _compile_translation_ir(cls, source):
6129
  """Compile a private, language-neutral translation intermediate representation."""
 
7097
  sources = []
7098
  strict_sources = []
7099
  flexible_sources = []
7100
+ source_kinds = {}
7101
  for row in rows:
7102
  if not isinstance(row, dict):
7103
  continue
 
7105
  if not term or term in sources:
7106
  continue
7107
  sources.append(term)
7108
+ source_kinds[term] = str(row.get('kind') or 'concept').strip()
7109
  policy = str(row.get('policy') or 'STRICT_CONSTRUCT').strip().upper()
7110
  if policy == 'FLEXIBLE_TERM':
7111
  flexible_sources.append(term)
 
7117
  # grammar or equivalent wording; semantic QA remains authoritative.
7118
  self._canonical_concept_sources = set(strict_sources)
7119
  self._canonical_flexible_sources = set(flexible_sources)
7120
+ self._canonical_concept_kinds = dict(source_kinds)
7121
  if not sources:
7122
  return mapping
7123
 
 
7206
  f"Independently review strict research-construct translations for {target_lang}. {language_rule} "
7207
  "Judge semantic identity and natural academic terminology, not stylistic preference. "
7208
  "Reject duplicated/malformed wording, concept narrowing/broadening, or a target that changes the construct. "
7209
+ "Every protected exact source anchor inside a construct (for example AI, LLM, model/tool IDs, hypothesis IDs, formulas, statistics or exact numbers) must remain byte-for-byte in the target canonical label; never localize, delete, duplicate, or rename such an anchor. "
7210
  "If the candidate is sound, return it unchanged with accepted=true. If it is materially flawed, return exactly one corrected canonical target with accepted=false. "
7211
  "Do not merge distinct constructs. Return JSON only as "
7212
  '{"terms":[{"source":"...","candidate_target":"...","accepted":true,"target":"..."}]}'
 
7235
  accepted_raw = item.get('accepted')
7236
  accepted = accepted_raw is True or str(accepted_raw).strip().casefold() == 'true'
7237
  reviewed_target = re.sub(r'\s+', ' ', str(item.get('target') or '')).strip()
7238
+ suspicious = (
7239
+ self._canonical_target_surface_suspicious(candidate, target_lang)
7240
+ or not self._canonical_target_preserves_source_anchors(source, candidate)
7241
+ )
7242
  if accepted and not suspicious:
7243
  # An acceptance cannot silently remap the frozen candidate.
7244
  if reviewed_target and reviewed_target != candidate:
 
7250
  )
7251
  if reviewed_target.casefold() == source.casefold() and re.search(r'[a-z]{4,}', source):
7252
  raise DeepSeekRequestError(f'Canonical concept remained untranslated after quality review: {source[:80]}')
7253
+ if (
7254
+ self._canonical_target_surface_suspicious(reviewed_target, target_lang)
7255
+ or not self._canonical_target_preserves_source_anchors(source, reviewed_target)
7256
+ ):
7257
  raise DeepSeekRequestError(f'Canonical concept correction remains malformed: {source[:80]}')
7258
  refined[source] = reviewed_target
7259
  self._record_translation_assurance(
 
7581
  canonical_violations = self._canonical_glossary_violations(
7582
  meta['english'], meta['target'], target_lang, relevant_glossary,
7583
  canonical_sources=getattr(self, '_canonical_concept_sources', set()),
7584
+ canonical_kinds=getattr(self, '_canonical_concept_kinds', {}),
7585
+ section_name=section_name,
7586
  )
7587
  if canonical_violations:
7588
  if 'TERMINOLOGY' not in codes:
 
8640
  section_name='Qualitative Appendix semantic cells', max_attempts=max_attempts,
8641
  stage_callback=stage_callback, claim_states=claim_states,
8642
  )
8643
+ body_map = dict((result or {}).get('body_translations') or {})
8644
+ if body_map:
8645
+ expected_block_ids = [f'{index:06d}' for index in range(1, len(ordered) + 1)]
8646
+ if set(body_map) != set(expected_block_ids):
8647
+ raise DeepSeekRequestError(
8648
+ 'Qualitative Appendix compiler block identity mismatch after translation: '
8649
+ f'expected={expected_block_ids[:4]} got={sorted(body_map)[:4]}.'
8650
+ )
8651
+ body_units = [str(body_map[block_id] or '').strip() for block_id in expected_block_ids]
8652
+ else:
8653
+ # Backward-compatible fallback for test doubles/legacy callers.
8654
+ body_units = [
8655
+ unit['text'] for unit in self._translation_body_units(result.get('text') or '')
8656
+ if unit.get('kind') == 'body'
8657
+ ]
8658
  if len(body_units) != len(ordered):
8659
  raise DeepSeekRequestError(
8660
  'Qualitative Appendix cell identity mismatch after translation: '
 
8672
  **dict(result or {}),
8673
  'translations': translations,
8674
  'cell_ids': list(cell_objects.keys()),
8675
+ 'appendix_cell_contract_version': 'typed-qualitative-appendix-cells-v2',
8676
  }
8677
 
8678
  def translate_frozen_section(self, section_text, target_lang, glossary=None, section_name='', max_attempts=2, stage_callback=None, claim_states=None):
 
9089
  # Rebuild after any localized repairs, then apply deterministic
9090
  # whole-unit integrity/language checks before publication.
9091
  output_units = []
9092
+ body_translations = {}
9093
  body_index = 0
9094
  for unit in units:
9095
  if unit['kind'] == 'heading':
 
9099
  output_units.append('#' * int(unit['level']) + ' ' + translated_heading)
9100
  else:
9101
  body_index += 1
9102
+ block_id = f'{body_index:06d}'
9103
+ clean_body = self._strip_translation_claim_markers(translations[block_id])
9104
+ body_translations[block_id] = clean_body
9105
+ output_units.append(clean_body)
9106
  output = '\n\n'.join(output_units).strip()
9107
  self.validate_translation_alignment(value, output)
9108
  self._validate_translation_language_quality(output, target_lang)
 
9120
  'diagnostics': diagnostics[-20:],
9121
  'claim_states': dict(self._last_translation_claim_states),
9122
  'claim_state_version': 'translation-claim-state-v2',
9123
+ # Internal typed consumers (notably the qualitative Appendix)
9124
+ # bind by compiler block identity rather than reparsing
9125
+ # paragraph breaks from reader-facing text.
9126
+ 'body_translations': dict(body_translations),
9127
  }
9128
  except PipelineCancelledError:
9129
  raise
core/qualitative_engine.py CHANGED
@@ -13,9 +13,10 @@ from config import APP_CONFIG
13
 
14
  class QualitativeEngine:
15
  QUALITATIVE_APPENDIX_MANIFEST_VERSION = "qualitative-appendix-manifest-v1"
16
- TARGET_APPENDIX_BINDING_VERSION = "target-qualitative-appendix-binding-v1"
17
  SEMANTIC_CLAIM_FRAME_VERSION = "smartpaper-core-semantic-claim-frame-v1"
18
  APPENDIX_FIELDS = ("theme", "concept", "stage", "case_label", "evidence_role")
 
19
  APPENDIX_HEADERS = (
20
  "Higher-order Theme", "Empirical Concept", "Process Stage",
21
  "Case/Actor", "Evidence Role", "Representative Quote",
@@ -412,6 +413,37 @@ class QualitativeEngine:
412
  cells[f"{row_id}:{field}"] = value
413
  return cells
414
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  @staticmethod
416
  def _escape_appendix_markdown_cell(value):
417
  return re.sub(r"\s+", " ", str(value or "").strip()).replace("|", r"\|")
 
13
 
14
  class QualitativeEngine:
15
  QUALITATIVE_APPENDIX_MANIFEST_VERSION = "qualitative-appendix-manifest-v1"
16
+ TARGET_APPENDIX_BINDING_VERSION = "target-qualitative-appendix-binding-v2"
17
  SEMANTIC_CLAIM_FRAME_VERSION = "smartpaper-core-semantic-claim-frame-v1"
18
  APPENDIX_FIELDS = ("theme", "concept", "stage", "case_label", "evidence_role")
19
+ APPENDIX_CANONICAL_LABEL_FIELDS = ("theme", "concept")
20
  APPENDIX_HEADERS = (
21
  "Higher-order Theme", "Empirical Concept", "Process Stage",
22
  "Case/Actor", "Evidence Role", "Representative Quote",
 
413
  cells[f"{row_id}:{field}"] = value
414
  return cells
415
 
416
+ @classmethod
417
+ def appendix_translation_plan(cls, manifest, glossary=None):
418
+ """Split Appendix cells into server-bound labels and model-owned surfaces.
419
+
420
+ Theme/concept cells are frozen research labels. When the task-level
421
+ canonical glossary already owns their target identity, the translation
422
+ model must not rewrite those labels or their internal anchors. Remaining
423
+ ordinary cells (stage/case/evidence-role) continue through the typed
424
+ translation compiler and are rebound by server-side cell ID.
425
+ """
426
+ expected = cls.appendix_translation_cells(manifest)
427
+ glossary_value = dict(glossary or {})
428
+ folded_glossary = {
429
+ str(key or '').strip().casefold(): str(value or '').strip()
430
+ for key, value in glossary_value.items() if str(key or '').strip() and str(value or '').strip()
431
+ }
432
+ prebound = {}
433
+ model_cells = {}
434
+ for cell_id, source in expected.items():
435
+ field = str(cell_id or '').rsplit(':', 1)[-1]
436
+ canonical_target = str(glossary_value.get(source) or '').strip()
437
+ if not canonical_target:
438
+ canonical_target = folded_glossary.get(str(source or '').strip().casefold(), '')
439
+ if field in cls.APPENDIX_CANONICAL_LABEL_FIELDS and canonical_target:
440
+ prebound[cell_id] = canonical_target
441
+ else:
442
+ model_cells[cell_id] = source
443
+ if set(prebound) & set(model_cells) or set(prebound) | set(model_cells) != set(expected):
444
+ raise RuntimeError("qualitative_appendix_translation_plan_identity_mismatch")
445
+ return prebound, model_cells
446
+
447
  @staticmethod
448
  def _escape_appendix_markdown_cell(value):
449
  return re.sub(r"\s+", " ", str(value or "").strip()).replace("|", r"\|")