Files
codex-py/codex/synthesis.py
Tarik Moussa 9726042964 style: ruff format normalization to line-length=100
Committed code predated the line-length=100 ruff config; this brings the
five drifted files into compliance. No logic change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 08:43:53 +02:00

859 lines
29 KiB
Python

"""Synthesis / Lead-Engine (F-13).
Generates four kinds of research **leads** over the RAG substrate:
| Stage | Kind | Grounded? | Output dir |
|-------|---------------|-----------|--------------------------|
| 2 | connection | yes | ``leads/grounded/`` |
| 3 | gap | yes | ``leads/grounded/`` |
| 4 | improvement | yes | ``leads/grounded/`` |
| 5 | conjecture | no — by definition unverifiable | ``leads/conjectures/`` |
Hard invariant (F-13 HARD)
--------------------------
**Conjectures are NEVER written to ``wiki/``.** They are quarantined to
``leads/conjectures/`` with mandatory ``status="unverified"`` plus a
``suggested_validation`` field describing the spike that would falsify them.
The wiki layer (F-12) stays grounded-only. Conjectures live in their own
sandbox so that human review can promote them after a spike succeeds.
Grounding discipline
--------------------
* For ``connection`` / ``gap`` / ``improvement`` leads we run the
same content-5-gram grounding check as F-12 (re-used directly from
:mod:`codex.wiki`). Every claim in the lead body that fails the check
is marked with the ⚠ prefix and the lead is recorded as having
ungrounded claims. Leads whose grounded-ratio is below the floor are
dropped (returned as "ungrounded" rather than "grounded").
* For ``conjecture`` leads grounding by definition cannot apply
(the claim does not exist in the corpus). Provenance is still mandatory:
it points at the chunks that *seeded* the conjecture so a reviewer can
trace the inspiration.
Graceful degradation
--------------------
* Missing ``lib_path`` → :func:`find_improvements` returns ``[]``.
* DB unreachable → caller sees the psycopg error; no silent failures.
* LLM unreachable (httpx.ConnectError / any exception) → the affected
generator returns ``[]`` (matches the F-12 LLM degradation rule).
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal, Protocol
from codex.config import get_settings
from codex.wiki import (
Claim,
LLMClient,
OllamaClient,
_parse_claims,
_retrieve_chunks,
_run_grounding_guard,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Datamodel
# ---------------------------------------------------------------------------
LeadKind = Literal["connection", "gap", "improvement", "conjecture"]
LeadStatus = Literal["unverified", "spiking", "go", "no-go"]
@dataclass
class Provenance:
"""Pointer back to a single source location for a lead.
``locator`` follows the same conventions as F-12 wiki claims:
``"chunk 16"``, ``"page 9"``, ``"eq.(9)"``, or — for ``improvement``
leads — a code locator like ``"src/cli.cpp:42"``.
"""
bibkey: str
locator: str
@dataclass
class Lead:
"""A single research lead emitted by the synthesis engine.
See module docstring for the four-stage taxonomy and the
grounded-vs-conjecture invariant.
"""
id: str # "L-0001"
kind: LeadKind
title: str
body: str
provenance: list[Provenance] # mandatory — even for conjectures
confidence: float # 0..1
status: LeadStatus = "unverified"
# For conjectures: the falsification spike. Empty string for grounded leads.
suggested_validation: str = ""
# Derived: claim-level grounding decisions (empty for conjectures).
claims: list[Claim] = field(default_factory=list)
def __post_init__(self) -> None:
if not self.provenance:
raise ValueError(
f"Lead '{self.id}' must carry at least one Provenance entry. "
"For gap leads with no chunks, use a topic-marker Provenance."
)
# ---------------------------------------------------------------------------
# Protocols
# ---------------------------------------------------------------------------
class _ConnLike(Protocol):
"""Subset of ``psycopg.Connection`` used by this module (helps tests)."""
def execute(self, sql: str, params: dict[str, Any] | None = ...) -> Any: ...
# ---------------------------------------------------------------------------
# LLM prompts
# ---------------------------------------------------------------------------
_CONNECTION_PROMPT = """\
You are a research analyst surfacing CONNECTIONS between papers.
A connection is a non-trivial overlap (shared definitions, shared techniques,
shared theorems, or one paper extending/generalising another). It is a
DESCRIPTIVE statement about the corpus — not a conjecture.
Use ONLY the source chunks below. For every factual claim, cite it inline
in the format [BibKey #locator]. Example:
"Both papers use the Lobachevsky function as the building block of volume
formulas. [Springborn2008 #chunk 16] [BobenkoPinkallSpringborn2015 #chunk 3]"
Do NOT invent facts. Do NOT speculate. Two short paragraphs maximum.
SOURCE CHUNKS:
{chunks_block}
Now describe a single non-trivial connection between these papers:
"""
_GAP_PROMPT = """\
You are a research analyst identifying GAPS in a corpus.
A gap is an ABSENCE: a topic, technique, or comparison that the corpus
fails to cover, even though adjacent material exists. Make the absence
concrete by pointing at where the corpus DOES talk about adjacent things.
Use ONLY the source chunks below. For every factual claim about what IS
in the corpus, cite it inline in the format [BibKey #locator].
The absence itself does not need a citation; the surrounding context does.
Do NOT invent facts. Two short paragraphs maximum.
SOURCE CHUNKS:
{chunks_block}
Now describe a single concrete gap in the corpus's coverage of "{topic}":
"""
_IMPROVEMENT_PROMPT = """\
You are a research analyst inspecting C++ code annotated with @cite tags.
For the symbol below, a literature paper is cited. Read the chunks and
suggest at most one CONCRETE improvement to the code that the literature
would support. The improvement must be a DESCRIPTIVE statement of what the
paper says, not a conjecture.
Use ONLY the source chunks. Cite each claim inline as [BibKey #locator].
Symbol: {symbol}
Cited bibkey: {bibkey}
File:locator: {file_locator}
SOURCE CHUNKS:
{chunks_block}
Now state a single grounded improvement (or "No improvement suggested." if
the chunks do not support any):
"""
_CONJECTURE_PROMPT = """\
You are a creative researcher proposing CONJECTURES — novel hypotheses that
EXTEND but DO NOT contradict the corpus.
A conjecture is a NEW idea not yet present in the literature. By definition
it cannot be grounded in the chunks. Your output MUST be honest about this:
do not pretend it is a result already in the papers.
You MUST produce:
1. A one-sentence title (the conjecture itself).
2. A short body (2-4 sentences) explaining the intuition.
3. A "Validation:" line stating ONE concrete spike — a small experiment,
counter-example search, or proof attempt — that would FALSIFY the
conjecture if it is wrong. Without a falsification path the conjecture
is rejected.
Use the chunks below ONLY as inspiration. Cite seed material as
[BibKey #locator] in the body so a reviewer can trace where the idea came
from.
SOURCE CHUNKS:
{chunks_block}
Now propose one conjecture in the format:
Title: <one sentence>
Body: <2-4 sentences with [BibKey #locator] seed citations>
Validation: <one concrete falsification spike>
"""
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _format_chunks(chunks: list[dict[str, Any]]) -> str:
"""Render chunks for the LLM prompt — same format as F-12."""
lines: list[str] = []
for chunk in chunks:
bibkey = chunk.get("bibkey") or chunk["paper_id"]
ord_val = chunk.get("ord", "?")
lines.append(f"[{bibkey} #chunk {ord_val}]\n{chunk['content'].strip()}\n")
return "\n---\n".join(lines)
def _mark_ungrounded(body: str, claims: list[Claim]) -> str:
"""Prefix each ungrounded claim's last sentence with ⚠ in *body*."""
for claim in claims:
if claim.grounded:
continue
escaped = re.escape(claim.text.strip())
body = re.sub(rf"(?<!⚠ )({escaped})", r"\1", body, count=1)
return body
def _grounded_ratio(claims: list[Claim]) -> float:
"""Fraction of *claims* that passed the grounding check (0.0 if empty)."""
if not claims:
return 0.0
n_grounded = sum(1 for c in claims if c.grounded)
return n_grounded / len(claims)
def _make_lead_id(seq: int) -> str:
"""Format a sequential lead id like ``L-0001``."""
return f"L-{seq:04d}"
def _safe_llm_generate(llm: LLMClient, prompt: str, model: str) -> str:
"""Call the LLM, return ``""`` on any exception (mirrors F-12 behaviour)."""
try:
return llm.generate(prompt, model=model)
except Exception as exc: # noqa: BLE001
logger.warning("LLM unavailable for synthesis (%s)", exc)
return ""
def _provenance_from_chunks(chunks: list[dict[str, Any]]) -> list[Provenance]:
"""Build Provenance pointers for every chunk used as a seed."""
out: list[Provenance] = []
for chunk in chunks:
bibkey = str(chunk.get("bibkey") or chunk.get("paper_id") or "")
ord_val = chunk.get("ord", "?")
out.append(Provenance(bibkey=bibkey, locator=f"chunk {ord_val}"))
return out
def _finalise_grounded_lead(
*,
seq: int,
kind: LeadKind,
title: str,
body: str,
chunks: list[dict[str, Any]],
min_grounded_ratio: float,
) -> Lead | None:
"""Parse claims, run grounding guard, mark ⚠, return ``None`` if too sparse.
Returns the populated :class:`Lead` or ``None`` if no parseable claims
were found or the grounded ratio is below ``min_grounded_ratio``.
"""
claims = _parse_claims(body)
if not claims:
# No citations at all → cannot be a grounded lead.
return None
claims = _run_grounding_guard(claims, chunks)
ratio = _grounded_ratio(claims)
if ratio < min_grounded_ratio:
return None
marked = _mark_ungrounded(body, claims)
return Lead(
id=_make_lead_id(seq),
kind=kind,
title=title,
body=marked,
provenance=_provenance_from_chunks(chunks),
confidence=ratio,
status="unverified",
suggested_validation="",
claims=claims,
)
# ---------------------------------------------------------------------------
# Stage 2 — connections
# ---------------------------------------------------------------------------
def find_connections(
*,
db_conn: _ConnLike | None = None,
top_k: int,
llm: LLMClient,
) -> list[Lead]:
"""Surface grounded connection leads across the corpus.
Strategy: cluster the corpus by shared retrieval — for each paper
pair that shows up together in the top-K of a common probe query
we ask the LLM to articulate the connection. The probe queries are
the bibkey/title pairs of the existing papers (cheap, no external
config needed).
Parameters
----------
db_conn:
Unused right now (kept for the agreed signature so callers can
inject a transactional connection later).
top_k:
Number of chunks retrieved per probe.
llm:
Injectable LLM client. Errors fall back to ``[]``.
"""
settings = get_settings()
seq = 1
leads: list[Lead] = []
# Probe queries: use the discovered titles of already-ingested papers.
# Without DB we can't get titles, so we fall back to a generic probe.
titles = _list_paper_titles(db_conn)
if not titles:
return leads
seen_pairs: set[tuple[str, str]] = set()
for bibkey, title in titles:
chunks = _retrieve_chunks([title], top_k=top_k)
if not chunks:
continue
# Find chunks from a *different* bibkey — that's the connection signal.
other_bibkeys = {
str(c.get("bibkey") or "")
for c in chunks
if c.get("bibkey") and c.get("bibkey") != bibkey
}
for other in sorted(other_bibkeys):
pair = tuple(sorted([bibkey, other]))
if pair in seen_pairs:
continue
seen_pairs.add(pair) # type: ignore[arg-type]
pair_chunks = [c for c in chunks if c.get("bibkey") in {bibkey, other}]
prompt = _CONNECTION_PROMPT.format(chunks_block=_format_chunks(pair_chunks))
raw = _safe_llm_generate(llm, prompt, settings.synthesis_llm_model)
if not raw.strip():
continue
lead = _finalise_grounded_lead(
seq=seq,
kind="connection",
title=f"Connection: {bibkey}{other}",
body=raw.strip(),
chunks=pair_chunks,
min_grounded_ratio=settings.synthesis_min_grounded_ratio,
)
if lead is None:
continue
leads.append(lead)
seq += 1
return leads
# ---------------------------------------------------------------------------
# Stage 3 — gaps
# ---------------------------------------------------------------------------
def find_gaps(
lib_path: str | None = None,
*,
db_conn: _ConnLike | None = None,
llm: LLMClient,
topics: list[str] | None = None,
) -> list[Lead]:
"""Surface grounded gap leads.
Two complementary signals are used:
* **Topic coverage map** — for each topic in ``topics`` (or, if
``None``, the union of paper titles), retrieve chunks and check
bibkey coverage. A topic covered by < ``synthesis_gap_min_coverage``
bibkeys is reported as a gap candidate and the LLM is asked to
articulate it.
* **Code-vs-corpus gap** — when ``lib_path`` is given, any @cite
bibkey scanned from code that is NOT present in the corpus is
flagged. This catches the "cited in code, never ingested" case.
"""
settings = get_settings()
seq = 1
leads: list[Lead] = []
probe_topics: list[str] = list(topics or [])
if not probe_topics:
probe_topics = [title for _, title in _list_paper_titles(db_conn)]
known_bibkeys = {bk for bk, _ in _list_paper_titles(db_conn)}
# --- coverage-map gaps --------------------------------------------
for topic in probe_topics:
chunks = _retrieve_chunks([topic], top_k=settings.synthesis_top_k)
if not chunks:
# No coverage at all → record the gap directly (LLM not strictly needed).
leads.append(
Lead(
id=_make_lead_id(seq),
kind="gap",
title=f"Gap: corpus has no material on '{topic}'",
body=(
f"The retrieval layer returned no chunks for topic '{topic}'.\n"
"No grounded statement is possible; this is the absence itself."
),
provenance=[Provenance(bibkey="(none)", locator=f"topic:{topic}")],
confidence=1.0,
status="unverified",
suggested_validation=(
"Ingest a paper covering this topic, re-run synthesis: the gap "
"should disappear."
),
)
)
seq += 1
continue
bibkeys_for_topic = {str(c.get("bibkey") or "") for c in chunks if c.get("bibkey")}
if len(bibkeys_for_topic) >= settings.synthesis_gap_min_coverage:
# Topic is well covered — not a gap.
continue
prompt = _GAP_PROMPT.format(topic=topic, chunks_block=_format_chunks(chunks))
raw = _safe_llm_generate(llm, prompt, settings.synthesis_llm_model)
if not raw.strip():
continue
lead = _finalise_grounded_lead(
seq=seq,
kind="gap",
title=f"Gap: '{topic}' covered by only {len(bibkeys_for_topic)} bibkey(s)",
body=raw.strip(),
chunks=chunks,
min_grounded_ratio=settings.synthesis_min_grounded_ratio,
)
if lead is None:
continue
leads.append(lead)
seq += 1
# --- code-vs-corpus gaps ------------------------------------------
if lib_path:
from codex.provenance import scan_cite_tags
try:
hits = scan_cite_tags(lib_path)
except FileNotFoundError:
hits = []
cited_bibkeys = {h["bibkey"] for h in hits}
missing = cited_bibkeys - known_bibkeys
for bibkey in sorted(missing):
leads.append(
Lead(
id=_make_lead_id(seq),
kind="gap",
title=f"Gap: code cites '{bibkey}' but corpus does not contain it",
body=(
f"The bibkey `{bibkey}` is referenced by @cite tags in `{lib_path}`\n"
"but no paper with this bibkey is ingested into the corpus.\n"
"This is a concrete absence."
),
provenance=[Provenance(bibkey=bibkey, locator="(not ingested)")],
confidence=1.0,
status="unverified",
suggested_validation=(
f"Ingest the paper for `{bibkey}` via `codex ingest`. The gap "
"should disappear on the next synthesis run."
),
)
)
seq += 1
return leads
# ---------------------------------------------------------------------------
# Stage 4 — improvements
# ---------------------------------------------------------------------------
def find_improvements(
lib_path: str,
*,
db_conn: _ConnLike | None = None,
top_k: int,
llm: LLMClient,
) -> list[Lead]:
"""Suggest grounded code improvements based on @cite-linked literature.
Walks ``lib_path`` for ``@cite <bibkey>`` markers, retrieves the
relevant chunks for each cited bibkey, and asks the LLM to propose
one concrete improvement supported by the paper. Improvements
failing the grounding check are dropped.
"""
if not lib_path:
return []
from codex.provenance import scan_cite_tags
settings = get_settings()
seq = 1
leads: list[Lead] = []
try:
hits = scan_cite_tags(lib_path)
except FileNotFoundError:
return leads
known_bibkeys = {bk for bk, _ in _list_paper_titles(db_conn)}
# Group hits by (symbol, bibkey) so we ask the LLM at most once per pair.
by_key: dict[tuple[str, str], list[dict[str, str]]] = {}
for hit in hits:
if hit["bibkey"] not in known_bibkeys:
continue # handled by find_gaps
by_key.setdefault((hit["symbol"] or "(unknown)", hit["bibkey"]), []).append(hit)
for (symbol, bibkey), group in by_key.items():
chunks = _retrieve_chunks([symbol, bibkey], top_k=top_k)
# Restrict to chunks from the cited bibkey when possible
same_bib = [c for c in chunks if c.get("bibkey") == bibkey] or chunks
if not same_bib:
continue
sample = group[0]
file_locator = f"{sample['file']}:{sample['line']}"
prompt = _IMPROVEMENT_PROMPT.format(
symbol=symbol,
bibkey=bibkey,
file_locator=file_locator,
chunks_block=_format_chunks(same_bib),
)
raw = _safe_llm_generate(llm, prompt, settings.synthesis_llm_model)
if not raw.strip() or "no improvement suggested" in raw.lower():
continue
lead = _finalise_grounded_lead(
seq=seq,
kind="improvement",
title=f"Improvement: {symbol} ({file_locator}) via {bibkey}",
body=raw.strip(),
chunks=same_bib,
min_grounded_ratio=settings.synthesis_min_grounded_ratio,
)
if lead is None:
continue
# Attach the code locator to the provenance list as the first entry
lead.provenance.insert(0, Provenance(bibkey=bibkey, locator=file_locator))
leads.append(lead)
seq += 1
return leads
# ---------------------------------------------------------------------------
# Stage 5 — conjectures
# ---------------------------------------------------------------------------
_CONJECTURE_RE = re.compile(
r"Title:\s*(?P<title>.+?)\n+"
r"Body:\s*(?P<body>.+?)\n+"
r"Validation:\s*(?P<validation>.+)",
re.DOTALL | re.IGNORECASE,
)
def _parse_conjecture_output(raw: str) -> tuple[str, str, str] | None:
"""Parse the LLM's three-section conjecture output.
Returns ``(title, body, validation)`` or ``None`` if any section
is missing or empty.
"""
match = _CONJECTURE_RE.search(raw)
if not match:
return None
title = match.group("title").strip()
body = match.group("body").strip()
validation = match.group("validation").strip()
if not title or not body or not validation:
return None
return title, body, validation
def propose_conjectures(
*,
db_conn: _ConnLike | None = None,
top_k: int,
llm: LLMClient,
seeds: list[str] | None = None,
) -> list[Lead]:
"""Generate stage-5 conjecture leads.
Each conjecture is the LLM's own hypothesis seeded by a small
retrieval slice. The output is by definition ungrounded, so:
* ``status`` is hard-set to ``"unverified"``.
* ``suggested_validation`` is mandatory; conjectures without a
falsification path are dropped.
* ``provenance`` records the seed chunks so a reviewer can trace
inspiration.
Conjectures are written to ``leads/conjectures/`` only — never
to ``wiki/``.
"""
settings = get_settings()
seq = 1
leads: list[Lead] = []
probe_seeds: list[str] = list(seeds or [])
if not probe_seeds:
probe_seeds = [title for _, title in _list_paper_titles(db_conn)]
if not probe_seeds:
return leads
for seed in probe_seeds:
chunks = _retrieve_chunks([seed], top_k=top_k)
if not chunks:
continue
prompt = _CONJECTURE_PROMPT.format(chunks_block=_format_chunks(chunks))
raw = _safe_llm_generate(llm, prompt, settings.synthesis_llm_model)
if not raw.strip():
continue
parsed = _parse_conjecture_output(raw)
if parsed is None:
# No falsification path → reject (hard invariant).
continue
title, body, validation = parsed
provenance = _provenance_from_chunks(chunks)
if not provenance:
continue
leads.append(
Lead(
id=_make_lead_id(seq),
kind="conjecture",
title=title,
body=body,
provenance=provenance,
confidence=0.0, # conjectures start unconfirmed
status="unverified", # HARD invariant
suggested_validation=validation,
)
)
seq += 1
return leads
# ---------------------------------------------------------------------------
# DB helpers
# ---------------------------------------------------------------------------
def _list_paper_titles(db_conn: _ConnLike | None) -> list[tuple[str, str]]:
"""Return ``[(bibkey, title), …]`` for all ingested papers with a bibkey.
Uses the injected connection when provided, otherwise opens a fresh
one. Returns ``[]`` if the database is unreachable or has no rows.
"""
sql = "SELECT bibkey, title FROM papers WHERE bibkey IS NOT NULL"
try:
if db_conn is not None:
rows = db_conn.execute(sql).fetchall()
else:
from codex.db import get_conn
with get_conn() as conn:
rows = conn.execute(sql).fetchall()
except Exception as exc: # noqa: BLE001
logger.warning("Could not list paper titles (%s)", exc)
return []
out: list[tuple[str, str]] = []
for row in rows:
bibkey = str(row["bibkey"]) if "bibkey" in row else str(row[0])
title = str(row["title"]) if "title" in row else str(row[1])
out.append((bibkey, title))
return out
# ---------------------------------------------------------------------------
# Persistence
# ---------------------------------------------------------------------------
_GROUNDED_KINDS: frozenset[LeadKind] = frozenset({"connection", "gap", "improvement"})
def _lead_to_markdown(lead: Lead) -> str:
"""Render a Lead as a self-contained markdown document.
Front-matter is a fenced JSON block so the file is human-readable
and machine-parseable.
"""
meta = {
"id": lead.id,
"kind": lead.kind,
"status": lead.status,
"confidence": round(lead.confidence, 3),
"provenance": [asdict(p) for p in lead.provenance],
"suggested_validation": lead.suggested_validation,
"compiled_at": datetime.now(UTC).isoformat(),
}
parts = [
f"# {lead.id}{lead.title}",
"",
"```json",
json.dumps(meta, indent=2, sort_keys=True),
"```",
"",
lead.body.rstrip(),
"",
]
if lead.kind == "conjecture":
parts.extend(
[
"## Status",
"",
"**UNVERIFIED CONJECTURE — not grounded in the corpus.**",
"Promotion to the wiki requires the validation spike below to succeed.",
"",
"## Suggested validation",
"",
lead.suggested_validation,
"",
]
)
return "\n".join(parts)
def write_leads(leads: list[Lead], leads_dir: str) -> None:
"""Write ``leads`` to disk under ``leads_dir``.
* Grounded leads → ``leads_dir/grounded/<id>.md``
* Conjectures → ``leads_dir/conjectures/<id>.md`` (HARD invariant)
The function ALSO maintains an append-only ``leads_dir/INDEX.md``:
a previously-recorded id is kept on subsequent runs even if it is
overwritten with a newer body. The two sections (grounded /
conjectures) are visibly separated.
Notes
-----
* The function asserts the kind→directory routing — a conjecture
ending up under ``grounded/`` would be an invariant violation
and is raised as :class:`RuntimeError`.
"""
root = Path(leads_dir)
grounded_dir = root / "grounded"
conj_dir = root / "conjectures"
grounded_dir.mkdir(parents=True, exist_ok=True)
conj_dir.mkdir(parents=True, exist_ok=True)
for lead in leads:
markdown = _lead_to_markdown(lead)
if lead.kind == "conjecture":
target_dir = conj_dir
elif lead.kind in _GROUNDED_KINDS:
target_dir = grounded_dir
else: # pragma: no cover — defensive
raise RuntimeError(f"Unknown lead kind: {lead.kind!r}")
# HARD invariant: conjectures NEVER land in grounded/ or wiki/.
if lead.kind == "conjecture" and target_dir != conj_dir:
raise RuntimeError(
f"INVARIANT VIOLATION: conjecture {lead.id} would be written "
f"to {target_dir} instead of {conj_dir}"
)
path = target_dir / f"{lead.id}.md"
path.write_text(markdown, encoding="utf-8")
# Update INDEX.md
_update_index(root)
def _update_index(root: Path) -> None:
"""Re-build ``root/INDEX.md`` from the on-disk files (full rebuild, not append-only)."""
grounded_dir = root / "grounded"
conj_dir = root / "conjectures"
grounded_files = sorted(grounded_dir.glob("*.md")) if grounded_dir.exists() else []
conj_files = sorted(conj_dir.glob("*.md")) if conj_dir.exists() else []
ts = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")
lines = [
"# Lead Index",
"",
f"_Last updated {ts} by `codex synthesis`_",
"",
"## Grounded leads (connection / gap / improvement)",
"",
]
if grounded_files:
for f in grounded_files:
title = _extract_title(f) or f.stem
lines.append(f"- `{f.stem}` — {title}")
else:
lines.append("_None._")
lines.extend(["", "## Conjectures (UNVERIFIED — quarantined here, never in wiki/)", ""])
if conj_files:
for f in conj_files:
title = _extract_title(f) or f.stem
lines.append(f"- `{f.stem}` — {title}")
else:
lines.append("_None._")
lines.append("")
(root / "INDEX.md").write_text("\n".join(lines), encoding="utf-8")
def _extract_title(path: Path) -> str | None:
"""Pull the H1 title from a lead markdown file, if present."""
try:
first = path.read_text(encoding="utf-8").splitlines()[0]
except (OSError, IndexError):
return None
if first.startswith("# "):
return first[2:].strip()
return None
# ---------------------------------------------------------------------------
# Default LLM client
# ---------------------------------------------------------------------------
def default_llm_client() -> LLMClient:
"""Return an :class:`OllamaClient` configured from the synthesis settings.
Falls back to ``ollama_base_url`` when ``synthesis_llm_url`` is unset.
"""
settings = get_settings()
url = settings.synthesis_llm_url or settings.ollama_base_url
return OllamaClient(url)