S0-R1-C5.5-R2 Deterministic Target-Language Quantitative Table Binding

#85
core/generation_runner.py CHANGED
@@ -4063,9 +4063,21 @@ The previous attempt for this same manuscript unit was rejected by validation. R
4063
  section_integrity_seals[section_key] = self._build_section_integrity_seal(
4064
  section_key, sec_title, frozen_section
4065
  )
 
 
 
 
 
 
 
 
 
 
 
 
4066
  self._enqueue_translation_unit(
4067
  translation_agent,
4068
- unit_key=section_key, unit_name=sec_title, source_text=frozen_section,
4069
  translation_sections=translation_sections,
4070
  english_commit_text=full_draft_en,
4071
  )
@@ -4423,13 +4435,41 @@ The previous attempt for this same manuscript unit was rejected by validation. R
4423
  for key, title in section_order:
4424
  final_units.append((key, title, self._extract_top_level_section(body_only, title)))
4425
  for index, (unit_key, unit_name, source_text) in enumerate(final_units, start=1):
 
 
 
 
 
 
 
 
 
4426
  translation_sections, translation_glossary = self._translate_frozen_unit(
4427
- translation_agent, unit_key=unit_key, unit_name=unit_name, source_text=source_text,
4428
  target_lang=target_lang, translation_sections=translation_sections,
4429
  translation_glossary=translation_glossary, adaptive_structure=adaptive_structure,
4430
  include_front=True, defer_failures=False,
4431
  english_commit_text=full_draft_en, final_gate=True,
4432
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4433
  partial = self._assemble_partial_translation(
4434
  translation_sections, adaptive_structure, include_front=True
4435
  )
 
4063
  section_integrity_seals[section_key] = self._build_section_integrity_seal(
4064
  section_key, sec_title, frozen_section
4065
  )
4066
+ translation_source = frozen_section
4067
+ if (
4068
+ mode == "amj"
4069
+ and str(section_key).strip().casefold() == "results"
4070
+ and data_metadata.get("quantitative_table_manifest")
4071
+ ):
4072
+ # Table values are manifest-owned and must never be handed to the
4073
+ # translation model as mutable prose. The target tables are rebound
4074
+ # deterministically after Results translation QA.
4075
+ translation_source = quant_engine._strip_target_quantitative_markdown_tables(
4076
+ frozen_section
4077
+ )
4078
  self._enqueue_translation_unit(
4079
  translation_agent,
4080
+ unit_key=section_key, unit_name=sec_title, source_text=translation_source,
4081
  translation_sections=translation_sections,
4082
  english_commit_text=full_draft_en,
4083
  )
 
4435
  for key, title in section_order:
4436
  final_units.append((key, title, self._extract_top_level_section(body_only, title)))
4437
  for index, (unit_key, unit_name, source_text) in enumerate(final_units, start=1):
4438
+ translation_source = source_text
4439
+ if (
4440
+ mode == "amj"
4441
+ and str(unit_key).strip().casefold() == "results"
4442
+ and data_metadata.get("quantitative_table_manifest")
4443
+ ):
4444
+ translation_source = quant_engine._strip_target_quantitative_markdown_tables(
4445
+ source_text
4446
+ )
4447
  translation_sections, translation_glossary = self._translate_frozen_unit(
4448
+ translation_agent, unit_key=unit_key, unit_name=unit_name, source_text=translation_source,
4449
  target_lang=target_lang, translation_sections=translation_sections,
4450
  translation_glossary=translation_glossary, adaptive_structure=adaptive_structure,
4451
  include_front=True, defer_failures=False,
4452
  english_commit_text=full_draft_en, final_gate=True,
4453
  )
4454
+ # R2: quantitative tables are manifest-owned artifacts, not translation-model output.
4455
+ # Rebind them only after the frozen Results translation has passed its language/semantic QA.
4456
+ # Re-running this block is idempotent because the engine first removes any prior/model table block.
4457
+ if mode == "amj" and str(unit_key).strip().casefold() == "results":
4458
+ table_manifest = dict(data_metadata.get("quantitative_table_manifest") or {})
4459
+ current_result_translation = dict(translation_sections.get(unit_key) or {})
4460
+ if (
4461
+ table_manifest
4462
+ and current_result_translation.get("qa_status") in {"passed", "passed_with_warning"}
4463
+ and current_result_translation.get("translated_text")
4464
+ ):
4465
+ current_result_translation["translated_text"] = quant_engine.bind_target_language_quantitative_tables(
4466
+ current_result_translation.get("translated_text"),
4467
+ table_manifest, target_lang, translation_glossary,
4468
+ )
4469
+ current_result_translation["quantitative_table_binding_version"] = (
4470
+ quant_engine.TARGET_TABLE_BINDING_VERSION
4471
+ )
4472
+ translation_sections[unit_key] = current_result_translation
4473
  partial = self._assemble_partial_translation(
4474
  translation_sections, adaptive_structure, include_front=True
4475
  )
core/quantitative_engine.py CHANGED
@@ -20,6 +20,91 @@ class QuantitativeEngine:
20
  QUANTITATIVE_EVIDENCE_LEDGER_VERSION = "quantitative-evidence-ledger-v2"
21
  QUANTITATIVE_TABLE_MANIFEST_VERSION = "quantitative-table-manifest-v1"
22
  HYPOTHESIS_LEDGER_VERSION = "quantitative-hypothesis-ledger-v1"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  @classmethod
25
  def build_hypothesis_ledger(cls, constructs):
@@ -126,6 +211,180 @@ class QuantitativeEngine:
126
  ]
