Skip to content

Commit b2c086d

Browse files
fluffy314cursoragent
authored andcommitted
fix(autoresearch): reject semantic restatement children
Compare normalized premise/conclusion structures across the complete ancestor chain, require full Generator coverage and explicit semantic deltas, and retroactively reject duplicate subtrees. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 941b401 commit b2c086d

5 files changed

Lines changed: 314 additions & 11 deletions

File tree

autoresearch/prefill/program.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ be falsifiable. A rejected cyclic frontier is `INCONCLUSIVE`, not progress.
7979

8080
`DECOMPOSED` is keepable only when the host actually persisted at least one
8181
new child that passed the ID, novelty, cycle, and falsifiability gates.
82+
Generator coverage must be complete. Before persistence, alpha-normalize
83+
mathematical variables, extract premise/conclusion structure, compare the child
84+
against every ancestor in both entailment directions, and require an explicit
85+
new assumption, narrower domain, or falsifiable conclusion. Existing semantic
86+
duplicates and all descendants beneath them are retained for audit but marked
87+
`REJECTED_DUPLICATE`; they are not pending leaves and do not reset stagnation.
8288

8389
Do not optimize output wording, scores, prizes, or other proof-irrelevant
8490
content. Prefill performance is a tertiary objective after mathematical

autoresearch/prefill/supervisor.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -922,6 +922,10 @@ def build_host_candidate(current: dict, ledger: dict) -> dict:
922922
def should_keep(result: dict, baseline: dict | None) -> bool:
923923
if not result["accepted"]:
924924
return False
925+
if int(result.get("proof_obligations_covered", 0)) != int(
926+
result.get("proof_obligations_total", 0),
927+
):
928+
return False
925929
outcome = result.get("research_outcome")
926930
if outcome not in {"SUPPORTED", "FALSIFIED", "DECOMPOSED"}:
927931
return False

scripts/agent_gan_repl.py

