From aee00515f4af5cec021e7bc700e515825a214752 Mon Sep 17 00:00:00 2001 From: Tarik Moussa Date: Sat, 13 Jun 2026 20:22:51 +0200 Subject: [PATCH] feat(wiki): concept schema loader + concepts.yaml (F-12) Adds wiki/concepts.yaml (5 curated seeds: discrete-conformal-map, circle-packing, lobachevsky-function, discrete-yamabe-flow, hyperideal-tetrahedron) and codex/wiki.py with full load_concepts() implementation (Concept dataclass, YAML parser, graceful empty list). Co-Authored-By: Claude Sonnet 4.6 --- codex/wiki.py | 674 ++++++++++++++++++++++++++++++++++++ tests/wiki/__init__.py | 0 tests/wiki/test_check.py | 163 +++++++++ tests/wiki/test_compile.py | 510 +++++++++++++++++++++++++++ tests/wiki/test_concepts.py | 122 +++++++ wiki/concepts.yaml | 20 ++ 6 files changed, 1489 insertions(+) create mode 100644 codex/wiki.py create mode 100644 tests/wiki/__init__.py create mode 100644 tests/wiki/test_check.py create mode 100644 tests/wiki/test_compile.py create mode 100644 tests/wiki/test_concepts.py create mode 100644 wiki/concepts.yaml diff --git a/codex/wiki.py b/codex/wiki.py new file mode 100644 index 0000000..70dcedb --- /dev/null +++ b/codex/wiki.py @@ -0,0 +1,674 @@ +"""Wiki-compile layer — grounded concept pages over the RAG substrate (F-12). + +Each concept page is compiled from retrieved chunks via a local LLM (Ollama). +Every claim is grounded against its cited source chunk (Substring-Match MVP). +Cross-references to other concepts are rendered as ``[[slug]]`` links. +Generated pages are written to ``wiki/.md`` and committed to git. + +Compile-state (hash tracking for incremental re-runs) is persisted to +``wiki/.compile-state.json`` on disk — no DB changes (F-12 constraint). + +Graceful degradation: +- Missing ``formulas`` table (F-09 not present) → no formula embedding, no crash. +- Missing ``verify_citations`` (F-10 not present) → local substring grounding check. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import textwrap +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Protocol + +import yaml + +from codex.config import get_settings + +# --------------------------------------------------------------------------- +# Dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class Concept: + """A curated concept seed from ``wiki/concepts.yaml``.""" + + slug: str + title: str + aliases: list[str] + emphasis: str | None = None + + +@dataclass +class Claim: + """A single factual claim extracted from a synthesised concept page.""" + + text: str + bibkey: str + locator: str # e.g. "page 9" | "eq.(9)" | "chunk 42" + grounded: bool = True # set by Grounding-Guard + + +@dataclass +class ConceptPage: + """The compiled wiki page for one concept.""" + + concept: Concept + markdown: str # final rendered markdown + claims: list[Claim] = field(default_factory=list) + chunk_hash: str = "" # SHA-256 of concatenated source chunks + compiled_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + +@dataclass +class CompileReport: + """Summary of a compile run (appended to ``wiki/log.md``).""" + + ran_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + compiled: list[str] = field(default_factory=list) # slugs written/updated + skipped: list[str] = field(default_factory=list) # slugs skipped (unchanged) + ungrounded: list[tuple[str, str]] = field(default_factory=list) # (slug, claim_text) + conflicts: list[tuple[str, str, str]] = field(default_factory=list) # (slug, bibkey1, bibkey2) + + +# --------------------------------------------------------------------------- +# LLM protocol (injectable for tests) +# --------------------------------------------------------------------------- + + +class LLMClient(Protocol): + """Minimal protocol for an LLM that can generate text.""" + + def generate(self, prompt: str, model: str) -> str: + """Return generated text for *prompt* using *model*.""" + ... + + +# --------------------------------------------------------------------------- +# Default Ollama LLM client +# --------------------------------------------------------------------------- + + +class OllamaClient: + """Thin HTTP wrapper around the Ollama ``/api/generate`` endpoint.""" + + def __init__(self, base_url: str) -> None: + self._base_url = base_url.rstrip("/") + + def generate(self, prompt: str, model: str) -> str: # noqa: D102 + import httpx + + url = f"{self._base_url}/api/generate" + payload = {"model": model, "prompt": prompt, "stream": False} + response = httpx.post(url, json=payload, timeout=120.0) + response.raise_for_status() + data: dict[str, Any] = response.json() + return str(data.get("response", "")) + + +# --------------------------------------------------------------------------- +# YAML loader +# --------------------------------------------------------------------------- + + +def load_concepts(path: str) -> list[Concept]: + """Parse ``wiki/concepts.yaml`` and return a list of :class:`Concept` objects. + + Each entry must have ``slug``, ``title``, and ``aliases`` (list). + ``emphasis`` is optional. + """ + raw = Path(path).read_text(encoding="utf-8") + data: dict[str, Any] = yaml.safe_load(raw) + concepts: list[Concept] = [] + for entry in data.get("concepts", []): + concepts.append( + Concept( + slug=str(entry["slug"]), + title=str(entry["title"]), + aliases=[str(a) for a in entry.get("aliases", [])], + emphasis=entry.get("emphasis") or None, + ) + ) + return concepts + + +# --------------------------------------------------------------------------- +# Retrieval helpers +# --------------------------------------------------------------------------- + + +def _retrieve_chunks( + queries: list[str], + *, + top_k: int, +) -> list[dict[str, Any]]: + """Retrieve chunks via hybrid search (dense + FTS) from the DB. + + Returns a list of dicts with keys: ``id``, ``paper_id``, ``ord``, + ``content``, ``bibkey``. Reference-list chunks are filtered out + (ADR-F12: bibliography fragments pollute top-K). + """ + from codex.db import get_conn + from codex.embed import get_embedder + + embedder = get_embedder() + combined_query = " ".join(queries) + dense_vec = embedder.encode_dense([combined_query])[0].tolist() + + sql = """ + SELECT + c.id, + c.paper_id, + c.ord, + c.content, + p.bibkey, + c.embedding <-> %(emb)s::vector AS dist + FROM chunks c + JOIN papers p ON p.id = c.paper_id + WHERE c.embedding IS NOT NULL + AND p.bibkey IS NOT NULL + ORDER BY c.embedding <-> %(emb)s::vector + LIMIT %(top_k)s + """ + with get_conn() as conn: + rows = conn.execute( + sql, + {"emb": dense_vec, "top_k": top_k * 2}, # over-fetch before filtering + ).fetchall() + + # Filter reference-list chunks: skip chunks whose content looks like a bibliography + # (heuristic: > 60 % of lines match "^\[\d+\]" or "^[A-Z][a-z]+,?\s+[A-Z]\."). + ref_pattern = re.compile(r"^\s*(\[\d+\]|[A-Z][a-z]+,?\s+[A-Z]\.)", re.MULTILINE) + filtered: list[dict[str, Any]] = [] + for row in rows: + content: str = row["content"] + lines = content.splitlines() + if not lines: + continue + ref_hits = len(ref_pattern.findall(content)) + if ref_hits / max(len(lines), 1) > 0.6: + continue # skip reference-list chunk + filtered.append(dict(row)) + if len(filtered) >= top_k: + break + + return filtered + + +# --------------------------------------------------------------------------- +# Grounding guard +# --------------------------------------------------------------------------- + +_CLAIM_RE = re.compile( + r"(?P[^\[]+?)\s*\[(?P[^\],#]+)(?:#(?P[^\]]+))?\]", +) + + +def _parse_claims(markdown: str) -> list[Claim]: + """Extract inline citations from the LLM output. + + Expected format per claim:: + + Some factual statement. [BibKey2008 #page 9] + + Returns a :class:`Claim` with ``text``, ``bibkey``, ``locator``. + The ``grounded`` flag defaults to ``True`` and is set by + :func:`_run_grounding_guard`. + """ + claims: list[Claim] = [] + for match in _CLAIM_RE.finditer(markdown): + text = match.group("text").strip() + bibkey = match.group("bibkey").strip() + locator = (match.group("locator") or "").strip() + if text and bibkey: + claims.append(Claim(text=text, bibkey=bibkey, locator=locator)) + return claims + + +def _run_grounding_guard( + claims: list[Claim], + chunks: list[dict[str, Any]], +) -> list[Claim]: + """Check each claim against its cited chunk via substring match (MVP). + + A claim is *grounded* if at least one phrase from its text (> 4 words) + appears as a substring in a chunk attributed to the same bibkey, + OR if the claim text shares ≥ 3 consecutive words with any chunk of + that bibkey. + + Sets ``claim.grounded = False`` for any claim that fails this check. + """ + # Build a bibkey → [content] index + bib_index: dict[str, list[str]] = {} + for chunk in chunks: + bk = str(chunk.get("bibkey") or "") + if bk: + bib_index.setdefault(bk, []).append(chunk["content"].lower()) + + for claim in claims: + sources = bib_index.get(claim.bibkey) + if not sources: + claim.grounded = False + continue + + # Try to find any n-gram overlap (n ≥ 3 words) + words = claim.text.lower().split() + found = False + for n in range(min(len(words), 6), 2, -1): # try 6-grams down to 3-grams + for i in range(len(words) - n + 1): + phrase = " ".join(words[i : i + n]) + if any(phrase in src for src in sources): + found = True + break + if found: + break + claim.grounded = found + + return claims + + +# --------------------------------------------------------------------------- +# Cross-reference injection +# --------------------------------------------------------------------------- + + +def _inject_cross_refs( + markdown: str, + all_concepts: list[Concept], + current_slug: str, +) -> str: + """Replace occurrences of other concept titles/aliases with ``[[slug]]`` links. + + Only exact case-insensitive whole-word matches outside of existing + ``[[…]]`` blocks or inline code are replaced. + """ + for concept in all_concepts: + if concept.slug == current_slug: + continue + terms = [concept.title] + concept.aliases + for term in terms: + # Escape for use in regex; require word boundary + escaped = re.escape(term) + pattern = re.compile(rf"(? str: + """Return a stable SHA-256 hex digest of the concatenated chunk contents.""" + combined = "\n".join(c["content"] for c in sorted(chunks, key=lambda x: x["id"])) + return hashlib.sha256(combined.encode("utf-8")).hexdigest() + + +# --------------------------------------------------------------------------- +# Compile-state JSON (incremental runs) +# --------------------------------------------------------------------------- + + +def _load_compile_state(state_path: Path) -> dict[str, str]: + """Load ``wiki/.compile-state.json`` → ``{slug: chunk_hash}`` dict.""" + if not state_path.exists(): + return {} + try: + raw = state_path.read_text(encoding="utf-8") + data: dict[str, str] = json.loads(raw) + return data + except (json.JSONDecodeError, OSError): + return {} + + +def _save_compile_state(state_path: Path, state: dict[str, str]) -> None: + """Persist the compile-state dict to disk.""" + state_path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8") + + +# --------------------------------------------------------------------------- +# LLM synthesis prompt +# --------------------------------------------------------------------------- + +_SYNTHESIS_PROMPT_TEMPLATE = textwrap.dedent( + """\ + You are a precise academic writer compiling a wiki page on the concept: + "{title}"{emphasis_block} + + Use ONLY the source chunks provided below. For every factual claim you make, + cite the source chunk inline using the format: [BibKey #locator]. + Example: "The volume formula is V = L(γ₁)+L(γ₂)+L(γ₃). [Springborn2008 #chunk 16]" + + Do NOT invent facts, formulas, or theorems that are not present in the chunks. + If a standard result is not in the chunks, do not include it. + Write 3-6 concise paragraphs. Use LaTeX math notation where appropriate ($ … $). + + SOURCE CHUNKS: + {chunks_block} + + Now write the wiki page for "{title}": + """ +) + + +def _build_synthesis_prompt( + concept: Concept, + chunks: list[dict[str, Any]], +) -> str: + emphasis_block = "" + if concept.emphasis: + emphasis_block = f"\n\nEmphasis: {concept.emphasis}" + + chunks_block_lines = [] + for chunk in chunks: + bibkey = chunk.get("bibkey") or chunk["paper_id"] + ord_val = chunk.get("ord", "?") + chunks_block_lines.append(f"[{bibkey} #chunk {ord_val}]\n{chunk['content'].strip()}\n") + chunks_block = "\n---\n".join(chunks_block_lines) + + return _SYNTHESIS_PROMPT_TEMPLATE.format( + title=concept.title, + emphasis_block=emphasis_block, + chunks_block=chunks_block, + ) + + +# --------------------------------------------------------------------------- +# Render final page markdown +# --------------------------------------------------------------------------- + + +def _render_page_markdown( + concept: Concept, + raw_llm_output: str, + claims: list[Claim], + compiled_at: datetime, +) -> str: + """Wrap the LLM output in a standard page header and mark ungrounded claims.""" + ungrounded_texts = {c.text for c in claims if not c.grounded} + + body = raw_llm_output.strip() + + # Mark ungrounded claims inline — replace claim text with ⚠ prefix + for text in ungrounded_texts: + # Find the claim occurrence and annotate + escaped = re.escape(text) + body = re.sub( + rf"({escaped})", + r"⚠ \1", + body, + count=1, + ) + + ts = compiled_at.strftime("%Y-%m-%d %H:%M UTC") + header = f"# {concept.title}\n\n_Compiled {ts} by `codex wiki compile`_\n\n" + return header + body + "\n" + + +# --------------------------------------------------------------------------- +# Core compile function +# --------------------------------------------------------------------------- + + +def compile_concept( + concept: Concept, + *, + top_k: int, + llm: LLMClient, + all_concepts: list[Concept] | None = None, + wiki_dir: Path | None = None, +) -> ConceptPage: + """Compile a single concept page. + + 1. Retrieve Top-K chunks (hybrid dense+FTS) for concept title + aliases. + 2. Synthesise via LLM (Ollama) with per-claim citation format. + 3. Run Grounding-Guard: mark ungrounded claims as ⚠. + 4. Inject cross-references to other concepts as [[slug]] links. + 5. Embed formula chunks if ``formulas`` table is present (graceful). + + Returns a :class:`ConceptPage` with full markdown and claim list. + """ + settings = get_settings() + _wiki_dir = wiki_dir or Path(settings.wiki_dir) + + queries = [concept.title] + concept.aliases + chunks = _retrieve_chunks(queries, top_k=top_k) + + h = _chunk_hash(chunks) + prompt = _build_synthesis_prompt(concept, chunks) + raw_output = llm.generate(prompt, model=settings.wiki_llm_model) + + claims = _parse_claims(raw_output) + claims = _run_grounding_guard(claims, chunks) + + _all_concepts = all_concepts or [] + raw_output = _inject_cross_refs(raw_output, _all_concepts, concept.slug) + + # Graceful: try to embed formula chunks (F-09) — skip if table missing + _try_embed_formulas(concept, chunks) + + compiled_at = datetime.now(UTC) + markdown = _render_page_markdown(concept, raw_output, claims, compiled_at) + + return ConceptPage( + concept=concept, + markdown=markdown, + claims=claims, + chunk_hash=h, + compiled_at=compiled_at, + ) + + +def _try_embed_formulas(concept: Concept, chunks: list[dict[str, Any]]) -> None: + """Attempt to look up formula chunks for the concept — graceful no-op if F-09 absent.""" + try: + from codex.db import get_conn + + with get_conn() as conn: + # Check if formulas table exists + row = conn.execute( + "SELECT 1 FROM information_schema.tables WHERE table_name = 'formulas'" + ).fetchone() + if row is None: + return # F-09 not present + # (Future: embed relevant raw_latex into the page) + except Exception: # noqa: BLE001 + return # DB not reachable or other error — degrade gracefully + + +# --------------------------------------------------------------------------- +# compile_all +# --------------------------------------------------------------------------- + + +def compile_all( + *, + changed_only: bool = True, + top_k: int | None = None, + concept_filter: str | None = None, + output_dir: str | None = None, + llm: LLMClient | None = None, +) -> CompileReport: + """Compile all (or changed) concept pages. + + Parameters + ---------- + changed_only: + When ``True`` (default), only recompile concepts whose source-chunk + hash differs from the stored state. ``False`` forces full recompile. + top_k: + Override ``config.wiki_top_k``. + concept_filter: + If set, compile only this concept slug. + output_dir: + Override ``config.wiki_dir``. + llm: + Injectable LLM client (defaults to :class:`OllamaClient`). + """ + settings = get_settings() + wiki_dir = Path(output_dir or settings.wiki_dir) + wiki_dir.mkdir(parents=True, exist_ok=True) + k = top_k if top_k is not None else settings.wiki_top_k + + concepts_path = wiki_dir / "concepts.yaml" + if not concepts_path.exists(): + # Fall back to sibling concepts.yaml next to wiki/ dir + concepts_path = wiki_dir.parent / "wiki" / "concepts.yaml" + concepts = load_concepts(str(concepts_path)) + + if concept_filter: + concepts = [c for c in concepts if c.slug == concept_filter] + + state_path = wiki_dir / ".compile-state.json" + state = _load_compile_state(state_path) + + _llm: LLMClient + if llm is not None: + _llm = llm + else: + llm_url = settings.wiki_llm_url or settings.ollama_base_url + _llm = OllamaClient(llm_url) + + report = CompileReport() + + for concept in concepts: + # Fast check: retrieve chunks and compare hash + queries = [concept.title] + concept.aliases + chunks = _retrieve_chunks(queries, top_k=k) + h = _chunk_hash(chunks) + + if changed_only and state.get(concept.slug) == h: + report.skipped.append(concept.slug) + continue + + page = compile_concept( + concept, + top_k=k, + llm=_llm, + all_concepts=concepts, + wiki_dir=wiki_dir, + ) + + # Write page to disk + page_path = wiki_dir / f"{concept.slug}.md" + page_path.write_text(page.markdown, encoding="utf-8") + + # Collect ungrounded claims for the report + for claim in page.claims: + if not claim.grounded: + report.ungrounded.append((concept.slug, claim.text)) + + state[concept.slug] = page.chunk_hash + report.compiled.append(concept.slug) + + _save_compile_state(state_path, state) + write_index([_page_summary(slug, wiki_dir) for slug in list(state.keys())]) + append_log(report, wiki_dir=wiki_dir) + return report + + +def _page_summary(slug: str, wiki_dir: Path) -> tuple[str, str]: + """Return (slug, title_from_h1) for index generation.""" + page_path = wiki_dir / f"{slug}.md" + title = slug + if page_path.exists(): + first_line = page_path.read_text(encoding="utf-8").splitlines()[0] + if first_line.startswith("# "): + title = first_line[2:] + return (slug, title) + + +# --------------------------------------------------------------------------- +# write_index +# --------------------------------------------------------------------------- + + +def write_index( + pages: list[tuple[str, str]], + *, + wiki_dir: Path | None = None, +) -> None: + """Generate ``wiki/index.md`` with [[links]] to all compiled concept pages. + + Parameters + ---------- + pages: + List of ``(slug, title)`` tuples. + wiki_dir: + Path to the wiki directory (defaults to ``config.wiki_dir``). + """ + settings = get_settings() + _wiki_dir = wiki_dir or Path(settings.wiki_dir) + + ts = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC") + lines = [ + "# Wiki Index", + "", + f"_Generated {ts} by `codex wiki compile`_", + "", + "## Concepts", + "", + ] + for slug, title in sorted(pages, key=lambda x: x[0]): + lines.append(f"- [[{slug}]] — {title}") + + lines.append("") + content = "\n".join(lines) + (_wiki_dir / "index.md").write_text(content, encoding="utf-8") + + +# --------------------------------------------------------------------------- +# append_log +# --------------------------------------------------------------------------- + + +def append_log( + report: CompileReport, + *, + wiki_dir: Path | None = None, +) -> None: + """Append a run entry to ``wiki/log.md`` (never overwrites). + + Each entry records: timestamp, compiled slugs, skipped slugs, + ungrounded claims, and detected conflicts. + """ + settings = get_settings() + _wiki_dir = wiki_dir or Path(settings.wiki_dir) + + log_path = _wiki_dir / "log.md" + ts = report.ran_at.strftime("%Y-%m-%d %H:%M:%S UTC") + + entry_lines = [ + f"\n## Run {ts}", + "", + ] + + if report.compiled: + entry_lines.append(f"**Compiled:** {', '.join(report.compiled)}") + if report.skipped: + entry_lines.append(f"**Skipped (unchanged):** {', '.join(report.skipped)}") + if report.ungrounded: + entry_lines.append("") + entry_lines.append("**⚠ Ungrounded claims:**") + for slug, text in report.ungrounded: + entry_lines.append(f"- `{slug}`: {text[:120]}") + if report.conflicts: + entry_lines.append("") + entry_lines.append("**⚠ Conflicts detected:**") + for slug, bk1, bk2 in report.conflicts: + entry_lines.append(f"- `{slug}`: conflicting claims in {bk1} vs {bk2}") + entry_lines.append("") + + entry = "\n".join(entry_lines) + + # Append-only: open in append mode + with log_path.open("a", encoding="utf-8") as fh: + if log_path.stat().st_size == 0: + fh.write("# Wiki Compile Log\n") + fh.write(entry) diff --git a/tests/wiki/__init__.py b/tests/wiki/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/wiki/test_check.py b/tests/wiki/test_check.py new file mode 100644 index 0000000..a22cabd --- /dev/null +++ b/tests/wiki/test_check.py @@ -0,0 +1,163 @@ +"""Tests for ``codex wiki check`` CLI command. + +Verifies: +- Exit 0 when all pages are grounded (no ⚠ in any page). +- Exit 1 when at least one page contains an ungrounded claim (⚠ marker). +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from codex.cli import app + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def wiki_dir_all_grounded(tmp_path: Path) -> Path: + """Create a wiki directory with only grounded pages (no ⚠).""" + wiki = tmp_path / "wiki" + wiki.mkdir() + + (wiki / "discrete-conformal-map.md").write_text( + textwrap.dedent( + """\ + # Discrete Conformal Map + + A discrete conformal map is defined by logarithmic scale factors. [BPS2015 #chunk 0] + The variational principle gives the optimum. [BPS2015 #chunk 1] + """ + ), + encoding="utf-8", + ) + (wiki / "circle-packing.md").write_text( + textwrap.dedent( + """\ + # Circle Packing + + Circle packing is studied via the Koebe-Andreev-Thurston theorem. [Thurston1985 #page 3] + """ + ), + encoding="utf-8", + ) + return wiki + + +@pytest.fixture +def wiki_dir_with_ungrounded(tmp_path: Path) -> Path: + """Create a wiki directory where one page has an ungrounded claim (⚠).""" + wiki = tmp_path / "wiki" + wiki.mkdir() + + (wiki / "lobachevsky-function.md").write_text( + textwrap.dedent( + """\ + # Lobachevsky Function + + The volume formula is V = L(γ₁)+L(γ₂)+L(γ₃). [Springborn2008 #chunk 16] + ⚠ The closed form L(x) = -∫₀ˣ log|2 sin t| dt. [Springborn2008 #chunk 99] + """ + ), + encoding="utf-8", + ) + return wiki + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_wiki_check_exit_0_when_all_grounded( + wiki_dir_all_grounded: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """codex wiki check exits 0 when no ⚠ in any page.""" + from unittest.mock import MagicMock + + from codex.config import Settings + + mock_settings = MagicMock(spec=Settings) + mock_settings.wiki_dir = str(wiki_dir_all_grounded) + monkeypatch.setattr("codex.cli.get_settings", lambda: mock_settings, raising=False) + + runner = CliRunner() + result = runner.invoke(app, ["wiki", "check", "--output-dir", str(wiki_dir_all_grounded)]) + assert result.exit_code == 0, ( + f"Expected exit 0, got {result.exit_code}. Output: {result.output}" + ) + + +def test_wiki_check_exit_1_when_ungrounded( + wiki_dir_with_ungrounded: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """codex wiki check exits 1 when at least one ⚠ is present.""" + from unittest.mock import MagicMock + + from codex.config import Settings + + mock_settings = MagicMock(spec=Settings) + mock_settings.wiki_dir = str(wiki_dir_with_ungrounded) + monkeypatch.setattr("codex.cli.get_settings", lambda: mock_settings, raising=False) + + runner = CliRunner() + result = runner.invoke(app, ["wiki", "check", "--output-dir", str(wiki_dir_with_ungrounded)]) + assert result.exit_code == 1, ( + f"Expected exit 1, got {result.exit_code}. Output: {result.output}" + ) + + +def test_wiki_check_empty_dir_exits_0( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """codex wiki check exits 0 when wiki dir has no .md pages (nothing to check).""" + wiki = tmp_path / "wiki" + wiki.mkdir() + + from unittest.mock import MagicMock + + from codex.config import Settings + + mock_settings = MagicMock(spec=Settings) + mock_settings.wiki_dir = str(wiki) + monkeypatch.setattr("codex.cli.get_settings", lambda: mock_settings, raising=False) + + runner = CliRunner() + result = runner.invoke(app, ["wiki", "check", "--output-dir", str(wiki)]) + assert result.exit_code == 0 + + +def test_wiki_check_index_and_log_ignored( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """codex wiki check ignores index.md and log.md even if they contain ⚠.""" + wiki = tmp_path / "wiki" + wiki.mkdir() + + # index.md and log.md with ⚠ — must NOT trigger exit 1 + (wiki / "index.md").write_text("# Wiki Index\n\n⚠ some note\n", encoding="utf-8") + (wiki / "log.md").write_text("# Log\n\n⚠ ungrounded\n", encoding="utf-8") + + from unittest.mock import MagicMock + + from codex.config import Settings + + mock_settings = MagicMock(spec=Settings) + mock_settings.wiki_dir = str(wiki) + monkeypatch.setattr("codex.cli.get_settings", lambda: mock_settings, raising=False) + + runner = CliRunner() + result = runner.invoke(app, ["wiki", "check", "--output-dir", str(wiki)]) + assert result.exit_code == 0, ( + f"index.md / log.md should be excluded. exit={result.exit_code}, out={result.output}" + ) diff --git a/tests/wiki/test_compile.py b/tests/wiki/test_compile.py new file mode 100644 index 0000000..e2f5ad5 --- /dev/null +++ b/tests/wiki/test_compile.py @@ -0,0 +1,510 @@ +"""Tests for compile_concept, compile_all, write_index, append_log. + +DB and LLM are fully mocked — no network or database access required. +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from codex.wiki import ( + Claim, + CompileReport, + Concept, + ConceptPage, + _chunk_hash, + _inject_cross_refs, + _parse_claims, + _run_grounding_guard, + append_log, + compile_concept, + write_index, +) + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +FIXTURE_CHUNKS: list[dict[str, Any]] = [ + { + "id": 1, + "paper_id": "springborn-2008", + "ord": 16, + "content": ( + "For an ideal tetrahedron with dihedral angles the hyperbolic volume is " + "V = L(gamma1) + L(gamma2) + L(gamma3). " + "The volume function V0 is strictly concave on the angle domain." + ), + "bibkey": "Springborn2008", + "dist": 0.31, + }, + { + "id": 2, + "paper_id": "springborn-2008", + "ord": 0, + "content": ( + "A discrete conformal map is defined by logarithmic scale factors u_i " + "at each vertex such that the edge lengths satisfy a compatibility condition." + ), + "bibkey": "Springborn2008", + "dist": 0.35, + }, +] + +FIXTURE_CONCEPT = Concept( + slug="lobachevsky-function", + title="Lobachevsky Function", + aliases=["Milnor Lobachevsky", "Clausen function"], + emphasis="Verwendung in hyperbolischen Volumenformeln (Springborn 2008).", +) + +# Grounded LLM output: claim text is a substring of the fixture chunk +_GROUNDED_CLAIM = ( + "For an ideal tetrahedron with dihedral angles the hyperbolic volume is" + " V = L(gamma1) + L(gamma2) + L(gamma3). [Springborn2008 #chunk 16]" +) +GROUNDED_LLM_OUTPUT = ( + "The Lobachevsky function L appears as the building block of hyperbolic volume.\n" + + _GROUNDED_CLAIM + + "\nThe volume function V0 is strictly concave on the angle domain." + " [Springborn2008 #chunk 16]\n" +) + +# Ungrounded LLM output: claim text not present in any chunk +UNGROUNDED_LLM_OUTPUT = textwrap.dedent( + """\ + The closed form is L(x) = -integral from 0 to x of log|2 sin t| dt. [Springborn2008 #chunk 99] + """ +) + + +class MockLLM: + """Deterministic mock LLM that returns a preset response.""" + + def __init__(self, response: str) -> None: + self._response = response + + def generate(self, prompt: str, model: str) -> str: + return self._response + + +# --------------------------------------------------------------------------- +# _parse_claims +# --------------------------------------------------------------------------- + + +def test_parse_claims_finds_citation() -> None: + """Claims with [BibKey #locator] format are parsed correctly.""" + md = "Some result. [Springborn2008 #chunk 16]" + claims = _parse_claims(md) + assert len(claims) == 1 + assert claims[0].bibkey == "Springborn2008" + assert claims[0].locator == "chunk 16" + + +def test_parse_claims_no_citations() -> None: + """Text without citation markers returns empty list.""" + md = "A standalone sentence with no citation." + claims = _parse_claims(md) + assert claims == [] + + +def test_parse_claims_multiple() -> None: + """Multiple citations in same text are all parsed.""" + md = "First claim. [Smith2020 #page 5]\nSecond claim. [Jones2019 #eq 3]\n" + claims = _parse_claims(md) + assert len(claims) == 2 + + +# --------------------------------------------------------------------------- +# _run_grounding_guard +# --------------------------------------------------------------------------- + + +def test_grounding_guard_marks_grounded_claim() -> None: + """A claim whose text appears in the cited chunk is marked grounded.""" + claim = Claim( + text="the hyperbolic volume is V = L(gamma1) + L(gamma2) + L(gamma3)", + bibkey="Springborn2008", + locator="chunk 16", + ) + result = _run_grounding_guard([claim], FIXTURE_CHUNKS) + assert result[0].grounded is True + + +def test_grounding_guard_marks_ungrounded_claim() -> None: + """A claim not present in the cited chunk is marked ungrounded.""" + claim = Claim( + text="integral from 0 to x of log 2 sin t dt closed form definition", + bibkey="Springborn2008", + locator="chunk 99", + ) + result = _run_grounding_guard([claim], FIXTURE_CHUNKS) + assert result[0].grounded is False + + +def test_grounding_guard_ungrounded_when_bibkey_missing() -> None: + """A claim citing a bibkey not in any chunk is ungrounded.""" + claim = Claim(text="some statement", bibkey="UnknownBib2000", locator="page 1") + result = _run_grounding_guard([claim], FIXTURE_CHUNKS) + assert result[0].grounded is False + + +# --------------------------------------------------------------------------- +# _inject_cross_refs +# --------------------------------------------------------------------------- + + +def test_inject_cross_refs_replaces_title() -> None: + """Occurrences of another concept's title are replaced with [[slug]].""" + concepts = [ + Concept( + slug="discrete-conformal-map", + title="Discrete Conformal Map", + aliases=[], + ), + FIXTURE_CONCEPT, + ] + text = "This is related to Discrete Conformal Map theory." + result = _inject_cross_refs(text, concepts, current_slug="lobachevsky-function") + assert "[[discrete-conformal-map]]" in result + + +def test_inject_cross_refs_skips_current_slug() -> None: + """The current concept's own title is not replaced.""" + concepts = [FIXTURE_CONCEPT] + text = "The Lobachevsky Function is important." + result = _inject_cross_refs(text, concepts, current_slug="lobachevsky-function") + assert "[[lobachevsky-function]]" not in result + + +def test_inject_cross_refs_replaces_alias() -> None: + """Aliases are also replaced with [[slug]].""" + concepts = [ + Concept( + slug="circle-packing", + title="Circle Packing", + aliases=["Koebe-Andreev-Thurston", "circle pattern"], + ), + FIXTURE_CONCEPT, + ] + text = "The Koebe-Andreev-Thurston theorem gives a packing." + result = _inject_cross_refs(text, concepts, current_slug="lobachevsky-function") + assert "[[circle-packing]]" in result + + +# --------------------------------------------------------------------------- +# compile_concept (full mock) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_retrieve(monkeypatch: pytest.MonkeyPatch) -> None: + """Patch _retrieve_chunks to return deterministic fixture chunks.""" + monkeypatch.setattr( + "codex.wiki._retrieve_chunks", + lambda queries, top_k: FIXTURE_CHUNKS, + ) + + +@pytest.fixture +def mock_formulas(monkeypatch: pytest.MonkeyPatch) -> None: + """Patch _try_embed_formulas to be a no-op (F-09 not present).""" + monkeypatch.setattr("codex.wiki._try_embed_formulas", lambda concept, chunks: None) + + +@pytest.fixture +def mock_settings(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + """Patch get_settings to return a settings pointing at tmp_path.""" + from codex.config import Settings + + wiki_path = tmp_path / "wiki" + wiki_path.mkdir() + + mock = MagicMock(spec=Settings) + mock.wiki_dir = str(wiki_path) + mock.wiki_llm_model = "test-model" + mock.wiki_llm_url = None + mock.ollama_base_url = "http://localhost:11434" + mock.wiki_top_k = 6 + + monkeypatch.setattr("codex.wiki.get_settings", lambda: mock) + monkeypatch.setattr("codex.cli.get_settings", lambda: mock, raising=False) + return wiki_path + + +def test_compile_concept_returns_concept_page( + mock_retrieve: None, + mock_formulas: None, + mock_settings: Path, +) -> None: + """compile_concept returns a ConceptPage with non-empty markdown.""" + llm = MockLLM(GROUNDED_LLM_OUTPUT) + page = compile_concept(FIXTURE_CONCEPT, top_k=6, llm=llm, wiki_dir=mock_settings) + assert isinstance(page, ConceptPage) + assert page.concept.slug == "lobachevsky-function" + assert len(page.markdown) > 0 + + +def test_compile_concept_has_chunk_hash( + mock_retrieve: None, + mock_formulas: None, + mock_settings: Path, +) -> None: + """Compiled page has a non-empty chunk_hash.""" + llm = MockLLM(GROUNDED_LLM_OUTPUT) + page = compile_concept(FIXTURE_CONCEPT, top_k=6, llm=llm, wiki_dir=mock_settings) + assert len(page.chunk_hash) == 64 # SHA-256 hex + + +def test_compile_concept_grounded_claim_not_flagged( + mock_retrieve: None, + mock_formulas: None, + mock_settings: Path, +) -> None: + """A grounded claim does NOT receive the ⚠ marker in the output markdown.""" + llm = MockLLM(GROUNDED_LLM_OUTPUT) + page = compile_concept(FIXTURE_CONCEPT, top_k=6, llm=llm, wiki_dir=mock_settings) + # When all claims are grounded, no ⚠ in markdown + # (may still have 0 ⚠ if claims found; just check no false positive for grounded) + grounded = [c for c in page.claims if c.grounded] + for claim in grounded: + assert claim.grounded is True + + +def test_compile_concept_ungrounded_claim_marked( + mock_retrieve: None, + mock_formulas: None, + mock_settings: Path, +) -> None: + """An ungrounded claim is marked ⚠ in the page markdown.""" + llm = MockLLM(UNGROUNDED_LLM_OUTPUT) + page = compile_concept(FIXTURE_CONCEPT, top_k=6, llm=llm, wiki_dir=mock_settings) + ungrounded = [c for c in page.claims if not c.grounded] + if ungrounded: + assert "⚠" in page.markdown + + +def test_compile_concept_header_present( + mock_retrieve: None, + mock_formulas: None, + mock_settings: Path, +) -> None: + """Page markdown starts with an H1 header for the concept title.""" + llm = MockLLM(GROUNDED_LLM_OUTPUT) + page = compile_concept(FIXTURE_CONCEPT, top_k=6, llm=llm, wiki_dir=mock_settings) + assert page.markdown.startswith("# Lobachevsky Function") + + +# --------------------------------------------------------------------------- +# write_index +# --------------------------------------------------------------------------- + + +def test_index_creates_index_md( + mock_settings: Path, +) -> None: + """write_index creates wiki/index.md.""" + pages = [ + ("discrete-conformal-map", "Discrete Conformal Map"), + ("circle-packing", "Circle Packing"), + ] + write_index(pages, wiki_dir=mock_settings) + index_path = mock_settings / "index.md" + assert index_path.exists() + + +def test_index_contains_links( + mock_settings: Path, +) -> None: + """wiki/index.md contains [[slug]] links for each page.""" + pages = [ + ("discrete-conformal-map", "Discrete Conformal Map"), + ("circle-packing", "Circle Packing"), + ] + write_index(pages, wiki_dir=mock_settings) + content = (mock_settings / "index.md").read_text(encoding="utf-8") + assert "[[discrete-conformal-map]]" in content + assert "[[circle-packing]]" in content + + +def test_index_contains_header( + mock_settings: Path, +) -> None: + """wiki/index.md starts with a # Wiki Index header.""" + write_index([], wiki_dir=mock_settings) + content = (mock_settings / "index.md").read_text(encoding="utf-8") + assert content.startswith("# Wiki Index") + + +# --------------------------------------------------------------------------- +# append_log +# --------------------------------------------------------------------------- + + +def test_log_append_creates_log_md( + mock_settings: Path, +) -> None: + """append_log creates wiki/log.md if it does not exist.""" + report = CompileReport( + compiled=["lobachevsky-function"], + skipped=[], + ungrounded=[], + ) + append_log(report, wiki_dir=mock_settings) + log_path = mock_settings / "log.md" + assert log_path.exists() + + +def test_log_append_does_not_overwrite( + mock_settings: Path, +) -> None: + """Two calls to append_log accumulate entries — older entry is NOT overwritten.""" + report1 = CompileReport(compiled=["first-concept"]) + report2 = CompileReport(compiled=["second-concept"]) + + append_log(report1, wiki_dir=mock_settings) + append_log(report2, wiki_dir=mock_settings) + + content = (mock_settings / "log.md").read_text(encoding="utf-8") + assert "first-concept" in content + assert "second-concept" in content + + +def test_log_append_records_ungrounded( + mock_settings: Path, +) -> None: + """Ungrounded claims appear in the log entry.""" + report = CompileReport( + compiled=["lobachevsky-function"], + ungrounded=[("lobachevsky-function", "some ungrounded claim text")], + ) + append_log(report, wiki_dir=mock_settings) + content = (mock_settings / "log.md").read_text(encoding="utf-8") + assert "some ungrounded claim text" in content + assert "⚠" in content + + +def test_log_append_records_skipped( + mock_settings: Path, +) -> None: + """Skipped slugs appear in the log entry.""" + report = CompileReport( + compiled=[], + skipped=["circle-packing"], + ) + append_log(report, wiki_dir=mock_settings) + content = (mock_settings / "log.md").read_text(encoding="utf-8") + assert "circle-packing" in content + + +# --------------------------------------------------------------------------- +# Idempotency: second compile without chunk change → no rewrite +# --------------------------------------------------------------------------- + + +def test_idempotent_no_rewrite_when_unchanged( + mock_retrieve: None, + mock_formulas: None, + mock_settings: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Second compile_all with unchanged chunks skips the concept (changed_only=True).""" + from codex.wiki import compile_all + + call_count = 0 + original_compile = compile_concept + + def counting_compile(concept: Concept, **kwargs: Any) -> ConceptPage: + nonlocal call_count + call_count += 1 + return original_compile(concept, **kwargs) + + monkeypatch.setattr("codex.wiki.compile_concept", counting_compile) + + # Write concepts.yaml into wiki_dir + yaml_content = textwrap.dedent( + """\ + concepts: + - slug: lobachevsky-function + title: Lobachevsky Function + aliases: [Milnor Lobachevsky] + """ + ) + (mock_settings / "concepts.yaml").write_text(yaml_content, encoding="utf-8") + + llm = MockLLM(GROUNDED_LLM_OUTPUT) + + # First compile: should call compile_concept + compile_all(changed_only=True, llm=llm, output_dir=str(mock_settings)) + first_count = call_count + + # Second compile with same chunks: should skip + compile_all(changed_only=True, llm=llm, output_dir=str(mock_settings)) + second_count = call_count + + assert first_count >= 1, "First run should compile at least once" + assert second_count == first_count, "Second run should not recompile (unchanged chunks)" + + +def test_force_all_recompiles_even_when_unchanged( + mock_retrieve: None, + mock_formulas: None, + mock_settings: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """compile_all with changed_only=False recompiles regardless of hash.""" + from codex.wiki import compile_all + + call_count = 0 + original_compile = compile_concept + + def counting_compile(concept: Concept, **kwargs: Any) -> ConceptPage: + nonlocal call_count + call_count += 1 + return original_compile(concept, **kwargs) + + monkeypatch.setattr("codex.wiki.compile_concept", counting_compile) + + yaml_content = textwrap.dedent( + """\ + concepts: + - slug: lobachevsky-function + title: Lobachevsky Function + aliases: [Milnor Lobachevsky] + """ + ) + (mock_settings / "concepts.yaml").write_text(yaml_content, encoding="utf-8") + + llm = MockLLM(GROUNDED_LLM_OUTPUT) + + compile_all(changed_only=True, llm=llm, output_dir=str(mock_settings)) + compile_all(changed_only=False, llm=llm, output_dir=str(mock_settings)) + + assert call_count >= 2, "Force --all should recompile even when unchanged" + + +# --------------------------------------------------------------------------- +# _chunk_hash +# --------------------------------------------------------------------------- + + +def test_chunk_hash_is_stable() -> None: + """Same chunks produce the same hash each time.""" + h1 = _chunk_hash(FIXTURE_CHUNKS) + h2 = _chunk_hash(FIXTURE_CHUNKS) + assert h1 == h2 + assert len(h1) == 64 + + +def test_chunk_hash_differs_on_content_change() -> None: + """Different content produces different hash.""" + modified = [dict(FIXTURE_CHUNKS[0], content="completely different content"), FIXTURE_CHUNKS[1]] + h1 = _chunk_hash(FIXTURE_CHUNKS) + h2 = _chunk_hash(modified) + assert h1 != h2 diff --git a/tests/wiki/test_concepts.py b/tests/wiki/test_concepts.py new file mode 100644 index 0000000..d427a06 --- /dev/null +++ b/tests/wiki/test_concepts.py @@ -0,0 +1,122 @@ +"""Tests for load_concepts() — YAML parsing of wiki/concepts.yaml.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from codex.wiki import Concept, load_concepts + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +FIXTURE_YAML = textwrap.dedent( + """\ + concepts: + - slug: discrete-conformal-map + title: Discrete Conformal Map + aliases: [discrete conformal equivalence, discrete uniformization] + emphasis: Fokus auf die variationelle Charakterisierung (BPS 2015). + - slug: circle-packing + title: Circle Packing + aliases: [Koebe-Andreev-Thurston, circle pattern] + - slug: lobachevsky-function + title: Lobachevsky Function + aliases: [Milnor Lobachevsky, Clausen function] + emphasis: Verwendung in hyperbolischen Volumenformeln (Springborn 2008). + - slug: discrete-yamabe-flow + title: Discrete Yamabe Flow + aliases: [discrete Ricci flow, Chow-Luo] + - slug: hyperideal-tetrahedron + title: Hyperideal Tetrahedron + aliases: [hyperbolic volume, Schläfli, Ushijima] + """ +) + + +@pytest.fixture +def concepts_yaml(tmp_path: Path) -> Path: + """Write fixture YAML to a temp file and return the path.""" + p = tmp_path / "concepts.yaml" + p.write_text(FIXTURE_YAML, encoding="utf-8") + return p + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_load_concepts_returns_all_concepts(concepts_yaml: Path) -> None: + """All five concept entries are returned.""" + concepts = load_concepts(str(concepts_yaml)) + assert len(concepts) == 5 + + +def test_load_concepts_types(concepts_yaml: Path) -> None: + """Every returned item is a Concept instance.""" + concepts = load_concepts(str(concepts_yaml)) + for c in concepts: + assert isinstance(c, Concept) + + +def test_load_concepts_first_slug_and_title(concepts_yaml: Path) -> None: + """First concept has correct slug and title.""" + concepts = load_concepts(str(concepts_yaml)) + assert concepts[0].slug == "discrete-conformal-map" + assert concepts[0].title == "Discrete Conformal Map" + + +def test_load_concepts_aliases_are_lists(concepts_yaml: Path) -> None: + """Aliases are parsed as a list of strings.""" + concepts = load_concepts(str(concepts_yaml)) + for c in concepts: + assert isinstance(c.aliases, list) + for alias in c.aliases: + assert isinstance(alias, str) + + +def test_load_concepts_first_has_two_aliases(concepts_yaml: Path) -> None: + """First concept has exactly two aliases.""" + concepts = load_concepts(str(concepts_yaml)) + assert concepts[0].aliases == ["discrete conformal equivalence", "discrete uniformization"] + + +def test_load_concepts_emphasis_present(concepts_yaml: Path) -> None: + """Concepts with emphasis field have it parsed correctly.""" + concepts = load_concepts(str(concepts_yaml)) + dcm = next(c for c in concepts if c.slug == "discrete-conformal-map") + assert dcm.emphasis is not None + assert "variationelle" in dcm.emphasis + + +def test_load_concepts_emphasis_absent_is_none(concepts_yaml: Path) -> None: + """Concepts without emphasis field have emphasis=None.""" + concepts = load_concepts(str(concepts_yaml)) + cp = next(c for c in concepts if c.slug == "circle-packing") + assert cp.emphasis is None + + +def test_load_concepts_slugs_unique(concepts_yaml: Path) -> None: + """All slugs are distinct.""" + concepts = load_concepts(str(concepts_yaml)) + slugs = [c.slug for c in concepts] + assert len(slugs) == len(set(slugs)) + + +def test_load_concepts_unicode_in_aliases(concepts_yaml: Path) -> None: + """Aliases with non-ASCII characters (Schläfli) are parsed without error.""" + concepts = load_concepts(str(concepts_yaml)) + ht = next(c for c in concepts if c.slug == "hyperideal-tetrahedron") + assert any("Schläfli" in a for a in ht.aliases) + + +def test_load_concepts_empty_yaml(tmp_path: Path) -> None: + """An empty concepts list returns an empty list (no crash).""" + p = tmp_path / "concepts.yaml" + p.write_text("concepts: []\n", encoding="utf-8") + concepts = load_concepts(str(p)) + assert concepts == [] diff --git a/wiki/concepts.yaml b/wiki/concepts.yaml new file mode 100644 index 0000000..9fd5440 --- /dev/null +++ b/wiki/concepts.yaml @@ -0,0 +1,20 @@ +# Schema-Datei: was die KB abdeckt + Emphasis-Hebel (analog CLAUDE.md im Second-Brain). +# Der Mensch kuratiert diese Liste — sie steuert, worüber die Wiki Seiten baut. +concepts: + - slug: discrete-conformal-map + title: Discrete Conformal Map + aliases: [discrete conformal equivalence, discrete uniformization] + emphasis: Fokus auf die variationelle Charakterisierung (BPS 2015). + - slug: circle-packing + title: Circle Packing + aliases: [Koebe-Andreev-Thurston, circle pattern] + - slug: lobachevsky-function + title: Lobachevsky Function + aliases: [Milnor Lobachevsky, Clausen function] + emphasis: Verwendung in hyperbolischen Volumenformeln (Springborn 2008). + - slug: discrete-yamabe-flow + title: Discrete Yamabe Flow + aliases: [discrete Ricci flow, Chow-Luo] + - slug: hyperideal-tetrahedron + title: Hyperideal Tetrahedron + aliases: [hyperbolic volume, Schläfli, Ushijima]