P0-R1.1-R2-R2 Shared Enumeration Scope Ownership

#72
Files changed (2) hide show
  1. core/generation_runner.py +3 -3
  2. core/pipeline_agent.py +245 -72
core/generation_runner.py CHANGED
@@ -3104,11 +3104,11 @@ Output only final academic prose. Do not report the word count."""
3104
  if mode == "asq" else "final_quantitative_study_fact_contract_failed"
3105
  )
3106
  raise RuntimeError(f"{prefix}:{section_title}") from exc
3107
- enum_checked, _enum_rows = agent.repair_and_validate_enumeration_contract(
 
 
3108
  sealed_section, section_name=f"Final sealed section: {section_title}"
3109
  )
3110
- if enum_checked.strip() != sealed_section.strip():
3111
- raise RuntimeError(f"final_enumeration_contract_failed:{section_title}")
3112
  # Validate the completed body while the Working Abstract is still in
3113
  # place. Final front matter must not be generated until all body
3114
  # sections, citation cleanup, and any Discussion rebalance are frozen.
 
3104
  if mode == "asq" else "final_quantitative_study_fact_contract_failed"
3105
  )
3106
  raise RuntimeError(f"{prefix}:{section_title}") from exc
3107
+ # Post-seal structural validation is pure validation. Never
3108
+ # invoke a mutating enumeration repair on frozen English text.
3109
+ agent.validate_enumeration_contract(
3110
  sealed_section, section_name=f"Final sealed section: {section_title}"
3111
  )
 
 
3112
  # Validate the completed body while the Working Abstract is still in
3113
  # place. Final front matter must not be generated until all body
3114
  # sections, citation cleanup, and any Discussion rebalance are frozen.
core/pipeline_agent.py CHANGED
@@ -3362,40 +3362,199 @@ INPUT:
3362
  passages.extend(re.findall(pattern, str(value)))
3363
  return [passage for passage in passages if len(passage.strip()) >= 8]
3364
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3365
  @classmethod
3366
  def _repair_local_discourse_after_citation_removal(cls, original, audited, section_name=''):
3367
- """Repair only enumeration/ordinal framing after fail-closed sentence removal."""
 
 
 
 
 
 
3368
  before = str(original or '')
3369
  value = str(audited or '')
3370
  if not value or value == before:
3371
  return value
3372
- ordinal_words = {'first':1, 'second':2, 'third':3, 'fourth':4, 'fifth':5, 'sixth':6}
3373
- ordinal_matches = list(re.finditer(r'\bThe\s+(first|second|third|fourth|fifth|sixth)\b', value, flags=re.I))
3374
- if len(ordinal_matches) < 2:
3375
- return value
3376
- nums = [ordinal_words[m.group(1).lower()] for m in ordinal_matches]
3377
- if nums == list(range(1, len(nums)+1)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3378
  return value
3379
- words = {1:'first',2:'second',3:'third',4:'fourth',5:'fifth',6:'sixth'}
3380
- index = 0
3381
- def repl(match):
3382
- nonlocal index
3383
- index += 1
3384
- return 'The ' + words[index]
3385
- repaired = re.sub(r'\bThe\s+(?:first|second|third|fourth|fifth|sixth)\b', repl, value, flags=re.I)
3386
- count_words = {'two':2,'three':3,'four':4,'five':5,'six':6}
3387
- reverse = {2:'two',3:'three',4:'four',5:'five',6:'six'}
3388
- def count_repl(match):
3389
- old_n = count_words.get(match.group(1).lower(), 0)
3390
- noun = match.group(2)
3391
- if old_n >= max(nums) and len(nums) in reverse:
3392
- return f"{reverse[len(nums)]} {noun}"
3393
- return match.group(0)
3394
- repaired = re.sub(
3395
- r'\b(two|three|four|five|six)\s+(process\s+domains?|mechanisms?|dimensions?|stages?|patterns?|components?)\b',
3396
- count_repl, repaired, flags=re.I,
3397
- )
3398
- return repaired
3399
 
3400
  @staticmethod
3401
  def _qual_source_family(value):
@@ -3688,59 +3847,73 @@ INPUT:
3688
  raise RuntimeError(f"qualitative_study_fact_contract_unrepaired:{section_name}:{diagnostics[:3]}")
3689
  return True
3690
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3691
  @classmethod
3692
  def repair_and_validate_enumeration_contract(cls, text, section_name=""):
3693
- """Repair only high-confidence ordinal shifts tied to an explicit count."""
 
 
 
 
 
 
3694
  value = str(text or "")
3695
- number_map = {"two":2, "three":3, "four":4, "five":5, "six":6, "2":2, "3":3, "4":4, "5":5, "6":6}
3696
- ordinal_map = {"first":1, "second":2, "third":3, "fourth":4, "fifth":5, "sixth":6}
3697
- reverse = {1:"first",2:"second",3:"third",4:"fourth",5:"fifth",6:"sixth"}
3698
- decl = re.compile(
3699
- r"\b(two|three|four|five|six|[2-6])\s+(?:provisional\s+)?"
3700
- r"(domains?|mechanisms?|dimensions?|stages?|patterns?|components?|themes?|processes?|conditions?)\b",
3701
- flags=re.I,
3702
- )
3703
- offset = 0
3704
  diagnostics = []
3705
- for match in list(decl.finditer(value)):
3706
- count = number_map[match.group(1).lower()]
3707
- start = match.end() + offset
3708
- next_heading = re.search(r"\n#{1,3}\s+", value[start:])
3709
- scope_end = start + min(3500, next_heading.start() if next_heading else 3500)
3710
- scope = value[start:scope_end]
3711
- ordinals = list(re.finditer(r"\bThe\s+(first|second|third|fourth|fifth|sixth)\b", scope, flags=re.I))
3712
- if len(ordinals) < count:
 
 
3713
  continue
3714
- selected = ordinals[:count]
3715
- nums = [ordinal_map[m.group(1).lower()] for m in selected]
3716
  expected = list(range(1, count + 1))
3717
  if nums == expected:
3718
  continue
3719
- consecutive = nums == list(range(nums[0], nums[0] + count))
3720
- if not consecutive:
3721
- raise RuntimeError(f"enumeration_contract_mismatch:{section_name}:declared={count}:ordinals={nums}")
3722
- scope_chars = list(scope)
3723
- for index, omatch in reversed(list(enumerate(selected, start=1))):
3724
- old = omatch.group(1)
3725
- new = reverse[index]
3726
- if old[:1].isupper():
3727
- new = new.capitalize()
3728
- a, b = omatch.start(1), omatch.end(1)
3729
- scope_chars[a:b] = list(new)
3730
- repaired_scope = "".join(scope_chars)
3731
- value = value[:start] + repaired_scope + value[scope_end:]
3732
- delta = len(repaired_scope) - len(scope)
3733
- offset += delta
3734
- diagnostics.append({"declared":count, "observed":nums, "repaired":expected})
3735
- # Validate the same high-confidence patterns after repair.
3736
- for match in decl.finditer(value):
3737
- count = number_map[match.group(1).lower()]
3738
- start = match.end()
3739
- next_heading = re.search(r"\n#{1,3}\s+", value[start:])
3740
- scope_end = start + min(3500, next_heading.start() if next_heading else 3500)
3741
- nums = [ordinal_map[m.group(1).lower()] for m in list(re.finditer(r"\bThe\s+(first|second|third|fourth|fifth|sixth)\b", value[start:scope_end], flags=re.I))[:count]]
3742
- if len(nums) >= count and nums != list(range(1, count + 1)):
3743
- raise RuntimeError(f"enumeration_contract_unresolved:{section_name}:declared={count}:ordinals={nums}")
3744
  return value, diagnostics
3745
 
3746
  @classmethod
 
3362
  passages.extend(re.findall(pattern, str(value)))
3363
  return [passage for passage in passages if len(passage.strip()) >= 8]
3364
 
3365
+ @staticmethod
3366
+ def _enumeration_declaration_pattern():
3367
+ return re.compile(
3368
+ r"\b(?P<count>two|three|four|five|six|[2-6])\s+(?:provisional\s+)?"
3369
+ r"(?P<noun>process\s+domains?|domains?|mechanisms?|dimensions?|stages?|patterns?|"
3370
+ r"components?|themes?|processes?|conditions?)\b",
3371
+ flags=re.I,
3372
+ )
3373
+
3374
+ @staticmethod
3375
+ def _enumeration_ordinal_pattern():
3376
+ return re.compile(
3377
+ r"\bThe\s+(?P<ordinal>first|second|third|fourth|fifth|sixth)\b"
3378
+ r"(?:\s+(?P<noun>process\s+domain|domain|mechanism|dimension|stage|pattern|"
3379
+ r"component|theme|process|condition)s?\b)?",
3380
+ flags=re.I,
3381
+ )
3382
+
3383
+ @staticmethod
3384
+ def _enumeration_noun_family(value):
3385
+ token = re.sub(r"\s+", " ", str(value or "")).strip().casefold()
3386
+ if "domain" in token:
3387
+ return "domain"
3388
+ for family in ("mechanism", "dimension", "stage", "pattern", "component", "theme", "process", "condition"):
3389
+ if family in token:
3390
+ return family
3391
+ return token
3392
+
3393
+ @classmethod
3394
+ def _enumeration_group_records(cls, text):
3395
+ """Return declaration-owned local ordinal runs without crossing list resets.
3396
+
3397
+ A declaration owns only the text before the next declaration/heading (and
3398
+ at most 3500 characters). Within that scope an ordinal reset/regression
3399
+ starts a different list, so ``first ... first ... second`` is not flattened
3400
+ into one synthetic enumeration. This is intentionally conservative.
3401
+ """
3402
+ value = str(text or "")
3403
+ decl = cls._enumeration_declaration_pattern()
3404
+ ordinal = cls._enumeration_ordinal_pattern()
3405
+ number_map = {"two":2, "three":3, "four":4, "five":5, "six":6,
3406
+ "2":2, "3":3, "4":4, "5":5, "6":6}
3407
+ ordinal_map = {"first":1, "second":2, "third":3, "fourth":4, "fifth":5, "sixth":6}
3408
+ declarations = list(decl.finditer(value))
3409
+ groups = []
3410
+ for index, match in enumerate(declarations):
3411
+ start = match.end()
3412
+ scope_end = min(len(value), start + 3500)
3413
+ if index + 1 < len(declarations):
3414
+ scope_end = min(scope_end, declarations[index + 1].start())
3415
+ heading = re.search(r"\n#{1,3}\s+", value[start:scope_end])
3416
+ if heading:
3417
+ scope_end = min(scope_end, start + heading.start())
3418
+ scope = value[start:scope_end]
3419
+ declared_family = cls._enumeration_noun_family(match.group("noun"))
3420
+ run = []
3421
+ previous_num = None
3422
+ for omatch in ordinal.finditer(scope):
3423
+ num = ordinal_map[omatch.group("ordinal").casefold()]
3424
+ explicit_family = cls._enumeration_noun_family(omatch.group("noun")) if omatch.group("noun") else ""
3425
+ if explicit_family and explicit_family != declared_family:
3426
+ if run:
3427
+ break
3428
+ # The first visible ordinal explicitly belongs to another
3429
+ # enumeration family, so this declaration has no owned run.
3430
+ break
3431
+ if previous_num is not None and num <= previous_num:
3432
+ # Restart/regression denotes a new/nested list. Never merge it
3433
+ # into the current declaration merely because it is nearby.
3434
+ break
3435
+ run.append({
3436
+ "match": omatch,
3437
+ "number": num,
3438
+ "word": omatch.group("ordinal"),
3439
+ "abs_start": start + omatch.start("ordinal"),
3440
+ "abs_end": start + omatch.end("ordinal"),
3441
+ })
3442
+ previous_num = num
3443
+ groups.append({
3444
+ "declaration": match,
3445
+ "immutable": cls._enumeration_context_is_immutable(value, match.start()),
3446
+ "count": number_map[match.group("count").casefold()],
3447
+ "count_surface": match.group("count"),
3448
+ "noun": match.group("noun"),
3449
+ "noun_family": declared_family,
3450
+ "scope_start": start,
3451
+ "scope_end": scope_end,
3452
+ "run": run,
3453
+ "numbers": [row["number"] for row in run],
3454
+ })
3455
+ return groups
3456
+
3457
+ @classmethod
3458
+ def _enumeration_context_is_immutable(cls, text, position):
3459
+ """Protect participant quotations and externally owned numbering."""
3460
+ value = str(text or "")
3461
+ pos = max(0, min(int(position or 0), len(value)))
3462
+ block_start = value.rfind("\n\n", 0, pos) + 2
3463
+ block_end = value.find("\n\n", pos)
3464
+ if block_end < 0:
3465
+ block_end = len(value)
3466
+ block = value[block_start:block_end].strip()
3467
+ if block.startswith(("\"", "“", ">")):
3468
+ return True
3469
+ for start, end, sentence in cls._sentence_spans(value):
3470
+ if start <= pos <= end:
3471
+ stripped = str(sentence or "").strip()
3472
+ if stripped.startswith(("\"", "“", "‘", ">")):
3473
+ return True
3474
+ if cls._has_external_literature_marker(sentence):
3475
+ return True
3476
+ if re.search(
3477
+ r"(?i)\b(?:prior|previous|earlier|existing)\s+(?:research|stud(?:y|ies)|literature)\b",
3478
+ str(sentence or ""),
3479
+ ):
3480
+ return True
3481
+ break
3482
+ return False
3483
+
3484
+ @staticmethod
3485
+ def _enumeration_count_surface(old_surface, new_count):
3486
+ words = {2:"two", 3:"three", 4:"four", 5:"five", 6:"six"}
3487
+ old = str(old_surface or "")
3488
+ if old.isdigit():
3489
+ return str(new_count)
3490
+ replacement = words.get(int(new_count), str(new_count))
3491
+ if old[:1].isupper():
3492
+ replacement = replacement.capitalize()
3493
+ return replacement
3494
+
3495
  @classmethod
3496
  def _repair_local_discourse_after_citation_removal(cls, original, audited, section_name=''):
3497
+ """Repair only a declaration-owned enumeration damaged by sentence removal.
3498
+
3499
+ The old implementation globally renumbered every ``The first/second/...``
3500
+ in the section, which could merge independent or nested lists. A count is
3501
+ now reduced only when the corresponding pre-audit declaration had a full
3502
+ local run and the post-audit run demonstrably lost one or more items.
3503
+ """
3504
  before = str(original or '')
3505
  value = str(audited or '')
3506
  if not value or value == before:
3507
  return value
3508
+ before_groups = cls._enumeration_group_records(before)
3509
+ after_groups = cls._enumeration_group_records(value)
3510
+ replacements = []
3511
+ used_before = set()
3512
+ reverse = {1:"first", 2:"second", 3:"third", 4:"fourth", 5:"fifth", 6:"sixth"}
3513
+ for after in after_groups:
3514
+ if after.get("immutable"):
3515
+ continue
3516
+ count = int(after["count"])
3517
+ run = list(after["run"])
3518
+ if not (2 <= len(run) < count):
3519
+ continue
3520
+ before_index = None
3521
+ before_group = None
3522
+ for idx, candidate in enumerate(before_groups):
3523
+ if idx in used_before:
3524
+ continue
3525
+ if (not candidate.get("immutable") and
3526
+ candidate["count"] == count and
3527
+ candidate["noun_family"] == after["noun_family"] and
3528
+ len(candidate["run"]) == count):
3529
+ before_index = idx
3530
+ before_group = candidate
3531
+ break
3532
+ if before_group is None:
3533
+ continue
3534
+ # Require a complete pre-audit local run. This proves that citation
3535
+ # removal, rather than an originally incomplete/nested list, caused
3536
+ # the missing ordinal item(s).
3537
+ if before_group["numbers"] != list(range(1, count + 1)):
3538
+ continue
3539
+ used_before.add(before_index)
3540
+ new_count = len(run)
3541
+ decl_match = after["declaration"]
3542
+ replacements.append((
3543
+ decl_match.start("count"), decl_match.end("count"),
3544
+ cls._enumeration_count_surface(after["count_surface"], new_count),
3545
+ ))
3546
+ for new_num, row in enumerate(run, start=1):
3547
+ old_word = row["word"]
3548
+ new_word = reverse[new_num]
3549
+ if old_word[:1].isupper():
3550
+ new_word = new_word.capitalize()
3551
+ if new_word.casefold() != old_word.casefold():
3552
+ replacements.append((row["abs_start"], row["abs_end"], new_word))
3553
+ if not replacements:
3554
  return value
3555
+ for start, end, replacement in sorted(replacements, reverse=True):
3556
+ value = value[:start] + replacement + value[end:]
3557
+ return value
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3558
 
3559
  @staticmethod
3560
  def _qual_source_family(value):
 
3847
  raise RuntimeError(f"qualitative_study_fact_contract_unrepaired:{section_name}:{diagnostics[:3]}")
3848
  return True
3849
 
3850
+ @classmethod
3851
+ def validate_enumeration_contract(cls, text, section_name=""):
3852
+ """Validate only unambiguous declaration-owned local enumeration runs.
3853
+
3854
+ Incomplete, nested, restarted, or adjacent independent lists are not a
3855
+ whole-manuscript failure condition. They are deliberately outside the
3856
+ deterministic repair boundary because ownership is ambiguous.
3857
+ """
3858
+ for group in cls._enumeration_group_records(text):
3859
+ if group.get("immutable"):
3860
+ continue
3861
+ count = int(group["count"])
3862
+ nums = list(group["numbers"])
3863
+ if len(nums) != count:
3864
+ continue
3865
+ expected = list(range(1, count + 1))
3866
+ if nums != expected:
3867
+ raise RuntimeError(
3868
+ f"enumeration_contract_unresolved:{section_name}:"
3869
+ f"owner={group['noun_family']}:declared={count}:ordinals={nums}"
3870
+ )
3871
+ return True
3872
+
3873
  @classmethod
3874
  def repair_and_validate_enumeration_contract(cls, text, section_name=""):
3875
+ """Repair only a complete, declaration-owned local ordinal run.
3876
+
3877
+ A run may be renumbered by order only when exactly the declared number
3878
+ of strictly increasing ordinal markers belongs to that declaration.
3879
+ Resets such as ``[1, 1, 2]`` mark a second/nested list and are therefore
3880
+ non-blocking rather than flattened into one synthetic sequence.
3881
+ """
3882
  value = str(text or "")
3883
+ reverse = {1:"first", 2:"second", 3:"third", 4:"fourth", 5:"fifth", 6:"sixth"}
 
 
 
 
 
 
 
 
3884
  diagnostics = []
3885
+ replacements = []
3886
+ for group in cls._enumeration_group_records(value):
3887
+ if group.get("immutable"):
3888
+ continue
3889
+ count = int(group["count"])
3890
+ run = list(group["run"])
3891
+ nums = list(group["numbers"])
3892
+ if len(run) != count:
3893
+ # Ambiguous/incomplete/multiple-list scope: preserve authorial
3894
+ # prose and let later reader-facing QA treat it as diagnostic.
3895
  continue
 
 
3896
  expected = list(range(1, count + 1))
3897
  if nums == expected:
3898
  continue
3899
+ # _enumeration_group_records stops at any reset/regression, so a
3900
+ # complete run here is strictly increasing and safe to renumber by
3901
+ # local order (e.g. 3/4/5 -> 1/2/3).
3902
+ for new_num, row in enumerate(run, start=1):
3903
+ old_word = row["word"]
3904
+ new_word = reverse[new_num]
3905
+ if old_word[:1].isupper():
3906
+ new_word = new_word.capitalize()
3907
+ if new_word.casefold() != old_word.casefold():
3908
+ replacements.append((row["abs_start"], row["abs_end"], new_word))
3909
+ diagnostics.append({
3910
+ "declared": count, "owner": group["noun_family"],
3911
+ "observed": nums, "repaired": expected,
3912
+ })
3913
+ if replacements:
3914
+ for start, end, replacement in sorted(replacements, reverse=True):
3915
+ value = value[:start] + replacement + value[end:]
3916
+ cls.validate_enumeration_contract(value, section_name=section_name)
 
 
 
 
 
 
 
3917
  return value, diagnostics
3918
 
3919
  @classmethod