Lines changed: 254 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,26 @@ def format_proof_ledger(
246246
obligations: list[ProofObligation] | None = None,
247247
) -> str:
248248
selected = obligations if obligations is not None else pending_obligations(ledger)
249+
ancestry = []
250+
if len(selected) == 1:
251+
by_id = {
252+
item.obligation_id: item
253+
for item in ledger.obligations
254+
}
255+
cursor = selected[0].parent_id
256+
visited = set()
257+
while cursor and cursor not in visited:
258+
visited.add(cursor)
259+
ancestor = by_id.get(cursor)
260+
if ancestor is None:
261+
break
262+
ancestry.append(ancestor)
263+
cursor = ancestor.parent_id
264+
ancestry.reverse()
265+
ancestry_text = "\n".join(
266+
f"- {item.obligation_id}: {item.statement}"
267+
for item in ancestry
268+
)
249269
items = "\n".join(
250270
f"- {item.obligation_id}"
251271
f"{f' (parent={item.parent_id})' if item.parent_id else ''}: "
@@ -254,7 +274,9 @@ def format_proof_ledger(
254274
)
255275
return (
256276
f"PROOF OBLIGATION LEDGER id={ledger.ledger_id} "
257-
f"version={ledger.version}\n{items}\n"
277+
f"version={ledger.version}\n"
278+
f"COMPLETE ANCESTOR CHAIN:\n{ancestry_text or '(root target)'}\n"
279+
f"CURRENT TARGET:\n{items}\n"
258280
"Generator requirement: emit `### ISSUE_RESPONSE <ID>` for every "
259281
"pending ID, with `Correction:`, `Derivation:`, and `Remaining gap:`. "
260282
"Critic requirement: emit `### ISSUE_VERDICT <ID>` for every pending "
@@ -411,6 +433,144 @@ def _obligation_terms(statement: str) -> set[str]:
411433
}
412434

413435

436+
def _canonical_claim(statement: str) -> str:
437+
text = _normalize_obligation_statement(statement)
438+
replacements = (
439+
(r"\blocal accumulation rate\b|\blocal density\b", "local_density"),
440+
(
441+
r"\bglobal exponent of convergence\b|\bglobal growth order\b|"
442+
r"\bglobal order\b|\bgrowth order\b|\bglobal growth\b",
443+
"global_order",
444+
),
445+
(
446+
r"\blower bound\b|\bimposes a lower bound\b|\bmust satisfy\b|"
447+
r"\bforces\b|\bforce\b",
448+
"implies_bound",
449+
),
450+
(r"\bcritical density\b|\bdensity threshold\b", "density_threshold"),
451+
(r"\baccumulation\b|\bclump(?:ing)?\b", "concentration"),
452+
(r"\bsingularity\b|\bpole\b", "singularity"),
453+
(r"\bsequence of zeros\b|\bzero sequence\b", "zero_sequence"),
454+
(r"\bfunction\b|\bfunctional relationship\b", "mapping"),
455+
(r"\bequivalence\b|\bsaturation\b|\bgap\b", "relation"),
456+
)
457+
for pattern, replacement in replacements:
458+
text = re.sub(pattern, replacement, text)
459+
# Alpha-normalize mathematical variable names while preserving operators
460+
# and semantic nouns. This makes rho/delta/lambda renamings comparable.
461+
text = re.sub(
462+
r"\b(?:rho|delta|lambda|epsilon|phi|sigma|p|m|s|z|r|n)\d*\b",
463+
"var",
464+
text,
465+
)
466+
return " ".join(text.split())
467+
468+
469+
def _claim_structure(statement: str) -> tuple[set[str], set[str]]:
470+
canonical = _canonical_claim(statement)
471+
markers = (
472+
" without implies_bound ",
473+
" then ",
474+
" implies_bound ",
475+
" such that ",
476+
" implies ",
477+
)
478+
split_at = -1
479+
marker_size = 0
480+
for marker in markers:
481+
position = canonical.find(marker)
482+
if position >= 0 and (split_at < 0 or position < split_at):
483+
split_at = position
484+
marker_size = len(marker)
485+
if split_at < 0:
486+
terms = set(canonical.split())
487+
return terms, terms
488+
premise = set(canonical[:split_at].split())
489+
conclusion = set(canonical[split_at + marker_size:].split())
490+
return premise, conclusion
491+
492+
493+
def _semantic_concepts(statement: str) -> set[str]:
494+
text = _canonical_claim(statement)
495+
concepts = set()
496+
checks = {
497+
"density": ("density", "concentration"),
498+
"global_order": ("global_order",),
499+
"singularity": ("singularity", "residue"),
500+
"genus_or_order": ("genus", " order "),
501+
"convergence": ("converge", "diverge", "limit"),
502+
"zero_sequence": ("zero_sequence", "zeros"),
503+
"threshold_or_bound": ("threshold", "bound", "above", "below"),
504+
"local_global_relation": ("local", "global"),
505+
}
506+
padded = f" {text} "
507+
for concept, needles in checks.items():
508+
if all(needle in padded for needle in needles) if (
509+
concept == "local_global_relation"
510+
) else any(needle in padded for needle in needles):
511+
concepts.add(concept)
512+
return concepts
513+
514+
515+
def _semantic_equivalence(left: str, right: str) -> tuple[bool, float]:
516+
left_canonical = _canonical_claim(left)
517+
right_canonical = _canonical_claim(right)
518+
left_terms = set(left_canonical.split())
519+
right_terms = set(right_canonical.split())
520+
union = left_terms | right_terms
521+
jaccard = (
522+
len(left_terms & right_terms) / len(union)
523+
if union else 1.0
524+
)
525+
sequence = difflib.SequenceMatcher(
526+
None,
527+
left_canonical,
528+
right_canonical,
529+
).ratio()
530+
left_premise, left_conclusion = _claim_structure(left)
531+
right_premise, right_conclusion = _claim_structure(right)
532+
533+
def overlap(first: set[str], second: set[str]) -> float:
534+
denominator = max(1, min(len(first), len(second)))
535+
return len(first & second) / denominator
536+
537+
structural = min(
538+
overlap(left_premise, right_premise),
539+
overlap(left_conclusion, right_conclusion),
540+
)
541+
left_concepts = _semantic_concepts(left)
542+
right_concepts = _semantic_concepts(right)
543+
concept_overlap = overlap(left_concepts, right_concepts)
544+
score = max(jaccard, sequence, structural, concept_overlap)
545+
equivalent = (
546+
(jaccard >= 0.52 and structural >= 0.60)
547+
or sequence >= 0.68
548+
or structural >= 0.78
549+
or (
550+
min(len(left_concepts), len(right_concepts)) >= 4
551+
and concept_overlap >= 0.80
552+
)
553+
)
554+
return equivalent, score
555+
556+
557+
def _child_has_structural_delta(parent: str, child: str) -> bool:
558+
parent_premise, parent_conclusion = _claim_structure(parent)
559+
child_premise, child_conclusion = _claim_structure(child)
560+
new_premise = child_premise - parent_premise
561+
new_conclusion = child_conclusion - parent_conclusion
562+
concrete = {
563+
"compact", "counterexample", "exists", "fixed", "forall", "limsup",
564+
"neighborhood", "explicit", "boundary", "constant", "inequality",
565+
"converges", "diverges", "residue", "genus",
566+
}
567+
return bool(
568+
len(new_premise) >= 3
569+
or len(new_conclusion) >= 3
570+
or (child_premise | child_conclusion) & concrete
571+
)
572+
573+
414574
def _frontier_rejection_reason(
415575
ledger: ProofObligationLedger,
416576
parent_id: str,
@@ -435,8 +595,27 @@ def _frontier_rejection_reason(
435595
existing_signature = _lemma_signature(item.statement)
436596
if signature and signature == existing_signature:
437597
return f"repeats existing lemma {item.obligation_id}"
598+
if item.obligation_id in ancestors:
599+
equivalent, score = _semantic_equivalence(
600+
item.statement,
601+
statement,
602+
)
603+
if equivalent:
604+
return (
605+
f"bidirectionally entails ancestor {item.obligation_id} "
606+
f"after variable normalization (score={score:.2f})"
607+
)
438608
if len(terms) < 6:
439609
return "frontier is too vague to be a falsifiable smaller obligation"
610+
parent = next(
611+
item for item in ledger.obligations
612+
if item.obligation_id == parent_id
613+
)
614+
if not _child_has_structural_delta(parent.statement, statement):
615+
return (
616+
"does not declare a new assumption, narrower domain, or "
617+
"falsifiable conclusion"
618+
)
440619
for item in ledger.obligations:
441620
existing_terms = _obligation_terms(item.statement)
442621
union = terms | existing_terms
@@ -450,6 +629,51 @@ def _frontier_rejection_reason(
450629
return ""
451630

452631

632+
def audit_ledger_semantic_duplicates(
633+
ledger: ProofObligationLedger,
634+
) -> list[tuple[str, str, float]]:
635+
by_id = {
636+
item.obligation_id: item
637+
for item in ledger.obligations
638+
}
639+
rejected: list[tuple[str, str, float]] = []
640+
rejected_ids = set()
641+
for item in ledger.obligations:
642+
if item.status != "UNRESOLVED" or not item.parent_id:
643+
continue
644+
cursor = item.parent_id
645+
visited = set()
646+
duplicate_of = ""
647+
duplicate_score = 0.0
648+
while cursor and cursor not in visited:
649+
visited.add(cursor)
650+
ancestor = by_id.get(cursor)
651+
if ancestor is None:
652+
break
653+
equivalent, score = _semantic_equivalence(
654+
ancestor.statement,
655+
item.statement,
656+
)
657+
if equivalent:
658+
duplicate_of = ancestor.obligation_id
659+
duplicate_score = score
660+
break
661+
cursor = ancestor.parent_id
662+
if duplicate_of or item.parent_id in rejected_ids:
663+
duplicate_of = duplicate_of or item.parent_id
664+
item.status = "REJECTED_DUPLICATE"
665+
item.last_evidence = (
666+
f"Semantic duplicate/cyclic descendant of {duplicate_of}."
667+
)
668+
rejected_ids.add(item.obligation_id)
669+
rejected.append(
670+
(item.obligation_id, duplicate_of, duplicate_score),
671+
)
672+
if rejected:
673+
ledger.version += 1
674+
return rejected
675+
676+
453677
def create_child_obligations(
454678
ledger: ProofObligationLedger,
455679
critic_text: str,
@@ -1244,6 +1468,19 @@ def get_stats():
12441468
if critic_issue_batch is not None else ""
12451469
)
12461470
proof_ledger = load_proof_ledger(proof_ledger_path)
1471+
semantic_rejections = (
1472+
audit_ledger_semantic_duplicates(proof_ledger)
1473+
if proof_ledger is not None else []
1474+
)
1475+
if proof_ledger is not None and semantic_rejections:
1476+
save_proof_ledger(proof_ledger_path, proof_ledger)
1477+
for obligation_id, ancestor_id, score in semantic_rejections:
1478+
print(
1479+
"[proof-obligation-retro-rejected] "
1480+
f"id={obligation_id} duplicate_of={ancestor_id} "
1481+
f"score={score:.2f}",
1482+
flush=True,
1483+
)
12471484
turn_obligations = pending_obligations(proof_ledger)
12481485
if research_candidate is not None and turn_obligations:
12491486
target_id = str(research_candidate.TARGET_OBLIGATION_ID)
@@ -1504,16 +1741,22 @@ def get_stats():
15041741
},
15051742
id_repairs,
15061743
)
1507-
created_obligations = create_child_obligations(
1508-
proof_ledger,
1509-
critic_text,
1510-
run_id,
1511-
{
1512-
item.obligation_id
1513-
for item in turn_obligations
1514-
},
1515-
rejected_frontiers,
1516-
)
1744+
if missing_issues:
1745+
rejected_frontiers.append(
1746+
"Generator coverage incomplete; child creation "
1747+
f"forbidden for {','.join(sorted(missing_issues))}",
1748+
)
1749+
else:
1750+
created_obligations = create_child_obligations(
1751+
proof_ledger,
1752+
critic_text,
1753+
run_id,
1754+
{
1755+
item.obligation_id
1756+
for item in turn_obligations
1757+
},
1758+
rejected_frontiers,
1759+
)
15171760
for model_id, target_id in id_repairs:
15181761
print(
15191762
"[critic-id-repaired] "

tests/inference_engine/bench/test_autoresearch_supervisor.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,16 @@ def test_keep_requires_novel_mathematical_advancement():
240240
"proof_obligations_unresolved": 5,
241241
"metric_cold_critic_prefill_s": 1,
242242
}, baseline)
243+
assert not should_keep({
244+
"accepted": True,
245+
"research_outcome": "SUPPORTED",
246+
"created_obligation_ids": [],
247+
"hypothesis_novel": True,
248+
"proof_obligations_total": 1,
249+
"proof_obligations_covered": 0,
250+
"proof_obligations_unresolved": 4,
251+
"metric_cold_critic_prefill_s": 1,
252+
}, baseline)
243253
assert not should_keep({
244254
"accepted": True,
245255
"research_outcome": "INCONCLUSIVE",

0 commit comments

Comments
 (0)