127
  return {"version": cls.QUANTITATIVE_TABLE_MANIFEST_VERSION, "tables": rows}
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  @staticmethod
130
  def generate_psd_corr_matrix(n_constructs, max_corr=0.5):
131
  if n_constructs == 1:
 
20
  QUANTITATIVE_EVIDENCE_LEDGER_VERSION = "quantitative-evidence-ledger-v2"
21
  QUANTITATIVE_TABLE_MANIFEST_VERSION = "quantitative-table-manifest-v1"
22
  HYPOTHESIS_LEDGER_VERSION = "quantitative-hypothesis-ledger-v1"
23
+ TARGET_TABLE_BINDING_VERSION = "target-quantitative-table-binding-v1"
24
+
25
+ _TARGET_TABLE_LOCALIZATION = {
26
+ "zh": {
27
+ "caption": "表 {number}:{title}",
28
+ "titles": {
29
+ 1: "信度与收敛效度",
30
+ 2: "描述性统计与判别效度(Fornell-Larcker 准则)",
31
+ 3: "关联模型结果",
32
+ 4: "秩次稳健性相关(Spearman)",
33
+ },
34
+ "cells": {
35
+ "Construct": "构念", "Items": "题项数", "Cronbach's α": "Cronbach α",
36
+ "Mean": "均值", "SD": "SD", "Association": "关联关系",
37
+ "Standardized estimate": "标准化估计", "R² of outcome": "结果变量的 R²",
38
+ "Path": "路径", "Beta": "Beta", "Estimate": "估计量",
39
+ "Point estimate": "点估计", "95% bootstrap CI lower": "95% bootstrap CI 下限",
40
+ "95% bootstrap CI upper": "95% bootstrap CI 上限",
41
+ },
42
+ "phrases": {
43
+ "Indirect association:": "间接关联:",
44
+ "Indirect association": "间接关联",
45
+ "Residual direct association": "剩余直接关联",
46
+ "controlling": "控制",
47
+ },
48
+ "notes": {
49
+ "Diagonal elements in parentheses are the square roots of AVE.": "括号中的对角线元素为 AVE 的平方根。",
50
+ },
51
+ "note_prefix": "注:",
52
+ },
53
+ "ru": {
54
+ "caption": "Таблица {number}: {title}",
55
+ "titles": {
56
+ 1: "Надёжность и конвергентная валидность",
57
+ 2: "Описательная статистика и дискриминантная валидность (критерий Форнелла–Ларкера)",
58
+ 3: "Результаты модели ассоциаций",
59
+ 4: "Ранговые корреляции для проверки устойчивости (Спирмен)",
60
+ },
61
+ "cells": {
62
+ "Construct": "Конструкт", "Items": "Пункты", "Cronbach's α": "α Кронбаха",
63
+ "Mean": "Среднее", "SD": "SD", "Association": "Ассоциация",
64
+ "Standardized estimate": "Стандартизированная оценка", "R² of outcome": "R² результата",
65
+ "Path": "Связь", "Beta": "Beta", "Estimate": "Оценка",
66
+ "Point estimate": "Точечная оценка", "95% bootstrap CI lower": "Нижняя граница 95% bootstrap CI",
67
+ "95% bootstrap CI upper": "Верхняя граница 95% bootstrap CI",
68
+ },
69
+ "phrases": {
70
+ "Indirect association:": "Косвенная ассоциация:",
71
+ "Indirect association": "Косвенная ассоциация",
72
+ "Residual direct association": "Остаточная прямая ассоциация",
73
+ "controlling": "при контроле",
74
+ },
75
+ "notes": {
76
+ "Diagonal elements in parentheses are the square roots of AVE.": "Диагональные элементы в скобках представляют квадратные корни из AVE.",
77
+ },
78
+ "note_prefix": "Примечание:",
79
+ },
80
+ "kk": {
81
+ "caption": "{number}-кесте: {title}",
82
+ "titles": {
83
+ 1: "Сенімділік және конвергенттік валидтілік",
84
+ 2: "Сипаттамалық статистика және дискриминанттық валидтілік (Форнелл–Ларкер критерийі)",
85
+ 3: "Байланыстар моделінің нәтижелері",
86
+ 4: "Орнықтылықты тексеруге арналған рангтік корреляциялар (Спирмен)",
87
+ },
88
+ "cells": {
89
+ "Construct": "Конструкт", "Items": "Тармақтар", "Cronbach's α": "Кронбах α",
90
+ "Mean": "Орташа", "SD": "SD", "Association": "Байланыс",
91
+ "Standardized estimate": "Стандартталған бағалау", "R² of outcome": "Нәтиженің R²",
92
+ "Path": "Байланыс", "Beta": "Beta", "Estimate": "Бағалау",
93
+ "Point estimate": "Нүктелік бағалау", "95% bootstrap CI lower": "95% bootstrap CI төменгі шегі",
94
+ "95% bootstrap CI upper": "95% bootstrap CI жоғарғы шегі",
95
+ },
96
+ "phrases": {
97
+ "Indirect association:": "Жанама байланыс:",
98
+ "Indirect association": "Жанама байланыс",
99
+ "Residual direct association": "Қалдық тікелей байланыс",
100
+ "controlling": "бақылау кезінде",
101
+ },
102
+ "notes": {
103
+ "Diagonal elements in parentheses are the square roots of AVE.": "Жақшадағы диагональ элементтер AVE квадрат түбірлерін көрсетеді.",
104
+ },
105
+ "note_prefix": "Ескертпе:",
106
+ },
107
+ }
108
 
