Skip to content

Commit 61e7327

Browse files
sfwclaude
andcommitted
synthesis: Phase 6 — tournament-ranked Best-of-N + Inspirations section
Phase 6 of the self-evolving verifier (first phase derived from external public systems rather than from CE's own register, though internally aligned with r-bd1386df + r-c2ee5c80). When a high-novelty cross-reference is promoted to an Insight, the synthesize step now generates N divergent candidate insights instead of a single one. Each candidate is canonicalized (Stage 1), scored by alias-gap to the existing register, and the candidate with the largest gap wins (ties within ±0.02 broken by self-reported confidence). Pushes the generator toward structurally distinct candidates instead of converging on the local attractor — directly addresses the failure mode the recent reverify exposed (17/18 dark entries downgrading to extension because the journal converged on a narrow architectural cluster). Implementation: - New SYNTHESIZE_VARIANTS_PROMPT asks for {N} candidates, each with an explicit divergence_axis. Single LLM call, batch output. - New _select_synthesis_candidate() canonicalizes each candidate using the existing _canonicalize_central_move (cross-mixin call resolves via engine MRO) and computes alias-gap via existing _alias_gap. - New _build_and_persist_insight() factors out the common Insight construction so single-candidate (pre-Phase-6, count==1) and Best-of-N paths share construction code. - New synthesis_candidate_count config knob (default 3, range 1-10). Setting to 1 disables Phase 6 entirely (single-candidate behavior). - Settings UI knob; CLI honors via engine.toml. Cost: synthesis_candidate_count × synthesis call per qualifying xref. Default 3× synthesis cost. Bounded — only fires above novelty_threshold. Borrows tournament-ranking pattern from Google's AI Co-Scientist (Generation / Reflection / Ranking / Evolution agents) + standard agentic best-of-N selection. README: - Add "Inspirations and prior work" section. Distinguishes Phases 1-5 (derived from CE's own validated register, the literal self-evolution claim) from Phases 6+ (borrowed from public research-agent systems where the borrow fits cleanly). Each Phase 6+ borrow names its source: Co-Scientist, Sakana AI Scientist, Stanford STORM, Tree of Thoughts. Acknowledges general agentic patterns (Claude Code, cross-family verification, RAG) and academic lineage (Popperian epistemology, Pareto theory, prediction markets). Lists the specific combinations that appear genuinely novel (canonical-form alias-gap, literature-watch leakage check, freshness probe, append-only reverification audit, self-evolving-from-own-register). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6fa50b0 commit 61e7327

6 files changed

Lines changed: 245 additions & 5 deletions

File tree

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,59 @@ python curiosity_engine.py --backfill-canonical-forms
220220

221221
---
222222

223+
## Inspirations and prior work
224+
225+
Two distinct sources have shaped CE's architecture, and they're worth distinguishing.
226+
227+
### Phases 1-5 came from CE's own validated register
228+
229+
The five phases of self-evolution we shipped are not borrowed from external systems — they were derived from validated insights *in the engine's own ideation_on_ideation journal*. Each phase has a source register entry:
230+
231+
- **Phase 1** (canonicalization + alias-gap) ← `r-c67457` (conf 0.82)
232+
- **Phase 2** (three-stage verifier) ← `r-fcbba1` (conf 0.81)
233+
- **Phase 3** (component-resolved novelty) ← `r-bedce5b1` (conf 0.79)
234+
- **Phase 4** (Pareto admission) ← `r-46988c97` (conf 0.78)
235+
- **Phase 5** (explore/verify space split) ← `r-9a35e387` (conf 0.77)
236+
237+
That is the literal self-evolution claim: the engine identified architectural patterns its own verifier should adopt, and we applied those patterns to the verifier.
238+
239+
### Phases 6+ borrow from comparable public systems
240+
241+
Once the verifier side stabilized, we audited public research-agent systems for ideas the engine could borrow on the *generator* side. Each subsequent phase credits its inspiration:
242+
243+
- **Phase 6** (Best-of-N synthesis with alias-gap ranking) — borrows the **tournament-ranking** pattern from **[Google's AI Co-Scientist](https://research.google/blog/accelerating-scientific-breakthroughs-with-an-ai-co-scientist/)** (Generation / Reflection / Ranking / Evolution agents) plus standard agentic best-of-N selection. Internally aligns with `r-bd1386df` ("cascading structured pairwise tournaments") and `r-c2ee5c80` ("two-player adversarial game over a library") from CE's own register.
244+
- **Phase 7** (persona-conditioned introspection, planned) — borrows multi-perspective question generation from **[Stanford STORM / Co-STORM](https://github.com/stanford-oval/storm)**. Each persona (skeptic / outsider / historian / contrarian / practitioner) surfaces blind spots the single-voice introspection misses.
245+
- **Phase 8** (idea evolution from downgraded extensions, planned) — borrows the mutation loop from **[Sakana AI's "AI Scientist"](https://github.com/SakanaAI/AI-Scientist)** and the Evolution agent from Co-Scientist. Internally aligns with `r-3c792e21` ("typed supervision from false positives via retrospective unification") — we treat verifier downgrades as typed supervision signal for generator-side mutation.
246+
- **Phase 9** (hypothesis variants in investigation, planned) — borrows branching exploration from **[Tree of Thoughts](https://arxiv.org/abs/2305.10601)**. The explorer persona generates N divergent priors; the most-distant-from-majority-literature variant drives the investigation.
247+
248+
### General agentic patterns CE builds on
249+
250+
- **[Claude Code](https://claude.com/claude-code) and the Claude API** — the directive's `Agentic Prompt` block is structured to be pasted directly into Claude Code, MCP orchestrators, or similar LLM-driven agents. The grounding allowlists + tool-call discipline borrow from established agentic patterns.
251+
- **Cross-family adversarial verification** — the principle that a model evaluating its own output produces no signal is broadly understood; using a different-family model as verifier is standard practice in multi-agent systems. CE's contribution is the *append-only audit trail* + *mechanical guards* on top of cross-family verification, not the cross-family idea itself.
252+
- **Retrieval-augmented generation patterns**`academic_search` + `web_fetch` + `archive_access` are standard RAG plumbing.
253+
254+
### Academic / methodological lineage
255+
256+
- **Hypothesis-first investigation** is broadly Popperian / falsificationist epistemology applied to LLM tool use. The mechanical surprise comparison is standard Bayesian-update structure.
257+
- **Pareto-dominance admission** (Phase 4) is multi-objective optimization theory applied to a register-admission gate.
258+
- **Negative-space mapping** (the `(method × problem)` matrix) is a long-standing literature-review discipline; CE just instruments it.
259+
- **Falsifiable predictions with target dates** is descended from prediction-market and forecasting-literature practice (Tetlock, Good Judgement Project, Metaculus).
260+
261+
### Where CE is genuinely novel
262+
263+
Some specific combinations don't appear (to my knowledge) in any public system:
264+
265+
- **Append-only audit trails** with `reverification_log` — most systems mutate state on re-evaluation.
266+
- **Self-evolving verifier where the engine's own validated insights have been applied to its own architecture** — Phases 1-5 above.
267+
- **Canonical-form alias-gap detection over a research register** — Phase 1's structural similarity check on `(predicate, substrate, mechanism, target_domain, key_constraints)` tuples is not a pattern I've seen in published agentic systems.
268+
- **Pareto admission gate over multi-axis register entries** — Phase 4's tournament between *existing* register entries and incoming candidates.
269+
- **Literature-watch leakage check on directive verification criteria** — preventing the directive from outsourcing its evidence to "by date X a paper appears."
270+
- **Freshness probe at prediction creation time** — closing the dead-on-arrival case where a "prediction" was already true at creation.
271+
272+
If you're aware of a system that does any of those, please open an issue — I'd want to learn from how they handled it.
273+
274+
---
275+
223276
## How novelty is verified — the technical detail
224277

225278
### The problem the verifier is built around

config.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,17 @@ class EngineSettings:
180180
# can't see.
181181
# Phase 4 of the self-evolving verifier.
182182
register_admission_mode: str = "scalar"
183+
# Phase 6 of the self-evolving verifier: tournament-ranked Best-of-N
184+
# synthesis. When a high-novelty xref is promoted to an Insight, the
185+
# synthesize step generates this many candidate insights, canonicalizes
186+
# each, and selects the candidate with the largest alias-gap to the
187+
# existing register (ties broken by self-reported confidence).
188+
# Default 3; minimum effective value is 2 (1 means single-candidate, no
189+
# tournament). Higher values trade more LLM cost for more divergent
190+
# selection. Cost scales linearly: candidate_count × synthesis call.
191+
# Borrows from Co-Scientist's Ranking agent + standard agentic
192+
# best-of-N selection patterns.
193+
synthesis_candidate_count: int = 3
183194
# Questions below this priority are rejected at enqueue time (except
184195
# human-sourced questions, which always bypass). Default 0.0 = disabled.
185196
# An earlier default of 0.70 was found to starve new journals — early
@@ -348,6 +359,9 @@ def load(cls, path: Path = CONFIG_PATH) -> CuriosityEngineConfig:
348359
register_admission_mode=str(
349360
eng_section.get("register_admission_mode", "scalar")
350361
).strip().lower() or "scalar",
362+
synthesis_candidate_count=max(
363+
1, int(eng_section.get("synthesis_candidate_count", 3))
364+
),
351365
held_entries_enabled=bool(eng_section.get("held_entries_enabled", True)),
352366
held_confidence_floor=float(eng_section.get("held_confidence_floor", 0.7)),
353367
cross_ref_role=str(eng_section.get("cross_ref_role", "")).strip(),
@@ -722,6 +736,12 @@ def _build_toml(
722736
# × inverse_alias_gap). Pareto rejects "just like X but slightly worse on
723737
# every axis" admissions that the scalar floor can't see.
724738
register_admission_mode = "{eng.register_admission_mode}"
739+
# Phase 6 — Best-of-N synthesis tournament. When a high-novelty xref is
740+
# promoted to an Insight, generate this many candidates and select the one
741+
# with the largest alias-gap to the existing register (ties broken by
742+
# self-reported confidence). 1 = single-candidate (Phase-6-disabled,
743+
# matches pre-Phase-6 behavior). 3 = default. Linear LLM cost scaling.
744+
synthesis_candidate_count = {eng.synthesis_candidate_count}
725745
# Held-state pipeline — when the verifier returns `inconclusive` (couldn't reach
726746
# the claim, not refuted it), insights become held register entries pending
727747
# settlement rather than being silently rejected. Held entries usually require

engine/cross_reference.py

Lines changed: 121 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from uuid import uuid4
1010

1111
from models import CrossReference, Insight
12-
from prompts import CROSS_REFERENCE_PROMPT, SYNTHESIZE_PROMPT
12+
from prompts import CROSS_REFERENCE_PROMPT, SYNTHESIZE_PROMPT, SYNTHESIZE_VARIANTS_PROMPT
1313

1414

1515
class CrossReferenceMixin:
@@ -215,18 +215,134 @@ def synthesize_orphaned_xrefs(self) -> dict:
215215
return stats
216216

217217
def synthesize(self, xref: CrossReference) -> Optional[Insight]:
218+
"""Synthesize an Insight from a cross-reference.
219+
220+
Phase 6 of the self-evolving verifier: when configured for
221+
candidate_count > 1, generate N divergent candidate insights,
222+
canonicalize each, and select the candidate with the largest
223+
alias-gap to the existing register. Ties broken by self-reported
224+
confidence. Borrows from Co-Scientist's Ranking agent + agentic
225+
best-of-N selection. Falls back to single-candidate synthesis
226+
when synthesis_candidate_count == 1 (pre-Phase-6 behavior).
227+
"""
218228
print("\n--- SYNTHESIZING INSIGHT ---")
219229

220230
supporting = [e for e in self.journal.entries if e["id"] in xref.source_entries]
221231

222-
prompt = SYNTHESIZE_PROMPT.format(
232+
candidate_count = max(
233+
1, int(getattr(self.connection.engine, "synthesis_candidate_count", 3)),
234+
)
235+
236+
if candidate_count <= 1:
237+
# Pre-Phase-6 path: single candidate, no tournament.
238+
prompt = SYNTHESIZE_PROMPT.format(
239+
focus_block=self._focus_block(),
240+
xref_json=json.dumps(asdict(xref), indent=2),
241+
supporting_entries_json=json.dumps(supporting, indent=2),
242+
)
243+
result = self._call_primary(prompt)
244+
return self._build_and_persist_insight(result, xref, divergence_axis="")
245+
246+
# Phase 6: tournament-ranked Best-of-N.
247+
prompt = SYNTHESIZE_VARIANTS_PROMPT.format(
223248
focus_block=self._focus_block(),
224249
xref_json=json.dumps(asdict(xref), indent=2),
225250
supporting_entries_json=json.dumps(supporting, indent=2),
251+
candidate_count=candidate_count,
252+
)
253+
try:
254+
result = self._call_primary(prompt)
255+
except Exception as e: # noqa: BLE001
256+
print(f" [error] variant synthesis failed: {type(e).__name__}: {e}")
257+
return None
258+
259+
candidates = list(result.get("candidates") or [])
260+
if not candidates:
261+
print(" [warn] variant synthesis returned no candidates")
262+
return None
263+
264+
print(f" [tournament] {len(candidates)} candidate(s) generated; scoring by alias-gap…")
265+
winner = self._select_synthesis_candidate(candidates, xref)
266+
if winner is None:
267+
print(" [warn] tournament selected no winner — using highest-confidence candidate as fallback")
268+
winner = max(
269+
candidates, key=lambda c: float(c.get("confidence", 0) or 0),
270+
)
271+
272+
return self._build_and_persist_insight(
273+
winner, xref, divergence_axis=(winner.get("divergence_axis") or "").strip(),
226274
)
227275

228-
result = self._call_primary(prompt)
276+
def _select_synthesis_candidate(
277+
self, candidates: list[dict], xref: CrossReference,
278+
) -> Optional[dict]:
279+
"""Tournament selector: canonicalize each candidate, score by
280+
alias-gap to the existing register, return the most-distant
281+
candidate. Confidence breaks ties when alias-gaps are within
282+
ALIAS_GAP_TIE_TOLERANCE of each other.
229283
284+
Falls back to the most-confident candidate if canonicalization
285+
produces no usable canonical forms across the cohort.
286+
"""
287+
# ALIAS_GAP_TIE_TOLERANCE — when two candidates' alias-gaps are
288+
# within this much of each other, treat them as tied and break
289+
# the tie by self-reported confidence. Keeps the tournament from
290+
# being decided by tiny embedding-similarity noise.
291+
ALIAS_GAP_TIE_TOLERANCE = 0.02
292+
293+
scored: list[tuple[float, float, int, dict, dict]] = []
294+
for idx, c in enumerate(candidates):
295+
title = (c.get("title") or "").strip()
296+
description = (c.get("description") or "").strip()
297+
# Try to canonicalize using a synthetic central_move derived
298+
# from the candidate's own title — _canonicalize_central_move
299+
# accepts an empty central_move and extracts from description
300+
# when needed.
301+
try:
302+
canonical = self._canonicalize_central_move(
303+
title, description, central_architectural_move=title,
304+
)
305+
except Exception as e: # noqa: BLE001 — canonicalization is best-effort
306+
print(f" [warn] canonicalize failed on candidate {idx + 1}: {type(e).__name__}: {e}")
307+
canonical = {}
308+
# Score: alias-gap if canonical form usable; 0 (worst) otherwise
309+
if canonical and canonical.get("move_predicate"):
310+
gap_signal = self._alias_gap(canonical)
311+
gap = gap_signal.get("gap", 1.0)
312+
else:
313+
gap = 0.0
314+
gap_signal = {"gap": 0.0, "nearest_ids": [], "scored_against": 0}
315+
conf = float(c.get("confidence") or 0.0)
316+
scored.append((gap, conf, idx, c, canonical))
317+
div = (c.get("divergence_axis") or "").strip()[:60]
318+
print(
319+
f" candidate {idx + 1}: gap={gap:.2f} conf={conf:.2f}"
320+
+ (f" · axis={div!r}" if div else "")
321+
)
322+
323+
# Sort by gap desc; among entries within ALIAS_GAP_TIE_TOLERANCE of
324+
# the top, prefer the higher confidence.
325+
scored.sort(key=lambda s: s[0], reverse=True)
326+
best_gap = scored[0][0]
327+
tied = [s for s in scored if abs(s[0] - best_gap) <= ALIAS_GAP_TIE_TOLERANCE]
328+
if len(tied) > 1:
329+
tied.sort(key=lambda s: s[1], reverse=True)
330+
print(
331+
f" [tournament] {len(tied)} candidates within "
332+
f"{ALIAS_GAP_TIE_TOLERANCE:.2f} of best gap; "
333+
"tiebreaking by confidence."
334+
)
335+
winner = tied[0]
336+
winner_idx = winner[2]
337+
print(
338+
f" [tournament] winner: candidate {winner_idx + 1} "
339+
f"(gap={winner[0]:.2f}, conf={winner[1]:.2f})"
340+
)
341+
return winner[3]
342+
343+
def _build_and_persist_insight(
344+
self, result: dict, xref: CrossReference, divergence_axis: str = "",
345+
) -> Insight:
230346
insight = Insight(
231347
id=f"i-{uuid4().hex[:8]}",
232348
timestamp=datetime.now(timezone.utc).isoformat(),
@@ -240,12 +356,12 @@ def synthesize(self, xref: CrossReference) -> Optional[Insight]:
240356
counter_arguments=result.get("counter_arguments", []),
241357
prior_art_check=result.get("prior_art_check", ""),
242358
)
243-
244359
self.journal.add_insight(insight)
245360
print(f" INSIGHT: {insight.title}")
246361
print(f" Confidence: {insight.confidence:.2f}")
362+
if divergence_axis:
363+
print(f" Divergence axis (selected): {divergence_axis[:140]}")
247364
if insight.prior_art_check:
248365
print(f" Prior art: {insight.prior_art_check[:120]}...")
249366
print(f" {insight.description[:200]}...")
250-
251367
return insight

prompts.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,39 @@
632632
}}"""
633633

634634

635+
SYNTHESIZE_VARIANTS_PROMPT = """You are a research engine synthesizing {candidate_count} structurally DIVERGENT candidate insights from the same cross-referenced findings. The system will pick the candidate that is most structurally distant from the existing register — your job is to ensure all candidates are real, defensible, and meaningfully different from each other on a specific architectural axis.
636+
637+
{focus_block}CROSS-REFERENCE:
638+
{xref_json}
639+
640+
SUPPORTING JOURNAL ENTRIES:
641+
{supporting_entries_json}
642+
643+
Rules:
644+
- Each candidate is a standalone insight (title + description + novelty_assessment + ...) AND names its `divergence_axis` — the specific architectural axis on which it differs from the others. Examples of axis shape (NOT for copy-paste): "mechanism: uses X instead of Y", "substrate: acts on A rather than B", "scale: large-N regime vs small-N", "constraint: relaxes assumption Z".
645+
- Candidates must GENUINELY diverge. If candidate A says "use mechanism X" and candidate B says "use mechanism X with refinement Y", that's NOT divergence — they share the architectural move. They should differ at the architectural level, not at the refinement level.
646+
- Each candidate honestly reports its `prior_art_check`. Don't pretend a candidate is novel just because the prompt asked for variants — if it's a restatement, say so and lower confidence. The downstream verifier filters for quality; your job is to maximize structural diversity, not to pass verification.
647+
- No padding. If you cannot produce {candidate_count} genuinely divergent candidates from the source material, return fewer. A list of 2 real divergent candidates beats 3 with one padding entry.
648+
- Same critical-self-check as single-candidate synthesis: a "novel" insight that merely restates common knowledge poisons the signal — be honest in `prior_art_check` and lower `confidence` accordingly.
649+
650+
Respond with EXACTLY this JSON structure (no other text):
651+
{{
652+
"candidates": [
653+
{{
654+
"title": "concise statement of the insight (one sentence)",
655+
"description": "full articulation of the insight (2-3 paragraphs)",
656+
"novelty_assessment": "why you believe this is genuinely novel",
657+
"prior_art_check": "honest assessment: is the core claim already well-established?",
658+
"confidence": 0.0-1.0,
659+
"implications": ["concrete implication 1", "concrete implication 2"],
660+
"open_questions": ["what would need to be investigated"],
661+
"counter_arguments": ["why this might be wrong"],
662+
"divergence_axis": "the specific architectural axis on which this candidate differs from the others"
663+
}}
664+
]
665+
}}"""
666+
667+
635668
DIRECTIVE_HYPOTHESIS_PROMPT = """You are composing one section of a RESEARCH DIRECTIVE.
636669
637670
A research directive is a plan a research team executes to take a verified concept from idea to a publishable result. The team runs experiments, gathers data, and produces measurements. The directive is NOT a literature-watch list waiting on other researchers to publish.

web/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1588,6 +1588,7 @@ def settings_save(
15881588
engine_confidence_drop_on_downgrade: float = Form(0.10),
15891589
engine_question_priority_floor: float = Form(0.70),
15901590
engine_register_admission_mode: str = Form("scalar"),
1591+
engine_synthesis_candidate_count: int = Form(3),
15911592
engine_analog_probe_max_analogs: int = Form(3),
15921593
engine_assumption_probe_max_assumptions: int = Form(3),
15931594
):
@@ -1719,6 +1720,7 @@ def _checkbox(v: str) -> bool:
17191720
f"confidence_drop_on_downgrade = {max(0.0, min(0.5, engine_confidence_drop_on_downgrade))}\n"
17201721
f"question_priority_floor = {max(0.0, min(1.0, engine_question_priority_floor))}\n"
17211722
f'register_admission_mode = "{register_admission_mode}"\n'
1723+
f"synthesis_candidate_count = {max(1, min(10, engine_synthesis_candidate_count))}\n"
17221724
f"analog_probe_max_analogs = {max(1, min(10, engine_analog_probe_max_analogs))}\n"
17231725
f"assumption_probe_max_assumptions = {max(1, min(10, engine_assumption_probe_max_assumptions))}\n"
17241726
)

0 commit comments

Comments
 (0)