Spaces:
Running
Running
P0-R1.1-R2-R2 Shared Enumeration Scope Ownership
#72
by GuXiaopeng - opened
- core/generation_runner.py +3 -3
- 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 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3368 |
before = str(original or '')
|
| 3369 |
value = str(audited or '')
|
| 3370 |
if not value or value == before:
|
| 3371 |
return value
|
| 3372 |
-
|
| 3373 |
-
|
| 3374 |
-
|
| 3375 |
-
|
| 3376 |
-
|
| 3377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3378 |
return value
|
| 3379 |
-
|
| 3380 |
-
|
| 3381 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3694 |
value = str(text or "")
|
| 3695 |
-
|
| 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 |
-
|
| 3706 |
-
|
| 3707 |
-
|
| 3708 |
-
|
| 3709 |
-
|
| 3710 |
-
|
| 3711 |
-
|
| 3712 |
-
if len(
|
|
|
|
|
|
|
| 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 |
-
|
| 3720 |
-
|
| 3721 |
-
|
| 3722 |
-
|
| 3723 |
-
|
| 3724 |
-
|
| 3725 |
-
|
| 3726 |
-
|
| 3727 |
-
|
| 3728 |
-
|
| 3729 |
-
|
| 3730 |
-
|
| 3731 |
-
|
| 3732 |
-
|
| 3733 |
-
|
| 3734 |
-
|
| 3735 |
-
|
| 3736 |
-
|
| 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
|