109
  @classmethod
110
  def build_hypothesis_ledger(cls, constructs):
 
211
  ]
212
  return {"version": cls.QUANTITATIVE_TABLE_MANIFEST_VERSION, "tables": rows}
213
 
214
+ @staticmethod
215
+ def _target_table_language_code(target_lang):
216
+ value = str(target_lang or "").strip().casefold().replace("_", "-")
217
+ if value.startswith("zh") or "chinese" in value or "中文" in value or "简体" in value:
218
+ return "zh"
219
+ if value.startswith("ru") or "russian" in value or "рус" in value:
220
+ return "ru"
221
+ if value.startswith("kk") or value.startswith("kz") or "kazakh" in value or "қазақ" in value:
222
+ return "kk"
223
+ return "en"
224
+
225
+ @staticmethod
226
+ def _replace_glossary_terms(value, glossary):
227
+ text = str(value or "")
228
+ pairs = []
229
+ for source, target in dict(glossary or {}).items():
230
+ source = re.sub(r"\s+", " ", str(source or "")).strip()
231
+ target = re.sub(r"\s+", " ", str(target or "")).strip()
232
+ if not source or not target or source == target:
233
+ continue
234
+ if not re.search(r"[A-Za-z]", source):
235
+ continue
236
+ pairs.append((source, target))
237
+ for source, target in sorted(pairs, key=lambda row: len(row[0]), reverse=True):
238
+ pattern = re.compile(
239
+ rf"(?<![A-Za-z0-9_]){re.escape(source)}(?![A-Za-z0-9_])",
240
+ flags=re.I,
241
+ )
242
+ text = pattern.sub(lambda _match, replacement=target: replacement, text)
243
+ return text
244
+
245
+ @classmethod
246
+ def _localize_quantitative_table_cell(cls, value, target_lang, glossary=None):
247
+ code = cls._target_table_language_code(target_lang)
248
+ if code == "en":
249
+ return cls._replace_glossary_terms(value, glossary)
250
+ profile = cls._TARGET_TABLE_LOCALIZATION[code]
251
+ text = str(value or "")
252
+ stripped = text.strip()
253
+ exact = dict(profile.get("cells") or {})
254
+ if stripped in exact:
255
+ return text.replace(stripped, str(exact[stripped]), 1)
256
+ text = cls._replace_glossary_terms(text, glossary)
257
+ for source, target in sorted(
258
+ dict(profile.get("phrases") or {}).items(), key=lambda row: len(row[0]), reverse=True
259
+ ):
260
+ text = re.sub(re.escape(source), str(target), text, flags=re.I)
261
+ return text
262
+
263
+ @classmethod
264
+ def _localize_quantitative_markdown_table(cls, markdown, target_lang, glossary=None):
265
+ lines = []
266
+ for line in str(markdown or "").splitlines():
267
+ if not line.lstrip().startswith("|"):
268
+ lines.append(line)
269
+ continue
270
+ raw = line.rstrip()
271
+ cells = raw.split("|")
272
+ # Alignment rows contain only punctuation/colons and must remain byte-stable.
273
+ if all(not re.search(r"[A-Za-z\u0400-\u04FF\u4e00-\u9fff]", cell) for cell in cells):
274
+ lines.append(raw)
275
+ continue
276
+ localized = [cls._localize_quantitative_table_cell(cell, target_lang, glossary) for cell in cells]
277
+ lines.append("|".join(localized))
278
+ return "\n".join(lines).strip()
279
+
280
+ @classmethod
281
+ def localize_quantitative_table_manifest(cls, manifest, target_lang, glossary=None):
282
+ record = dict(manifest or {})
283
+ rows = [dict(item) for item in (record.get("tables") or []) if isinstance(item, dict)]
284
+ if {int(row.get("number") or 0) for row in rows} != {1, 2, 3, 4}:
285
+ return {}
286
+ code = cls._target_table_language_code(target_lang)
287
+ profile = cls._TARGET_TABLE_LOCALIZATION.get(code, {})
288
+ localized_rows = []
289
+ for row in sorted(rows, key=lambda item: int(item.get("number") or 0)):
290
+ number = int(row.get("number") or 0)
291
+ title = str(row.get("title") or "").strip()
292
+ note = str(row.get("note") or "").strip()
293
+ if code != "en":
294
+ title = str(dict(profile.get("titles") or {}).get(number) or title)
295
+ note = str(dict(profile.get("notes") or {}).get(note) or note)
296
+ localized_rows.append({
297
+ **row,
298
+ "number": number,
299
+ "title": title,
300
+ "markdown": cls._localize_quantitative_markdown_table(
301
+ row.get("markdown"), target_lang, glossary
302
+ ),
303
+ **({"note": note} if note else {}),
304
+ })
305
+ return {
306
+ "version": cls.TARGET_TABLE_BINDING_VERSION,
307
+ "source_manifest_version": str(record.get("version") or ""),
308
+ "language": code,
309
+ "tables": localized_rows,
310
+ }
311
+
312
+ @staticmethod
313
+ def _strip_target_quantitative_markdown_tables(text):
314
+ """Remove any model-rendered table blocks before manifest-owned rebinding."""
315
+ lines = str(text or "").splitlines()
316
+ kept = []
317
+ index = 0
318
+ caption_pattern = re.compile(
319
+ r"^\s*(?:Table\s+\d+|表\s*\d+|Таблиц(?:а|ы|е|у|ей|ами|ах)?\s*\d+|Кесте\s*\d+|\d+\s*[-–—]?\s*кесте)\s*[::.\-–—]",
320
+ flags=re.I,
321
+ )
322
+ note_pattern = re.compile(r"^\s*\*?(?:Note|注|Примечание|Ескертпе)\s*[::]", flags=re.I)
323
+ while index < len(lines):
324
+ line = lines[index]
325
+ if caption_pattern.match(line):
326
+ index += 1
327
+ while index < len(lines) and not lines[index].strip():
328
+ index += 1
329
+ if index < len(lines) and lines[index].lstrip().startswith("|"):
330
+ while index < len(lines) and lines[index].lstrip().startswith("|"):
331
+ index += 1
332
+ while index < len(lines) and not lines[index].strip():
333
+ index += 1
334
+ if index < len(lines) and note_pattern.match(lines[index]):
335
+ index += 1
336
+ continue
337
+ if line.lstrip().startswith("|"):
338
+ start = index
339
+ while index < len(lines) and lines[index].lstrip().startswith("|"):
340
+ index += 1
341
+ block = lines[start:index]
342
+ if len(block) >= 2 and re.search(r"-{3,}", block[1]):
343
+ continue
344
+ kept.extend(block)
345
+ continue
346
+ kept.append(line)
347
+ index += 1
348
+ return re.sub(r"\n{3,}", "\n\n", "\n".join(kept)).strip()
349
+
350
+ @classmethod
351
+ def bind_target_language_quantitative_tables(cls, translated_results, manifest, target_lang, glossary=None):
352
+ """Bind exactly Table 1-4 to one translated Results unit without model ownership.
353
+
354
+ This operation is deterministic and idempotent. Statistical values remain
355
+ source-manifest owned; only captions, generic statistical labels, notes,
356
+ and canonical construct surfaces are localized.
357
+ """
358
+ localized = cls.localize_quantitative_table_manifest(manifest, target_lang, glossary)
359
+ rows = [item for item in (localized.get("tables") or []) if isinstance(item, dict)]
360
+ if {int(row.get("number") or 0) for row in rows} != {1, 2, 3, 4}:
361
+ raise RuntimeError("target_quantitative_table_manifest_invalid")
362
+ code = cls._target_table_language_code(target_lang)
363
+ profile = cls._TARGET_TABLE_LOCALIZATION.get(code, {})
364
+ base = cls._strip_target_quantitative_markdown_tables(translated_results)
365
+ blocks = []
366
+ for row in rows:
367
+ number = int(row.get("number") or 0)
368
+ title = str(row.get("title") or "").strip()
369
+ markdown = str(row.get("markdown") or "").strip()
370
+ if not number or not title or not markdown:
371
+ raise RuntimeError(f"target_quantitative_table_manifest_invalid:Table {number}")
372
+ if code == "zh":
373
+ caption = f"表 {number}:{title}"
374
+ elif code == "ru":
375
+ caption = f"Таблица {number}: {title}"
376
+ elif code == "kk":
377
+ caption = f"{number}-кесте: {title}"
378
+ else:
379
+ caption = f"Table {number}: {title}"
380
+ block = caption + "\n" + markdown
381
+ note = str(row.get("note") or "").strip()
382
+ if note:
383
+ prefix = str(profile.get("note_prefix") or "Note:")
384
+ block += f"\n\n*{prefix} {note}*"
385
+ blocks.append(block)
386
+ return (base.rstrip() + "\n\n" + "\n\n".join(blocks)).strip()
387
+
388
  @staticmethod
389
  def generate_psd_corr_matrix(n_constructs, max_corr=0.5):
390
  if n_constructs == 1: