diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json deleted file mode 100644 index f7e0fac10..000000000 --- a/.agents/plugins/marketplace.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "gaia-marketplace", - "interface": { - "displayName": "Gaia" - }, - "plugins": [ - { - "name": "gaia", - "source": "./skills/", - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - } - } - ] -} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json deleted file mode 100644 index 92f4b9ef7..000000000 --- a/.claude-plugin/marketplace.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "gaia-marketplace", - "owner": { - "name": "SiliconEinstein" - }, - "plugins": [ - { - "name": "gaia", - "source": "./", - "description": "Gaia knowledge formalization skills", - "version": "0.4.3" - } - ] -} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json deleted file mode 100644 index fde0c74d9..000000000 --- a/.claude-plugin/plugin.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "gaia", - "version": "0.4.3", - "description": "Gaia knowledge formalization skills — convert scientific papers, textbooks, or any knowledge source into Gaia knowledge packages.", - "author": { - "name": "SiliconEinstein" - }, - "repository": "https://github.com/SiliconEinstein/Gaia", - "license": "MIT", - "keywords": ["gaia", "knowledge", "formalization", "science", "reasoning"] -} diff --git a/.claude/projects/-Users-kunchen-project-Gaia/memory/bug_formalexpr_relation_prior.md b/.claude/projects/-Users-kunchen-project-Gaia/memory/bug_formalexpr_relation_prior.md deleted file mode 100644 index 985e0d3b3..000000000 --- a/.claude/projects/-Users-kunchen-project-Gaia/memory/bug_formalexpr_relation_prior.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -name: FormalExpr relation conclusion prior bug -description: FormalExpr internal relation operator conclusions get π=0.5 instead of 1-ε, causing dead-end constraint vanishing in elimination/case_analysis -type: project ---- - -FormalExpr expand path in `gaia/bp/lowering.py:232-237` uses `_ensure_claim_var` (default π=0.5) for ALL operator conclusions, including relation operators. Top-level operators (line 81-106) correctly distinguish relation vs directed, but FormalExpr path does not. - -**Why:** This causes dead-end relation conclusions (Eq, Contra in elimination; Eq in case_analysis) to lose their constraint — the exact bug #340 describes. - -**How to apply:** Fix by adding relation-type prior logic to the FormalExpr expand path. The prior should be 1-ε for relation operator conclusions, 0.5 for directed operator conclusions, matching the top-level operator path. - -**Files:** `gaia/bp/lowering.py` lines 232-237 (FormalExpr expand), line 126-131 (`_ensure_claim_var`). diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 34b0215b5..000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "enabledPlugins": { - "codex@openai-codex": true - } -} diff --git a/.claude/skills/brainstorming/SKILL.md b/.claude/skills/brainstorming/SKILL.md deleted file mode 100644 index 54d7d384e..000000000 --- a/.claude/skills/brainstorming/SKILL.md +++ /dev/null @@ -1,147 +0,0 @@ ---- -name: brainstorming -description: "You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation." ---- - -# Brainstorming Ideas Into Designs - -Help turn ideas into fully formed designs and specs through natural collaborative dialogue. - -Start by understanding the current project context, then ask questions one at a time to refine the idea. Once you understand what you're building, present the design and get user approval. - - -Do NOT invoke any implementation skill, write any code, scaffold any project, or take any implementation action until you have presented a design and the user has approved it. This applies to EVERY project regardless of perceived simplicity. - - -## Anti-Pattern: "This Is Too Simple To Need A Design" - -Every project goes through this process. A todo list, a single-function utility, a config change — all of them. "Simple" projects are where unexamined assumptions cause the most wasted work. The design can be short (a few sentences for truly simple projects), but you MUST present it and get approval. - -## Checklist - -You MUST create a task for each of these items and complete them in order: - -1. **Explore project context** — check files, docs, recent commits -2. **Offer visual companion** (if topic will involve visual questions) — this is its own message, not combined with a clarifying question. See the Visual Companion section below. -3. **Ask clarifying questions** — one at a time, understand purpose/constraints/success criteria -4. **Propose 2-3 approaches** — with trade-offs and your recommendation -5. **Present design** — in sections scaled to their complexity, get user approval after each section -6. **Write design doc** — save to `docs/specs/YYYY-MM-DD--design.md` and commit -7. **Transition to implementation** — invoke writing-plans skill to create implementation plan - -## Process Flow - -```dot -digraph brainstorming { - "Explore project context" [shape=box]; - "Visual questions ahead?" [shape=diamond]; - "Offer Visual Companion\n(own message, no other content)" [shape=box]; - "Ask clarifying questions" [shape=box]; - "Propose 2-3 approaches" [shape=box]; - "Present design sections" [shape=box]; - "User approves design?" [shape=diamond]; - "Write design doc" [shape=box]; - "Invoke writing-plans skill" [shape=doublecircle]; - - "Explore project context" -> "Visual questions ahead?"; - "Visual questions ahead?" -> "Offer Visual Companion\n(own message, no other content)" [label="yes"]; - "Visual questions ahead?" -> "Ask clarifying questions" [label="no"]; - "Offer Visual Companion\n(own message, no other content)" -> "Ask clarifying questions"; - "Ask clarifying questions" -> "Propose 2-3 approaches"; - "Propose 2-3 approaches" -> "Present design sections"; - "Present design sections" -> "User approves design?"; - "User approves design?" -> "Present design sections" [label="no, revise"]; - "User approves design?" -> "Write design doc" [label="yes"]; - "Write design doc" -> "Invoke writing-plans skill"; -} -``` - -**The terminal state is invoking writing-plans.** Do NOT invoke frontend-design, mcp-builder, or any other implementation skill. The ONLY skill you invoke after brainstorming is writing-plans. - -## The Process - -**Understanding the idea:** - -- Check out the current project state first (files, docs, recent commits) -- Before asking detailed questions, assess scope: if the request describes multiple independent subsystems (e.g., "build a platform with chat, file storage, billing, and analytics"), flag this immediately. Don't spend questions refining details of a project that needs to be decomposed first. -- If the project is too large for a single spec, help the user decompose into sub-projects: what are the independent pieces, how do they relate, what order should they be built? Then brainstorm the first sub-project through the normal design flow. Each sub-project gets its own spec → plan → implementation cycle. -- For appropriately-scoped projects, ask questions one at a time to refine the idea -- Prefer multiple choice questions when possible, but open-ended is fine too -- Only one question per message - if a topic needs more exploration, break it into multiple questions -- Focus on understanding: purpose, constraints, success criteria - -**Exploring approaches:** - -- Propose 2-3 different approaches with trade-offs -- Present options conversationally with your recommendation and reasoning -- Lead with your recommended option and explain why - -**Presenting the design:** - -- Once you believe you understand what you're building, present the design -- Scale each section to its complexity: a few sentences if straightforward, up to 200-300 words if nuanced -- Ask after each section whether it looks right so far -- Cover: architecture, components, data flow, error handling, testing -- Be ready to go back and clarify if something doesn't make sense - -**Design for isolation and clarity:** - -- Break the system into smaller units that each have one clear purpose, communicate through well-defined interfaces, and can be understood and tested independently -- For each unit, you should be able to answer: what does it do, how do you use it, and what does it depend on? -- Can someone understand what a unit does without reading its internals? Can you change the internals without breaking consumers? If not, the boundaries need work. -- Smaller, well-bounded units are also easier for you to work with - you reason better about code you can hold in context at once, and your edits are more reliable when files are focused. When a file grows large, that's often a signal that it's doing too much. - -**Working in existing codebases:** - -- Explore the current structure before proposing changes. Follow existing patterns. -- Where existing code has problems that affect the work (e.g., a file that's grown too large, unclear boundaries, tangled responsibilities), include targeted improvements as part of the design - the way a good developer improves code they're working in. -- Don't propose unrelated refactoring. Stay focused on what serves the current goal. - -## After the Design - -**Documentation:** - -- Write the validated design (spec) to `docs/specs/YYYY-MM-DD--design.md` - - (User preferences for spec location override this default) -- Use elements-of-style:writing-clearly-and-concisely skill if available -- Commit the design document to git - -**Spec Review Loop:** -After writing the spec document: - -1. Dispatch spec-document-reviewer subagent (see spec-document-reviewer-prompt.md) -2. If Issues Found: fix, re-dispatch, repeat until Approved -3. If loop exceeds 5 iterations, surface to human for guidance - -**Implementation:** - -- Invoke the writing-plans skill to create a detailed implementation plan -- Do NOT invoke any other skill. writing-plans is the next step. - -## Key Principles - -- **One question at a time** - Don't overwhelm with multiple questions -- **Multiple choice preferred** - Easier to answer than open-ended when possible -- **YAGNI ruthlessly** - Remove unnecessary features from all designs -- **Explore alternatives** - Always propose 2-3 approaches before settling -- **Incremental validation** - Present design, get approval before moving on -- **Be flexible** - Go back and clarify when something doesn't make sense - -## Visual Companion - -A browser-based companion for showing mockups, diagrams, and visual options during brainstorming. Available as a tool — not a mode. Accepting the companion means it's available for questions that benefit from visual treatment; it does NOT mean every question goes through the browser. - -**Offering the companion:** When you anticipate that upcoming questions will involve visual content (mockups, layouts, diagrams), offer it once for consent: -> "Some of what we're working on might be easier to explain if I can show it to you in a web browser. I can put together mockups, diagrams, comparisons, and other visuals as we go. This feature is still new and can be token-intensive. Want to try it? (Requires opening a local URL)" - -**This offer MUST be its own message.** Do not combine it with clarifying questions, context summaries, or any other content. The message should contain ONLY the offer above and nothing else. Wait for the user's response before continuing. If they decline, proceed with text-only brainstorming. - -**Per-question decision:** Even after the user accepts, decide FOR EACH QUESTION whether to use the browser or the terminal. The test: **would the user understand this better by seeing it than reading it?** - -- **Use the browser** for content that IS visual — mockups, wireframes, layout comparisons, architecture diagrams, side-by-side visual designs -- **Use the terminal** for content that is text — requirements questions, conceptual choices, tradeoff lists, A/B/C/D text options, scope decisions - -A question about a UI topic is not automatically a visual question. "What does personality mean in this context?" is a conceptual question — use the terminal. "Which wizard layout works better?" is a visual question — use the browser. - -If they agree to the companion, read the detailed guide before proceeding: -`skills/brainstorming/visual-companion.md` diff --git a/.claude/skills/brainstorming/spec-document-reviewer-prompt.md b/.claude/skills/brainstorming/spec-document-reviewer-prompt.md deleted file mode 100644 index d693d2f46..000000000 --- a/.claude/skills/brainstorming/spec-document-reviewer-prompt.md +++ /dev/null @@ -1,50 +0,0 @@ -# Spec Document Reviewer Prompt Template - -Use this template when dispatching a spec document reviewer subagent. - -**Purpose:** Verify the spec is complete, consistent, and ready for implementation planning. - -**Dispatch after:** Spec document is written to docs/specs/ - -``` -Task tool (general-purpose): - description: "Review spec document" - prompt: | - You are a spec document reviewer. Verify this spec is complete and ready for planning. - - **Spec to review:** [SPEC_FILE_PATH] - - ## What to Check - - | Category | What to Look For | - |----------|------------------| - | Completeness | TODOs, placeholders, "TBD", incomplete sections | - | Coverage | Missing error handling, edge cases, integration points | - | Consistency | Internal contradictions, conflicting requirements | - | Clarity | Ambiguous requirements | - | YAGNI | Unrequested features, over-engineering | - | Scope | Focused enough for a single plan — not covering multiple independent subsystems | - | Architecture | Units with clear boundaries, well-defined interfaces, independently understandable and testable | - - ## CRITICAL - - Look especially hard for: - - Any TODO markers or placeholder text - - Sections saying "to be defined later" or "will spec when X is done" - - Sections noticeably less detailed than others - - Units that lack clear boundaries or interfaces — can you understand what each unit does without reading its internals? - - ## Output Format - - ## Spec Review - - **Status:** ✅ Approved | ❌ Issues Found - - **Issues (if any):** - - [Section X]: [specific issue] - [why it matters] - - **Recommendations (advisory):** - - [suggestions that don't block approval] -``` - -**Reviewer returns:** Status, Issues (if any), Recommendations diff --git a/.claude/skills/brainstorming/visual-companion.md b/.claude/skills/brainstorming/visual-companion.md deleted file mode 100644 index 62e15a723..000000000 --- a/.claude/skills/brainstorming/visual-companion.md +++ /dev/null @@ -1,260 +0,0 @@ -# Visual Companion Guide - -Browser-based visual brainstorming companion for showing mockups, diagrams, and options. - -## When to Use - -Decide per-question, not per-session. The test: **would the user understand this better by seeing it than reading it?** - -**Use the browser** when the content itself is visual: - -- **UI mockups** — wireframes, layouts, navigation structures, component designs -- **Architecture diagrams** — system components, data flow, relationship maps -- **Side-by-side visual comparisons** — comparing two layouts, two color schemes, two design directions -- **Design polish** — when the question is about look and feel, spacing, visual hierarchy -- **Spatial relationships** — state machines, flowcharts, entity relationships rendered as diagrams - -**Use the terminal** when the content is text or tabular: - -- **Requirements and scope questions** — "what does X mean?", "which features are in scope?" -- **Conceptual A/B/C choices** — picking between approaches described in words -- **Tradeoff lists** — pros/cons, comparison tables -- **Technical decisions** — API design, data modeling, architectural approach selection -- **Clarifying questions** — anything where the answer is words, not a visual preference - -A question *about* a UI topic is not automatically a visual question. "What kind of wizard do you want?" is conceptual — use the terminal. "Which of these wizard layouts feels right?" is visual — use the browser. - -## How It Works - -The server watches a directory for HTML files and serves the newest one to the browser. You write HTML content, the user sees it in their browser and can click to select options. Selections are recorded to a `.events` file that you read on your next turn. - -**Content fragments vs full documents:** If your HTML file starts with ` -
-

Continuing in terminal...

-
- ``` - - This prevents the user from staring at a resolved choice while the conversation has moved on. When the next visual question comes up, push a new content file as usual. - -6. Repeat until done. - -## Writing Content Fragments - -Write just the content that goes inside the page. The server wraps it in the frame template automatically (header, theme CSS, selection indicator, and all interactive infrastructure). - -**Minimal example:** - -```html -

Which layout works better?

-

Consider readability and visual hierarchy

- -
-
-
A
-
-

Single Column

-

Clean, focused reading experience

-
-
-
-
B
-
-

Two Column

-

Sidebar navigation with main content

-
-
-
-``` - -That's it. No ``, no CSS, no `" + return template.replace(GRAPH_DATA_PLACEHOLDER, injection, 1) + + +def _render_svg(dot_source: str, *, theme: str) -> str: + """Render *dot_source* to SVG via the appropriate Graphviz binary. + + For ``stellaris`` / ``dark`` the resulting SVG is post-processed to inject + the ```` glow filter block and recolour the canvas background — see + :mod:`gaia.cli.commands._stellaris_svg`. + + Raises: + GaiaPackagingError: when the required Graphviz binary is missing from + ``PATH``, or when it exits non-zero. + """ + binary = _SVG_LAYOUT_BINARY[theme] + binary_path = shutil.which(binary) + if binary_path is None: + raise GaiaPackagingError( + f"Error: Graphviz `{binary}` binary not found on PATH. Install Graphviz " + "first (`apt install graphviz` / `brew install graphviz`) and retry. " + "Alternatively, emit the dot source with `--format dot` and render " + "it manually." + ) + try: + proc = subprocess.run( + [binary_path, "-Tsvg"], + input=dot_source, + capture_output=True, + text=True, + check=False, + ) + except OSError as exc: + raise GaiaPackagingError(f"Error: failed to invoke Graphviz `{binary}`: {exc}") from exc + if proc.returncode != 0: + stderr = (proc.stderr or "").strip() + raise GaiaPackagingError( + f"Error: Graphviz `{binary}` exited with code {proc.returncode}." + + (f"\n stderr: {stderr}" if stderr else "") + ) + svg = proc.stdout + if theme in ("stellaris", "dark"): + svg = post_process_stellaris_svg(svg) + return svg + + +def _validate_starmap_options(fmt: str, theme: str) -> None: + """Validate `gaia inspect starmap` format and theme options.""" + if fmt not in _DEFAULT_OUT: + typer.echo( + f"Error: --format must be one of {sorted(_DEFAULT_OUT)}; got {fmt!r}.", + err=True, + ) + raise typer.Exit(2) + + if theme not in _VALID_THEMES: + typer.echo( + f"Error: --theme must be one of {sorted(_VALID_THEMES)}; got {theme!r}.", + err=True, + ) + raise typer.Exit(2) + + +def _load_starmap_inputs(path: str) -> tuple[Any, Any]: + """Load and compile package inputs for starmap rendering.""" + try: + loaded = load_gaia_package(path) + apply_package_priors(loaded) + compiled = compile_loaded_package_artifact(loaded) + except GaiaPackagingError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + return loaded, compiled + + +def _emit_starmap_validation(compiled: Any) -> None: + """Validate compiled IR before rendering a starmap.""" + graph_validation = validate_local_graph(compiled.graph) + for warning in graph_validation.warnings: + typer.echo(f"Warning: {warning}") + if graph_validation.errors: + for error in graph_validation.errors: + typer.echo(f"Error: {error}", err=True) + raise typer.Exit(1) + + +def _require_starmap_artifacts_fresh(loaded: Any, compiled: Any, ir: dict[str, Any]) -> None: + """Require stored compile artifacts to match the in-memory compiled IR.""" + gaia_dir = loaded.pkg_path / ".gaia" + ir_hash_path = gaia_dir / "ir_hash" + ir_json_path = gaia_dir / "ir.json" + if not ir_hash_path.exists() or not ir_json_path.exists(): + typer.echo("Error: missing compiled artifacts; run `gaia build compile` first.", err=True) + raise typer.Exit(1) + if ir_hash_path.read_text().strip() != compiled.graph.ir_hash: + typer.echo("Error: compiled artifacts are stale; run `gaia build compile` again.", err=True) + raise typer.Exit(1) + try: + stored_ir = json.loads(ir_json_path.read_text()) + except json.JSONDecodeError as exc: + typer.echo(f"Error: .gaia/ir.json is not valid JSON: {exc}", err=True) + raise typer.Exit(1) from exc + if stored_ir.get("ir_hash") != compiled.graph.ir_hash or stored_ir != ir: + typer.echo("Error: compiled artifacts are stale; run `gaia build compile` again.", err=True) + raise typer.Exit(1) + + +def _load_starmap_beliefs(loaded: Any, compiled: Any) -> dict[str, Any] | None: + """Load optional beliefs.json and require freshness when present.""" + beliefs_path = loaded.pkg_path / ".gaia" / "beliefs.json" + if not beliefs_path.exists(): + return None + try: + beliefs_data = cast(dict[str, Any], json.loads(beliefs_path.read_text())) + except json.JSONDecodeError as exc: + typer.echo(f"Error: {beliefs_path} is not valid JSON: {exc}", err=True) + raise typer.Exit(1) from exc + if beliefs_data.get("ir_hash") != compiled.graph.ir_hash: + typer.echo( + "Error: beliefs are stale; run `gaia run infer` again.", + err=True, + ) + raise typer.Exit(1) + return beliefs_data + + +def _render_starmap_content(graph_json: str, *, fmt: str, theme: str) -> str: + """Render graph JSON into the requested starmap output format.""" + if fmt == "html": + try: + return _render_html(_load_template(), graph_json) + except GaiaPackagingError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + if fmt == "svg": + dot_source = to_dot(graph_json, theme=theme) + try: + return _render_svg(dot_source, theme=theme) + except GaiaPackagingError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + return to_dot(graph_json, theme=theme) + + +def starmap_command( + path: str = typer.Argument(".", help="Path to knowledge package directory"), + out: str = typer.Option( + None, + "--out", + help=( + "Output file. Defaults to '.gaia/starmap.html' (html) or " + "'.gaia/starmap.dot' (dot), relative to the package directory; " + "absolute paths are honored as-is." + ), + ), + fmt: str = typer.Option( + "html", + "--format", + help=( + "Output format: 'html' (interactive Sigma.js), 'dot' " + "(paper-ready Graphviz source), or 'svg' (rendered figure, " + "stellaris glow filters baked in)." + ), + ), + theme: str = typer.Option( + "light", + "--theme", + help=( + "Visual theme for 'dot' / 'svg' output. 'light' (default) is the " + "flat paper-friendly palette. 'stellaris' (alias: 'dark') is a " + "deep-space dark variant. For 'svg' the stellaris variant gets " + "an injected block with radial-gradient background and " + "glow filters bound to contradiction / support / root nodes." + ), + ), +) -> None: + r"""Emit a starmap of the compiled package. + + Three formats are supported: + + * ``html`` (default) — single-file interactive Sigma.js visualization. + Double-click to open in a browser; no server required. + * ``dot`` — a Graphviz ``digraph`` source. Pipe through ``dot`` (Graphviz) + to get a paper-ready figure. ``graphviz`` must be installed separately + (``brew install graphviz`` / ``apt install graphviz``). + * ``svg`` — rendered figure, end-to-end. Internally calls ``dot`` + (light theme) or ``sfdp`` (stellaris/dark) on the dot source, then for + the stellaris theme injects an SVG ```` block with a radial + gradient background and three glow filters keyed off ``class="..."`` + markers (contradiction / support / root). Requires ``graphviz`` on + ``PATH``. + + Compile freshness, beliefs freshness, and graph validation gates apply to + all formats. + + Examples: + # Interactive HTML (default): + gaia inspect starmap path/to/pkg + + # DOT source (manually pipe through dot/sfdp for full control): + gaia inspect starmap path/to/pkg --format dot --out figures/starmap.dot + dot -Tsvg figures/starmap.dot -o figures/starmap.svg + + # End-to-end paper figure (light, no glow): + gaia inspect starmap path/to/pkg --format svg --out figures/starmap.svg + + # End-to-end paper figure with stellaris glow defs baked in: + gaia inspect starmap path/to/pkg --format svg --theme stellaris \ + --out figures/starmap_stellaris.svg + + # PNG preview at higher DPI from the dot source: + dot -Tpng -Gdpi=200 figures/starmap.dot -o figures/starmap.png + + # PDF for direct LaTeX \includegraphics inclusion: + dot -Tpdf figures/starmap.dot -o figures/starmap.pdf + """ + _validate_starmap_options(fmt, theme) + loaded, compiled = _load_starmap_inputs(path) + _emit_starmap_validation(compiled) + ir = compiled.to_json() + _require_starmap_artifacts_fresh(loaded, compiled, ir) + + # Beliefs are optional — degrade gracefully when absent. When present they + # MUST be fresh, mirroring `render`. + beliefs_data = _load_starmap_beliefs(loaded, compiled) + param_data = param_data_from_ir_metadata(ir) + exported_ids = {k["id"] for k in ir.get("knowledges", []) if k.get("exported")} + + graph_json = generate_graph_json( + ir, + beliefs_data=beliefs_data, + param_data=param_data, + exported_ids=exported_ids, + ) + graph_payload = json.loads(graph_json) + content = _render_starmap_content(graph_json, fmt=fmt, theme=theme) + + out_path = Path(out) if out is not None else Path(_DEFAULT_OUT[fmt]) + if not out_path.is_absolute(): + out_path = loaded.pkg_path / out_path + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(content, encoding="utf-8") + + node_count = len(graph_payload.get("nodes", [])) + edge_count = len(graph_payload.get("edges", [])) + typer.echo(f"Wrote starmap to {out_path} ({node_count} nodes, {edge_count} edges)") diff --git a/gaia/cli/commands/starmap_replay.py b/gaia/cli/commands/starmap_replay.py new file mode 100644 index 000000000..185773983 --- /dev/null +++ b/gaia/cli/commands/starmap_replay.py @@ -0,0 +1,416 @@ +"""gaia inspect starmap-replay v4 — IR-tick replay with pinned graphviz layout. + +Reads the two JSONL logs an ``lkm-to-gaia`` run leaves under a package's +``artifacts/lkm-discovery/`` directory and renders a single self-contained +HTML file that plays back the IR-side construction of the package. + +v4 contract (vs v3): + +* **Tick axis is per-``gaia_action``, not per-event.** Each entry of + ``event.gaia_actions`` whose ``action`` lands an IR change + (``claim``/``support``/``deduction``/``contradiction``/``equivalence``/ + ``prior``) is one IR-tick. Events with no IR-relevant actions still + appear on the timeline as informational markers (``round_open``, + ``stage_transition``, retrievals, etc.) but contribute zero ticks. + +* **Pinned canonical layout.** The frontend gets a ``final_layout`` table + baked from ``dot -Tjson0`` against the same DOT source ``gaia inspect starmap + --format dot`` produces. Nodes are placed at their pinned coordinates + on first appearance; cluster boxes match ``_dot.py`` styling. This + command degrades gracefully when graphviz is missing or the package + has no compiled IR — replay still renders, with no pinned layout + (frontend falls back to a centred no-op). + +* **Per-round belief snapshots.** For each ``round_id`` seen in the + growth-log stream, a truncated IR (only knowledges introduced by + end-of-round R) is run through ``InferenceEngine`` and the resulting + beliefs are baked as ``round_beliefs``. The frontend animates each + claim node's belief number across round boundaries. + +The frontend half lives in ``viz/src/starmap-replay.ts`` (entry) and +``viz/src/replay/*.ts``. The build pipeline (``cd viz && npm run +build:replay``) inlines bundle + CSS into a single template HTML which +is shipped at ``gaia/cli/starmap_replay_assets/template.html``; this +command injects the timeline JSON into that template at the +```` placeholder and writes the result. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import typer + +from gaia.cli.commands._dot import to_dot +from gaia.cli.commands._graph_json import generate_graph_json +from gaia.cli.commands._render_priors import param_data_from_ir_metadata +from gaia.cli.commands._replay_build import ( + annotate_layout_with_kinds, + annotate_ticks_with_survival, + bridge_event_symbols_to_layout, + collect_round_order, + compute_dot_layout, + compute_round_beliefs, + rekey_layout_to_lkm_ids, + split_into_ir_ticks, + topo_reorder_ticks, +) +from gaia.engine.packaging import ( + GaiaPackagingError, + apply_package_priors, + compile_loaded_package_artifact, + ensure_package_env, + load_gaia_package, +) + +TIMELINE_PLACEHOLDER = "" +DEFAULT_OUT_RELATIVE = ".gaia/starmap-replay.html" +ARTIFACTS_SUBDIR = "artifacts/lkm-discovery" +RETRIEVAL_LOG_NAME = "retrieval_log.jsonl" +GROWTH_LOG_NAME = "graph_growth_log.jsonl" +SCHEMA_VERSION = "1" + + +def _load_template() -> str: + """Read the shipped placeholder HTML template.""" + import gaia.cli.starmap_replay_assets as assets_pkg + + template_path = Path(assets_pkg.__file__).parent / "template.html" + return template_path.read_text(encoding="utf-8") + + +def _render_html(template: str, timeline_json: str) -> str: + """Inject the timeline JSON payload into *template* at the placeholder.""" + if TIMELINE_PLACEHOLDER not in template: + raise RuntimeError( + f"Error: starmap-replay template is missing the {TIMELINE_PLACEHOLDER!r} placeholder." + ) + injection = f"" + return template.replace(TIMELINE_PLACEHOLDER, injection, 1) + + +def _read_jsonl(path: Path) -> list[dict[str, Any]]: + """Read newline-delimited JSON. Skip blank lines, raise on parse errors.""" + events: list[dict[str, Any]] = [] + for lineno, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + line = raw.strip() + if not line: + continue + try: + events.append(json.loads(line)) + except json.JSONDecodeError as exc: + raise typer.BadParameter(f"{path}: line {lineno} is not valid JSON: {exc}") from exc + return events + + +def _is_replayable(event: dict[str, Any]) -> bool: + """Drop retry / failure events — replay ignores transient retries.""" + if event.get("retry_of_event_id"): + return False + if event.get("decision") == "retry": + return False + # response_code != 0 implies a failed retrieval — also skip. + return "response_code" not in event or event.get("response_code") in (None, 0) + + +def _validate_schema(events: list[dict[str, Any]], source: str) -> list[str]: + """Return a list of warning strings for events with non-"1" schema_version.""" + warnings: list[str] = [] + for event in events: + if event.get("schema_version") != SCHEMA_VERSION: + warnings.append( + f"{source}: event {event.get('event_id', '')} " + f"has schema_version={event.get('schema_version')!r} " + f"(expected {SCHEMA_VERSION!r})" + ) + return warnings + + +def merge_events( + retrieval_events: list[dict[str, Any]], growth_events: list[dict[str, Any]] +) -> list[dict[str, Any]]: + """Merge two streams into one timeline. + + Sort key is ``(timestamp_utc, actor_id, seq)``. ISO-8601 timestamps with a + fixed millisecond format and trailing ``Z`` sort lexicographically. The + sort is stable — events with identical keys retain their input order, so + we tag each with ``event_kind`` (``retrieval`` / ``growth``) before + merging to disambiguate downstream. + """ + tagged: list[dict[str, Any]] = [] + for event in retrieval_events: + # Note: we mutate via a shallow copy so the caller's list stays intact. + e = dict(event) + e.setdefault("event_kind", "retrieval") + tagged.append(e) + for event in growth_events: + e = dict(event) + e.setdefault("event_kind", "growth") + tagged.append(e) + + tagged.sort( + key=lambda e: ( + e.get("timestamp_utc", ""), + e.get("actor_id", ""), + e.get("seq", 0), + ) + ) + return tagged + + +def _try_load_ir_artifacts( + pkg_dir: Path, +) -> tuple[dict[str, Any] | None, dict[str, Any] | None, list[str]]: + """Best-effort load of compiled IR + DOT layout for a package. + + Returns ``(ir, layout, warnings)``. Either of ``ir``/``layout`` may + be ``None`` when: + + * the package has no ``.gaia/ir.json`` (e.g. the unit-test fixture), + * graphviz ``dot`` isn't on ``PATH``, + * compilation fails for any reason. + + Replay still renders in those cases — without round beliefs / pinned + layout, but with the timeline + tick + structured-detail features + intact. + """ + warnings: list[str] = [] + + # Try compiling the package fresh — that gives us the canonical IR + # used by `gaia inspect starmap` / `gaia run infer`. If compilation fails (no + # pyproject.toml, missing src/, etc.), fall back to the on-disk + # ``.gaia/ir.json`` if present. + ir: dict[str, Any] | None = None + try: + ensure_package_env(pkg_dir) + loaded = load_gaia_package(str(pkg_dir)) + apply_package_priors(loaded) + compiled = compile_loaded_package_artifact(loaded) + ir = compiled.to_json() + except (GaiaPackagingError, Exception) as exc: + warnings.append(f"compilation skipped: {exc}") + ir_json_path = pkg_dir / ".gaia" / "ir.json" + if ir_json_path.is_file(): + try: + ir = json.loads(ir_json_path.read_text(encoding="utf-8")) + warnings.append(f"using stored IR at {ir_json_path}") + except json.JSONDecodeError as parse_err: + warnings.append(f"stored IR is invalid JSON: {parse_err}") + ir = None + else: + ir = None + + # Pinned layout requires a working IR + graphviz. Skip silently on + # failure (warnings surface to the CLI caller). + layout: dict[str, Any] | None = None + if ir is not None: + try: + param_data = param_data_from_ir_metadata(ir) + exported_ids = {k["id"] for k in ir.get("knowledges", []) if k.get("exported")} + graph_json = generate_graph_json( + ir, + beliefs_data=None, + param_data=param_data, + exported_ids=exported_ids, + ) + dot_source = to_dot(graph_json) + layout = compute_dot_layout(dot_source) + # Re-key knowledge layout entries to raw lkm_ids so the + # frontend (which admits nodes by event-side id) can find + # their pinned coordinates. See rekey_layout_to_lkm_ids docs. + layout, rekey_warns = rekey_layout_to_lkm_ids(layout, ir) + warnings.extend(rekey_warns) + # Decorate every layout entry with kind + styling info pulled + # from the IR so the replay frontend can render strategies as + # ellipses and operators as hexagons (red for contradictions) + # at their pinned positions on first admission. Without this + # the canvas can't tell strat_ from oper_ from a normal + # claim and silently degrades to claim-only rendering. + annotate_layout_with_kinds(layout, ir) + except FileNotFoundError as exc: + warnings.append(f"pinned layout skipped: {exc}") + except Exception as exc: + warnings.append(f"pinned layout failed: {exc}") + + return ir, layout, warnings + + +def build_timeline_payload( + retrieval_events: list[dict[str, Any]], + growth_events: list[dict[str, Any]], + *, + package_name: str | None = None, + pkg_dir: Path | None = None, +) -> dict[str, Any]: + """Construct the JSON payload the frontend reads from ``window.TIMELINE_DATA``. + + Pulled out as a function so unit tests can call it on synthetic + inputs without touching the filesystem. + """ + replayable_retrievals = [e for e in retrieval_events if _is_replayable(e)] + replayable_growths = [e for e in growth_events if _is_replayable(e)] + merged = merge_events(replayable_retrievals, replayable_growths) + ticks = split_into_ir_ticks(merged) + + final_layout: dict[str, Any] | None = None + round_beliefs: dict[str, dict[str, float]] = {} + rounds_in_order: list[str] = collect_round_order(merged) + build_warnings: list[str] = [] + + ir_for_survival: dict[str, Any] | None = None + layout_for_survival: dict[str, Any] | None = None + if pkg_dir is not None: + ir, layout, warns = _try_load_ir_artifacts(pkg_dir) + build_warnings.extend(warns) + if layout is not None and ir is not None: + # Bridge event-side strategy / operator symbols (gfac_*, + # human-readable contradiction ids) to their strat_ / + # oper_ pinned positions so they don't pile up at the + # canvas centre. + layout, bridge_warns = bridge_event_symbols_to_layout(layout, ir, merged) + build_warnings.extend(bridge_warns) + if layout is not None: + final_layout = layout + layout_for_survival = layout + if ir is not None: + round_beliefs = compute_round_beliefs(ir, merged) + ir_for_survival = ir + + # Mark each IR-tick with whether its action survives into the final + # compiled IR. Orphan ticks (action symbols that the agent admitted + # mid-run but later merged/repaired away) get `survives_to_final=False` + # so the frontend skips them on the canvas — keeping the hard + # invariant that the replay's final state equals the static SVG. + ticks, survival_warnings = annotate_ticks_with_survival( + ticks, merged, layout_for_survival, ir_for_survival + ) + build_warnings.extend(survival_warnings) + + # Topologically reorder surviving ticks so a strategy / operator + # tick fires only after all its referenced claims are admitted. This + # turns the IR-tick axis from a chronological-event axis into a + # logical-dependency axis: the lkm-to-gaia agent occasionally admits + # a contradiction operator before all of its variable claims are on + # canvas (later revising which claims it references). Replayed in + # chronological order, that produces transient frames where a + # hexagon's edges fan into not-yet-drawn nodes. The reorder uses + # original tick_index as a tiebreaker so chronology is preserved + # whenever no dependency forces a swap. + ticks, topo_warnings = topo_reorder_ticks(ticks, merged, layout_for_survival, ir_for_survival) + build_warnings.extend(topo_warnings) + + return { + "schema_version": SCHEMA_VERSION, + "package_name": package_name, + "retrieval_count": len(replayable_retrievals), + "growth_count": len(replayable_growths), + "events": merged, + "ticks": ticks, + "rounds": rounds_in_order, + "round_beliefs": round_beliefs, + "final_layout": final_layout, + "build_warnings": build_warnings, + } + + +def starmap_replay_command( + path: str = typer.Argument(".", help="Path to knowledge package directory"), + out: str = typer.Option( + None, + "--out", + help=( + "Output file. Defaults to '.gaia/starmap-replay.html' relative to " + "the package directory; absolute paths are honored as-is." + ), + ), +) -> None: + """Emit an HTML replay of a package's lkm-discovery run. + + Reads ``/artifacts/lkm-discovery/retrieval_log.jsonl`` and + ``/artifacts/lkm-discovery/graph_growth_log.jsonl`` (the two + JSONL logs an ``lkm-to-gaia`` orchestrator + worker pair leave behind), + merges them on ``(timestamp_utc, actor_id, seq)``, drops retry / + failure events, splits each event into per-``gaia_action`` IR-ticks, + and writes a single self-contained HTML page that plays back the run + on a pinned canonical layout. Round-by-round beliefs are computed by + re-running BP on the compiled IR truncated to each round's + cumulative knowledge set. + + Examples: + # Default — write .gaia/starmap-replay.html into the package: + gaia inspect starmap-replay path/to/pkg + + # Custom output path: + gaia inspect starmap-replay path/to/pkg --out figures/replay.html + """ + pkg_dir = Path(path).resolve() + if not pkg_dir.is_dir(): + typer.echo(f"Error: {pkg_dir} is not a directory.", err=True) + raise typer.Exit(1) + + artifacts_dir = pkg_dir / ARTIFACTS_SUBDIR + retrieval_log = artifacts_dir / RETRIEVAL_LOG_NAME + growth_log = artifacts_dir / GROWTH_LOG_NAME + + missing = [p for p in (retrieval_log, growth_log) if not p.is_file()] + if missing: + for p in missing: + typer.echo(f"Error: missing timeline log: {p}", err=True) + typer.echo( + "Run the lkm-to-gaia discovery pipeline first; both logs must " + f"exist under {artifacts_dir}.", + err=True, + ) + raise typer.Exit(1) + + retrieval_events = _read_jsonl(retrieval_log) + growth_events = _read_jsonl(growth_log) + + for warning in _validate_schema(retrieval_events, str(retrieval_log)): + typer.echo(f"Warning: {warning}") + for warning in _validate_schema(growth_events, str(growth_log)): + typer.echo(f"Warning: {warning}") + + payload = build_timeline_payload( + retrieval_events, + growth_events, + package_name=pkg_dir.name, + pkg_dir=pkg_dir, + ) + for warning in payload.get("build_warnings", []): + typer.echo(f"Note: {warning}") + + timeline_json = json.dumps(payload, ensure_ascii=False) + + try: + template = _load_template() + content = _render_html(template, timeline_json) + except RuntimeError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + except FileNotFoundError as exc: + typer.echo( + "Error: starmap-replay template asset not found. The viz/ bundle " + f"may not have been shipped: {exc}", + err=True, + ) + raise typer.Exit(1) from exc + + out_path = Path(out) if out is not None else Path(DEFAULT_OUT_RELATIVE) + if not out_path.is_absolute(): + out_path = pkg_dir / out_path + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(content, encoding="utf-8") + + tick_count = len(payload["ticks"]) + rounds_count = len(payload["rounds"]) + typer.echo( + f"Wrote starmap replay to {out_path} " + f"({payload['retrieval_count']} retrievals, " + f"{payload['growth_count']} growth events, " + f"{len(payload['events'])} total, " + f"{tick_count} IR-ticks, " + f"{rounds_count} rounds)" + ) diff --git a/gaia/cli/commands/trace.py b/gaia/cli/commands/trace.py index 5f366dd62..c3e83e3dc 100644 --- a/gaia/cli/commands/trace.py +++ b/gaia/cli/commands/trace.py @@ -1,4 +1,4 @@ -"""gaia trace — public CLI sub-app(与 gaia inquiry 平行)。 +"""Public `gaia trace` CLI sub-app. Commands per ARM Trace v1: verify — 仅 schema + hash chain 校验,秒级 fail-fast @@ -13,14 +13,13 @@ from __future__ import annotations -from typing import Optional - import typer -from gaia.trace.hashing import compute_events_root, compute_manifest_hash, recompute_chain -from gaia.trace.loader import load_trace -from gaia.trace.render import render_json, render_markdown, render_text -from gaia.trace.review import run_trace_review +from gaia.engine.trace.hashing import compute_events_root, compute_manifest_hash, recompute_chain +from gaia.engine.trace.loader import load_trace +from gaia.engine.trace.render import render_json, render_markdown, render_text +from gaia.engine.trace.review import run_trace_review +from gaia.engine.trace.schema import Trace trace_app = typer.Typer( name="trace", @@ -39,7 +38,7 @@ def verify_command( trace_path: str = typer.Argument(..., help="Path to trace file (.json/.jsonl)."), quiet: bool = typer.Option(False, "--quiet", help="Suppress non-error output."), ) -> None: - """schema + hash chain 校验。 + """Verify trace schema and hash chain. exit 0:clean exit 1:hash chain / manifest mismatch @@ -56,58 +55,76 @@ def verify_command( trace = res.trace assert trace is not None # 没有 issues 就一定有 trace - chain = recompute_chain(trace.events) + errors = _trace_verify_errors(trace, recompute_chain(trace.events)) + if errors: + _raise_trace_verify_failure(errors, quiet=quiet) + _emit_trace_verify_ok(trace, quiet=quiet) + + +# --------------------------------------------------------------------------- +# review +# --------------------------------------------------------------------------- + + +_SUPPORTED_REVIEW_MODES = {"trace", "publish"} + + +def _trace_chain_mismatches(trace: Trace, chain: list[str]) -> list[str]: + """Return the first event prev-hash mismatch after genesis.""" + for i in range(1, len(trace.events)): + if trace.events[i].prev_hash != chain[i - 1]: + return [f"events[{i}] (seq={trace.events[i].seq}) prev_hash mismatch"] + return [] + + +def _trace_verify_errors(trace: Trace, chain: list[str]) -> list[str]: + """Return hash-chain and manifest mismatches for a loaded trace.""" expected_root = compute_events_root(trace.events) expected_manifest_hash = compute_manifest_hash(trace.manifest) - errors: list[str] = [] - # 链 + if trace.events: - from gaia.trace.hashing import GENESIS_PREV_HASH + from gaia.engine.trace.hashing import GENESIS_PREV_HASH if trace.events[0].prev_hash != GENESIS_PREV_HASH: errors.append(f"events[0].prev_hash != GENESIS ({trace.events[0].prev_hash!r})") - for i in range(1, len(trace.events)): - if trace.events[i].prev_hash != chain[i - 1]: - errors.append(f"events[{i}] (seq={trace.events[i].seq}) prev_hash mismatch") - break + errors.extend(_trace_chain_mismatches(trace, chain)) if trace.manifest.events_root != expected_root: errors.append("manifest.events_root mismatch") if trace.manifest.manifest_hash and trace.manifest.manifest_hash != expected_manifest_hash: errors.append("manifest.manifest_hash mismatch") - - if errors: - if not quiet: - typer.echo("[verify] FAIL", err=True) - for e in errors: - typer.echo(f" - {e}", err=True) - raise typer.Exit(1) - - if not quiet: - typer.echo("[verify] OK") - typer.echo(f" events : {len(trace.events)}") - typer.echo(f" events_root : {expected_root}") - typer.echo(f" manifest_hash : {trace.manifest.manifest_hash or '(none)'}") + return errors -# --------------------------------------------------------------------------- -# review -# --------------------------------------------------------------------------- +def _emit_trace_verify_ok(trace: Trace, *, quiet: bool) -> None: + """Print successful trace verification details.""" + if quiet: + return + typer.echo("[verify] OK") + typer.echo(f" events : {len(trace.events)}") + typer.echo(f" events_root : {compute_events_root(trace.events)}") + typer.echo(f" manifest_hash : {trace.manifest.manifest_hash or '(none)'}") -_SUPPORTED_REVIEW_MODES = {"trace", "publish"} +def _raise_trace_verify_failure(errors: list[str], *, quiet: bool) -> None: + """Print trace verification errors and exit with status 1.""" + if not quiet: + typer.echo("[verify] FAIL", err=True) + for error in errors: + typer.echo(f" - {error}", err=True) + raise typer.Exit(1) @trace_app.command("review") def review_command( trace_path: str = typer.Argument(..., help="Path to trace file (.json/.jsonl)."), mode: str = typer.Option("trace", "--mode", help="Ranking mode: trace|publish."), - package: Optional[str] = typer.Option( + package: str | None = typer.Option( None, "--package", help="Gaia package path used to resolve claim_ref review_ids." ), json_out: bool = typer.Option(False, "--json", help="Emit JSON report (deterministic)."), markdown_out: bool = typer.Option(False, "--markdown", help="Emit Markdown report."), - snapshot_dir: Optional[str] = typer.Option( + snapshot_dir: str | None = typer.Option( None, "--snapshot-dir", help="Override snapshot output directory." ), strict: bool = typer.Option( @@ -116,7 +133,7 @@ def review_command( help="Exit non-zero whenever any error/warning diagnostic is present.", ), ) -> None: - """完整八段 review。 + """Run the full ARM trace review. exit 0:clean exit 1:含 error 级 diagnostic 或 --strict 下含 warning @@ -163,12 +180,12 @@ def review_command( def show_command( trace_path: str = typer.Argument(..., help="Path to trace file (.json/.jsonl)."), limit: int = typer.Option(50, "--limit", help="Max events to print (0 = all)."), - kind: Optional[str] = typer.Option( + kind: str | None = typer.Option( None, "--kind", help="Filter by event kind (decision/tool_call/...)." ), json_out: bool = typer.Option(False, "--json", help="Emit JSONL of selected events."), ) -> None: - """打印事件流(tactic_log 风格)。 + """Print the trace event stream. schema_violation 时仍然尽量打可解析事件 + 报错到 stderr。 """ diff --git a/gaia/cli/main.py b/gaia/cli/main.py index 943b74de8..655cbabca 100644 --- a/gaia/cli/main.py +++ b/gaia/cli/main.py @@ -1,15 +1,31 @@ -"""Gaia CLI — knowledge package authoring toolkit.""" +"""Gaia CLI — knowledge package authoring toolkit. + +The CLI organizes verbs into 6 groups + `trace` independent: + + build init / compile / check + run infer / render + inspect starmap / starmap-replay + review (empty skeleton — held for downstream reviewer tooling) + inquiry (sub-app: focus / review / obligation / hypothesis / tactics / reject) + pkg add / register + trace (sub-app, NOT part of the 6 groups: verify / review / show) + +See `docs/migration.md` for guidance on moving off pre-alpha-0 invocations. +""" import typer +from gaia._meta import IR_SCHEMA, get_channel, get_commit, get_library_version from gaia.cli.commands.add import add_command from gaia.cli.commands.check import check_command from gaia.cli.commands.compile import compile_command from gaia.cli.commands.infer import infer_command from gaia.cli.commands.init import init_command +from gaia.cli.commands.inquiry import inquiry_app from gaia.cli.commands.register import register_command from gaia.cli.commands.render import render_command -from gaia.cli.commands.inquiry import inquiry_app +from gaia.cli.commands.starmap import starmap_command +from gaia.cli.commands.starmap_replay import starmap_replay_command from gaia.cli.commands.trace import trace_app app = typer.Typer( @@ -19,19 +35,127 @@ ) +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"gaia-lang {get_library_version()}") + typer.echo(f"channel: {get_channel()}") + typer.echo(f"commit: {get_commit()}") + typer.echo(f"ir_schema: {IR_SCHEMA}") + raise typer.Exit() + + @app.callback() -def _callback() -> None: +def _callback( + version: bool = typer.Option( + False, + "--version", + callback=_version_callback, + is_eager=True, + help="Show version, channel, commit, and ir_schema; then exit.", + ), +) -> None: """Gaia — knowledge package authoring toolkit.""" -app.command(name="add")(add_command) -app.command(name="compile")(compile_command) -app.command(name="check")(check_command) -app.command(name="infer")(infer_command) -app.command(name="init")(init_command) -app.command(name="register")(register_command) -app.command(name="render")(render_command) +# --------------------------------------------------------------------------- # +# build — init / compile / check # +# --------------------------------------------------------------------------- # + +build_app = typer.Typer( + name="build", + help="Build artifacts (init / compile / check).", + no_args_is_help=True, +) +build_app.command(name="init")(init_command) +build_app.command(name="compile")(compile_command) +build_app.command(name="check")(check_command) +app.add_typer(build_app, name="build") + + +# --------------------------------------------------------------------------- # +# run — infer / render # +# --------------------------------------------------------------------------- # + +run_app = typer.Typer( + name="run", + help="Run inference and rendering (infer / render).", + no_args_is_help=True, +) +run_app.command(name="infer")(infer_command) +run_app.command(name="render")(render_command) +app.add_typer(run_app, name="run") + + +# --------------------------------------------------------------------------- # +# inspect — starmap / starmap-replay # +# --------------------------------------------------------------------------- # + +inspect_app = typer.Typer( + name="inspect", + help="Inspect compiled artifacts (starmap / starmap-replay).", + no_args_is_help=True, +) +inspect_app.command(name="starmap")(starmap_command) +inspect_app.command(name="starmap-replay")(starmap_replay_command) +app.add_typer(inspect_app, name="inspect") + + +# --------------------------------------------------------------------------- # +# review — reviewer tooling skeleton (alpha 0: empty) # +# --------------------------------------------------------------------------- # +# +# Per 协作单 二·共识, the `review` top-level group lands as a help-visible +# empty skeleton so downstream reviewer-tooling work has a stable home. +# It is *different* from `gaia inquiry review` and `gaia trace review` — +# those are pre-existing inner subcommands, untouched by alpha 0. + +review_app = typer.Typer( + name="review", + help="Reviewer tooling (alpha 0: skeleton only — no commands yet).", + no_args_is_help=True, +) + + +@review_app.callback(invoke_without_command=True) +def _review_skeleton(ctx: typer.Context) -> None: + """Placeholder for the reviewer-tooling group. + + Alpha 0 ships this group as a help-visible skeleton; concrete commands + will arrive in a later release. Invoking `gaia review` directly with no + subcommand prints the help text (no_args_is_help=True). + """ + if ctx.invoked_subcommand is None: + # no_args_is_help handles the bare case; this branch is defensive. + return + + +app.add_typer(review_app, name="review") + + +# --------------------------------------------------------------------------- # +# inquiry — existing sub-app (internals untouched) # +# --------------------------------------------------------------------------- # app.add_typer(inquiry_app, name="inquiry") app.add_typer(inquiry_app, name="inquery", hidden=True) # typo alias + + +# --------------------------------------------------------------------------- # +# pkg — add / register # +# --------------------------------------------------------------------------- # + +pkg_app = typer.Typer( + name="pkg", + help="Package operations (add / register).", + no_args_is_help=True, +) +pkg_app.command(name="add")(add_command) +pkg_app.command(name="register")(register_command) +app.add_typer(pkg_app, name="pkg") + + +# --------------------------------------------------------------------------- # +# trace — existing sub-app, independent of the 6 groups # +# --------------------------------------------------------------------------- # + app.add_typer(trace_app, name="trace") diff --git a/gaia/cli/starmap_assets/__init__.py b/gaia/cli/starmap_assets/__init__.py new file mode 100644 index 000000000..797b3f04d --- /dev/null +++ b/gaia/cli/starmap_assets/__init__.py @@ -0,0 +1,8 @@ +"""Static assets bundled with `gaia inspect starmap`. + +Holds the single-file HTML template (`template.html`) into which the +CLI injects a JSON graph payload. The current template is a minimal +placeholder; a richer interactive bundle replaces it later without +any change to the CLI plumbing as long as the +```` placeholder is preserved in ````. +""" diff --git a/gaia/cli/starmap_assets/template.html b/gaia/cli/starmap_assets/template.html new file mode 100644 index 000000000..03bd4fb62 --- /dev/null +++ b/gaia/cli/starmap_assets/template.html @@ -0,0 +1,364 @@ + + + + + + Gaia Starmap + + + + + +
+
+
+
gaia · starmap
+
+
+
+ +
+
+
belief 0
+
belief 0.5 / unknown
+
belief 1
+
strategy
+
operator
+
+
loading…
+
+ + diff --git a/gaia/cli/starmap_replay_assets/__init__.py b/gaia/cli/starmap_replay_assets/__init__.py new file mode 100644 index 000000000..b8af8601b --- /dev/null +++ b/gaia/cli/starmap_replay_assets/__init__.py @@ -0,0 +1,9 @@ +"""Static assets bundled with `gaia inspect starmap-replay`. + +Holds the single-file HTML template (``template.html``) into which the +CLI injects the JSONL timeline payload. Mirrors the +``gaia.cli.starmap_assets`` shipping pattern: ``viz/`` builds a +self-contained bundle, the ship script copies it here, and the CLI +substitutes the ```` placeholder in ```` +with a ```` tag at run time. +""" diff --git a/gaia/cli/starmap_replay_assets/template.html b/gaia/cli/starmap_replay_assets/template.html new file mode 100644 index 000000000..7f31ef625 --- /dev/null +++ b/gaia/cli/starmap_replay_assets/template.html @@ -0,0 +1,79 @@ + + + + + + Gaia Starmap — Replay + + + + + +
+
+
gaia · starmap · replay
+
+
+
+
+ + + +
+ + + +
+ +
+ +
scroll to zoom · drag canvas to pan · hover for prior & belief · double-click to reset
+
+ +
+ + + + + + 0 / 0 + +
+ + +
+ + diff --git a/gaia/cli/templates/pages/.github/workflows/pages.yml b/gaia/cli/templates/pages/.github/workflows/pages.yml deleted file mode 100644 index cc4d51888..000000000 --- a/gaia/cli/templates/pages/.github/workflows/pages.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Deploy Pages -on: - push: - branches: [main] - paths: ['docs/**'] -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '20' - - run: cd docs && npm ci && npm run build - - uses: actions/upload-pages-artifact@v3 - with: - path: docs/dist - deploy: - needs: build - permissions: - pages: write - id-token: write - environment: - name: github-pages - runs-on: ubuntu-latest - steps: - - uses: actions/deploy-pages@v4 diff --git a/gaia/cli/templates/pages/index.html b/gaia/cli/templates/pages/index.html deleted file mode 100644 index 9f87518e9..000000000 --- a/gaia/cli/templates/pages/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Gaia Knowledge Paper - - -
- - - diff --git a/gaia/cli/templates/pages/package.json b/gaia/cli/templates/pages/package.json deleted file mode 100644 index 373a3919d..000000000 --- a/gaia/cli/templates/pages/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "gaia-knowledge-paper", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview", - "test": "vitest run" - }, - "dependencies": { - "elkjs": "^0.9", - "react": "^18.3", - "react-dom": "^18.3", - "react-markdown": "^9", - "remark-gfm": "^4" - }, - "devDependencies": { - "@testing-library/jest-dom": "^6", - "@testing-library/react": "^16", - "@types/node": "^25.5.2", - "@types/react": "^18", - "@types/react-dom": "^18", - "@vitejs/plugin-react": "^4", - "jsdom": "^25", - "typescript": "^5.5", - "vite": "^6", - "vitest": "^3" - } -} diff --git a/gaia/cli/templates/pages/src/App.tsx b/gaia/cli/templates/pages/src/App.tsx deleted file mode 100644 index 343c1fd03..000000000 --- a/gaia/cli/templates/pages/src/App.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useState, useCallback } from 'react' -import { useGraphData } from './hooks/useGraphData' -import ModuleOverview from './components/ModuleOverview' -import ModuleSubgraph from './components/ModuleSubgraph' -import LanguageSwitch from './components/LanguageSwitch' -import SectionView from './components/SectionView' - -type ViewState = - | { level: 'overview' } - | { level: 'module'; moduleId: string; focusNodeId?: string } - -export default function App() { - const state = useGraphData() - const [view, setView] = useState({ level: 'overview' }) - const [lang, setLang] = useState<'en' | 'zh'>('en') - - const sections = state.status === 'ready' - ? state.graph.modules.map(m => m.id) - : [] - - const handleSelectModule = useCallback((moduleId: string) => { - setView({ level: 'module', moduleId }) - }, []) - - const handleBack = useCallback(() => { - setView({ level: 'overview' }) - }, []) - - const handleNavigateToModule = useCallback((moduleId: string, nodeId: string) => { - setView({ level: 'module', moduleId, focusNodeId: nodeId }) - }, []) - - if (state.status === 'loading') { - return
Loading...
- } - - if (state.status === 'error') { - return
{state.message}
- } - - const { graph, meta } = state - - return ( -
-
-

{meta.package_name}

- -
- -
- {view.level === 'overview' ? ( - - ) : ( - - )} -
- -
- -
-
- ) -} diff --git a/gaia/cli/templates/pages/src/__tests__/App.test.tsx b/gaia/cli/templates/pages/src/__tests__/App.test.tsx deleted file mode 100644 index cfc9bfc7f..000000000 --- a/gaia/cli/templates/pages/src/__tests__/App.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, waitFor, fireEvent } from '@testing-library/react' - -vi.mock('elkjs/lib/elk.bundled.js', () => ({ - default: class { - layout(graph: { children?: { id: string; width: number; height: number }[]; edges?: { id: string; sources: string[]; targets: string[] }[] }) { - return Promise.resolve({ - ...graph, - width: 800, - height: 600, - children: (graph.children ?? []).map((c, i) => ({ - ...c, - x: i * 150, - y: i * 80, - })), - edges: (graph.edges ?? []).map((edge: { id: string; sources: string[]; targets: string[] }, i: number) => ({ - id: edge.id, - source: edge.sources[0], - target: edge.targets[0], - sections: [{ - startPoint: { x: i * 150 + 120, y: i * 80 + 24 }, - endPoint: { x: i * 150 + 240, y: i * 80 + 24 }, - }], - })), - }) - } - }, -})) - -import App from '../App' - -const mockGraph = { - modules: [ - { id: 'm1', order: 0, node_count: 1, strategy_count: 0 }, - { id: 'm2', order: 1, node_count: 1, strategy_count: 0 }, - ], - cross_module_edges: [{ from_module: 'm2', to_module: 'm1', count: 1 }], - nodes: [ - { id: 'a', label: 'A', type: 'claim', module: 'm1', content: 'Test', - exported: false, metadata: {}, prior: null, belief: null }, - { id: 'b', label: 'B', type: 'claim', module: 'm2', content: 'External node', - exported: false, metadata: {}, prior: null, belief: null }, - ], - edges: [{ source: 'b', target: 'a', role: 'premise' }], -} -const mockMeta = { package_name: 'test-pkg', namespace: 'github' } - -beforeEach(() => { - vi.stubGlobal( - 'fetch', - vi.fn((url: string) => { - let data: unknown = {} - if (url.includes('graph.json')) data = mockGraph - if (url.includes('meta.json')) data = mockMeta - return Promise.resolve({ - ok: true, - json: () => Promise.resolve(data), - text: () => Promise.resolve(''), - }) - }), - ) -}) - -describe('App', () => { - it('shows loading then title', async () => { - render() - expect(screen.getByText(/loading/i)).toBeInTheDocument() - await waitFor(() => expect(screen.getByText('test-pkg')).toBeInTheDocument()) - }) - - it('renders module overview when ready', async () => { - render() - await waitFor(() => expect(screen.getByText('test-pkg')).toBeInTheDocument()) - await waitFor(() => expect(screen.getAllByText('m1').length).toBeGreaterThan(0)) - }) - - it('renders grouped external node labels after navigating into a module view', async () => { - render() - - await waitFor(() => expect(screen.getByText('test-pkg')).toBeInTheDocument()) - expect(screen.queryByText('↗ B')).not.toBeInTheDocument() - - const moduleLink = screen.getAllByText('m1').find(element => element.closest('svg')) - expect(moduleLink).toBeTruthy() - fireEvent.click(moduleLink!) - - await waitFor(() => expect(screen.getByText('↗ B')).toBeInTheDocument()) - }) - - it('shows error on fetch failure', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(() => Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({}) })), - ) - render() - await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()) - }) -}) diff --git a/gaia/cli/templates/pages/src/__tests__/DetailPanel.test.tsx b/gaia/cli/templates/pages/src/__tests__/DetailPanel.test.tsx deleted file mode 100644 index 17c940530..000000000 --- a/gaia/cli/templates/pages/src/__tests__/DetailPanel.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { render, screen } from '@testing-library/react' -import DetailPanel from '../components/DetailPanel' -import type { GraphNode } from '../types' - -const baseKnowledgeNode: GraphNode = { - id: 'node-1', - label: 'Readable node', - type: 'claim', - module: 'm1', - content: '', - exported: false, - metadata: {}, - prior: 0.4, - belief: 0.7, -} - -describe('DetailPanel', () => { - it('renders structured content with body-first layout and separate metadata', () => { - render( - , - ) - - expect(screen.getByText('It is common to build systems that simplify user interactions with complex underlying data.')).toBeInTheDocument() - expect(screen.getByText('QID: Q2 · Type: context · Role: motivation · source_ref: N/A')).toBeInTheDocument() - }) - - it('falls back to raw content for unstructured text', () => { - render( - , - ) - - expect(screen.getByText('Plain unstructured body text.')).toBeInTheDocument() - expect(screen.queryByText('QID')).not.toBeInTheDocument() - }) -}) diff --git a/gaia/cli/templates/pages/src/__tests__/EdgeRenderer.offset.test.tsx b/gaia/cli/templates/pages/src/__tests__/EdgeRenderer.offset.test.tsx deleted file mode 100644 index 9af2da66a..000000000 --- a/gaia/cli/templates/pages/src/__tests__/EdgeRenderer.offset.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { render } from '@testing-library/react' -import { describe, it, expect } from 'vitest' -import EdgeRenderer from '../components/EdgeRenderer' -import type { ElkEdge } from '../hooks/useElkLayout' - -function renderInSvg(element: React.ReactElement) { - return render( - - {element} - , - ) -} - -describe('EdgeRenderer offset grouping', () => { - it('assigns distinct path geometry for different groupIndex values', () => { - const base: ElkEdge = { - id: 'edge-x', - source: 'n1', - target: 'n2', - sections: [{ - startPoint: { x: 10, y: 20 }, - bendPoints: [ { x: 60, y: 20 }, { x: 60, y: 80 } ], - endPoint: { x: 120, y: 80 }, - }], - } - - const { container: c1 } = renderInSvg( - - ) - const { container: c2 } = renderInSvg( - - ) - - const p1 = c1.querySelector('path[marker-end]')! - const p2 = c2.querySelector('path[marker-end]')! - - expect(p1.getAttribute('d')).not.toBe(p2.getAttribute('d')) - expect(p1.getAttribute('marker-end')).toContain('-g0') - expect(p2.getAttribute('marker-end')).toContain('-g1') - }) - - it('separates a shared vertical terminal stem instead of leaving it collapsed', () => { - const base: ElkEdge = { - id: 'edge-stem', - source: 'n1', - target: 'n2', - sections: [{ - startPoint: { x: 10, y: 20 }, - bendPoints: [ - { x: 60, y: 20 }, - { x: 60, y: 80 }, - { x: 120, y: 80 }, - { x: 120, y: 140 }, - ], - endPoint: { x: 180, y: 140 }, - }], - } - - const { container: c1 } = renderInSvg( - - ) - const { container: c2 } = renderInSvg( - - ) - - const d1 = c1.querySelector('path[marker-end]')!.getAttribute('d')! - const d2 = c2.querySelector('path[marker-end]')!.getAttribute('d')! - const points1 = [...d1.matchAll(/[-\d.]+ [-\d.]+/g)].map(match => match[0].split(' ').map(Number)) - const points2 = [...d2.matchAll(/[-\d.]+ [-\d.]+/g)].map(match => match[0].split(' ').map(Number)) - - expect(points1[3][0]).not.toBe(points2[3][0]) - expect(points1[4][0]).not.toBe(points2[4][0]) - }) - - it('expands separation when expanded is true', () => { - const base: ElkEdge = { - id: 'edge-y', - source: 'n1', - target: 'n2', - sections: [{ startPoint: { x: 0, y: 0 }, endPoint: { x: 100, y: 0 } }], - } - - const { container: cA } = renderInSvg( - - ) - const { container: cB } = renderInSvg( - - ) - - // Compare the end X coordinate embedded in the 'd'; expanded path should have different coordinates - const dA = cA.querySelector('path[marker-end]')!.getAttribute('d')! - const dB = cB.querySelector('path[marker-end]')!.getAttribute('d')! - expect(dA).not.toBe(dB) - }) -}) diff --git a/gaia/cli/templates/pages/src/__tests__/EdgeRenderer.test.tsx b/gaia/cli/templates/pages/src/__tests__/EdgeRenderer.test.tsx deleted file mode 100644 index b8027a7f5..000000000 --- a/gaia/cli/templates/pages/src/__tests__/EdgeRenderer.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { describe, it, expect } from 'vitest' -import EdgeRenderer from '../components/EdgeRenderer' -import type { ElkEdge } from '../hooks/useElkLayout' -import type { GraphEdge } from '../types' - -function renderInSvg(element: React.ReactElement) { - return render( - - {element} - , - ) -} - -describe('EdgeRenderer', () => { - it('renders stronger styling and a halo for external-connected edges', () => { - const layoutEdge: ElkEdge = { - id: 'edge-1', - source: 'ext-a1', - target: 'internal-1', - sections: [{ - startPoint: { x: 40, y: 50 }, - bendPoints: [ - { x: 64, y: 50 }, - { x: 64, y: 100 }, - { x: 182, y: 100 }, - ], - endPoint: { x: 200, y: 100 }, - }], - } - const graphEdge: GraphEdge = { source: 'ext-a1', target: 'internal-1', role: 'premise' } - - const { container } = renderInSvg( - , - ) - - const pathElement = container.querySelector('path[marker-end]') - const markerElement = container.querySelector('marker') - const markerPath = markerElement?.querySelector('path') - - expect(pathElement).toHaveAttribute('marker-end', expect.stringContaining('arrow-')) - expect(pathElement).toHaveAttribute('stroke-width', '2') - expect(markerElement).toHaveAttribute('markerWidth', '8') - expect(markerElement).toHaveAttribute('markerHeight', '8') - expect(markerPath).toHaveAttribute('fill', '#555') - expect(screen.getByTestId('external-edge-halo')).toBeInTheDocument() - }) -}) diff --git a/gaia/cli/templates/pages/src/__tests__/LanguageSwitch.test.tsx b/gaia/cli/templates/pages/src/__tests__/LanguageSwitch.test.tsx deleted file mode 100644 index f26323563..000000000 --- a/gaia/cli/templates/pages/src/__tests__/LanguageSwitch.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' -import LanguageSwitch from '../components/LanguageSwitch' - -describe('LanguageSwitch', () => { - it('renders EN and chinese buttons', () => { - render( {}} />) - expect(screen.getByText('EN')).toBeInTheDocument() - expect(screen.getByText('中文')).toBeInTheDocument() - }) - - it('marks the active language button', () => { - const { rerender } = render( {}} />) - expect(screen.getByText('EN').className).toMatch(/active/) - expect(screen.getByText('中文').className).not.toMatch(/active/) - - rerender( {}} />) - expect(screen.getByText('中文').className).toMatch(/active/) - expect(screen.getByText('EN').className).not.toMatch(/active/) - }) - - it('calls onChange with zh when clicking chinese button', () => { - const onChange = vi.fn() - render() - fireEvent.click(screen.getByText('中文')) - expect(onChange).toHaveBeenCalledWith('zh') - }) - - it('calls onChange with en when clicking EN button', () => { - const onChange = vi.fn() - render() - fireEvent.click(screen.getByText('EN')) - expect(onChange).toHaveBeenCalledWith('en') - }) -}) diff --git a/gaia/cli/templates/pages/src/__tests__/ModuleSubgraph.dismiss.test.tsx b/gaia/cli/templates/pages/src/__tests__/ModuleSubgraph.dismiss.test.tsx deleted file mode 100644 index df96baa56..000000000 --- a/gaia/cli/templates/pages/src/__tests__/ModuleSubgraph.dismiss.test.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import ModuleSubgraph from '../components/ModuleSubgraph' -import type { GraphNode, GraphEdge } from '../types' - -// Mock elkjs -vi.mock('elkjs/lib/elk.bundled.js', () => ({ - default: class { - layout(graph: { children?: { id: string; width: number; height: number }[], edges?: any[] }) { - return Promise.resolve({ - ...graph, - width: 800, - height: 600, - children: (graph.children ?? []).map((c, i) => ({ - ...c, - x: i * 150, - y: i * 80, - width: c.width || 140, - height: c.height || 48, - })), - edges: (graph.edges ?? []).map((edge: { id: string; sources: string[]; targets: string[] }, i: number) => ({ - id: edge.id, - source: edge.sources[0], - target: edge.targets[0], - sections: [{ startPoint: { x: i * 150 + 120, y: i * 80 + 24 }, endPoint: { x: i * 150 + 240, y: i * 80 + 24 } }], - })), - }) - } - }, -})) - -const nodes: GraphNode[] = [ - { id: 'a', label: 'A', type: 'claim', module: 'm1', content: '', exported: false, metadata: {}, prior: null, belief: null }, - { id: 'b', label: 'B', type: 'claim', module: 'm1', content: '', exported: false, metadata: {}, prior: null, belief: null }, -] - -const edges: GraphEdge[] = [ - { source: 'a', target: 'b', role: 'premise' }, - { source: 'a', target: 'b', role: 'premise' }, -] - -describe('ModuleSubgraph dismiss via scrim', () => { - beforeEach(() => vi.clearAllMocks()) - - it('shows scrim when a node is selected and closes on click', async () => { - const { container } = render( - {}} onNavigateToModule={() => {}} /> - ) - - // Wait for layout - await waitFor(() => expect(container.querySelector('svg')).toBeInTheDocument()) - - // Click the first node to open the panel - const graphNodes = container.querySelectorAll('g.graph-node') - expect(graphNodes.length).toBeGreaterThan(0) - fireEvent.click(graphNodes[0]) - - // Scrim should appear - await waitFor(() => expect(screen.getByLabelText('graph-scrim')).toBeInTheDocument()) - - // Click scrim to close - fireEvent.click(screen.getByLabelText('graph-scrim')) - - await waitFor(() => expect(screen.queryByLabelText('graph-scrim')).not.toBeInTheDocument()) - }) - - it('does not close on scrim click if dragging occurred (guarded by isDragging)', async () => { - const { container } = render( - {}} onNavigateToModule={() => {}} /> - ) - - await waitFor(() => expect(container.querySelector('svg')).toBeInTheDocument()) - - // Open panel - const graphNodes = container.querySelectorAll('g.graph-node') - fireEvent.click(graphNodes[0]) - await waitFor(() => expect(screen.getByLabelText('graph-scrim')).toBeInTheDocument()) - - // Simulate drag sequence then click scrim - const canvas = screen.getByLabelText('graph-scrim') - fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 }) - fireEvent.mouseMove(canvas, { clientX: 40, clientY: 40 }) - fireEvent.mouseUp(canvas, { clientX: 40, clientY: 40 }) - - fireEvent.click(canvas) - - // Best-effort check: scrim may still be present (no close) — depends on internal isDragging timing - // Here we only assert it didn't immediately disappear. - expect(screen.getByLabelText('graph-scrim')).toBeInTheDocument() - }) -}) diff --git a/gaia/cli/templates/pages/src/__tests__/ModuleSubgraph.layout.test.ts b/gaia/cli/templates/pages/src/__tests__/ModuleSubgraph.layout.test.ts deleted file mode 100644 index 8b010ce99..000000000 --- a/gaia/cli/templates/pages/src/__tests__/ModuleSubgraph.layout.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { describe, it, expect } from 'vitest' -import type { ExternalRef } from '../hooks/useGraphData' -import type { ElkEdge, ElkNode } from '../hooks/useElkLayout' -import { - buildExternalModuleGroups, - adjustExternalLayout, - computeLayoutBounds, - computeModuleGroups, -} from '../components/ModuleSubgraph' - -const externalRefs: ExternalRef[] = [ - { id: 'ext-a1', sourceModule: 'module-a', label: 'A1' }, - { id: 'ext-b1', sourceModule: 'module-b', label: 'B1' }, - { id: 'ext-a2', sourceModule: 'module-a', label: 'A2' }, - { id: 'ext-unknown', sourceModule: '', label: 'Unknown' }, -] - -const rawLayoutNodes: ElkNode[] = [ - { id: 'internal-1', x: 300, y: 120, width: 120, height: 48 }, - { id: 'internal-2', x: 520, y: 180, width: 120, height: 48 }, - { id: 'ext-a1', x: 60, y: 90, width: 120, height: 48 }, - { id: 'ext-b1', x: 210, y: 220, width: 120, height: 48 }, - { id: 'ext-a2', x: 90, y: 260, width: 120, height: 48 }, - { id: 'ext-unknown', x: 260, y: 320, width: 120, height: 48 }, -] - -const rawLayoutEdges: ElkEdge[] = [ - { - id: 'e0', - source: 'ext-a1', - target: 'internal-1', - sections: [{ startPoint: { x: 180, y: 114 }, endPoint: { x: 300, y: 120 } }], - }, - { - id: 'e1', - source: 'internal-1', - target: 'ext-a2', - sections: [{ startPoint: { x: 360, y: 144 }, endPoint: { x: 150, y: 284 } }], - }, - { - id: 'e2', - source: 'internal-1', - target: 'ext-b1', - sections: [{ startPoint: { x: 360, y: 144 }, endPoint: { x: 270, y: 244 } }], - }, - { - id: 'e3', - source: 'internal-1', - target: 'internal-2', - sections: [{ startPoint: { x: 360, y: 144 }, endPoint: { x: 520, y: 180 } }], - }, -] - -function boxesOverlap( - a: { x: number; y: number; width: number; height: number }, - b: { x: number; y: number; width: number; height: number }, -): boolean { - return a.x < b.x + b.width - && a.x + a.width > b.x - && a.y < b.y + b.height - && a.y + a.height > b.y -} - -function computePreferredGroupY( - groupSourceModule: string, - nodes: ElkNode[], - edges: ElkEdge[], - refs: ExternalRef[], -): number { - const groupNodeIds = refs - .filter(ref => (ref.sourceModule || 'External') === groupSourceModule) - .map(ref => ref.id) - const nodeMap = new Map(nodes.map(node => [node.id, node])) - const connectedCenters = edges - .filter(edge => groupNodeIds.includes(edge.source) || groupNodeIds.includes(edge.target)) - .flatMap(edge => [edge.source, edge.target]) - .filter(nodeId => !groupNodeIds.includes(nodeId) && !nodeId.startsWith('ext-')) - .map(nodeId => nodeMap.get(nodeId)) - .filter((node): node is ElkNode => node != null) - .map(node => node.y + node.height / 2) - - const groupNodes = groupNodeIds - .map(nodeId => nodeMap.get(nodeId)) - .filter((node): node is ElkNode => node != null) - const totalStackHeight = groupNodes.reduce( - (sum, node, index) => sum + node.height + (index === 0 ? 0 : 20), - 0, - ) - const preferredCenterY = connectedCenters.reduce((sum, center) => sum + center, 0) / connectedCenters.length - - return preferredCenterY - totalStackHeight / 2 - 20 -} - -describe('buildExternalModuleGroups', () => { - it('groups external refs by normalized sourceModule in first-seen order', () => { - expect(buildExternalModuleGroups(externalRefs)).toEqual([ - { groupKey: 'module-a', sourceModule: 'module-a', nodeIds: ['ext-a1', 'ext-a2'] }, - { groupKey: 'module-b', sourceModule: 'module-b', nodeIds: ['ext-b1'] }, - { - groupKey: '__gaia_unnamed_external__', - sourceModule: 'External', - nodeIds: ['ext-unknown'], - }, - ]) - }) - - it('preserves first-seen node order within each external group', () => { - expect(buildExternalModuleGroups(externalRefs)[0]?.nodeIds).toEqual(['ext-a1', 'ext-a2']) - }) - - it('keeps unnamed externals distinct from a real module named External', () => { - expect(buildExternalModuleGroups([ - { id: 'ext-real', sourceModule: 'External' }, - { id: 'ext-unnamed', sourceModule: '' }, - ])).toEqual([ - { groupKey: 'External', sourceModule: 'External', nodeIds: ['ext-real'] }, - { - groupKey: '__gaia_unnamed_external__', - sourceModule: 'External', - nodeIds: ['ext-unnamed'], - }, - ]) - }) -}) - -describe('adjustExternalLayout', () => { - it('repositions only external nodes', () => { - const adjusted = adjustExternalLayout(rawLayoutNodes, rawLayoutEdges, externalRefs) - const internal = adjusted.nodes.find(n => n.id === 'internal-1') - expect(internal).toEqual(rawLayoutNodes[0]) - }) - - it('places all external module groups in a shared lane left of the full internal footprint', () => { - const adjusted = adjustExternalLayout(rawLayoutNodes, rawLayoutEdges, externalRefs) - const groups = computeModuleGroups(adjusted.nodes, externalRefs, 20) - const externalNodes = adjusted.nodes.filter(node => node.id.startsWith('ext-')) - const internalNodes = adjusted.nodes.filter(node => !node.id.startsWith('ext-')) - const internalOnlyEdges = adjusted.edges.filter( - edge => !edge.source.startsWith('ext-') && !edge.target.startsWith('ext-'), - ) - const internalBounds = computeLayoutBounds(internalNodes, internalOnlyEdges, []) - - expect(new Set(groups.map(group => group.x)).size).toBe(1) - expect(Math.max(...externalNodes.map(node => node.x + node.width))).toBeLessThan( - internalBounds.minX, - ) - }) - - it('preserves the fixed horizontal lane gap at the rendered group-box level even when external node widths differ', () => { - const variableWidthNodes: ElkNode[] = rawLayoutNodes.map(node => { - if (node.id === 'ext-a1' || node.id === 'ext-a2') { - return { ...node, width: 180 } - } - if (node.id === 'ext-b1') { - return { ...node, width: 100 } - } - return node - }) - const adjusted = adjustExternalLayout(variableWidthNodes, rawLayoutEdges, externalRefs) - const groups = computeModuleGroups(adjusted.nodes, externalRefs, 20) - const internalNodes = adjusted.nodes.filter(node => !node.id.startsWith('ext-')) - const internalOnlyEdges = adjusted.edges.filter( - edge => !edge.source.startsWith('ext-') && !edge.target.startsWith('ext-'), - ) - const internalBounds = computeLayoutBounds(internalNodes, internalOnlyEdges, []) - - expect(new Set(groups.map(group => group.x)).size).toBe(1) - for (const group of groups) { - expect(internalBounds.minX - (group.x + group.width)).toBe(48) - } - }) - - it('keeps the external lane left of internal-only edge geometry that protrudes beyond node bounds', () => { - const protrudingInternalEdges: ElkEdge[] = [ - ...rawLayoutEdges.slice(0, 3), - { - ...rawLayoutEdges[3], - sections: [{ - startPoint: { x: 360, y: 144 }, - bendPoints: [{ x: 240, y: 144 }], - endPoint: { x: 520, y: 180 }, - }], - }, - ] - - const adjusted = adjustExternalLayout(rawLayoutNodes, protrudingInternalEdges, externalRefs) - const groups = computeModuleGroups(adjusted.nodes, externalRefs, 20) - const externalNodes = adjusted.nodes.filter(node => node.id.startsWith('ext-')) - const internalNodes = adjusted.nodes.filter(node => !node.id.startsWith('ext-')) - const internalOnlyEdges = adjusted.edges.filter( - edge => !edge.source.startsWith('ext-') && !edge.target.startsWith('ext-'), - ) - const internalBounds = computeLayoutBounds(internalNodes, internalOnlyEdges, []) - - expect(new Set(groups.map(group => group.x)).size).toBe(1) - expect(Math.max(...externalNodes.map(node => node.x + node.width))).toBeLessThan( - internalBounds.minX, - ) - }) - - it('keeps module groups vertically near their connected internal targets', () => { - const adjusted = adjustExternalLayout(rawLayoutNodes, rawLayoutEdges, externalRefs) - const groups = computeModuleGroups(adjusted.nodes, externalRefs, 20) - const moduleA = groups.find(group => group.sourceModule === 'module-a')! - const moduleB = groups.find(group => group.sourceModule === 'module-b')! - - expect(moduleA.y).toBeLessThan(moduleB.y) - }) - - it('keeps the first group at its preferred Y when nothing earlier forces overlap resolution', () => { - const adjusted = adjustExternalLayout(rawLayoutNodes, rawLayoutEdges, externalRefs) - const groups = computeModuleGroups(adjusted.nodes, externalRefs, 20) - const moduleA = groups.find(group => group.sourceModule === 'module-a')! - - expect(moduleA.y).toBe(computePreferredGroupY('module-a', rawLayoutNodes, rawLayoutEdges, externalRefs)) - }) - - it('preserves the fixed external gap between rendered module group boxes without moving internal nodes', () => { - const adjusted = adjustExternalLayout(rawLayoutNodes, rawLayoutEdges, externalRefs) - const groups = computeModuleGroups(adjusted.nodes, externalRefs, 20) - const internal = adjusted.nodes.find(node => node.id === 'internal-1') - const realizedGap = groups[1]!.y - (groups[0]!.y + groups[0]!.height) - - expect(boxesOverlap(groups[0]!, groups[1]!)).toBe(false) - expect(realizedGap).toBe(24) - expect(internal).toEqual(rawLayoutNodes.find(node => node.id === 'internal-1')) - }) - - it('updates edge geometry when a moved external node is the source of an edge', () => { - const adjusted = adjustExternalLayout(rawLayoutNodes, rawLayoutEdges, externalRefs) - expect(adjusted.edges[0].sections?.[0].startPoint).not.toEqual(rawLayoutEdges[0].sections?.[0].startPoint) - expect(adjusted.edges[0].sections?.[0].endPoint).toEqual(rawLayoutEdges[0].sections?.[0].endPoint) - }) - - it('moves bend points together with a moved external source node', () => { - const bentEdges: ElkEdge[] = [ - { - ...rawLayoutEdges[0], - sections: [{ - startPoint: { x: 180, y: 114 }, - bendPoints: [{ x: 220, y: 114 }, { x: 220, y: 150 }], - endPoint: { x: 300, y: 120 }, - }], - }, - ...rawLayoutEdges.slice(1), - ] - - const adjusted = adjustExternalLayout(rawLayoutNodes, bentEdges, externalRefs) - expect(adjusted.edges[0].sections?.[0].bendPoints).not.toEqual(bentEdges[0].sections?.[0].bendPoints) - }) - - it('updates edge geometry when a moved external node is the target of an edge', () => { - const adjusted = adjustExternalLayout(rawLayoutNodes, rawLayoutEdges, externalRefs) - expect(adjusted.edges[1].sections?.[0].endPoint).not.toEqual(rawLayoutEdges[1].sections?.[0].endPoint) - expect(adjusted.edges[1].sections?.[0].startPoint).toEqual(rawLayoutEdges[1].sections?.[0].startPoint) - }) - - it('recomputes orthogonal bend points for edges that touch external nodes', () => { - const adjusted = adjustExternalLayout(rawLayoutNodes, rawLayoutEdges, externalRefs) - const externalSourceEdge = adjusted.edges.find(edge => edge.id === 'e0')! - const externalTargetEdge = adjusted.edges.find(edge => edge.id === 'e1')! - - expect(externalSourceEdge.sections?.[0].bendPoints).toHaveLength(3) - expect(externalTargetEdge.sections?.[0].bendPoints).toHaveLength(3) - }) - - it('routes external-connected edges with orthogonal first and last segments', () => { - const adjusted = adjustExternalLayout(rawLayoutNodes, rawLayoutEdges, externalRefs) - const section = adjusted.edges.find(edge => edge.id === 'e0')!.sections?.[0]! - const [firstBend, secondBend, thirdBend] = section.bendPoints! - - expect(firstBend.y).toBe(section.startPoint.y) - expect(secondBend.x).toBe(firstBend.x) - expect(thirdBend.y).toBe(section.endPoint.y) - }) - - it('preserves geometry for edges whose endpoints are both internal', () => { - const adjusted = adjustExternalLayout(rawLayoutNodes, rawLayoutEdges, externalRefs) - expect(adjusted.edges[3]).toEqual(rawLayoutEdges[3]) - }) -}) - -describe('computeModuleGroups', () => { - it('computes module boxes from adjusted node positions rather than raw layout positions', () => { - const adjusted = adjustExternalLayout(rawLayoutNodes, rawLayoutEdges, externalRefs) - const groups = computeModuleGroups(adjusted.nodes, externalRefs, 20) - const moduleA = groups.find(g => g.sourceModule === 'module-a')! - const adjustedA1 = adjusted.nodes.find(node => node.id === 'ext-a1')! - const adjustedA2 = adjusted.nodes.find(node => node.id === 'ext-a2')! - - expect(moduleA.x).toBe(Math.min(adjustedA1.x, adjustedA2.x) - 20) - expect(moduleA.y).toBe(Math.min(adjustedA1.y, adjustedA2.y) - 20) - expect(moduleA.width).toBeGreaterThan(120) - }) -}) diff --git a/gaia/cli/templates/pages/src/__tests__/ModuleSubgraph.zoom.test.tsx b/gaia/cli/templates/pages/src/__tests__/ModuleSubgraph.zoom.test.tsx deleted file mode 100644 index 0287f43e7..000000000 --- a/gaia/cli/templates/pages/src/__tests__/ModuleSubgraph.zoom.test.tsx +++ /dev/null @@ -1,348 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import ModuleSubgraph from '../components/ModuleSubgraph' -import { adjustExternalLayout, computeLayoutBounds, computeModuleGroups } from '../components/ModuleSubgraph' -import type { GraphNode, GraphEdge } from '../types' - -function defaultMockLayout(graph: { children?: { id: string; width: number; height: number }[], edges?: { id: string; sources: string[]; targets: string[] }[] }) { - return Promise.resolve({ - ...graph, - width: 800, - height: 600, - children: (graph.children ?? []).map((c, i) => ({ - ...c, - x: i * 150, - y: i * 80, - width: c.width || 140, - height: c.height || 48, - })), - edges: (graph.edges ?? []).map((edge: { id: string; sources: string[]; targets: string[] }, i: number) => ({ - id: edge.id, - source: edge.sources[0], - target: edge.targets[0], - sections: [{ - startPoint: { x: i * 150 + 120, y: i * 80 + 24 }, - endPoint: { x: i * 150 + 240, y: i * 80 + 24 }, - }], - })), - }) -} - -let mockLayoutImpl = defaultMockLayout - -// Mock elkjs -vi.mock('elkjs/lib/elk.bundled.js', () => ({ - default: class { - layout(graph: { children?: { id: string; width: number; height: number }[], edges?: { id: string; sources: string[]; targets: string[] }[] }) { - return mockLayoutImpl(graph) - } - }, -})) - -const mockNodes: GraphNode[] = [ - { id: 'a', label: 'Node A', type: 'claim', module: 'm1', content: 'Test content A', - exported: false, metadata: {}, prior: 0.6, belief: 0.8 }, - { id: 'b', label: 'Node B', type: 'question', module: 'm1', content: 'Test content B', - exported: false, metadata: {}, prior: null, belief: null }, - { id: 'c', label: 'Node C', type: 'setting', module: 'm2', content: 'External node', - exported: false, metadata: { _external: true, _sourceModule: 'm2' }, prior: null, belief: null }, -] - -const mockEdges: GraphEdge[] = [ - { source: 'a', target: 'b', role: 'premise' }, - { source: 'c', target: 'a', role: 'conclusion' }, -] - -const overlappingNodes: GraphNode[] = [ - { id: 's1', label: 'Source 1', type: 'claim', module: 'm1', content: '', exported: false, metadata: {}, prior: null, belief: null }, - { id: 's2', label: 'Source 2', type: 'claim', module: 'm1', content: '', exported: false, metadata: {}, prior: null, belief: null }, - { id: 't1', label: 'Target 1', type: 'claim', module: 'm1', content: '', exported: false, metadata: {}, prior: null, belief: null }, - { id: 't2', label: 'Target 2', type: 'claim', module: 'm1', content: '', exported: false, metadata: {}, prior: null, belief: null }, -] - -const overlappingEdges: GraphEdge[] = [ - { source: 's1', target: 't1', role: 'premise' }, - { source: 's2', target: 't2', role: 'premise' }, -] - -function expectedAdjustedBounds() { - const rawNodes = [ - { id: 'a', x: 0, y: 0, width: 120, height: 48 }, - { id: 'b', x: 150, y: 80, width: 120, height: 48 }, - { id: 'c', x: 300, y: 160, width: 120, height: 48 }, - ] - const rawEdges = [ - { - id: 'e0', - source: 'a', - target: 'b', - sections: [{ startPoint: { x: 120, y: 24 }, endPoint: { x: 240, y: 24 } }], - }, - { - id: 'e1', - source: 'c', - target: 'a', - sections: [{ startPoint: { x: 270, y: 104 }, endPoint: { x: 390, y: 104 } }], - }, - ] - const externalRefs = [{ id: 'c', sourceModule: 'm2', label: 'Node C' }] - const adjusted = adjustExternalLayout(rawNodes, rawEdges, externalRefs) - const groups = computeModuleGroups(adjusted.nodes, externalRefs, 20) - return computeLayoutBounds(adjusted.nodes, adjusted.edges, groups) -} - -describe('ModuleSubgraph - Zoom & Pan', () => { - beforeEach(() => { - vi.clearAllMocks() - mockLayoutImpl = defaultMockLayout - }) - - it('keeps the external lane inside the rendered svg bounds', async () => { - const { container } = render( - {}} - onNavigateToModule={() => {}} - /> - ) - - const bounds = expectedAdjustedBounds() - - await waitFor(() => expect(container.querySelector('svg')).toBeInTheDocument()) - - const svg = container.querySelector('svg')! - const width = Number(svg.getAttribute('width')) - const height = Number(svg.getAttribute('height')) - const positiveOnlyWidth = bounds.maxX + 80 - const positiveOnlyHeight = bounds.maxY + 80 - - expect(bounds.minX).toBeLessThan(0) - expect(width).toBeGreaterThan(positiveOnlyWidth) - expect(height).toBeGreaterThan(positiveOnlyHeight) - }) - - it('renders zoom control buttons', async () => { - render( - {}} - onNavigateToModule={() => {}} - /> - ) - - await waitFor(() => { - expect(screen.getByTitle(/zoom in/i)).toBeInTheDocument() - expect(screen.getByTitle(/zoom out/i)).toBeInTheDocument() - expect(screen.getByTitle(/reset view/i)).toBeInTheDocument() - }) - }) - - it('displays current zoom level', async () => { - render( - {}} - onNavigateToModule={() => {}} - /> - ) - - await waitFor(() => { - // Zoom level shows something like "100%" after auto-fit - expect(screen.getByText(/\d+%/)).toBeInTheDocument() - }) - }) - - it('zooms in when clicking plus button', async () => { - render( - {}} - onNavigateToModule={() => {}} - /> - ) - - await waitFor(() => expect(screen.getByTitle(/zoom in/i)).toBeInTheDocument()) - - // Get initial zoom level - const zoomDisplay = screen.getByText(/\d+%/) - const initialValue = parseInt(zoomDisplay.textContent || '0', 10) - - // Click zoom in - fireEvent.click(screen.getByTitle(/zoom in/i)) - - // Wait for zoom to increase - await waitFor(() => { - const newZoomDisplay = screen.getByText(/\d+%/) - const newValue = parseInt(newZoomDisplay.textContent || '0', 10) - expect(newValue).toBeGreaterThan(initialValue) - }) - }) - - it('zooms out when clicking minus button', async () => { - render( - {}} - onNavigateToModule={() => {}} - /> - ) - - await waitFor(() => expect(screen.getByTitle(/zoom out/i)).toBeInTheDocument()) - - // Get initial zoom level - const zoomDisplay = screen.getByText(/\d+%/) - // First zoom in so we can then zoom out (avoid going below minimum) - fireEvent.click(screen.getByTitle(/zoom in/i)) - fireEvent.click(screen.getByTitle(/zoom in/i)) - - await waitFor(() => { - const zoomedInDisplay = screen.getByText(/\d+%/) - const zoomedInValue = parseInt(zoomedInDisplay.textContent || '0', 10) - expect(zoomedInValue).toBeGreaterThan(100) - }) - - // Now zoom out - fireEvent.click(screen.getByTitle(/zoom out/i)) - - await waitFor(() => { - const newZoomDisplay = screen.getByText(/\d+%/) - const newValue = parseInt(newZoomDisplay.textContent || '0', 10) - expect(newValue).toBeLessThan(parseInt(zoomDisplay.textContent || '0', 10) + 40) - }) - }) - - it('resets zoom when clicking reset button', async () => { - render( - {}} - onNavigateToModule={() => {}} - /> - ) - - await waitFor(() => expect(screen.getByTitle(/reset view/i)).toBeInTheDocument()) - - // Get initial zoom level - const zoomDisplay = screen.getByText(/\d+%/) - const initialZoom = zoomDisplay.textContent - - // Zoom in first - fireEvent.click(screen.getByTitle(/zoom in/i)) - await waitFor(() => { - const newZoom = screen.getByText(/\d+%/).textContent - expect(newZoom).not.toBe(initialZoom) - }) - - // Then reset - fireEvent.click(screen.getByTitle(/reset view/i)) - await waitFor(() => { - // After reset, should be back to a valid zoom level - expect(screen.getByText(/\d+%/)).toBeInTheDocument() - }) - }) - - it('limits zoom to minimum of 10%', async () => { - render( - {}} - onNavigateToModule={() => {}} - /> - ) - - await waitFor(() => expect(screen.getByTitle(/zoom out/i)).toBeInTheDocument()) - - // Click zoom out many times - const zoomOutBtn = screen.getByTitle(/zoom out/i) - for (let i = 0; i < 20; i++) { - fireEvent.click(zoomOutBtn) - } - - await waitFor(() => { - const zoomDisplay = screen.getByText(/\d+%/) - const value = parseInt(zoomDisplay.textContent || '0', 10) - expect(value).toBeLessThanOrEqual(11) // Should hit minimum around 10% - }) - }) - - it('separates edges that share the same terminal segment near the right side even with different endpoints', async () => { - mockLayoutImpl = (graph) => Promise.resolve({ - ...graph, - width: 800, - height: 600, - children: [ - { id: 's1', x: 0, y: 0, width: 120, height: 48 }, - { id: 's2', x: 0, y: 120, width: 120, height: 48 }, - { id: 't1', x: 260, y: 40, width: 120, height: 48 }, - { id: 't2', x: 260, y: 100, width: 120, height: 48 }, - ], - edges: [ - { - id: 'e0', - source: 's1', - target: 't1', - sections: [{ - startPoint: { x: 120, y: 24 }, - bendPoints: [ - { x: 180, y: 24 }, - { x: 180, y: 84 }, - { x: 362, y: 84 }, - ], - endPoint: { x: 380, y: 84 }, - }], - }, - { - id: 'e1', - source: 's2', - target: 't2', - sections: [{ - startPoint: { x: 120, y: 144 }, - bendPoints: [ - { x: 180, y: 144 }, - { x: 180, y: 84 }, - { x: 362, y: 84 }, - ], - endPoint: { x: 380, y: 84 }, - }], - }, - ], - }) - - const { container } = render( - {}} - onNavigateToModule={() => {}} - /> - ) - - await waitFor(() => expect(container.querySelector('svg')).toBeInTheDocument()) - - const renderedEdges = [...container.querySelectorAll('path[marker-end]')] - expect(renderedEdges).toHaveLength(2) - - const terminalSegments = renderedEdges.map(path => { - const d = path.getAttribute('d') ?? '' - const matches = [...d.matchAll(/[-\d.]+ [-\d.]+/g)].map(match => match[0]) - return matches.slice(-2) - }) - - expect(terminalSegments[0]).not.toEqual(terminalSegments[1]) - }) -}) diff --git a/gaia/cli/templates/pages/src/__tests__/SectionView.test.tsx b/gaia/cli/templates/pages/src/__tests__/SectionView.test.tsx deleted file mode 100644 index ba0c85aa3..000000000 --- a/gaia/cli/templates/pages/src/__tests__/SectionView.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' -import SectionView from '../components/SectionView' - -beforeEach(() => { - vi.restoreAllMocks() -}) - -describe('SectionView', () => { - it('fetches markdown and renders content', async () => { - const md = '# Motivation\n\nThis section explains the motivation.' - - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(md, { status: 200 }), - ) - - render() - - await waitFor(() => { - expect(screen.getByText('This section explains the motivation.')).toBeInTheDocument() - }) - }) - - it('falls back to default md when zh fetch fails', async () => { - const fallbackMd = '# Motivation\n\nFallback content here.' - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = typeof input === 'string' ? input : (input as Request).url - if (url.includes('-zh.md')) { - return new Response('', { status: 404 }) - } - return new Response(fallbackMd, { status: 200 }) - }) - - render() - - await waitFor(() => { - expect(screen.getByText('Fallback content here.')).toBeInTheDocument() - }) - }) - - it('rewrites relative image paths', async () => { - const md = '![diagram](foo.png)' - - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(md, { status: 200 }), - ) - - render() - - await waitFor(() => { - const img = screen.getByRole('img') as HTMLImageElement - expect(img.src).toContain('data/assets/foo.png') - }) - }) -}) diff --git a/gaia/cli/templates/pages/src/__tests__/pages-workflow.test.ts b/gaia/cli/templates/pages/src/__tests__/pages-workflow.test.ts deleted file mode 100644 index a082e83d8..000000000 --- a/gaia/cli/templates/pages/src/__tests__/pages-workflow.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { readFileSync } from 'fs' -import { resolve } from 'path' - -describe('GitHub Actions pages workflow', () => { - const workflowPath = resolve(__dirname, '../../.github/workflows/pages.yml') - const content = readFileSync(workflowPath, 'utf-8') - - it('contains deploy-pages action', () => { - expect(content).toContain('deploy-pages') - }) - - it('contains npm ci step', () => { - expect(content).toContain('npm ci') - }) - - it('contains npm run build step', () => { - expect(content).toContain('npm run build') - }) - - it('triggers on push to main', () => { - expect(content).toContain('branches: [main]') - }) - - it('uploads pages artifact from docs/dist', () => { - expect(content).toContain('docs/dist') - }) -}) diff --git a/gaia/cli/templates/pages/src/__tests__/useChainHighlight.test.ts b/gaia/cli/templates/pages/src/__tests__/useChainHighlight.test.ts deleted file mode 100644 index 90470133b..000000000 --- a/gaia/cli/templates/pages/src/__tests__/useChainHighlight.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { computeUpstreamChain } from '../hooks/useChainHighlight' -import type { GraphEdge } from '../types' - -const edges: GraphEdge[] = [ - { source: 'a', target: 'strat_0', role: 'premise' }, - { source: 'strat_0', target: 'b', role: 'conclusion' }, - { source: 'b', target: 'strat_1', role: 'premise' }, - { source: 'strat_1', target: 'c', role: 'conclusion' }, -] - -describe('computeUpstreamChain', () => { - it('returns the clicked node itself when it has no premises', () => { - const chain = computeUpstreamChain('a', edges) - expect(chain).toEqual(new Set(['a'])) - }) - - it('traces back one step from a conclusion', () => { - const chain = computeUpstreamChain('b', edges) - expect(chain).toEqual(new Set(['a', 'strat_0', 'b'])) - }) - - it('traces back multiple steps', () => { - const chain = computeUpstreamChain('c', edges) - expect(chain).toEqual(new Set(['a', 'strat_0', 'b', 'strat_1', 'c'])) - }) - - it('returns only the node for disconnected nodes', () => { - const chain = computeUpstreamChain('d', edges) - expect(chain).toEqual(new Set(['d'])) - }) -}) diff --git a/gaia/cli/templates/pages/src/__tests__/useElkLayout.test.ts b/gaia/cli/templates/pages/src/__tests__/useElkLayout.test.ts deleted file mode 100644 index 0aa62bcdf..000000000 --- a/gaia/cli/templates/pages/src/__tests__/useElkLayout.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { renderHook, waitFor, act } from '@testing-library/react' - -const { layoutMock } = vi.hoisted(() => ({ - layoutMock: vi.fn(), -})) - -vi.mock('elkjs/lib/elk.bundled.js', () => ({ - default: class { layout = layoutMock }, -})) - -import { buildElkGraph, useElkLayout } from '../hooks/useElkLayout' -import type { GraphNode, GraphEdge } from '../types' - -const nodes: GraphNode[] = [ - { id: 'a', label: 'a', type: 'setting', module: 'm1', content: '', exported: false, metadata: {} }, - { id: 'strat_0', type: 'strategy', strategy_type: 'deduction', module: 'm1' }, - { id: 'b', label: 'b', type: 'claim', module: 'm1', content: '', exported: false, metadata: {} }, -] - -const edges: GraphEdge[] = [ - { source: 'a', target: 'strat_0', role: 'premise' }, - { source: 'strat_0', target: 'b', role: 'conclusion' }, -] - -beforeEach(() => { - layoutMock.mockReset() - layoutMock.mockResolvedValue({ children: [], edges: [], width: 0, height: 0 }) -}) - -describe('buildElkGraph', () => { - it('produces ELK-compatible graph with children and edges', () => { - const elk = buildElkGraph(nodes, edges) - expect(elk.id).toBe('root') - expect(elk.children).toHaveLength(3) - expect(elk.edges).toHaveLength(2) - }) - - it('assigns different dimensions by node type', () => { - const elk = buildElkGraph(nodes, edges) - const setting = elk.children!.find(c => c.id === 'a')! - const strategy = elk.children!.find(c => c.id === 'strat_0')! - expect(setting.width).toBeGreaterThan(strategy.width!) - }) -}) - -describe('useElkLayout', () => { - it('clears layout when ELK rejects the current request', async () => { - layoutMock.mockRejectedValueOnce(new Error('elk failed')) - - const { result } = renderHook(() => useElkLayout(nodes, edges)) - - await waitFor(() => { - expect(result.current).toBeNull() - }) - }) -}) diff --git a/gaia/cli/templates/pages/src/__tests__/useGraphData.test.ts b/gaia/cli/templates/pages/src/__tests__/useGraphData.test.ts deleted file mode 100644 index 0bc093f14..000000000 --- a/gaia/cli/templates/pages/src/__tests__/useGraphData.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { filterNodesByModule, getExternalRefs } from '../hooks/useGraphData' -import type { GraphNode, GraphEdge } from '../types' - -const nodes: GraphNode[] = [ - { id: 'a', label: 'a', type: 'claim', module: 'm1', content: '', exported: false, metadata: {} }, - { id: 'b', label: 'b', type: 'claim', module: 'm1', content: '', exported: false, metadata: {} }, - { id: 'c', label: 'c', type: 'claim', module: 'm2', content: '', exported: false, metadata: {} }, - { id: 'd', label: 'd', type: 'claim', module: '', content: '', exported: false, metadata: {} }, - { id: 'e', label: 'e', type: 'claim', module: 'm3', content: '', exported: false, metadata: {} }, - { id: 'strat_0', type: 'strategy', strategy_type: 'deduction', module: 'm1' }, -] - -const edges: GraphEdge[] = [ - { source: 'a', target: 'strat_0', role: 'premise' }, - { source: 'c', target: 'strat_0', role: 'premise' }, - { source: 'strat_0', target: 'b', role: 'conclusion' }, -] - -describe('filterNodesByModule', () => { - it('returns only nodes belonging to the given module', () => { - const result = filterNodesByModule(nodes, 'm1') - expect(result.map(n => n.id)).toEqual(['a', 'b', 'strat_0']) - }) -}) - -describe('getExternalRefs', () => { - it('finds nodes referenced by edges but not in the module', () => { - const moduleNodes = filterNodesByModule(nodes, 'm1') - const moduleNodeIds = new Set(moduleNodes.map(n => n.id)) - const refs = getExternalRefs(edges, moduleNodeIds, nodes) - expect(refs).toHaveLength(1) - expect(refs[0].id).toBe('c') - expect(refs[0].sourceModule).toBe('m2') - }) - - it('preserves first-seen external discovery order across modules and keeps empty module names empty', () => { - const moduleNodes = filterNodesByModule(nodes, 'm1') - const moduleNodeIds = new Set(moduleNodes.map(n => n.id)) - const refs = getExternalRefs( - [ - { source: 'e', target: 'strat_0', role: 'premise' }, - { source: 'strat_0', target: 'd', role: 'conclusion' }, - { source: 'c', target: 'strat_0', role: 'premise' }, - { source: 'e', target: 'b', role: 'background' }, - ], - moduleNodeIds, - nodes, - ) - - expect(refs).toEqual([ - { id: 'e', label: 'e', sourceModule: 'm3' }, - { id: 'd', label: 'd', sourceModule: '' }, - { id: 'c', label: 'c', sourceModule: 'm2' }, - ]) - }) -}) diff --git a/gaia/cli/templates/pages/src/components/DetailPanel.module.css b/gaia/cli/templates/pages/src/components/DetailPanel.module.css deleted file mode 100644 index 0f5c5b1ba..000000000 --- a/gaia/cli/templates/pages/src/components/DetailPanel.module.css +++ /dev/null @@ -1,179 +0,0 @@ -.panel { - position: fixed; - top: 0; - right: 0; - width: 400px; - height: 100vh; - overflow-y: auto; - background: #fff; - box-shadow: -2px 0 12px rgba(0, 0, 0, 0.15); - z-index: 10; - padding: 24px; - transform: translateX(0); - transition: transform 0.3s ease; -} - -.hidden { - transform: translateX(100%); -} - -.header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 16px; -} - -.header h2 { - margin: 0; - font-size: 18px; -} - -.badge { - display: inline-block; - padding: 2px 8px; - border-radius: 4px; - font-size: 12px; - background: #e0e7ff; - color: #3730a3; -} - -.exported { - color: #b45309; - font-size: 14px; -} - -.probBar { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 16px; - font-size: 14px; - color: #555; -} - -.probValue { - font-weight: 600; - font-family: monospace; -} - -.content { - margin-bottom: 16px; - line-height: 1.6; - -.contentBody { - margin: 0 0 8px; - font-size: 14px; - line-height: 1.6; - color: #1f2937; -} - -.contentMeta { - margin: 0; - font-size: 11px; - color: #9ca3af; - font-style: italic; -} - -.contentMetaRow { - display: flex; - gap: 16px; - margin-bottom: 3px; - font-size: 11px; - color: #9ca3af; -} - -.contentMetaRow dt { - margin: 0; - color: #d1d5db; - font-weight: 500; - text-transform: lowercase; -} - -.contentMetaRow dd { - margin: 0; - color: #9ca3af; -} - margin-bottom: 16px; -} - -.reasoning h3 { - margin: 0 0 8px; - font-size: 14px; - color: #666; -} - -.chainItem { - padding: 8px; - margin-bottom: 6px; - background: #f8f8f8; - border-radius: 4px; - font-size: 13px; -} - -.strategyType { - font-weight: 600; - color: #4338ca; -} - -.figure img { - max-width: 100%; - border-radius: 4px; - margin-bottom: 16px; -} - -.closeBtn { - position: absolute; - top: 16px; - right: 16px; - background: none; - border: none; - font-size: 20px; - cursor: pointer; - color: #666; -} - -.closeBtn:hover { - color: #222; -} - -.abduction { - margin-bottom: 16px; -} - -.abduction h3 { - margin: 0 0 8px; - font-size: 14px; - color: #7c3aed; -} - -.abductionRow { - display: flex; - align-items: center; - gap: 8px; - padding: 8px; - margin-bottom: 6px; - background: #f5f0ff; - border-radius: 4px; - border-left: 3px solid #7c3aed; - font-size: 13px; -} - -.abductionHypothesis, -.abductionAlternative { - display: flex; - align-items: center; - gap: 4px; - flex: 1; -} - -.abductionLabel { - font-weight: 600; - color: #7c3aed; -} - -.abductionVs { - font-weight: 700; - color: #666; - font-size: 12px; -} diff --git a/gaia/cli/templates/pages/src/components/DetailPanel.tsx b/gaia/cli/templates/pages/src/components/DetailPanel.tsx deleted file mode 100644 index e87e8d191..000000000 --- a/gaia/cli/templates/pages/src/components/DetailPanel.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import type { GraphNode, GraphEdge } from '../types' -import { isKnowledgeNode, isStrategyNode, isOperatorNode } from '../types' -import styles from './DetailPanel.module.css' - -interface Props { - node: GraphNode | null - edges: GraphEdge[] - nodesById: Record - onClose: () => void -} - -interface ParsedKnowledgeContent { - body: string - metadata: Array<{ label: string; value: string }> -} - -function formatProb(v: number | null | undefined): string { - return v != null ? v.toFixed(2) : '\u2014' -} - -function parseStructuredKnowledgeContent(content: string): ParsedKnowledgeContent | null { - const lines = content - .split('\n') - .map(line => line.trim()) - .filter(Boolean) - - if (lines.length < 2) return null - - const knownLabels = new Map([ - ['qid', 'QID'], - ['type', 'Type'], - ['role', 'Role'], - ['content', 'Content'], - ['source_ref', 'source_ref'], - ]) - - const metadata: Array<{ label: string; value: string }> = [] - let body: string | null = null - - for (const line of lines) { - const match = line.match(/^([A-Za-z_]+):\s*(.*)$/) - if (!match) return null - - const [, rawKey, rawValue] = match - const key = rawKey.toLowerCase() - const value = rawValue.trim() - - if (!value) return null - - if (key === 'content') { - if (body != null) return null - body = value - continue - } - - const label = knownLabels.get(key) - if (!label) return null - metadata.push({ label, value }) - } - - if (!body) return null - - return { body, metadata } -} - -export default function DetailPanel({ node, edges, nodesById, onClose }: Props) { - const incomingEdges = node ? edges.filter(e => e.target === node.id) : [] - const outgoingEdges = node ? edges.filter(e => e.source === node.id) : [] - const parsedKnowledgeContent = node && isKnowledgeNode(node) - ? parseStructuredKnowledgeContent(node.content) - : null - - return ( -
- {node && ( - <> - - -
-

{'label' in node ? node.label : node.id}

- {node.type} - {isKnowledgeNode(node) && node.exported && ( - {'\u2605'} - )} -
- - {isKnowledgeNode(node) && ( - <> -
- Prior: - {formatProb(node.prior)} - - Belief: - {formatProb(node.belief)} -
-
- {parsedKnowledgeContent ? ( - <> -

{parsedKnowledgeContent.body}

-

- {parsedKnowledgeContent.metadata - .filter(m => m.label !== 'Content') - .map(m => `${m.label}: ${m.value}`) - .join(' · ')} -

- - ) : ( -

{node.content}

- )} -
- - )} - - {isStrategyNode(node) && ( -
-

Strategy: {node.strategy_type}

- {node.reason &&

{node.reason}

} -
- )} - - {isOperatorNode(node) && ( -
-

Operator: {node.operator_type}

-
- )} - - {incomingEdges.length > 0 && ( -
-

Incoming

- {incomingEdges.map((edge, i) => { - const src = nodesById[edge.source] - return ( -
- {edge.role} - {' from '} - {src && 'label' in src ? src.label : edge.source} -
- ) - })} -
- )} - - {outgoingEdges.length > 0 && ( -
-

Outgoing

- {outgoingEdges.map((edge, i) => { - const tgt = nodesById[edge.target] - return ( -
- {edge.role} - {' to '} - {tgt && 'label' in tgt ? tgt.label : edge.target} -
- ) - })} -
- )} - - {isKnowledgeNode(node) && typeof node.metadata.figure === 'string' && ( -
- {`${node.label} -
- )} - - )} -
- ) -} diff --git a/gaia/cli/templates/pages/src/components/EdgeRenderer.tsx b/gaia/cli/templates/pages/src/components/EdgeRenderer.tsx deleted file mode 100644 index 88e659068..000000000 --- a/gaia/cli/templates/pages/src/components/EdgeRenderer.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import type { ElkEdge } from '../hooks/useElkLayout' -import type { GraphEdge } from '../types' - -interface Props { - layoutEdge: ElkEdge - graphEdge: GraphEdge | undefined - highlighted: boolean | null - // New props for overlapping-edge handling - groupIndex?: number - groupCount?: number - expanded?: boolean -} - -const OFFSET_STEP = 7 -const EXPAND_MULTIPLIER = 1.6 - -function buildPathD(section: NonNullable[number]): string { - const points = [ - section.startPoint, - ...(section.bendPoints ?? []), - section.endPoint, - ] - - return points - .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`) - .join(' ') -} - -function pointKey(point: { x: number; y: number }) { - return `${point.x},${point.y}` -} - -function unitNormal(start: { x: number; y: number }, end: { x: number; y: number }) { - const dx = end.x - start.x - const dy = end.y - start.y - const len = Math.hypot(dx, dy) || 1 - // Rotate by +90deg to get a consistent normal - return { nx: -dy / len, ny: dx / len } -} - -function offsetPoint(p: { x: number; y: number }, nx: number, ny: number, amount: number) { - return { x: p.x + nx * amount, y: p.y + ny * amount } -} - -export default function EdgeRenderer({ layoutEdge, graphEdge, highlighted, groupIndex = 0, groupCount = 1, expanded = false }: Props) { - const opacity = highlighted === false ? 0.1 : 1 - const role = graphEdge?.role ?? 'premise' - const isBackground = role === 'background' - const hasExternalEndpoint = layoutEdge.source.startsWith('ext-') || layoutEdge.target.startsWith('ext-') - const stroke = hasExternalEndpoint ? '#555' : isBackground ? '#999' : '#666' - const dashArray = isBackground ? '6,4' : undefined - const strokeWidth = hasExternalEndpoint ? 2 : 1.5 - const markerWidth = hasExternalEndpoint ? 8 : 6 - const markerHeight = hasExternalEndpoint ? 8 : 6 - - const section = layoutEdge.sections?.[0] - if (!section) return null - - // Compute offset amount for overlapping edges - let amount = (groupIndex - (groupCount - 1) / 2) * OFFSET_STEP - if (expanded) amount *= EXPAND_MULTIPLIER - - // Determine a stable normal from the last non-zero segment shared by the final path run. - const points = [section.startPoint, ...(section.bendPoints ?? []), section.endPoint] - const segmentStarts = points.slice(0, -1) - const segmentEnds = points.slice(1) - const finalStartKey = pointKey(segmentStarts[segmentStarts.length - 1] ?? section.startPoint) - - let refStart = segmentStarts[segmentStarts.length - 1] ?? section.startPoint - let refEnd = segmentEnds[segmentEnds.length - 1] ?? section.endPoint - - for (let index = segmentStarts.length - 2; index >= 0; index -= 1) { - if (pointKey(segmentEnds[index]) !== finalStartKey) { - break - } - refStart = segmentStarts[index] - refEnd = segmentEnds[index] - } - - const { nx, ny } = unitNormal(refStart, refEnd) - - // Apply uniform offset to all points (no endpoint fan-out) - const adjustedStart = offsetPoint(section.startPoint, nx, ny, amount) - const adjustedBends = (section.bendPoints ?? []).map(bp => offsetPoint(bp, nx, ny, amount)) - const adjustedEnd = offsetPoint(section.endPoint, nx, ny, amount) - - const adjustedSection = { - startPoint: adjustedStart, - bendPoints: adjustedBends, - endPoint: adjustedEnd, - } - - const pathD = buildPathD(adjustedSection) - const markerId = `arrow-${layoutEdge.id}-g${groupIndex}` - const haloStart = adjustedSection.bendPoints?.at(-1) ?? adjustedSection.startPoint - - return ( - - - - - - - {hasExternalEndpoint && ( - - )} - - - ) -} diff --git a/gaia/cli/templates/pages/src/components/LanguageSwitch.tsx b/gaia/cli/templates/pages/src/components/LanguageSwitch.tsx deleted file mode 100644 index 89027ea07..000000000 --- a/gaia/cli/templates/pages/src/components/LanguageSwitch.tsx +++ /dev/null @@ -1,23 +0,0 @@ -interface Props { - lang: 'en' | 'zh' - onChange: (lang: 'en' | 'zh') => void -} - -export default function LanguageSwitch({ lang, onChange }: Props) { - return ( -
- - -
- ) -} diff --git a/gaia/cli/templates/pages/src/components/ModuleOverview.tsx b/gaia/cli/templates/pages/src/components/ModuleOverview.tsx deleted file mode 100644 index e061e589d..000000000 --- a/gaia/cli/templates/pages/src/components/ModuleOverview.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useMemo } from 'react' -import { useElkLayout } from '../hooks/useElkLayout' -import type { ModuleInfo, CrossModuleEdge, GraphNode, GraphEdge } from '../types' - -interface Props { - modules: ModuleInfo[] - crossModuleEdges: CrossModuleEdge[] - onSelectModule: (moduleId: string) => void -} - -const MODULE_COLORS = [ - { fill: '#ddeeff', stroke: '#4488bb' }, - { fill: '#ddffdd', stroke: '#44bb44' }, - { fill: '#fff3dd', stroke: '#cc9944' }, - { fill: '#f3e8ff', stroke: '#7c3aed' }, - { fill: '#fce4ec', stroke: '#c62828' }, - { fill: '#e0f7fa', stroke: '#00838f' }, -] - -export default function ModuleOverview({ modules, crossModuleEdges, onSelectModule }: Props) { - const { pseudoNodes, pseudoEdges } = useMemo(() => { - const pNodes: GraphNode[] = modules.map(m => ({ - id: m.id, - label: m.id, - type: 'setting' as const, - module: m.id, - content: '', - exported: false, - metadata: {}, - })) - const pEdges: GraphEdge[] = crossModuleEdges.map(e => ({ - source: e.from_module, - target: e.to_module, - role: 'premise' as const, - })) - return { pseudoNodes: pNodes, pseudoEdges: pEdges } - }, [modules, crossModuleEdges]) - - const layout = useElkLayout(pseudoNodes, pseudoEdges) - - if (!layout) return
Computing layout...
- - const padding = 40 - - return ( - - {layout.edges.map(e => { - const section = e.sections?.[0] - if (!section) return null - return ( - - - - - - - - - ) - })} - {layout.nodes.map((ln, i) => { - const mod = modules.find(m => m.id === ln.id) - if (!mod) return null - const color = MODULE_COLORS[i % MODULE_COLORS.length] - return ( - onSelectModule(ln.id)}> - - - {mod.id} - - - {mod.node_count} nodes, {mod.strategy_count} strategies - - - ) - })} - - ) -} diff --git a/gaia/cli/templates/pages/src/components/ModuleSubgraph.tsx b/gaia/cli/templates/pages/src/components/ModuleSubgraph.tsx deleted file mode 100644 index 9766092e9..000000000 --- a/gaia/cli/templates/pages/src/components/ModuleSubgraph.tsx +++ /dev/null @@ -1,764 +0,0 @@ -import { useMemo, useRef, useCallback, useState, useEffect } from 'react' -import { useElkLayout, type ElkEdge, type ElkNode } from '../hooks/useElkLayout' -import { useZoomPan } from '../hooks/useZoomPan' -import { - filterNodesByModule, - filterEdgesForModule, - getExternalRefs, - type ExternalRef, -} from '../hooks/useGraphData' -import { useChainHighlight } from '../hooks/useChainHighlight' -import NodeRenderer from './NodeRenderer' -import EdgeRenderer from './EdgeRenderer' -import DetailPanel from './DetailPanel' -import type { GraphNode, GraphEdge } from '../types' - -const MODULE_GROUP_PADDING = 20 -const EXTERNAL_STACK_GAP = 20 -const EXTERNAL_LANE_GAP = 48 -const EXTERNAL_GROUP_GAP = 24 -const VIEWPORT_PADDING = 80 - -export interface ExternalModuleGroup { - groupKey: string - sourceModule: string - nodeIds: string[] -} - -export interface ModuleGroupBounds { - groupKey: string - sourceModule: string - x: number - y: number - width: number - height: number -} - -interface LayoutBounds { - minX: number - minY: number - maxX: number - maxY: number -} - -interface RenderViewport { - offsetX: number - offsetY: number - width: number - height: number -} - -const UNNAMED_EXTERNAL_GROUP_KEY = '__gaia_unnamed_external__' - -function getExternalModuleGroupKey(sourceModule: string): string { - return sourceModule || UNNAMED_EXTERNAL_GROUP_KEY -} - -function normalizeExternalSourceModule(sourceModule: string): string { - return sourceModule || 'External' -} - -function computeBoundsFromNodes(nodes: ElkNode[], padding: number): ModuleGroupBounds { - const minX = Math.min(...nodes.map(node => node.x)) - const maxX = Math.max(...nodes.map(node => node.x + node.width)) - const minY = Math.min(...nodes.map(node => node.y)) - const maxY = Math.max(...nodes.map(node => node.y + node.height)) - - return { - groupKey: '', - sourceModule: '', - x: minX - padding, - y: minY - padding, - width: maxX - minX + padding * 2, - height: maxY - minY + padding * 2, - } -} - -function collectSectionPoints(section: NonNullable[number]): Array<{ x: number; y: number }> { - return [ - section.startPoint, - ...(section.bendPoints ?? []), - section.endPoint, - ] -} - -function average(values: number[]): number { - return values.reduce((sum, value) => sum + value, 0) / values.length -} - -interface BoxBounds { - x: number - y: number - width: number - height: number -} - -function boxesOverlap(a: BoxBounds, b: BoxBounds): boolean { - return a.x < b.x + b.width - && a.x + a.width > b.x - && a.y < b.y + b.height - && a.y + a.height > b.y -} - -function hasMovement(delta: { dx: number; dy: number } | undefined): boolean { - return !!delta && (delta.dx !== 0 || delta.dy !== 0) -} - -function movePoint( - point: { x: number; y: number }, - delta: { dx: number; dy: number } | undefined, -): { x: number; y: number } { - if (!hasMovement(delta)) { - return point - } - - return { - x: point.x + delta!.dx, - y: point.y + delta!.dy, - } -} - -function buildOrthogonalSection( - startPoint: { x: number; y: number }, - endPoint: { x: number; y: number }, -) { - const horizontalOffset = 24 - const entryOffset = 18 - const exitX = startPoint.x < endPoint.x - ? startPoint.x + horizontalOffset - : startPoint.x - horizontalOffset - const entryX = startPoint.x < endPoint.x - ? endPoint.x - entryOffset - : endPoint.x + entryOffset - - return { - startPoint, - bendPoints: [ - { x: exitX, y: startPoint.y }, - { x: exitX, y: endPoint.y }, - { x: entryX, y: endPoint.y }, - ], - endPoint, - } -} - -function moduleGroupBoxesOverlap(a: ModuleGroupBounds, b: ModuleGroupBounds): boolean { - return boxesOverlap(a, b) -} - -function groupOverlapsAnyNode(groupBounds: ModuleGroupBounds, nodes: ElkNode[]): boolean { - return nodes.some(node => boxesOverlap(groupBounds, node)) -} - -export function buildExternalModuleGroups( - externalRefs: Array<{ id: string; sourceModule: string }>, -): ExternalModuleGroup[] { - const groups = new Map() - const orderedGroups: ExternalModuleGroup[] = [] - - for (const ref of externalRefs) { - const groupKey = getExternalModuleGroupKey(ref.sourceModule) - let group = groups.get(groupKey) - if (!group) { - group = { - groupKey, - sourceModule: normalizeExternalSourceModule(ref.sourceModule), - nodeIds: [], - } - groups.set(groupKey, group) - orderedGroups.push(group) - } - group.nodeIds.push(ref.id) - } - - return orderedGroups -} - -export function computeModuleGroups( - layoutNodes: ElkNode[], - externalRefs: Array<{ id: string; sourceModule: string }>, - padding: number, -): ModuleGroupBounds[] { - const groups = buildExternalModuleGroups(externalRefs) - const nodeMap = new Map(layoutNodes.map(node => [node.id, node])) - const laneLeft = Math.min( - ...groups.flatMap(group => group.nodeIds - .map(nodeId => nodeMap.get(nodeId)) - .filter((node): node is ElkNode => node != null) - .map(node => node.x - padding)), - ) - const laneRight = Math.max( - ...groups.flatMap(group => group.nodeIds - .map(nodeId => nodeMap.get(nodeId)) - .filter((node): node is ElkNode => node != null) - .map(node => node.x + node.width + padding)), - ) - - return groups - .map(group => { - const nodes = group.nodeIds - .map(nodeId => nodeMap.get(nodeId)) - .filter((node): node is ElkNode => node != null) - - if (nodes.length === 0) { - return null - } - - const minY = Math.min(...nodes.map(node => node.y)) - const maxY = Math.max(...nodes.map(node => node.y + node.height)) - - return { - groupKey: group.groupKey, - sourceModule: group.sourceModule, - x: laneLeft, - y: minY - padding, - width: laneRight - laneLeft, - height: maxY - minY + padding * 2, - } - }) - .filter((group): group is ModuleGroupBounds => group != null) -} - -export function adjustExternalLayout( - layoutNodes: ElkNode[], - layoutEdges: ElkEdge[], - externalRefs: Array<{ id: string; sourceModule: string }>, -): { nodes: ElkNode[]; edges: ElkEdge[] } { - const externalIds = new Set(externalRefs.map(ref => ref.id)) - const originalNodeMap = new Map(layoutNodes.map(node => [node.id, node])) - const adjustedExternalNodes = new Map() - const internalNodes = layoutNodes.filter(node => !externalIds.has(node.id)) - const internalOnlyEdges = layoutEdges.filter( - edge => !externalIds.has(edge.source) && !externalIds.has(edge.target), - ) - const internalBounds = computeLayoutBounds(internalNodes, internalOnlyEdges, []) - const laneNodeRight = internalBounds.minX - EXTERNAL_LANE_GAP - MODULE_GROUP_PADDING - const laneGroupX = laneNodeRight - Math.max(...layoutNodes - .filter(node => externalIds.has(node.id)) - .map(node => node.width)) - MODULE_GROUP_PADDING - let nextAvailableY = Number.NEGATIVE_INFINITY - - for (const group of buildExternalModuleGroups(externalRefs)) { - const originalNodes = group.nodeIds - .map(nodeId => originalNodeMap.get(nodeId)) - .filter((node): node is ElkNode => node != null) - - if (originalNodes.length === 0) { - continue - } - - const connectedInternalNodeIds = new Set() - for (const edge of layoutEdges) { - const touchesGroup = group.nodeIds.includes(edge.source) || group.nodeIds.includes(edge.target) - if (!touchesGroup) { - continue - } - if (!externalIds.has(edge.source)) { - connectedInternalNodeIds.add(edge.source) - } - if (!externalIds.has(edge.target)) { - connectedInternalNodeIds.add(edge.target) - } - } - - const connectedCenters = [...connectedInternalNodeIds] - .map(nodeId => originalNodeMap.get(nodeId)) - .filter((node): node is ElkNode => node != null) - .map(node => node.y + node.height / 2) - - const groupNodeWidth = Math.max(...originalNodes.map(node => node.width)) - const laneX = laneGroupX + MODULE_GROUP_PADDING - const totalStackHeight = originalNodes.reduce( - (sum, node, index) => sum + node.height + (index === 0 ? 0 : EXTERNAL_STACK_GAP), - 0, - ) - const preferredCenterY = connectedCenters.length > 0 - ? average(connectedCenters) - : average(originalNodes.map(node => node.y + node.height / 2)) - const preferredTopY = preferredCenterY - totalStackHeight / 2 - const stackedTopY = Math.max(preferredTopY, nextAvailableY) - - let nextNodeY = stackedTopY - const stackedNodes = originalNodes.map(node => { - const positionedNode = { ...node, x: laneX, y: nextNodeY } - nextNodeY += node.height + EXTERNAL_STACK_GAP - return positionedNode - }) - - const groupBounds = computeBoundsFromNodes(stackedNodes, MODULE_GROUP_PADDING) - nextAvailableY = groupBounds.y + groupBounds.height + EXTERNAL_GROUP_GAP + MODULE_GROUP_PADDING - - for (const node of stackedNodes) { - adjustedExternalNodes.set(node.id, node) - } - } - - const adjustedNodes = layoutNodes.map(node => adjustedExternalNodes.get(node.id) ?? node) - const movementByNodeId = new Map() - - for (const node of adjustedNodes) { - if (!externalIds.has(node.id)) { - continue - } - const originalNode = originalNodeMap.get(node.id) - if (!originalNode) { - continue - } - movementByNodeId.set(node.id, { - dx: node.x - originalNode.x, - dy: node.y - originalNode.y, - }) - } - - const adjustedEdges = layoutEdges.map(edge => { - const sourceDelta = movementByNodeId.get(edge.source) - const targetDelta = movementByNodeId.get(edge.target) - - if (!hasMovement(sourceDelta) && !hasMovement(targetDelta)) { - return edge - } - - if (!edge.sections) { - return edge - } - - return { - ...edge, - sections: edge.sections.map(section => { - const startPoint = movePoint(section.startPoint, sourceDelta) - const endPoint = movePoint(section.endPoint, targetDelta) - const touchesExternal = externalIds.has(edge.source) || externalIds.has(edge.target) - - if (touchesExternal) { - return buildOrthogonalSection(startPoint, endPoint) - } - - return { - ...section, - startPoint, - bendPoints: section.bendPoints?.map(point => { - if (hasMovement(sourceDelta) && hasMovement(targetDelta)) { - return { - x: point.x + (sourceDelta!.dx + targetDelta!.dx) / 2, - y: point.y + (sourceDelta!.dy + targetDelta!.dy) / 2, - } - } - if (hasMovement(sourceDelta)) { - return { - x: point.x + sourceDelta!.dx, - y: point.y + sourceDelta!.dy, - } - } - if (hasMovement(targetDelta)) { - return { - x: point.x + targetDelta!.dx, - y: point.y + targetDelta!.dy, - } - } - return point - }), - endPoint, - } - }), - } - }) - - return { - nodes: adjustedNodes, - edges: adjustedEdges, - } -} - -export function computeLayoutBounds( - layoutNodes: ElkNode[], - layoutEdges: ElkEdge[], - moduleGroups: ModuleGroupBounds[], -): LayoutBounds { - const xs: number[] = [] - const ys: number[] = [] - - for (const node of layoutNodes) { - xs.push(node.x, node.x + node.width) - ys.push(node.y, node.y + node.height) - } - - for (const edge of layoutEdges) { - for (const section of edge.sections ?? []) { - for (const point of collectSectionPoints(section)) { - xs.push(point.x) - ys.push(point.y) - } - } - } - - for (const group of moduleGroups) { - xs.push(group.x, group.x + group.width) - ys.push(group.y, group.y + group.height) - } - - if (xs.length === 0 || ys.length === 0) { - return { minX: 0, minY: 0, maxX: 0, maxY: 0 } - } - - return { - minX: Math.min(...xs), - minY: Math.min(...ys), - maxX: Math.max(...xs), - maxY: Math.max(...ys), - } -} - -function buildRenderViewport(bounds: LayoutBounds, padding: number): RenderViewport { - return { - offsetX: padding - bounds.minX, - offsetY: padding - bounds.minY, - width: bounds.maxX - bounds.minX + padding * 2, - height: bounds.maxY - bounds.minY + padding * 2, - } -} - -interface Props { - moduleId: string - allNodes: GraphNode[] - allEdges: GraphEdge[] - onBack: () => void - onNavigateToModule: (moduleId: string, nodeId: string) => void -} - -export default function ModuleSubgraph({ - moduleId, allNodes, allEdges, onBack, onNavigateToModule, -}: Props) { - const svgRef = useRef(null) - const containerRef = useRef(null) - const [containerSize, setContainerSize] = useState({ width: 800, height: 600 }) - - const { - transform, - isDragging, - zoomIn, - zoomOut, - reset, - fitToBounds, - setTransform, - getTransformString, - handleMouseDown, - handleMouseMove, - handleMouseUp, - handleWheel, - handleTouchStart, - handleTouchMove, - handleTouchEnd, - } = useZoomPan({ minScale: 0.1, maxScale: 3, zoomStep: 0.2 }) - - const { moduleNodes, moduleEdges, externalRefs } = useMemo(() => { - const mNodes = filterNodesByModule(allNodes, moduleId) - const mNodeIds = new Set(mNodes.map(n => n.id)) - const mEdges = filterEdgesForModule(allEdges, mNodeIds) - const refs = getExternalRefs(mEdges, mNodeIds, allNodes) - const extNodes: GraphNode[] = refs.map(r => ({ - id: r.id, - label: `↗ ${r.label}`, - type: 'setting' as const, - module: r.sourceModule, - content: '', - exported: false, - metadata: { _external: true, _sourceModule: r.sourceModule }, - })) - return { - moduleNodes: [...mNodes, ...extNodes], - moduleEdges: mEdges, - externalRefs: refs, - } - }, [allNodes, allEdges, moduleId]) - - const layout = useElkLayout(moduleNodes, moduleEdges) - const { highlightedIds, selectedNodeId, selectNode, clearSelection } = useChainHighlight(allEdges) - - const [hoverGroupKey, setHoverGroupKey] = useState(null) -const scrimDraggedRef = useRef(false) - - const nodesById = useMemo(() => { - const m = new Map() - for (const n of allNodes) m.set(n.id, n) - return m - }, [allNodes]) - - const handleNodeSelect = useCallback((id: string) => { - const ext = externalRefs.find(r => r.id === id) - if (ext) { - onNavigateToModule(ext.sourceModule, id) - return - } - selectNode(id) - }, [externalRefs, onNavigateToModule, selectNode]) - - const selectedNode = selectedNodeId ? nodesById.get(selectedNodeId) ?? null : null - - const adjustedLayout = useMemo(() => { - if (!layout) { - return null - } - - const adjusted = adjustExternalLayout(layout.nodes, layout.edges, externalRefs) - const moduleGroups = computeModuleGroups(adjusted.nodes, externalRefs, MODULE_GROUP_PADDING) - const bounds = computeLayoutBounds(adjusted.nodes, adjusted.edges, moduleGroups) - const viewport = buildRenderViewport(bounds, VIEWPORT_PADDING) - - return { - ...adjusted, - moduleGroups, - bounds, - viewport, - } - }, [layout, externalRefs]) - - // Update container size on mount and resize - useEffect(() => { - const updateSize = () => { - if (containerRef.current) { - setContainerSize({ - width: containerRef.current.clientWidth, - height: containerRef.current.clientHeight, - }) - } - } - - updateSize() - window.addEventListener('resize', updateSize) - return () => window.removeEventListener('resize', updateSize) - }, []) - - // Auto-fit graph to viewport when layout is ready - useEffect(() => { - if (!adjustedLayout || containerSize.width === 0) return - - const { bounds } = adjustedLayout - fitToBounds( - { - width: bounds.maxX - bounds.minX, - height: bounds.maxY - bounds.minY, - }, - containerSize, - VIEWPORT_PADDING - ) - }, [adjustedLayout, containerSize, fitToBounds]) - - if (!layout || !adjustedLayout) { - return
Computing layout...
- } - - const edgeByKey = new Map() - for (const e of moduleEdges) { - edgeByKey.set(`${e.source}->${e.target}`, e) - } - - // Build layout edge groups by source-target key - const edgeGroups = new Map() - for (const le of adjustedLayout.edges) { - const key = `${le.source}->${le.target}` - const arr = edgeGroups.get(key) - if (arr) arr.push(le) - else edgeGroups.set(key, [le]) - } - - return ( -
-
- {/* Header with breadcrumbs */} -
-
- - ← All Modules - - {' / '} - {moduleId} -
- {/* Zoom controls */} -
- - - {Math.round(transform.scale * 100)}% - - - -
-
- - {/* Graph container with zoom/pan */} -
-
- - - {adjustedLayout.moduleGroups.map(group => ( - - - - {group.sourceModule} - - - ))} - {[...edgeGroups.entries()].map(([groupKey, groupEdges]) => { - const ge = edgeByKey.get(`${groupEdges[0].source}->${groupEdges[0].target}`) - const inChainGroup = (id: string) => highlightedIds ? highlightedIds.has(id) : null - const expanded = hoverGroupKey === groupKey - return ( - setHoverGroupKey(groupKey)} onMouseLeave={() => setHoverGroupKey(null)}> - {groupEdges.map((le, idx) => { - const inChain = highlightedIds - ? highlightedIds.has(le.source) && highlightedIds.has(le.target) - : null - return ( - - ) - })} - - ) - })} - {adjustedLayout.nodes.map(ln => { - const gn = moduleNodes.find(n => n.id === ln.id) - if (!gn) return null - const inChain = highlightedIds ? highlightedIds.has(ln.id) : null - return ( - - ) - })} - - -
- - {selectedNode && ( -
{ scrimDraggedRef.current = false }} - onMouseMove={() => { scrimDraggedRef.current = true }} - onClick={() => { if (!isDragging && !scrimDraggedRef.current) clearSelection() }} - style={{ position: 'absolute', inset: 0, background: 'transparent', zIndex: 10 }} - /> - )} -
-
- - -
- ) -} diff --git a/gaia/cli/templates/pages/src/components/NodeRenderer.tsx b/gaia/cli/templates/pages/src/components/NodeRenderer.tsx deleted file mode 100644 index 34be22dc2..000000000 --- a/gaia/cli/templates/pages/src/components/NodeRenderer.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import type { GraphNode } from '../types' -import { isKnowledgeNode, isStrategyNode, isOperatorNode } from '../types' - -interface Props { - node: GraphNode - x: number - y: number - width: number - height: number - highlighted: boolean | null - onSelect: (id: string) => void -} - -const BELIEF_COLORS = { - high: '#4caf50', - mid: '#ff9800', - low: '#f44336', - none: '#999', -} as const - -function beliefColor(belief?: number | null): string { - if (belief == null) return BELIEF_COLORS.none - if (belief >= 0.7) return BELIEF_COLORS.high - if (belief >= 0.4) return BELIEF_COLORS.mid - return BELIEF_COLORS.low -} - -const DETERMINISTIC = new Set(['deduction', 'reductio', 'elimination', 'mathematical_induction', 'case_analysis']) - -const OP_SYMBOLS: Record = { - contradiction: '\u2297', - equivalence: '\u2261', - complement: '\u2295', - disjunction: '\u2228', - conjunction: '\u2227', - implication: '\u2192', -} - -export default function NodeRenderer({ node, x, y, width, height, highlighted, onSelect }: Props) { - const opacity = highlighted === false ? 0.2 : 1 - - if (isKnowledgeNode(node)) { - const isExternal = node.metadata?._external === true - const fill = isExternal ? '#fff' - : node.type === 'setting' ? '#f0f0f0' - : node.type === 'question' ? '#fff3dd' - : '#ddeeff' - const stroke = isExternal ? '#aaa' - : node.type === 'setting' ? '#999' - : node.type === 'question' ? '#cc9944' - : '#4488bb' - const dashArray = isExternal ? '5,3' : undefined - const rx = isExternal ? 8 - : node.type === 'setting' ? 2 - : node.type === 'question' ? height / 2 - : 8 - const label = node.title || node.label - const truncated = label.length > 28 ? label.slice(0, 25) + '...' : label - - return ( - onSelect(node.id)}> - - - {truncated} - - {node.belief != null && ( - - - - {node.belief.toFixed(1)} - - - )} - {`${label}\nPrior: ${node.prior ?? '—'} → Belief: ${node.belief ?? '—'}`} - - ) - } - - if (isStrategyNode(node)) { - const isDeterministic = DETERMINISTIC.has(node.strategy_type) - const fill = isDeterministic ? '#e8f5e9' : '#fff9c4' - const stroke = isDeterministic ? '#44bb44' : '#f9a825' - const dashArray = isDeterministic ? undefined : '5,3' - const cx = x + width / 2 - const cy = y + height / 2 - const inset = (width / 2) * 0.25 - const points = [ - `${x + inset},${y}`, - `${x + width - inset},${y}`, - `${x + width},${cy}`, - `${x + width - inset},${y + height}`, - `${x + inset},${y + height}`, - `${x},${cy}`, - ].join(' ') - - return ( - onSelect(node.id)}> - - - {node.strategy_type} - - {node.reason && {node.reason}} - - ) - } - - if (isOperatorNode(node)) { - const cx = x + width / 2 - const cy = y + height / 2 - const r = Math.min(width, height) / 2 - const isContra = node.operator_type === 'contradiction' - const symbol = OP_SYMBOLS[node.operator_type] ?? node.operator_type - - return ( - onSelect(node.id)}> - - - {symbol} - - - ) - } - - return null -} diff --git a/gaia/cli/templates/pages/src/components/SectionView.module.css b/gaia/cli/templates/pages/src/components/SectionView.module.css deleted file mode 100644 index 701e396a6..000000000 --- a/gaia/cli/templates/pages/src/components/SectionView.module.css +++ /dev/null @@ -1,41 +0,0 @@ -.container { - display: flex; - flex-direction: column; - gap: 24px; -} - -.section { - padding: 16px; - border: 1px solid #e5e7eb; - border-radius: 8px; - background: #fafafa; - overflow-y: auto; -} - -.section h2 { - margin: 0 0 12px; - font-size: 16px; - color: #374151; - text-transform: capitalize; -} - -.section img { - max-width: 100%; -} - -.section table { - border-collapse: collapse; - width: 100%; -} - -.section th, -.section td { - border: 1px solid #d1d5db; - padding: 6px 10px; - text-align: left; - font-size: 14px; -} - -.section th { - background: #f3f4f6; -} diff --git a/gaia/cli/templates/pages/src/components/SectionView.tsx b/gaia/cli/templates/pages/src/components/SectionView.tsx deleted file mode 100644 index 5905a5bb4..000000000 --- a/gaia/cli/templates/pages/src/components/SectionView.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useEffect, useState } from 'react' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import styles from './SectionView.module.css' - -interface Props { - sections: string[] - lang: 'en' | 'zh' -} - -/** Rewrite relative image paths so ![](foo.png) becomes ![](data/assets/foo.png). */ -function rewriteImagePaths(md: string): string { - return md.replace( - /!\[([^\]]*)\]\((?!https?:\/\/|data\/)(.*?)\)/g, - '![$1](data/assets/$2)', - ) -} - -async function fetchSection(name: string, lang: 'en' | 'zh'): Promise { - if (lang === 'zh') { - const zhResp = await fetch(`data/sections/${name}-zh.md`) - if (zhResp.ok) return zhResp.text() - } - const resp = await fetch(`data/sections/${name}.md`) - if (resp.ok) return resp.text() - return '' -} - -export default function SectionView({ sections, lang }: Props) { - const [contents, setContents] = useState>({}) - - useEffect(() => { - let cancelled = false - async function load() { - const entries: [string, string][] = await Promise.all( - sections.map(async (s) => { - const md = await fetchSection(s, lang) - return [s, md] as [string, string] - }), - ) - if (!cancelled) { - setContents(Object.fromEntries(entries)) - } - } - load() - return () => { - cancelled = true - } - }, [sections, lang]) - - return ( -
- {sections.map((name) => ( -
-

{name}

- - {rewriteImagePaths(contents[name] ?? '')} - -
- ))} -
- ) -} diff --git a/gaia/cli/templates/pages/src/env.d.ts b/gaia/cli/templates/pages/src/env.d.ts deleted file mode 100644 index 876959d72..000000000 --- a/gaia/cli/templates/pages/src/env.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/// - -declare module 'cytoscape-dagre' { - const ext: cytoscape.Ext - export default ext -} - -declare module '*.module.css' { - const classes: Record - export default classes -} diff --git a/gaia/cli/templates/pages/src/hooks/useChainHighlight.ts b/gaia/cli/templates/pages/src/hooks/useChainHighlight.ts deleted file mode 100644 index be2b2cb20..000000000 --- a/gaia/cli/templates/pages/src/hooks/useChainHighlight.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useState, useCallback } from 'react' -import type { GraphEdge } from '../types' - -export function computeUpstreamChain(nodeId: string, edges: GraphEdge[]): Set { - const reverseAdj = new Map() - for (const e of edges) { - const sources = reverseAdj.get(e.target) ?? [] - sources.push(e.source) - reverseAdj.set(e.target, sources) - } - - const visited = new Set() - const queue = [nodeId] - while (queue.length > 0) { - const current = queue.shift()! - if (visited.has(current)) continue - visited.add(current) - for (const parent of reverseAdj.get(current) ?? []) { - if (!visited.has(parent)) queue.push(parent) - } - } - return visited -} - -export interface ChainHighlightState { - highlightedIds: Set | null - selectedNodeId: string | null - selectNode: (id: string) => void - clearSelection: () => void -} - -export function useChainHighlight(edges: GraphEdge[]): ChainHighlightState { - const [selectedNodeId, setSelectedNodeId] = useState(null) - const [highlightedIds, setHighlightedIds] = useState | null>(null) - - const selectNode = useCallback( - (id: string) => { - setSelectedNodeId(id) - setHighlightedIds(computeUpstreamChain(id, edges)) - }, - [edges], - ) - - const clearSelection = useCallback(() => { - setSelectedNodeId(null) - setHighlightedIds(null) - }, []) - - return { highlightedIds, selectedNodeId, selectNode, clearSelection } -} diff --git a/gaia/cli/templates/pages/src/hooks/useElkLayout.ts b/gaia/cli/templates/pages/src/hooks/useElkLayout.ts deleted file mode 100644 index 1128a4279..000000000 --- a/gaia/cli/templates/pages/src/hooks/useElkLayout.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { useState, useEffect } from 'react' -import ELK from 'elkjs/lib/elk.bundled.js' -import type { GraphNode, GraphEdge } from '../types' - -const elk = new ELK() - -export interface ElkNode { - id: string - x: number - y: number - width: number - height: number -} - -export interface ElkEdge { - id: string - source: string - target: string - sections?: Array<{ - startPoint: { x: number; y: number } - bendPoints?: Array<{ x: number; y: number }> - endPoint: { x: number; y: number } - }> -} - -export interface LayoutResult { - nodes: ElkNode[] - edges: ElkEdge[] - width: number - height: number -} - -function nodeDimensions(node: GraphNode): { width: number; height: number } { - if (node.type === 'strategy') return { width: 100, height: 40 } - if (node.type === 'operator') return { width: 48, height: 48 } - // KnowledgeNode: claim, setting, question, action - const label = node.label - const charWidth = 8 - const padding = 32 - return { width: Math.max(120, Math.min(label.length * charWidth + padding, 240)), height: 48 } -} - -export function buildElkGraph(nodes: GraphNode[], edges: GraphEdge[]) { - const nodeIds = new Set(nodes.map(n => n.id)) - return { - id: 'root', - layoutOptions: { - 'elk.algorithm': 'layered', - 'elk.direction': 'DOWN', - 'elk.spacing.nodeNode': '30', - 'elk.layered.spacing.nodeNodeBetweenLayers': '60', - 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP', - }, - children: nodes.map(n => { - const dims = nodeDimensions(n) - return { id: n.id, width: dims.width, height: dims.height } - }), - edges: edges - .filter(e => nodeIds.has(e.source) && nodeIds.has(e.target)) - .map((e, i) => ({ - id: `e${i}`, - sources: [e.source], - targets: [e.target], - })), - } -} - -export function useElkLayout(nodes: GraphNode[], edges: GraphEdge[]): LayoutResult | null { - const [layout, setLayout] = useState(null) - - useEffect(() => { - let isStale = false - - if (nodes.length === 0) { - setLayout(null) - return () => { - isStale = true - } - } - const graph = buildElkGraph(nodes, edges) - elk.layout(graph) - .then(result => { - if (isStale) { - return - } - const layoutNodes: ElkNode[] = (result.children ?? []).map(c => ({ - id: c.id, x: c.x ?? 0, y: c.y ?? 0, width: c.width ?? 100, height: c.height ?? 40, - })) - const layoutEdges: ElkEdge[] = (result.edges ?? []).map(e => ({ - id: e.id, source: (e.sources ?? [])[0] ?? '', target: (e.targets ?? [])[0] ?? '', - sections: e.sections, - })) - setLayout({ nodes: layoutNodes, edges: layoutEdges, width: result.width ?? 800, height: result.height ?? 600 }) - }) - .catch(() => { - if (isStale) { - return - } - setLayout(null) - }) - - return () => { - isStale = true - } - }, [nodes, edges]) - - return layout -} diff --git a/gaia/cli/templates/pages/src/hooks/useGraphData.ts b/gaia/cli/templates/pages/src/hooks/useGraphData.ts deleted file mode 100644 index 83e5b0ce3..000000000 --- a/gaia/cli/templates/pages/src/hooks/useGraphData.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useState, useEffect } from 'react' -import type { GraphData, GraphNode, GraphEdge, MetaData } from '../types' - -export interface ExternalRef { - id: string - label: string - sourceModule: string -} - -export function filterNodesByModule(nodes: GraphNode[], moduleId: string): GraphNode[] { - return nodes.filter(n => 'module' in n && n.module === moduleId) -} - -export function getExternalRefs( - edges: GraphEdge[], - moduleNodeIds: Set, - allNodes: GraphNode[], -): ExternalRef[] { - const nodesById = new Map(allNodes.map(n => [n.id, n])) - const externalIds = new Set() - const refs: ExternalRef[] = [] - - for (const e of edges) { - for (const endpoint of [e.source, e.target]) { - if (!moduleNodeIds.has(endpoint) && !externalIds.has(endpoint)) { - externalIds.add(endpoint) - const node = nodesById.get(endpoint) - if (node) { - refs.push({ - id: node.id, - label: 'label' in node ? node.label : node.id, - sourceModule: ('module' in node && node.module) || '', - }) - } - } - } - } - return refs -} - -export function filterEdgesForModule(edges: GraphEdge[], nodeIds: Set): GraphEdge[] { - return edges.filter(e => nodeIds.has(e.source) || nodeIds.has(e.target)) -} - -export type LoadState = - | { status: 'loading' } - | { status: 'error'; message: string } - | { status: 'ready'; graph: GraphData; meta: MetaData } - -export function useGraphData(): LoadState { - const [state, setState] = useState({ status: 'loading' }) - - useEffect(() => { - Promise.all([ - fetch('data/graph.json').then(r => { - if (!r.ok) throw new Error(`graph.json: ${r.status}`) - return r.json() as Promise - }), - fetch('data/meta.json').then(r => { - if (!r.ok) throw new Error(`meta.json: ${r.status}`) - return r.json() as Promise - }), - ]) - .then(([graph, meta]) => setState({ status: 'ready', graph, meta })) - .catch((err: Error) => setState({ status: 'error', message: err.message })) - }, []) - - return state -} diff --git a/gaia/cli/templates/pages/src/hooks/useZoomPan.ts b/gaia/cli/templates/pages/src/hooks/useZoomPan.ts deleted file mode 100644 index bec040002..000000000 --- a/gaia/cli/templates/pages/src/hooks/useZoomPan.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { useState, useCallback, useRef, useEffect } from 'react' - -export interface Transform { - x: number - y: number - scale: number -} - -export interface UseZoomPanOptions { - minScale?: number - maxScale?: number - zoomStep?: number -} - -export function useZoomPan(options: UseZoomPanOptions = {}) { - const { minScale = 0.1, maxScale = 3, zoomStep = 0.2 } = options - const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 }) - const [isDragging, setIsDragging] = useState(false) - const dragStartRef = useRef<{ x: number; y: number } | null>(null) - const transformStartRef = useRef(null) - - const clampScale = useCallback((scale: number) => { - return Math.max(minScale, Math.min(maxScale, scale)) - }, [minScale, maxScale]) - - const setTransformValue = useCallback((t: Transform) => { - setTransform({ - x: t.x, - y: t.y, - scale: clampScale(t.scale), - }) - }, [clampScale]) - - const zoom = useCallback((delta: number, center?: { x: number; y: number }) => { - setTransform(prev => { - const newScale = clampScale(prev.scale + delta) - if (newScale === prev.scale) return prev - - if (center) { - // Zoom towards center point - const scaleRatio = newScale / prev.scale - const newX = center.x - (center.x - prev.x) * scaleRatio - const newY = center.y - (center.y - prev.y) * scaleRatio - return { x: newX, y: newY, scale: newScale } - } - - return { ...prev, scale: newScale } - }) - }, [clampScale]) - - const zoomIn = useCallback(() => zoom(zoomStep), [zoom, zoomStep]) - const zoomOut = useCallback(() => zoom(-zoomStep), [zoom, zoomStep]) - - const reset = useCallback(() => { - setTransform({ x: 0, y: 0, scale: 1 }) - }, []) - - const fitToBounds = useCallback((bounds: { width: number; height: number }, container: { width: number; height: number }, padding = 40) => { - const graphWidth = bounds.width + padding * 2 - const graphHeight = bounds.height + padding * 2 - - const scaleX = container.width / graphWidth - const scaleY = container.height / graphHeight - const scale = Math.max(minScale, Math.min(1.5, Math.min(scaleX, scaleY))) - - const x = container.width / 2 - (bounds.width / 2) * scale - const y = container.height / 2 - (bounds.height / 2) * scale - - setTransform({ x, y, scale }) - }, [minScale]) - - const setScale = useCallback((scale: number) => { - setTransform(prev => ({ ...prev, scale: clampScale(scale) })) - }, [clampScale]) - - const getTransformString = useCallback(() => { - return `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})` - }, [transform]) - - // Mouse drag handlers - const handleMouseDown = useCallback((e: React.MouseEvent) => { - // Don't drag if clicking on a node or interactive element - if ((e.target as Element).closest('.graph-node, button, input')) return - - setIsDragging(true) - dragStartRef.current = { x: e.clientX, y: e.clientY } - transformStartRef.current = { ...transform } - e.preventDefault() - }, [transform]) - - const handleMouseMove = useCallback((e: React.MouseEvent) => { - if (!isDragging || !dragStartRef.current || !transformStartRef.current) return - - const dx = e.clientX - dragStartRef.current.x - const dy = e.clientY - dragStartRef.current.y - - setTransform({ - ...transformStartRef.current, - x: transformStartRef.current.x + dx, - y: transformStartRef.current.y + dy, - }) - }, [isDragging]) - - const handleMouseUp = useCallback(() => { - setIsDragging(false) - dragStartRef.current = null - transformStartRef.current = null - }, []) - - // Wheel zoom handler - const handleWheel = useCallback((e: React.WheelEvent) => { - e.preventDefault() - const delta = e.deltaY > 0 ? -zoomStep * 0.5 : zoomStep * 0.5 - zoom(delta) - }, [zoom, zoomStep]) - - // Touch support for mobile - const touchStartRef = useRef<{ x: number; y: number } | null>(null) - - const handleTouchStart = useCallback((e: React.TouchEvent) => { - if (e.touches.length === 1) { - touchStartRef.current = { x: e.touches[0].clientX, y: e.touches[0].clientY } - transformStartRef.current = { ...transform } - } - }, [transform]) - - const handleTouchMove = useCallback((e: React.TouchEvent) => { - if (e.touches.length === 1 && touchStartRef.current && transformStartRef.current) { - const dx = e.touches[0].clientX - touchStartRef.current.x - const dy = e.touches[0].clientY - touchStartRef.current.y - - setTransform({ - ...transformStartRef.current, - x: transformStartRef.current.x + dx, - y: transformStartRef.current.y + dy, - }) - } - }, []) - - const handleTouchEnd = useCallback(() => { - touchStartRef.current = null - transformStartRef.current = null - }, []) - - return { - transform, - isDragging, - zoomIn, - zoomOut, - reset, - fitToBounds, - setScale, - setTransform: setTransformValue, - getTransformString, - handleMouseDown, - handleMouseMove, - handleMouseUp, - handleWheel, - handleTouchStart, - handleTouchMove, - handleTouchEnd, - } -} diff --git a/gaia/cli/templates/pages/src/index.css b/gaia/cli/templates/pages/src/index.css deleted file mode 100644 index 65a322ef5..000000000 --- a/gaia/cli/templates/pages/src/index.css +++ /dev/null @@ -1,27 +0,0 @@ -* { margin: 0; padding: 0; box-sizing: border-box; } -body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } - -.app-layout { - display: grid; - grid-template-columns: 1fr; - grid-template-rows: auto 1fr auto; - grid-template-areas: "header" "graph" "sections"; - min-height: 100vh; -} -.app-header { - grid-area: header; - padding: 1rem 2rem; - border-bottom: 1px solid #eee; - display: flex; - justify-content: space-between; - align-items: center; -} -.graph-panel { - grid-area: graph; - min-height: 500px; - position: relative; -} -.section-panel { - grid-area: sections; - padding: 2rem; -} diff --git a/gaia/cli/templates/pages/src/main.tsx b/gaia/cli/templates/pages/src/main.tsx deleted file mode 100644 index 22ca753e6..000000000 --- a/gaia/cli/templates/pages/src/main.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { createRoot } from 'react-dom/client' -import App from './App' -import './index.css' - -createRoot(document.getElementById('root')!).render() diff --git a/gaia/cli/templates/pages/src/setupTests.ts b/gaia/cli/templates/pages/src/setupTests.ts deleted file mode 100644 index c44951a68..000000000 --- a/gaia/cli/templates/pages/src/setupTests.ts +++ /dev/null @@ -1 +0,0 @@ -import '@testing-library/jest-dom' diff --git a/gaia/cli/templates/pages/src/types.ts b/gaia/cli/templates/pages/src/types.ts deleted file mode 100644 index 2f493685b..000000000 --- a/gaia/cli/templates/pages/src/types.ts +++ /dev/null @@ -1,83 +0,0 @@ -// --- Node types --- - -export interface KnowledgeNode { - id: string - label: string - title?: string - type: 'claim' | 'setting' | 'question' | 'action' - module?: string - content: string - prior?: number | null - belief?: number | null - exported: boolean - metadata: Record -} - -export interface StrategyNode { - id: string - type: 'strategy' - strategy_type: string - module?: string - reason?: string -} - -export interface OperatorNode { - id: string - type: 'operator' - operator_type: string - module?: string -} - -export type GraphNode = KnowledgeNode | StrategyNode | OperatorNode - -// --- Edge types --- - -export interface GraphEdge { - source: string - target: string - role: 'premise' | 'background' | 'conclusion' | 'variable' -} - -// --- Module types --- - -export interface ModuleInfo { - id: string - order: number - node_count: number - strategy_count: number -} - -export interface CrossModuleEdge { - from_module: string - to_module: string - count: number -} - -// --- Top-level data --- - -export interface GraphData { - modules: ModuleInfo[] - cross_module_edges: CrossModuleEdge[] - nodes: GraphNode[] - edges: GraphEdge[] -} - -export interface MetaData { - package_name: string - namespace: string - description?: string -} - -// --- Type guards --- - -export function isKnowledgeNode(n: GraphNode): n is KnowledgeNode { - return n.type === 'claim' || n.type === 'setting' || n.type === 'question' || n.type === 'action' -} - -export function isStrategyNode(n: GraphNode): n is StrategyNode { - return n.type === 'strategy' -} - -export function isOperatorNode(n: GraphNode): n is OperatorNode { - return n.type === 'operator' -} diff --git a/gaia/cli/templates/pages/tsconfig.json b/gaia/cli/templates/pages/tsconfig.json deleted file mode 100644 index 99b738246..000000000 --- a/gaia/cli/templates/pages/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "bundler", - "jsx": "react-jsx", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true - }, - "include": ["src"] -} diff --git a/gaia/cli/templates/pages/vite.config.ts b/gaia/cli/templates/pages/vite.config.ts deleted file mode 100644 index 17b207c53..000000000 --- a/gaia/cli/templates/pages/vite.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' - -export default defineConfig({ - base: './', - plugins: [react()], -}) diff --git a/gaia/cli/templates/pages/vitest.config.ts b/gaia/cli/templates/pages/vitest.config.ts deleted file mode 100644 index 1a98584b4..000000000 --- a/gaia/cli/templates/pages/vitest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'vitest/config' -import react from '@vitejs/plugin-react' - -export default defineConfig({ - plugins: [react()], - test: { - environment: 'jsdom', - globals: true, - setupFiles: ['./src/setupTests.ts'], - }, -}) diff --git a/gaia/constants.py b/gaia/constants.py new file mode 100644 index 000000000..748ab22ba --- /dev/null +++ b/gaia/constants.py @@ -0,0 +1,60 @@ +"""Gaia-blessed physical constants as unit-bearing quantities.""" + +from gaia.unit import ureg + +# Fundamental constants +speed_of_light = c = (1 * ureg.speed_of_light).to("m/s") +planck = h = (1 * ureg.planck_constant).to("J*s") +hbar = (1 * ureg.hbar).to("J*s") +boltzmann = k_B = (1 * ureg.boltzmann_constant).to("J/K") +elementary_charge = e = (1 * ureg.elementary_charge).to("C") + +# Gravitation +gravitational_constant = G = (1 * ureg.gravitational_constant).to("m^3/(kg*s^2)") +standard_gravity = g_0 = (1 * ureg.standard_gravity).to("m/s^2") + +# Thermodynamics +avogadro = N_A = (1 * ureg.N_A).to("1/mol") +molar_gas_constant = R = (1 * ureg.molar_gas_constant).to("J/(mol*K)") +stefan_boltzmann = sigma_SB = (1 * ureg.stefan_boltzmann_constant).to("W/(m^2*K^4)") + +# Electromagnetism +vacuum_permittivity = eps_0 = (1 * ureg.vacuum_permittivity).to("F/m") +vacuum_permeability = mu_0 = (1 * ureg.vacuum_permeability).to("N/A^2") + +# Particle masses +electron_mass = m_e = (1 * ureg.electron_mass).to("kg") +proton_mass = m_p = (1 * ureg.proton_mass).to("kg") +neutron_mass = m_n = (1 * ureg.neutron_mass).to("kg") + +__all__ = [ + "N_A", + "G", + "R", + "avogadro", + "boltzmann", + "c", + "e", + "electron_mass", + "elementary_charge", + "eps_0", + "g_0", + "gravitational_constant", + "h", + "hbar", + "k_B", + "m_e", + "m_n", + "m_p", + "molar_gas_constant", + "mu_0", + "neutron_mass", + "planck", + "proton_mass", + "sigma_SB", + "speed_of_light", + "standard_gravity", + "stefan_boltzmann", + "vacuum_permeability", + "vacuum_permittivity", +] diff --git a/gaia/engine/__init__.py b/gaia/engine/__init__.py new file mode 100644 index 000000000..f6366ed89 --- /dev/null +++ b/gaia/engine/__init__.py @@ -0,0 +1,7 @@ +"""Gaia engine — public sub-facades under `gaia.engine.{bp,ir,lang,logic,inquiry,trace,packaging}`. + +Alpha 0 architectural split: engine code is the stable contract surface, +distinct from `gaia.cli`. Each sub-package owns its own `__all__`; this +namespace package intentionally does not flat-re-export the 244 public +symbols — callers import from the relevant sub-facade. +""" diff --git a/gaia/engine/_stale_check.py b/gaia/engine/_stale_check.py new file mode 100644 index 000000000..eb6fa5418 --- /dev/null +++ b/gaia/engine/_stale_check.py @@ -0,0 +1,88 @@ +"""Engine-side helper: detect stale compiled artifacts. + +Both ``gaia run infer`` and ``gaia build check`` need to compare a freshly compiled +``LocalCanonicalGraph`` against the persisted ``.gaia/ir_hash`` and +``.gaia/ir.json`` files. The detection logic is identical; only the +reporting style differs (``infer`` exits hard, ``check`` accumulates +diagnostics). This module owns the detection; callers own the reporting. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass +class ArtifactStaleness: + """Result of comparing persisted artifacts against a freshly compiled graph.""" + + ir_hash_path: Path + ir_json_path: Path + ir_hash_exists: bool + ir_json_exists: bool + ir_hash_stale: bool = False + ir_json_invalid_reason: str | None = None + ir_json_hash_mismatch: bool = False + ir_json_payload_mismatch: bool = False + stored_ir: dict[str, Any] | None = field(default=None, repr=False) + + @property + def any_artifact_present(self) -> bool: + return self.ir_hash_exists or self.ir_json_exists + + @property + def is_stale(self) -> bool: + """True if any persisted artifact disagrees with the fresh compile.""" + return ( + self.ir_hash_stale + or self.ir_json_invalid_reason is not None + or self.ir_json_hash_mismatch + or self.ir_json_payload_mismatch + ) + + +def check_compiled_artifacts( + pkg_path: Path, + *, + ir_hash: str, + compiled_payload: dict[str, Any] | None = None, +) -> ArtifactStaleness: + """Compare persisted ``.gaia/ir_hash`` / ``.gaia/ir.json`` against a fresh compile. + + ``compiled_payload`` is optional: when provided, the helper also + compares the persisted ``ir.json`` byte-payload against it (the + ``infer`` flow needs this; ``check`` does not). + """ + ir_hash_path = pkg_path / ".gaia" / "ir_hash" + ir_json_path = pkg_path / ".gaia" / "ir.json" + result = ArtifactStaleness( + ir_hash_path=ir_hash_path, + ir_json_path=ir_json_path, + ir_hash_exists=ir_hash_path.exists(), + ir_json_exists=ir_json_path.exists(), + ) + + if result.ir_hash_exists: + stored_hash = ir_hash_path.read_text().strip() + if stored_hash != ir_hash: + result.ir_hash_stale = True + + if result.ir_json_exists: + try: + stored_ir = json.loads(ir_json_path.read_text()) + except json.JSONDecodeError as exc: + result.ir_json_invalid_reason = str(exc) + else: + result.stored_ir = stored_ir + if stored_ir.get("ir_hash") != ir_hash: + result.ir_json_hash_mismatch = True + if compiled_payload is not None and stored_ir != compiled_payload: + result.ir_json_payload_mismatch = True + + return result + + +__all__ = ["ArtifactStaleness", "check_compiled_artifacts"] diff --git a/gaia/engine/bayes/README.md b/gaia/engine/bayes/README.md new file mode 100644 index 000000000..17f2a9013 --- /dev/null +++ b/gaia/engine/bayes/README.md @@ -0,0 +1,25 @@ +# gaia.engine.bayes + +`gaia.engine.bayes` provides the lifted Bayes authoring surface: + +- distribution literals backed by `scipy.stats` +- `model(...)` for one-hypothesis predictive-model helpers +- `likelihood(...)` for model-preference helpers and IR `infer` lowering + +The module intentionally keeps distribution recipes as typed values rather than +Knowledge nodes. `PredictiveModel` and `Likelihood` are `BayesInference` +reasoning records whose helper claims compile through the existing IR schema, +operators, and BP factor types. + +Use the namespace form in packages: + +```python +import gaia.engine.bayes as bayes + +model_a = bayes.model(h_a, observable=x, distribution=bayes.Normal(mu=mu, sigma=1.0)) +model_b = bayes.model(h_b, observable=x, distribution=bayes.Normal(mu=mu, sigma=1.0)) +comparison = bayes.likelihood(data, model=model_a, against=[model_b]) +``` + +See `docs/foundations/gaia-lang/bayes.md` for the executable Mendel example and +the lowering contract. diff --git a/gaia/engine/bayes/__init__.py b/gaia/engine/bayes/__init__.py new file mode 100644 index 000000000..1be55d447 --- /dev/null +++ b/gaia/engine/bayes/__init__.py @@ -0,0 +1,74 @@ +"""gaia.engine.bayes — hypothesis-data inference helpers.""" + +from __future__ import annotations + +from gaia.engine.bayes.compiler import register_bayes_lowerer as _register_bayes_lowerer +from gaia.engine.bayes.distributions import ( + Beta, + BetaBinomial, + Binomial, + Cauchy, + ChiSquared, + DistParam, + Distribution, + Exponential, + Gamma, + LogNormal, + Normal, + Poisson, + StudentT, + UnresolvedParameterError, +) +from gaia.engine.bayes.dsl.likelihood import likelihood +from gaia.engine.bayes.dsl.model import model +from gaia.engine.bayes.runtime import BayesInference, Likelihood, PredictiveModel +from gaia.engine.lang.runtime.action import Action +from gaia.engine.lang.runtime.roles import RoleAdder, register_role_handler + + +def _register_bayes_roles() -> None: + def predictive_model_roles(action: Action, add: RoleAdder) -> None: + if not isinstance(action, PredictiveModel): + return + add(action.hypothesis, "hypothesis") + add(action.helper, "model_helper") + + def likelihood_roles(action: Action, add: RoleAdder) -> None: + if not isinstance(action, Likelihood): + return + add(action.model, "compared_model") + for alternative in action.against: + add(alternative, "compared_alternative") + for data_claim in action.data: + add(data_claim, "likelihood_data") + add(action.helper, "model_preference_helper") + + register_role_handler(PredictiveModel, predictive_model_roles) + register_role_handler(Likelihood, likelihood_roles) + + +_register_bayes_roles() + +_register_bayes_lowerer() + +__all__ = [ + "BayesInference", + "Beta", + "BetaBinomial", + "Binomial", + "Cauchy", + "ChiSquared", + "DistParam", + "Distribution", + "Exponential", + "Gamma", + "Likelihood", + "LogNormal", + "Normal", + "Poisson", + "PredictiveModel", + "StudentT", + "UnresolvedParameterError", + "likelihood", + "model", +] diff --git a/gaia/engine/bayes/adapters/__init__.py b/gaia/engine/bayes/adapters/__init__.py new file mode 100644 index 000000000..395251f2e --- /dev/null +++ b/gaia/engine/bayes/adapters/__init__.py @@ -0,0 +1 @@ +"""Distribution backend adapters.""" diff --git a/gaia/engine/bayes/adapters/scipy_backend.py b/gaia/engine/bayes/adapters/scipy_backend.py new file mode 100644 index 000000000..86a2d0658 --- /dev/null +++ b/gaia/engine/bayes/adapters/scipy_backend.py @@ -0,0 +1,27 @@ +"""Internal scipy.stats backend for Bayes distribution literals.""" + +from __future__ import annotations + +import math +from collections.abc import Callable +from typing import Any + +import scipy.stats as stats + +_BUILDERS: dict[str, Callable[[dict[str, float]], Any]] = { + "beta": lambda p: stats.beta(a=p["alpha"], b=p["beta"]), + "betabinomial": lambda p: stats.betabinom(n=int(p["n"]), a=p["alpha"], b=p["beta"]), + "binomial": lambda p: stats.binom(n=int(p["n"]), p=p["p"]), + "cauchy": lambda p: stats.cauchy(loc=p["mu"], scale=p["gamma"]), + "chisquared": lambda p: stats.chi2(df=p["df"]), + "exponential": lambda p: stats.expon(scale=1.0 / p["rate"]), + "gamma": lambda p: stats.gamma(a=p["alpha"], scale=1.0 / p["rate"]), + "lognormal": lambda p: stats.lognorm(s=p["sigma"], scale=math.exp(p["mu"])), + "normal": lambda p: stats.norm(loc=p["mu"], scale=p["sigma"]), + "poisson": lambda p: stats.poisson(mu=p["rate"]), + "studentt": lambda p: stats.t(df=p["df"], loc=p["mu"], scale=p["sigma"]), +} + + +def _to_scipy_dist(kind: str, resolved_params: dict[str, float]) -> Any: + return _BUILDERS[kind](resolved_params) diff --git a/gaia/engine/bayes/compiler/__init__.py b/gaia/engine/bayes/compiler/__init__.py new file mode 100644 index 000000000..d52184343 --- /dev/null +++ b/gaia/engine/bayes/compiler/__init__.py @@ -0,0 +1,72 @@ +"""Bayes compiler lowering.""" + +from gaia.engine.bayes.compiler.lower import BayesLoweringResult, lower_bayes_claims +from gaia.engine.bayes.runtime import BayesInference +from gaia.engine.lang.compiler.extensions import ( + ActionLoweringContext, + ActionLoweringResult, + register_action_lowerer, + registered_action_lowerers, +) + +_LOWERER_NAME = "bayes" + + +def _is_bayes_action(action: object) -> bool: + return isinstance(action, BayesInference) + + +def _lower_bayes_actions(context: ActionLoweringContext) -> ActionLoweringResult: + lowered = lower_bayes_claims( + context.knowledge_nodes, + actions=context.actions, + namespace=context.namespace, + package_name=context.package_name, + knowledge_map=context.knowledge_map, + action_labels_by_object=context.action_labels_by_object, + existing_operators=context.existing_operators, + ) + return ActionLoweringResult( + knowledges=lowered.knowledges, + operators=lowered.operators, + strategies=lowered.strategies, + metadata_updates=lowered.metadata_updates, + action_label_map=lowered.action_label_map, + target_action_labels_by_id=lowered.target_action_labels_by_id, + ) + + +def register_bayes_lowerer() -> None: + """Register Bayes action lowering with the Gaia Lang compiler. + + Identity-aware idempotency: returns early only when the existing + ``"bayes"`` registration uses the official ``_is_bayes_action`` / + ``_lower_bayes_actions`` pair. If a different lowerer is already + registered under the ``"bayes"`` name, raise :class:`ValueError` + instead of silently shadowing it — that case is the exact scenario the + duplicate-name guard on :func:`register_action_lowerer` exists to + surface, and a name-only idempotency check would mask it. + + Safe to call from both ``gaia.engine.bayes.__init__`` (import-time + self-registration) and ``discover_and_register_extensions`` (called by + the compiler at compile time). + """ + for existing in registered_action_lowerers(): + if existing.name != _LOWERER_NAME: + continue + if existing.handles is _is_bayes_action and existing.lower is _lower_bayes_actions: + return + raise ValueError( + f"action lowerer {_LOWERER_NAME!r} already registered with a " + f"different handler/lowerer pair; refusing to silently shadow. " + f"Pass override=True to register_action_lowerer if the " + f"replacement was intentional." + ) + register_action_lowerer( + _LOWERER_NAME, + handles=_is_bayes_action, + lower=_lower_bayes_actions, + ) + + +__all__ = ["BayesLoweringResult", "lower_bayes_claims"] diff --git a/gaia/engine/bayes/compiler/lower.py b/gaia/engine/bayes/compiler/lower.py new file mode 100644 index 000000000..724c387c9 --- /dev/null +++ b/gaia/engine/bayes/compiler/lower.py @@ -0,0 +1,466 @@ +"""Lower Bayes runtime actions into existing Gaia IR strategies/operators.""" + +from __future__ import annotations + +import hashlib +import math +from dataclasses import dataclass, field +from typing import Any + +from gaia.engine.bayes.distributions.base import _is_deferred_reference +from gaia.engine.bayes.runtime import Likelihood, PredictiveModel +from gaia.engine.bp.factor_graph import CROMWELL_EPS +from gaia.engine.ir import Knowledge as IrKnowledge +from gaia.engine.ir import Operator as IrOperator +from gaia.engine.ir import Strategy as IrStrategy +from gaia.engine.ir.knowledge import KnowledgeType, make_qid +from gaia.engine.ir.operator import OperatorType +from gaia.engine.ir.strategy import StrategyType +from gaia.engine.lang.formula.connective import Land +from gaia.engine.lang.formula.predicate import Equals +from gaia.engine.lang.formula.term import Constant +from gaia.engine.lang.runtime import Claim, Knowledge, Variable + + +@dataclass(frozen=True) +class BayesLoweringResult: + """IR additions and metadata updates emitted by Bayes action lowering.""" + + knowledges: list[IrKnowledge] = field(default_factory=list) + operators: list[IrOperator] = field(default_factory=list) + strategies: list[IrStrategy] = field(default_factory=list) + metadata_updates: dict[str, dict[str, Any]] = field(default_factory=dict) + action_label_map: dict[str, str] = field(default_factory=dict) + target_action_labels_by_id: dict[str, str] = field(default_factory=dict) + + +def lower_bayes_claims( + knowledge_nodes: list[Knowledge], + *, + actions: list[Any] | tuple[Any, ...] = (), + namespace: str, + package_name: str, + knowledge_map: dict[int, str], + action_labels_by_object: dict[int, str] | None = None, + existing_operators: list[IrOperator] | None = None, +) -> BayesLoweringResult: + """Lower Bayes predictive-model and likelihood actions into Gaia IR records.""" + del knowledge_nodes + knowledges: list[IrKnowledge] = [] + operators: list[IrOperator] = [] + strategies: list[IrStrategy] = [] + metadata_updates: dict[str, dict[str, Any]] = {} + action_label_map: dict[str, str] = {} + target_action_labels_by_id: dict[str, str] = {} + existing_relations = _existing_relations(existing_operators or []) + labels_by_object = action_labels_by_object or {} + + for action in actions: + if not isinstance(action, PredictiveModel): + continue + action_label = labels_by_object.get(id(action)) + helper_id = knowledge_map[id(action.helper)] + metadata_updates[helper_id] = _prediction_metadata( + action, + knowledge_map, + action_label=action_label, + ) + if action_label: + action_label_map[action_label] = helper_id + target_action_labels_by_id[helper_id] = action_label + + for action in actions: + if not isinstance(action, Likelihood): + continue + action_label = labels_by_object.get(id(action)) + lowered = _lower_likelihood( + action, + namespace=namespace, + package_name=package_name, + knowledge_map=knowledge_map, + action_label=action_label, + existing_relations=existing_relations, + ) + knowledges.extend(lowered.knowledges) + operators.extend(lowered.operators) + strategies.extend(lowered.strategies) + metadata_updates.update(lowered.metadata_updates) + action_label_map.update(lowered.action_label_map) + target_action_labels_by_id.update(lowered.target_action_labels_by_id) + existing_relations.update(_existing_relations(lowered.operators)) + + return BayesLoweringResult( + knowledges=knowledges, + operators=operators, + strategies=strategies, + metadata_updates=metadata_updates, + action_label_map=action_label_map, + target_action_labels_by_id=target_action_labels_by_id, + ) + + +def _prediction_metadata( + action: PredictiveModel, + knowledge_map: dict[int, str], + *, + action_label: str | None, +) -> dict[str, Any]: + if action.hypothesis is None or action.observable is None or action.distribution is None: + raise ValueError( + "Bayes PredictiveModel action requires hypothesis, observable, distribution" + ) + bayes = { + "role": "prediction", + "distribution": action.distribution.model_dump(), + "hypothesis": knowledge_map[id(action.hypothesis)], + "hypotheses": [knowledge_map[id(action.hypothesis)]], + "observable": _variable_descriptor(action.observable), + } + payload: dict[str, Any] = {"bayes": bayes} + if action_label: + payload["review_target"] = {"action_label": action_label, "pattern": "prediction"} + return payload + + +def _model_action(helper: Claim) -> PredictiveModel: + for action in helper.from_actions: + if isinstance(action, PredictiveModel) and action.helper is helper: + return action + raise ValueError(f"{helper.label or helper.content!r} is not a bayes.model() helper") + + +def _likelihood_model_actions(action: Likelihood) -> tuple[PredictiveModel, ...]: + if action.model is None: + raise ValueError("Bayes Likelihood action requires model") + return (_model_action(action.model), *(_model_action(helper) for helper in action.against)) + + +def _model_hypotheses(action: Likelihood) -> tuple[Claim, ...]: + hypotheses = tuple( + model_action.hypothesis for model_action in _likelihood_model_actions(action) + ) + if any(hypothesis is None for hypothesis in hypotheses): + raise ValueError("Bayes PredictiveModel action is missing a hypothesis") + return tuple(hypothesis for hypothesis in hypotheses if hypothesis is not None) + + +def _lower_likelihood( + action: Likelihood, + *, + namespace: str, + package_name: str, + knowledge_map: dict[int, str], + action_label: str | None, + existing_relations: set[tuple[str, frozenset[str]]], +) -> BayesLoweringResult: + if action.helper is None or action.model is None: + raise ValueError("Bayes Likelihood action requires helper and model") + cmp_id = knowledge_map[id(action.helper)] + model_id = knowledge_map[id(action.model)] + against_ids = [knowledge_map[id(model)] for model in action.against] + data_ids = [knowledge_map[id(d)] for d in action.data] + model_actions = _likelihood_model_actions(action) + hypotheses = _model_hypotheses(action) + likelihoods = _likelihoods(action, model_actions) + action.log_likelihoods = dict(likelihoods) + if not any(math.isfinite(value) for value in likelihoods.values()): + raise ValueError( + f"BayesLikelihoodError: likelihood {action.label or action.helper.content!r} has zero " + "support under every hypothesis. Fix: check the observation value, the " + "predictive distribution support, or use precomputed likelihoods." + ) + + metadata_updates = { + cmp_id: { + "bayes": { + "role": "comparison", + "exclusivity": action.exclusivity, + "likelihoods": {knowledge_map[id(h)]: value for h, value in likelihoods.items()}, + "data": data_ids, + "model": model_id, + "against": against_ids, + "hypotheses": [knowledge_map[id(h)] for h in hypotheses], + } + } + } + + log_l_max = max(likelihoods.values()) + strategies = [] + target_action_labels_by_id: dict[str, str] = {} + for hypothesis, log_likelihood in likelihoods.items(): + lr = math.exp(log_likelihood - log_l_max) + p1 = _clamp((1.0 - CROMWELL_EPS) * lr) + h_id = knowledge_map[id(hypothesis)] + metadata: dict[str, Any] = { + "pattern": "inference", + "bayes": { + "role": "likelihood_factor", + "comparison": cmp_id, + "hypothesis": h_id, + "log_likelihood": log_likelihood, + }, + } + if action_label: + metadata["action_label"] = action_label + strategy = IrStrategy( + scope="local", + type=StrategyType.INFER, + premises=[h_id], + conclusion=cmp_id, + conditional_probabilities=[0.5, p1], + metadata=metadata, + ) + strategies.append(strategy) + if action_label and strategy.strategy_id: + target_action_labels_by_id[strategy.strategy_id] = action_label + + helper_knowledges, operators = _exhaustive_disjunction_operator( + list(hypotheses), + action, + namespace=namespace, + package_name=package_name, + knowledge_map=knowledge_map, + cmp_id=cmp_id, + existing_relations=existing_relations, + ) + return BayesLoweringResult( + knowledges=helper_knowledges, + operators=operators, + strategies=strategies, + metadata_updates=metadata_updates, + action_label_map={action_label: cmp_id} if action_label else {}, + target_action_labels_by_id=target_action_labels_by_id, + ) + + +def _likelihoods( + action: Likelihood, + model_actions: tuple[PredictiveModel, ...], +) -> dict[Claim, float]: + if action.precomputed is not None: + hypotheses = { + model_action.hypothesis + for model_action in model_actions + if model_action.hypothesis is not None + } + for key in action.precomputed: + if not isinstance(key, Claim) or key not in hypotheses: + raise ValueError("precomputed likelihood keys must be original hypothesis Claims") + provided = set(action.precomputed) + if provided != hypotheses: + missing = sorted(claim.label or claim.content for claim in hypotheses - provided) + details = [] + if missing: + details.append(f"missing {missing}") + suffix = f": {', '.join(details)}" if details else "" + raise ValueError( + "precomputed likelihoods must cover exactly the model hypotheses" + suffix + ) + return {hypothesis: float(value) for hypothesis, value in action.precomputed.items()} + likelihoods: dict[Claim, float] = {} + for model_action in model_actions: + if ( + model_action.hypothesis is None + or model_action.distribution is None + or model_action.observable is None + ): + raise ValueError("Bayes PredictiveModel action is incomplete") + hypothesis = model_action.hypothesis + distribution = _bind_distribution(model_action.distribution, hypothesis) + total = 0.0 + for data_claim in action.data: + value = _observation_value(data_claim, model_action.observable) + total += _log_likelihood(distribution, value, data_claim) + likelihoods[hypothesis] = total + return likelihoods + + +def _bind_distribution(distribution: Any, hypothesis: Claim) -> Any: + bindings = _claim_bindings(hypothesis) + params: dict[str, Any] = {} + for name, value in distribution.params.items(): + if _is_deferred_reference(value): + bound = bindings.by_object.get(id(value)) + if bound is None: + matches = bindings.by_symbol.get(value.symbol, []) + if len(matches) == 1: + bound = matches[0] + if bound is None: + raise ValueError( + f"BindingError: Variable {value.symbol!r} is unbound under " + f"{hypothesis.label or hypothesis.content!r}. " + "Fix: add parameter(variable, value) for this hypothesis." + ) + params[name] = bound + else: + params[name] = value + return distribution._replace_params(params) + + +@dataclass(frozen=True) +class _Bindings: + by_object: dict[int, Any] + by_symbol: dict[str, list[Any]] + + +def _claim_bindings(claim: Claim) -> _Bindings: + by_object: dict[int, Any] = {} + by_symbol: dict[str, list[Any]] = {} + for variable, value in _equals_variable_constant_pairs(getattr(claim, "formula", None)): + by_object[id(variable)] = value + by_symbol.setdefault(variable.symbol, []).append(value) + return _Bindings(by_object=by_object, by_symbol=by_symbol) + + +def _observation_value(claim: Claim, observable: Variable) -> Any: + values: list[Any] = [] + for variable, value in _equals_variable_constant_pairs(getattr(claim, "formula", None)): + if variable is observable or variable.symbol == observable.symbol: + values.append(value) + if not values: + raise ValueError( + f"likelihood() data {claim.label or claim.content!r} has no observation " + f"for variable {observable.symbol!r}" + ) + if len(values) > 1: + raise ValueError( + f"likelihood() data {claim.label or claim.content!r} has multiple values " + f"for variable {observable.symbol!r}" + ) + return values[0] + + +def _equals_variable_constant_pairs(formula: Any) -> list[tuple[Variable, Any]]: + if isinstance(formula, Equals): + left, right = formula.left, formula.right + if isinstance(left, Variable) and isinstance(right, Constant): + return [(left, right.value)] + if isinstance(right, Variable) and isinstance(left, Constant): + return [(right, left.value)] + return [] + if isinstance(formula, Land): + pairs: list[tuple[Variable, Any]] = [] + for operand in formula.operands: + pairs.extend(_equals_variable_constant_pairs(operand)) + return pairs + return [] + + +def _log_likelihood(distribution: Any, value: Any, data_claim: Claim) -> float: + noise_payload = ((data_claim.metadata or {}).get("bayes") or {}).get("noise") + if noise_payload: + return _log_likelihood_with_noise(distribution, value, noise_payload) + if distribution.kind in {"betabinomial", "binomial", "poisson"}: + return float(distribution.logpmf(value)) + return float(distribution.logpdf(float(value))) + + +def _log_likelihood_with_noise( + distribution: Any, value: Any, noise_payload: dict[str, Any] +) -> float: + if noise_payload.get("kind") != "normal": + raise NotImplementedError("Bayes likelihood currently supports only Normal additive noise") + from gaia.engine.bayes.distributions.continuous import Normal + + noise = Normal(**noise_payload.get("params", {})) + low, high = distribution.support() + if distribution.kind in {"betabinomial", "binomial", "poisson"}: + if not math.isfinite(high): + high = max(int(value + 10 * noise.params["sigma"]), int(value) + 50) + terms = [] + for x in range(int(low), int(high) + 1): + terms.append(distribution.logpmf(x) + noise.logpdf(float(value) - x)) + return _logsumexp(terms) + + from scipy.integrate import quad + + def integrand(x: float) -> float: + return math.exp(distribution.logpdf(x) + noise.logpdf(float(value) - x)) + + integral, _ = quad(integrand, float(low), float(high), limit=100) + if integral <= 0.0 or not math.isfinite(integral): + return -math.inf + return math.log(integral) + + +def _logsumexp(values: list[float]) -> float: + finite = [v for v in values if math.isfinite(v)] + if not finite: + return -math.inf + m = max(finite) + return m + math.log(sum(math.exp(v - m) for v in finite)) + + +def _exhaustive_disjunction_operator( + hypotheses: list[Claim], + action: Likelihood, + *, + namespace: str, + package_name: str, + knowledge_map: dict[int, str], + cmp_id: str, + existing_relations: set[tuple[str, frozenset[str]]], +) -> tuple[list[IrKnowledge], list[IrOperator]]: + if action.exclusivity != "exhaustive_pairwise_complement" or len(hypotheses) < 3: + return [], [] + variables = [knowledge_map[id(h)] for h in hypotheses] + relation_key = ("disjunction", frozenset(variables)) + if relation_key in existing_relations: + return [], [] + + label = _helper_label("bayes_exhaustive", cmp_id) + helper_id = make_qid(namespace, package_name, label) + helper = IrKnowledge( + id=helper_id, + label=label, + type=KnowledgeType.CLAIM, + content="At least one Bayes hypothesis in the comparison is true.", + metadata={ + "generated": True, + "review": False, + "helper_kind": "bayes_exhaustive_result", + "prior": 1.0 - CROMWELL_EPS, + "bayes": {"auto_generated_by": f"likelihood:{cmp_id}"}, + }, + ) + op = IrOperator( + operator_id=_operator_id("disjunction", variables, helper_id), + scope="local", + operator=OperatorType.DISJUNCTION, + variables=variables, + conclusion=helper_id, + metadata={"bayes": {"auto_generated_by": f"likelihood:{cmp_id}"}}, + ) + return [helper], [op] + + +def _helper_label(prefix: str, payload: str) -> str: + digest = hashlib.sha256(payload.encode()).hexdigest()[:12] + return f"__{prefix}_{digest}" + + +_SYMMETRIC_OPS = frozenset( + {"equivalence", "contradiction", "complement", "disjunction", "conjunction"} +) + + +def _operator_id(operator: str, variables: list[str], conclusion: str) -> str: + var_ids = sorted(variables) if operator in _SYMMETRIC_OPS else list(variables) + raw = f"{operator}|{'|'.join(var_ids)}|{conclusion}" + return f"lco_{hashlib.sha256(raw.encode()).hexdigest()[:16]}" + + +def _existing_relations(operators: list[IrOperator]) -> set[tuple[str, frozenset[str]]]: + relations: set[tuple[str, frozenset[str]]] = set() + for operator in operators: + relations.add((str(operator.operator), frozenset(operator.variables))) + return relations + + +def _variable_descriptor(variable: Variable) -> dict[str, Any]: + domain = getattr(variable.domain, "name", None) or getattr(variable.domain, "label", None) + return {"symbol": variable.symbol, "domain": domain} + + +def _clamp(value: float) -> float: + return max(CROMWELL_EPS, min(1.0 - CROMWELL_EPS, value)) diff --git a/gaia/engine/bayes/distributions/__init__.py b/gaia/engine/bayes/distributions/__init__.py new file mode 100644 index 000000000..a957098c8 --- /dev/null +++ b/gaia/engine/bayes/distributions/__init__.py @@ -0,0 +1,37 @@ +"""Distribution literals for `gaia.engine.bayes`.""" + +from __future__ import annotations + +from gaia.engine.bayes.distributions.continuous import ( + Beta, + Cauchy, + ChiSquared, + Exponential, + Gamma, + LogNormal, + Normal, + StudentT, +) +from gaia.engine.bayes.distributions.discrete import BetaBinomial, Binomial, Poisson +from gaia.engine.bayes.distributions.protocol import ( + DistParam, + Distribution, + UnresolvedParameterError, +) + +__all__ = [ + "Beta", + "BetaBinomial", + "Binomial", + "Cauchy", + "ChiSquared", + "DistParam", + "Distribution", + "Exponential", + "Gamma", + "LogNormal", + "Normal", + "Poisson", + "StudentT", + "UnresolvedParameterError", +] diff --git a/gaia/engine/bayes/distributions/base.py b/gaia/engine/bayes/distributions/base.py new file mode 100644 index 000000000..18dbc770a --- /dev/null +++ b/gaia/engine/bayes/distributions/base.py @@ -0,0 +1,82 @@ +"""Shared machinery for Bayes distribution literals.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, model_validator + +from gaia.engine.bayes.distributions.protocol import UnresolvedParameterError + + +def _is_concrete_number(value: Any) -> bool: + return not isinstance(value, bool) and isinstance(value, (int, float)) + + +def _is_deferred_reference(value: Any) -> bool: + return isinstance(getattr(value, "symbol", None), str) + + +def _domain_descriptor(value: Any) -> str: + if isinstance(value, str): + return value + for attr in ("name", "label", "content"): + candidate = getattr(value, attr, None) + if isinstance(candidate, str): + return candidate + return repr(value) + + +def _deferred_reference_descriptor(value: Any) -> dict[str, Any]: + descriptor: dict[str, Any] = {"symbol": value.symbol} + domain = getattr(value, "domain", None) + if domain is not None: + descriptor["domain"] = _domain_descriptor(domain) + label = getattr(value, "label", None) + if label is not None: + descriptor["label"] = label + return descriptor + + +class _BaseDistribution(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) + + kind: str + params: dict[str, Any] + + @model_validator(mode="after") + def _validate_params(self) -> _BaseDistribution: + for name, value in self.params.items(): + if not _is_concrete_number(value) and not _is_deferred_reference(value): + raise ValueError( + f"{self.kind} parameter {name!r} must be a number " + "or a deferred reference with a string `.symbol` attribute" + ) + return self + + def _deferred_param_names(self) -> list[str]: + return sorted(name for name, value in self.params.items() if _is_deferred_reference(value)) + + def _resolved_params(self) -> dict[str, float]: + deferred = self._deferred_param_names() + if deferred: + raise UnresolvedParameterError(self.kind, deferred) + return {name: float(value) for name, value in self.params.items()} + + def _replace_params(self, params: dict[str, Any]) -> _BaseDistribution: + return self.__class__(**params) + + def model_dump(self, **kwargs: Any) -> dict[str, Any]: + del kwargs + concrete = { + name: value for name, value in self.params.items() if not _is_deferred_reference(value) + } + deferred = { + name: _deferred_reference_descriptor(value) + for name, value in sorted(self.params.items()) + if _is_deferred_reference(value) + } + payload: dict[str, Any] = {"kind": self.kind, "params": concrete} + if deferred: + payload["deferred_params"] = deferred + return payload diff --git a/gaia/engine/bayes/distributions/continuous.py b/gaia/engine/bayes/distributions/continuous.py new file mode 100644 index 000000000..e4bd3581c --- /dev/null +++ b/gaia/engine/bayes/distributions/continuous.py @@ -0,0 +1,172 @@ +"""Continuous Bayes distribution literals.""" + +from __future__ import annotations + +import math +from typing import Any + +from pydantic import model_validator + +from gaia.engine.bayes.adapters.scipy_backend import _to_scipy_dist +from gaia.engine.bayes.distributions.base import _BaseDistribution, _is_concrete_number + + +class _ContinuousDistribution(_BaseDistribution): + _support: tuple[float, float] = (-math.inf, math.inf) + + def logpdf(self, x: float) -> float: + return float(_to_scipy_dist(self.kind, self._resolved_params()).logpdf(float(x))) + + def logpmf(self, k: int) -> float: + del k + raise TypeError(f"{self.__class__.__name__} is a continuous distribution; use .logpdf()") + + def support(self) -> tuple[float, float]: + return self._support + + +class Normal(_ContinuousDistribution): + """Normal distribution literal with mean and standard deviation.""" + + kind: str = "normal" + _support = (-math.inf, math.inf) + + def __init__(self, *, mu: Any, sigma: Any) -> None: + """Create a Normal distribution literal.""" + super().__init__(kind="normal", params={"mu": mu, "sigma": sigma}) + + @model_validator(mode="after") + def _validate_normal(self) -> Normal: + sigma = self.params["sigma"] + if _is_concrete_number(sigma) and float(sigma) <= 0.0: + raise ValueError(f"Normal sigma must be > 0, got {sigma!r}") + return self + + +class Beta(_ContinuousDistribution): + """Beta distribution literal over the unit interval.""" + + kind: str = "beta" + _support = (0.0, 1.0) + + def __init__(self, *, alpha: Any, beta: Any) -> None: + """Create a Beta distribution literal.""" + super().__init__(kind="beta", params={"alpha": alpha, "beta": beta}) + + @model_validator(mode="after") + def _validate_beta(self) -> Beta: + for name in ("alpha", "beta"): + value = self.params[name] + if _is_concrete_number(value) and float(value) <= 0.0: + raise ValueError(f"Beta {name} must be > 0, got {value!r}") + return self + + +class Exponential(_ContinuousDistribution): + """Exponential distribution literal parameterized by rate.""" + + kind: str = "exponential" + _support = (0.0, math.inf) + + def __init__(self, *, rate: Any) -> None: + """Create an Exponential distribution literal.""" + super().__init__(kind="exponential", params={"rate": rate}) + + @model_validator(mode="after") + def _validate_exponential(self) -> Exponential: + rate = self.params["rate"] + if _is_concrete_number(rate) and float(rate) <= 0.0: + raise ValueError(f"Exponential rate must be > 0, got {rate!r}") + return self + + +class LogNormal(_ContinuousDistribution): + """Log-normal distribution literal with log-space mean and scale.""" + + kind: str = "lognormal" + _support = (0.0, math.inf) + + def __init__(self, *, mu: Any, sigma: Any) -> None: + """Create a LogNormal distribution literal.""" + super().__init__(kind="lognormal", params={"mu": mu, "sigma": sigma}) + + @model_validator(mode="after") + def _validate_lognormal(self) -> LogNormal: + sigma = self.params["sigma"] + if _is_concrete_number(sigma) and float(sigma) <= 0.0: + raise ValueError(f"LogNormal sigma must be > 0, got {sigma!r}") + return self + + +class StudentT(_ContinuousDistribution): + """Student's t distribution literal with location and scale.""" + + kind: str = "studentt" + _support = (-math.inf, math.inf) + + def __init__(self, *, df: Any, mu: Any = 0.0, sigma: Any = 1.0) -> None: + """Create a StudentT distribution literal.""" + super().__init__(kind="studentt", params={"df": df, "mu": mu, "sigma": sigma}) + + @model_validator(mode="after") + def _validate_studentt(self) -> StudentT: + for name in ("df", "sigma"): + value = self.params[name] + if _is_concrete_number(value) and float(value) <= 0.0: + raise ValueError(f"StudentT {name} must be > 0, got {value!r}") + return self + + +class Cauchy(_ContinuousDistribution): + """Cauchy distribution literal with location and scale.""" + + kind: str = "cauchy" + _support = (-math.inf, math.inf) + + def __init__(self, *, mu: Any, gamma: Any) -> None: + """Create a Cauchy distribution literal.""" + super().__init__(kind="cauchy", params={"mu": mu, "gamma": gamma}) + + @model_validator(mode="after") + def _validate_cauchy(self) -> Cauchy: + gamma = self.params["gamma"] + if _is_concrete_number(gamma) and float(gamma) <= 0.0: + raise ValueError(f"Cauchy gamma must be > 0, got {gamma!r}") + return self + + +class Gamma(_ContinuousDistribution): + """Gamma distribution literal parameterized by shape and rate.""" + + kind: str = "gamma" + _support = (0.0, math.inf) + + def __init__(self, *, alpha: Any, rate: Any) -> None: + """Create a Gamma distribution literal.""" + super().__init__(kind="gamma", params={"alpha": alpha, "rate": rate}) + + @model_validator(mode="after") + def _validate_gamma(self) -> Gamma: + for name in ("alpha", "rate"): + value = self.params[name] + if _is_concrete_number(value) and float(value) <= 0.0: + raise ValueError(f"Gamma {name} must be > 0, got {value!r}") + return self + + +class ChiSquared(_ContinuousDistribution): + """Chi-squared distribution literal parameterized by degrees of freedom.""" + + kind: str = "chisquared" + _support = (0.0, math.inf) + + def __init__(self, *, df: Any) -> None: + """Create a ChiSquared distribution literal.""" + super().__init__(kind="chisquared", params={"df": df}) + + @model_validator(mode="after") + def _validate_chisquared(self) -> ChiSquared: + df = self.params["df"] + if _is_concrete_number(df) and float(df) <= 0.0: + raise ValueError(f"ChiSquared df must be > 0, got {df!r}") + return self diff --git a/gaia/engine/bayes/distributions/discrete.py b/gaia/engine/bayes/distributions/discrete.py new file mode 100644 index 000000000..0cc2d99a2 --- /dev/null +++ b/gaia/engine/bayes/distributions/discrete.py @@ -0,0 +1,142 @@ +"""Discrete Bayes distribution literals.""" + +from __future__ import annotations + +import math +from typing import Any + +from pydantic import model_validator + +from gaia.engine.bayes.adapters.scipy_backend import _to_scipy_dist +from gaia.engine.bayes.distributions.base import _BaseDistribution, _is_concrete_number + + +class Binomial(_BaseDistribution): + """Binomial distribution literal for integer success counts.""" + + kind: str = "binomial" + + def __init__(self, *, n: Any, p: Any) -> None: + """Create a Binomial distribution literal.""" + super().__init__(kind="binomial", params={"n": n, "p": p}) + + @model_validator(mode="after") + def _validate_binomial(self) -> Binomial: + n = self.params["n"] + p = self.params["p"] + if _is_concrete_number(n): + if isinstance(n, float) and not n.is_integer(): + raise ValueError(f"Binomial n must be an integer, got {n!r}") + if int(n) < 0: + raise ValueError(f"Binomial n must be >= 0, got {n!r}") + if _is_concrete_number(p) and not 0.0 <= float(p) <= 1.0: + raise ValueError(f"Binomial p must be in [0, 1], got {p!r}") + return self + + def logpmf(self, k: int) -> float: + """Evaluate the log probability mass at integer count ``k``.""" + if not isinstance(k, int) or isinstance(k, bool): + raise TypeError(f"Binomial.logpmf(k): k must be integer, got {type(k).__name__}") + resolved = self._resolved_params() + n = int(resolved["n"]) + if k < 0 or k > n: + return -math.inf + return float(_to_scipy_dist(self.kind, resolved).logpmf(k)) + + def logpdf(self, x: float) -> float: + """Reject density evaluation for the discrete Binomial distribution.""" + del x + raise TypeError("Binomial is a discrete distribution; use .logpmf()") + + def support(self) -> tuple[int, int]: + """Return the inclusive integer support bounds.""" + resolved = self._resolved_params() + return (0, int(resolved["n"])) + + +class BetaBinomial(_BaseDistribution): + """Beta-binomial distribution literal for integer success counts. + + Predictive distribution obtained by integrating ``Binomial(n, p)`` over + ``p ~ Beta(alpha, beta)``. Useful as a model-comparison reference when + the success probability has a Beta prior rather than a fixed value. + + The special case ``BetaBinomial(n, alpha=1, beta=1)`` corresponds to + ``p ~ Uniform[0, 1]`` and gives the closed-form uniform marginal + ``P(k) = 1 / (n + 1)`` for every ``k ∈ [0, n]``. + """ + + kind: str = "betabinomial" + + def __init__(self, *, n: Any, alpha: Any, beta: Any) -> None: + """Create a BetaBinomial distribution literal.""" + super().__init__(kind="betabinomial", params={"n": n, "alpha": alpha, "beta": beta}) + + @model_validator(mode="after") + def _validate_betabinomial(self) -> BetaBinomial: + n = self.params["n"] + if _is_concrete_number(n): + if isinstance(n, float) and not n.is_integer(): + raise ValueError(f"BetaBinomial n must be an integer, got {n!r}") + if int(n) < 0: + raise ValueError(f"BetaBinomial n must be >= 0, got {n!r}") + for name in ("alpha", "beta"): + value = self.params[name] + if _is_concrete_number(value) and float(value) <= 0.0: + raise ValueError(f"BetaBinomial {name} must be > 0, got {value!r}") + return self + + def logpmf(self, k: int) -> float: + """Evaluate the log probability mass at integer count ``k``.""" + if not isinstance(k, int) or isinstance(k, bool): + raise TypeError(f"BetaBinomial.logpmf(k): k must be integer, got {type(k).__name__}") + resolved = self._resolved_params() + n = int(resolved["n"]) + if k < 0 or k > n: + return -math.inf + return float(_to_scipy_dist(self.kind, resolved).logpmf(k)) + + def logpdf(self, x: float) -> float: + """Reject density evaluation for the discrete BetaBinomial distribution.""" + del x + raise TypeError("BetaBinomial is a discrete distribution; use .logpmf()") + + def support(self) -> tuple[int, int]: + """Return the inclusive integer support bounds.""" + resolved = self._resolved_params() + return (0, int(resolved["n"])) + + +class Poisson(_BaseDistribution): + """Poisson distribution literal for non-negative integer counts.""" + + kind: str = "poisson" + + def __init__(self, *, rate: Any) -> None: + """Create a Poisson distribution literal.""" + super().__init__(kind="poisson", params={"rate": rate}) + + @model_validator(mode="after") + def _validate_poisson(self) -> Poisson: + rate = self.params["rate"] + if _is_concrete_number(rate) and float(rate) <= 0.0: + raise ValueError(f"Poisson rate must be > 0, got {rate!r}") + return self + + def logpmf(self, k: int) -> float: + """Evaluate the log probability mass at integer count ``k``.""" + if not isinstance(k, int) or isinstance(k, bool): + raise TypeError(f"Poisson.logpmf(k): k must be integer, got {type(k).__name__}") + if k < 0: + return -math.inf + return float(_to_scipy_dist(self.kind, self._resolved_params()).logpmf(k)) + + def logpdf(self, x: float) -> float: + """Reject density evaluation for the discrete Poisson distribution.""" + del x + raise TypeError("Poisson is a discrete distribution; use .logpmf()") + + def support(self) -> tuple[int, float]: + """Return the support bounds for non-negative counts.""" + self._resolved_params() + return (0, math.inf) diff --git a/gaia/engine/bayes/distributions/protocol.py b/gaia/engine/bayes/distributions/protocol.py new file mode 100644 index 000000000..c16e9c324 --- /dev/null +++ b/gaia/engine/bayes/distributions/protocol.py @@ -0,0 +1,45 @@ +"""Distribution protocol and deferred-parameter errors.""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + +DistParam = int | float | Any + + +class UnresolvedParameterError(ValueError): + """Raised when evaluating a distribution with unresolved deferred params.""" + + def __init__(self, distribution_kind: str, deferred_params: list[str]) -> None: + """Create an unresolved-parameter error message.""" + self.distribution_kind = distribution_kind + self.deferred_params = list(deferred_params) + names = ", ".join(deferred_params) + super().__init__( + f"{distribution_kind} has unresolved deferred parameter(s): {names}. " + "Resolve Variable-backed distribution parameters before likelihood evaluation." + ) + + +@runtime_checkable +class Distribution(Protocol): + """Runtime protocol implemented by Bayes distribution literals.""" + + kind: str + params: dict[str, DistParam] + + def logpmf(self, x: int) -> float: + """Evaluate the log probability mass at integer value ``x``.""" + ... + + def logpdf(self, x: float) -> float: + """Evaluate the log probability density at real value ``x``.""" + ... + + def support(self) -> tuple[float, float]: + """Return lower and upper distribution support bounds.""" + ... + + def model_dump(self, **kwargs: Any) -> dict[str, Any]: + """Serialize the distribution literal for metadata or IR emission.""" + ... diff --git a/gaia/engine/bayes/dsl/__init__.py b/gaia/engine/bayes/dsl/__init__.py new file mode 100644 index 000000000..de99a4c1c --- /dev/null +++ b/gaia/engine/bayes/dsl/__init__.py @@ -0,0 +1,6 @@ +"""Bayes DSL verbs.""" + +from gaia.engine.bayes.dsl.likelihood import likelihood +from gaia.engine.bayes.dsl.model import model + +__all__ = ["likelihood", "model"] diff --git a/gaia/engine/bayes/dsl/likelihood.py b/gaia/engine/bayes/dsl/likelihood.py new file mode 100644 index 000000000..7a1ea3d69 --- /dev/null +++ b/gaia/engine/bayes/dsl/likelihood.py @@ -0,0 +1,262 @@ +"""Bayes likelihood helper.""" + +from __future__ import annotations + +import hashlib +import re +from itertools import combinations +from typing import Any + +from gaia.engine.bayes.runtime import Likelihood, PredictiveModel +from gaia.engine.bp.factor_graph import CROMWELL_EPS +from gaia.engine.lang.runtime.action import ( + Contradict, + Exclusive, + attach_reasoning, + validate_no_self_warrant, +) +from gaia.engine.lang.runtime.knowledge import Claim, Knowledge, _current_package + +_EXCLUSIVITY_VALUES = { + "none", + "pairwise_contradiction", + "exhaustive_pairwise_complement", +} + +_LABEL_RE = re.compile(r"[^a-z0-9_]") + + +def _as_claim_tuple( + value: Claim | list[Claim] | tuple[Claim, ...], *, name: str +) -> tuple[Claim, ...]: + items: tuple[Claim, ...] + items = (value,) if isinstance(value, Claim) else tuple(value) + if not items: + raise ValueError(f"likelihood() requires at least one {name} claim") + for item in items: + if not isinstance(item, Claim): + raise TypeError(f"likelihood() {name} entries must be Claim objects") + return items + + +def _model_action(helper: Claim) -> PredictiveModel: + for action in helper.from_actions: + if isinstance(action, PredictiveModel) and action.helper is helper: + return action + raise TypeError("likelihood() model entries must be Claims returned by bayes.model()") + + +def _claim_ref(claim: Claim) -> str: + if claim.label: + return f"[@{claim.label}]" + return claim.content + + +def _label_part(claim: Claim) -> str: + raw = claim.label or claim.content or "claim" + normalized = _LABEL_RE.sub("_", raw.strip().lower()) + normalized = normalized.strip("_") + if not normalized: + digest = hashlib.sha256(raw.encode()).hexdigest()[:8] + normalized = f"claim_{digest}" + if not (normalized[0].isalpha() or normalized[0] == "_"): + normalized = f"_{normalized}" + return normalized + + +def _relation_exists(kind: type[Contradict] | type[Exclusive], a: Claim, b: Claim) -> bool: + pkg = _current_package.get() + if pkg is None: + return False + pair = {id(a), id(b)} + for action in pkg.actions: + if isinstance(action, kind) and {id(action.a), id(action.b)} == pair: + return True + return False + + +def _auto_structural_label(base: str | None, relation: str, a: Claim, b: Claim) -> str: + prefix = base or "likelihood" + return f"{prefix}_{relation}_{_label_part(a)}_{_label_part(b)}" + + +def _auto_generated_by(label: str | None) -> str: + return f"likelihood:{label or 'anonymous'}" + + +def _auto_contradict(a: Claim, b: Claim, *, label: str | None) -> None: + if _relation_exists(Contradict, a, b): + return + helper = Claim( + f"{_claim_ref(a)} and {_claim_ref(b)} contradict.", + metadata={ + "generated": True, + "helper_kind": "contradiction_result", + "review": True, + "auto_generated_by": _auto_generated_by(label), + "bayes": {"auto_generated_by": _auto_generated_by(label)}, + }, + ) + action = Contradict( + label=_auto_structural_label(label, "contradict", a, b), + rationale="Bayes likelihood alternatives are pairwise contradictory.", + metadata={"bayes": {"auto_generated_by": _auto_generated_by(label)}}, + a=a, + b=b, + helper=helper, + ) + validate_no_self_warrant(action, helper) + attach_reasoning(helper, action) + + +def _auto_exclusive(a: Claim, b: Claim, *, label: str | None) -> None: + if _relation_exists(Exclusive, a, b): + return + helper = Claim( + f"exactly one of {_claim_ref(a)} and {_claim_ref(b)} is true.", + metadata={ + "generated": True, + "helper_kind": "complement_result", + "review": True, + "auto_generated_by": _auto_generated_by(label), + "bayes": {"auto_generated_by": _auto_generated_by(label)}, + }, + ) + action = Exclusive( + label=_auto_structural_label(label, "exclusive", a, b), + rationale="Bayes likelihood alternatives form a closed binary partition.", + metadata={"bayes": {"auto_generated_by": _auto_generated_by(label)}}, + a=a, + b=b, + helper=helper, + ) + validate_no_self_warrant(action, helper) + attach_reasoning(helper, action) + + +def _ensure_structural_actions( + hypotheses: tuple[Claim, ...], + *, + exclusivity: str, + label: str | None, +) -> None: + if exclusivity == "none" or len(hypotheses) < 2: + return + if exclusivity == "exhaustive_pairwise_complement" and len(hypotheses) == 2: + _auto_exclusive(hypotheses[0], hypotheses[1], label=label) + return + for a, b in combinations(hypotheses, 2): + _auto_contradict(a, b, label=label) + + +def _likelihood_hypotheses(model_actions: tuple[PredictiveModel, ...]) -> tuple[Claim, ...]: + hypotheses = tuple(action.hypothesis for action in model_actions) + if any(h is None for h in hypotheses): + raise ValueError("bayes.model() action is missing its hypothesis") + hypothesis_tuple = tuple(h for h in hypotheses if h is not None) + if len({id(h) for h in hypothesis_tuple}) != len(hypothesis_tuple): + raise ValueError("likelihood() received duplicate hypotheses through model helpers") + return hypothesis_tuple + + +def _validate_shared_observable(model_actions: tuple[PredictiveModel, ...]) -> None: + observable_symbols = {action.observable.symbol for action in model_actions if action.observable} + if len(observable_symbols) != 1: + raise ValueError("likelihood() model helpers must share one observable") + + +def _precomputed_log_likelihoods( + precomputed: dict[Claim, float] | None, + hypothesis_tuple: tuple[Claim, ...], +) -> dict[Claim, float]: + log_likelihoods: dict[Claim, float] = {} + if precomputed is None: + return log_likelihoods + + allowed = set(hypothesis_tuple) + for key in precomputed: + if not isinstance(key, Claim) or key not in allowed: + raise ValueError("precomputed likelihood keys must be original hypothesis Claims") + provided = set(precomputed) + if provided != allowed: + missing = sorted(claim.label or claim.content for claim in allowed - provided) + details = [] + if missing: + details.append(f"missing {missing}") + suffix = f": {', '.join(details)}" if details else "" + raise ValueError("precomputed likelihoods must cover exactly the model hypotheses" + suffix) + for key, value in precomputed.items(): + log_likelihoods[key] = float(value) + return log_likelihoods + + +def _comparison_metadata( + metadata: dict[str, Any] | None, + *, + exclusivity: str, + rationale: str, +) -> dict[str, Any]: + merged = dict(metadata or {}) + merged["bayes"] = { + **dict(merged.get("bayes", {})), + "role": "comparison", + "exclusivity": exclusivity, + } + merged.setdefault("generated", True) + merged.setdefault("helper_kind", "model_preference") + merged.setdefault("review", True) + if rationale: + merged["reason"] = rationale + return merged + + +def likelihood( + data: Claim | list[Claim] | tuple[Claim, ...], + *, + model: Claim, + against: Claim | list[Claim] | tuple[Claim, ...] = (), + background: list[Knowledge] | None = None, + rationale: str = "", + label: str | None = None, + exclusivity: str = "pairwise_contradiction", + precomputed: dict[Claim, float] | None = None, + metadata: dict[str, Any] | None = None, +) -> Claim: + """Compare observed data against one or more Bayes model helpers.""" + data_tuple = _as_claim_tuple(data, name="data") + if not isinstance(model, Claim): + raise TypeError("likelihood() model= must be a Claim returned by bayes.model()") + against_tuple = () if against == () else _as_claim_tuple(against, name="against") + if exclusivity not in _EXCLUSIVITY_VALUES: + raise ValueError(f"unknown exclusivity mode: {exclusivity!r}") + + model_actions = (_model_action(model), *(_model_action(item) for item in against_tuple)) + hypothesis_tuple = _likelihood_hypotheses(model_actions) + _validate_shared_observable(model_actions) + log_likelihoods = _precomputed_log_likelihoods(precomputed, hypothesis_tuple) + + _ensure_structural_actions(hypothesis_tuple, exclusivity=exclusivity, label=label) + + helper = Claim( + "Bayes likelihood comparison.", + background=background or [], + metadata=_comparison_metadata(metadata, exclusivity=exclusivity, rationale=rationale), + prior=1.0 - CROMWELL_EPS, + ) + helper.label = label + action = Likelihood( + label=label, + rationale=rationale, + background=list(background or []), + metadata={"bayes": {"action": "likelihood"}}, + helper=helper, + model=model, + against=against_tuple, + data=data_tuple, + exclusivity=exclusivity, + precomputed=dict(precomputed) if precomputed is not None else None, + log_likelihoods=log_likelihoods, + ) + validate_no_self_warrant(action, helper) + attach_reasoning(helper, action) + return helper diff --git a/gaia/engine/bayes/dsl/model.py b/gaia/engine/bayes/dsl/model.py new file mode 100644 index 000000000..873824f3f --- /dev/null +++ b/gaia/engine/bayes/dsl/model.py @@ -0,0 +1,67 @@ +"""Bayes predictive-model helper.""" + +from __future__ import annotations + +from typing import Any + +from gaia.engine.bayes.distributions.protocol import Distribution +from gaia.engine.bayes.runtime import PredictiveModel +from gaia.engine.lang.runtime import Claim, Knowledge, Variable +from gaia.engine.lang.runtime.action import attach_reasoning, validate_no_self_warrant + + +def _claim_ref(claim: Claim) -> str: + if claim.label: + return f"[@{claim.label}]" + return claim.content + + +def model( + hypothesis: Claim, + *, + observable: Variable, + distribution: Distribution, + background: list[Knowledge] | None = None, + rationale: str = "", + label: str | None = None, + metadata: dict[str, Any] | None = None, +) -> Claim: + """Declare a predictive model for one hypothesis. Returns the helper Claim.""" + if not isinstance(hypothesis, Claim): + raise TypeError("bayes.model() hypothesis must be a Claim") + if not isinstance(observable, Variable): + raise TypeError("bayes.model() observable must be a Variable") + if not hasattr(distribution, "model_dump"): + raise TypeError("bayes.model() distribution must implement the Distribution protocol") + + merged = dict(metadata or {}) + bayes_meta = { + "role": "prediction", + "observable": {"symbol": observable.symbol}, + } + merged["bayes"] = {**dict(merged.get("bayes", {})), **bayes_meta} + merged.setdefault("generated", True) + merged.setdefault("helper_kind", "predictive_model") + merged.setdefault("review", True) + if rationale: + merged["reason"] = rationale + + helper = Claim( + f"{_claim_ref(hypothesis)} predicts {observable.symbol} under {distribution.kind}.", + background=background or [], + metadata=merged, + ) + helper.label = label + action = PredictiveModel( + label=label, + rationale=rationale, + background=list(background or []), + metadata={"bayes": {"action": "predictive_model"}}, + hypothesis=hypothesis, + observable=observable, + distribution=distribution, + helper=helper, + ) + validate_no_self_warrant(action, helper) + attach_reasoning(helper, action) + return helper diff --git a/gaia/engine/bayes/runtime/__init__.py b/gaia/engine/bayes/runtime/__init__.py new file mode 100644 index 000000000..06f5029d9 --- /dev/null +++ b/gaia/engine/bayes/runtime/__init__.py @@ -0,0 +1,5 @@ +"""Runtime action shapes for Bayes helpers.""" + +from gaia.engine.bayes.runtime.actions import BayesInference, Likelihood, PredictiveModel + +__all__ = ["BayesInference", "Likelihood", "PredictiveModel"] diff --git a/gaia/engine/bayes/runtime/actions.py b/gaia/engine/bayes/runtime/actions.py new file mode 100644 index 000000000..812b3c5c0 --- /dev/null +++ b/gaia/engine/bayes/runtime/actions.py @@ -0,0 +1,41 @@ +"""Bayes runtime action shapes.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from gaia.engine.lang.runtime.action import Reasoning +from gaia.engine.lang.runtime.knowledge import Claim +from gaia.engine.lang.runtime.variable import Variable + +if TYPE_CHECKING: + from gaia.engine.bayes.distributions.protocol import Distribution + + +@dataclass +class BayesInference(Reasoning): + """Bayes-family reasoning record.""" + + +@dataclass +class PredictiveModel(BayesInference): + """Predictive model for one hypothesis and one observable.""" + + hypothesis: Claim | None = None + observable: Variable | None = None + distribution: Distribution | None = None + helper: Claim | None = None + + +@dataclass +class Likelihood(BayesInference): + """Likelihood comparison between predictive-model helper claims.""" + + helper: Claim | None = None + model: Claim | None = None + against: tuple[Claim, ...] = () + data: tuple[Claim, ...] = () + exclusivity: str = "pairwise_contradiction" + precomputed: dict[Claim, float] | None = None + log_likelihoods: dict[Claim, float] = field(default_factory=dict) diff --git a/gaia/engine/bp/__init__.py b/gaia/engine/bp/__init__.py new file mode 100644 index 000000000..2ae95294e --- /dev/null +++ b/gaia/engine/bp/__init__.py @@ -0,0 +1,133 @@ +"""BP v2 — belief propagation aligned with theory and Gaia IR. + +Theory: docs/foundations/theory/06-factor-graphs.md, 07-belief-propagation.md +IR lowering: docs/foundations/gaia-ir/07-lowering.md + +CLI 主路径使用 `InferenceEngine.run()` 自动 dispatch: + junction_tree → treewidth ≤ 20,精确 + trw_bp → n ≤ 2000 且 treewidth > 20,有界近似 + mean_field → n > 2000,大图快速近似 + +本模块下方的 `infer()` 是旧的便利函数,仍保留 `loopy_bp` 强制模式和 +大图 loopy-BP fallback 以兼容旧调用;新代码需要和 `gaia run infer` 一致时, +应直接使用 `InferenceEngine`。 +""" + +import warnings + +# Re-exported for internal use; not in __all__. +# Alpha 0 cut per 协作单 一-❓4: contract is not access. These names remain +# reachable via internal paths under gaia.engine.bp.{bp,mean_field,trw_bp,exact,lowering}. +from gaia.engine.bp.bp import ( + BeliefPropagation, + BPDiagnostics, + BPResult, +) +from gaia.engine.bp.engine import EngineConfig, InferenceEngine, InferenceResult +from gaia.engine.bp.exact import ( + comparison_table, + exact_inference, + exact_joint_over, +) +from gaia.engine.bp.factor_graph import CROMWELL_EPS, Factor, FactorGraph, FactorType +from gaia.engine.bp.junction_tree import JunctionTreeInference, jt_treewidth +from gaia.engine.bp.lowering import ( + lower_local_graph, + lower_operator, + merge_factor_graphs, +) +from gaia.engine.bp.mean_field import ( + MeanFieldVI, + MFDiagnostics, + MFResult, +) +from gaia.engine.bp.trw_bp import ( + TRWBeliefPropagation, + TRWDiagnostics, + TRWResult, +) + +__all__ = [ + "CROMWELL_EPS", + "BeliefPropagation", + "EngineConfig", + "Factor", + "FactorGraph", + "FactorType", + "InferenceEngine", + "InferenceResult", + "JunctionTreeInference", + "MeanFieldVI", + "TRWBeliefPropagation", + "exact_inference", + "exact_joint_over", + "infer", + "jt_treewidth", + "lower_local_graph", + "merge_factor_graphs", +] + +# 旧便利函数的路由阈值;CLI 使用 InferenceEngine.EngineConfig。 +_JT_TREEWIDTH_LIMIT = 20 +_LOOPY_BP_NODE_LIMIT = 2000 # legacy infer(): n > 2000 使用 Loopy BP fallback + + +def infer( + graph: FactorGraph, + method: str = "auto", +) -> dict[str, float]: + """Legacy convenience wrapper: infer FactorGraph marginals. + + Prefer :class:`InferenceEngine` for new code and CLI-parity behavior. + + Parameters + ---------- + graph: + 已 lower 好的 FactorGraph。 + method: + "auto" — 按 treewidth / n 自动选择算法 + "junction_tree" — 强制 JT(精确,treewidth ≤ 20) + "trw_bp" — 强制 TRW-BP + "loopy_bp" — legacy force Loopy BP + "mean_field" — force Mean Field VI + + Returns: + ------- + dict[str, float] + 变量 ID → P(x=1) 的边缘概率。 + """ + if method == "auto": + n = len(graph.variables) + if n > _LOOPY_BP_NODE_LIMIT: + # Legacy convenience fallback. The CLI's InferenceEngine routes + # n > 2000 to Mean Field VI instead. + method = "loopy_bp" + else: + tw = jt_treewidth(graph) + method = "junction_tree" if tw <= _JT_TREEWIDTH_LIMIT else "trw_bp" + + result: TRWResult | MFResult | BPResult + + if method == "junction_tree": + jt = JunctionTreeInference() + result = jt.run(graph) + return result.beliefs + + if method == "trw_bp": + trw = TRWBeliefPropagation() + result = trw.run(graph) + return result.beliefs + + if method == "loopy_bp": + bp = BeliefPropagation(damping=0.5, max_iterations=500, convergence_threshold=1e-6) + result = bp.run(graph) + return result.beliefs + + if method == "mean_field": + mf = MeanFieldVI() + result = mf.run(graph) + return result.beliefs + + raise ValueError( + f"method must be auto, junction_tree, trw_bp, loopy_bp, or mean_field; got {method!r}" + ) diff --git a/gaia/bp/bp.py b/gaia/engine/bp/bp.py similarity index 61% rename from gaia/bp/bp.py rename to gaia/engine/bp/bp.py index d32968d7e..524c3fce9 100644 --- a/gaia/bp/bp.py +++ b/gaia/engine/bp/bp.py @@ -5,12 +5,12 @@ Implements the exact algorithm from bp.md §3: Initialize: - all messages = [0.5, 0.5] (uniform, MaxEnt prior) - priors = {var_id: [1-π, π]} + all messages = [0.5, 0.5] (uniform computational seed) + unary factors = explicit external/assertion terms only Repeat (up to max_iterations): 1. Compute all variable→factor messages (exclude-self rule): - msg(v→f) = prior(v) * prod_{f'≠f} msg(f'→v) + msg(v→f) = unary(v) * prod_{f'≠f} msg(f'→v) normalize. 2. Compute all factor→variable messages (marginalize): msg(f→v) = Σ_{other vars} potential(assignment) * prod_{v'≠v} msg(v'→f) @@ -18,7 +18,7 @@ 3. Damp and normalize: msg = α * new_msg + (1-α) * old_msg (α=0.5 default per bp.md §4) 4. Compute beliefs: - b(v) = normalize(prior(v) * prod_f msg(f→v)) + b(v) = normalize(unary(v) * prod_f msg(f→v)) output belief = b(v)[1] i.e. P(x=1) 5. Check convergence: if max|new_belief - old_belief| < threshold: stop. @@ -41,10 +41,10 @@ import numpy as np from numpy.typing import NDArray -from gaia.bp.factor_graph import FactorGraph -from gaia.bp.potentials import evaluate_potential +from gaia.engine.bp.factor_graph import Factor, FactorGraph +from gaia.engine.bp.potentials import evaluate_potential -__all__ = ["BeliefPropagation", "BPDiagnostics", "BPResult"] +__all__ = ["BPDiagnostics", "BPResult", "BeliefPropagation"] # 2-vector: [P(x=0), P(x=1)], always normalized to sum=1 Msg = NDArray[np.float64] @@ -56,13 +56,18 @@ def _uniform_msg() -> Msg: - """Return uniform [0.5, 0.5] message (MaxEnt initial state).""" - return np.array([0.5, 0.5]) + """Return uniform [0.5, 0.5] computational seed.""" + return np.array([0.5, 0.5], dtype=np.float64) def _prior_to_msg(pi: float) -> Msg: """Convert scalar prior π=P(x=1) to normalized 2-vector.""" - return np.array([1.0 - pi, pi]) + return np.array([1.0 - pi, pi], dtype=np.float64) + + +def _evidence_to_msg(value: int) -> Msg: + """Convert hard evidence to a strict delta message.""" + return np.array([1.0, 0.0], dtype=np.float64) if value == 0 else np.array([0.0, 1.0]) def _normalize(msg: Msg) -> Msg: @@ -93,8 +98,7 @@ def _normalize(msg: Msg) -> Msg: class BPDiagnostics: """Diagnostic information collected during a BP run. - Attributes - ---------- + Attributes: converged: True if the run stopped due to belief change < convergence_threshold. iterations_run: @@ -157,8 +161,7 @@ def belief_table(self, variables: list[str] | None = None) -> str: class BPResult: """Return value of BeliefPropagation.run(). - Attributes - ---------- + Attributes: beliefs: {var_id: posterior_belief} where belief = P(x=1) after BP. diagnostics: @@ -206,7 +209,7 @@ def _compute_v2f( def _compute_f2v( factor_idx: int, target_var: str, - factor, # Factor object + factor: Factor, v2f_msgs: dict[tuple[str, int], Msg], ) -> Msg: """Compute a single factor→variable message by marginalizing. @@ -225,14 +228,14 @@ def _compute_f2v( all_vars = factor.all_vars other_vars = [v for v in all_vars if v != target_var] - msg_out = np.zeros(2) + msg_out = np.zeros(2, dtype=np.float64) for target_val in (0, 1): total = 0.0 for other_vals in cartesian_product((0, 1), repeat=len(other_vars)): # Build full assignment assignment: dict[str, int] = {} - for v, val in zip(other_vars, other_vals): + for v, val in zip(other_vars, other_vals, strict=True): assignment[v] = val assignment[target_var] = target_val @@ -241,7 +244,7 @@ def _compute_f2v( # Product of incoming v2f messages from other variables weight = 1.0 - for v, val in zip(other_vars, other_vals): + for v, val in zip(other_vars, other_vals, strict=True): v2f = v2f_msgs.get((v, factor_idx)) if v2f is not None: weight *= float(v2f[val]) @@ -255,6 +258,152 @@ def _compute_f2v( return _normalize(msg_out) +def _unfactored_beliefs(graph: FactorGraph) -> dict[str, float]: + """Return unary or neutral beliefs for a graph with no factors.""" + return { + vid: float(graph.hard_evidence[vid]) + if vid in graph.hard_evidence + else graph.unary_factors.get(vid, 0.5) + for vid in graph.variables + } + + +def _graph_prior_messages(graph: FactorGraph) -> dict[str, Msg]: + """Build per-variable prior message vectors from unary factors.""" + return { + vid: _evidence_to_msg(graph.hard_evidence[vid]) + if vid in graph.hard_evidence + else _prior_to_msg(graph.unary_factors[vid]) + if vid in graph.unary_factors + else _uniform_msg() + for vid in graph.variables + } + + +def _initial_message_maps( + graph: FactorGraph, +) -> tuple[dict[tuple[int, str], Msg], dict[tuple[str, int], Msg]]: + """Initialize every factor/variable edge message to uniform.""" + f2v_msgs: dict[tuple[int, str], Msg] = {} + v2f_msgs: dict[tuple[str, int], Msg] = {} + for fi, factor in enumerate(graph.factors): + for vid in factor.all_vars: + if vid in graph.variables: + f2v_msgs[(fi, vid)] = _uniform_msg() + v2f_msgs[(vid, fi)] = _uniform_msg() + return f2v_msgs, v2f_msgs + + +def _initialize_belief_history(graph: FactorGraph, diag: BPDiagnostics) -> dict[str, float]: + """Seed diagnostic belief history from unary factors only.""" + prev_beliefs: dict[str, float] = {} + for vid in graph.variables: + pi = ( + float(graph.hard_evidence[vid]) + if vid in graph.hard_evidence + else graph.unary_factors.get(vid, 0.5) + ) + prev_beliefs[vid] = pi + diag.belief_history[vid] = [pi] + return prev_beliefs + + +def _compute_all_v2f( + v2f_msgs: dict[tuple[str, int], Msg], + priors: dict[str, Msg], + var_to_factors: dict[str, list[int]], + f2v_msgs: dict[tuple[int, str], Msg], +) -> dict[tuple[str, int], Msg]: + """Compute one synchronous sweep of variable-to-factor messages.""" + return { + (vid, fi): _compute_v2f( + var=vid, + factor_idx=fi, + prior_msg=priors[vid], + var_to_factors=var_to_factors, + f2v_msgs=f2v_msgs, + ) + for vid, fi in v2f_msgs + } + + +def _compute_all_f2v( + graph: FactorGraph, + f2v_msgs: dict[tuple[int, str], Msg], + new_v2f: dict[tuple[str, int], Msg], +) -> dict[tuple[int, str], Msg]: + """Compute one synchronous sweep of factor-to-variable messages.""" + return { + (fi, vid): _compute_f2v( + factor_idx=fi, + target_var=vid, + factor=graph.factors[fi], + v2f_msgs=new_v2f, + ) + for fi, vid in f2v_msgs + } + + +def _blend_message(old: Msg, new: Msg, damping: float) -> Msg: + """Blend and normalize one damped message update.""" + return _normalize(damping * new + (1.0 - damping) * old) + + +def _damp_f2v_messages( + current: dict[tuple[int, str], Msg], + new: dict[tuple[int, str], Msg], + damping: float, +) -> None: + """Blend new factor-to-variable messages into the existing map.""" + for key in current: + current[key] = _blend_message(current[key], new[key], damping) + + +def _damp_v2f_messages( + current: dict[tuple[str, int], Msg], + new: dict[tuple[str, int], Msg], + damping: float, +) -> None: + """Blend new variable-to-factor messages into the existing map.""" + for key in current: + current[key] = _blend_message(current[key], new[key], damping) + + +def _compute_beliefs( + graph: FactorGraph, + priors: dict[str, Msg], + var_to_factors: dict[str, list[int]], + f2v_msgs: dict[tuple[int, str], Msg], + diag: BPDiagnostics, +) -> dict[str, float]: + """Compute posterior beliefs from priors and incoming factor messages.""" + beliefs: dict[str, float] = {} + for vid in graph.variables: + b = priors[vid].copy() + for fi in var_to_factors[vid]: + incoming = f2v_msgs.get((fi, vid)) + if incoming is not None: + b = b * incoming + b = _normalize(b) + beliefs[vid] = float(b[1]) + diag.belief_history[vid].append(beliefs[vid]) + return beliefs + + +def _complete_diagnostics( + diag: BPDiagnostics, + *, + converged: bool, + iterations_run: int, + max_change: float, +) -> None: + """Finalize convergence fields and direction-change diagnostics.""" + diag.converged = converged + diag.iterations_run = iterations_run + diag.max_change_at_stop = max_change + diag.compute_direction_changes() + + # --------------------------------------------------------------------------- # BeliefPropagation # --------------------------------------------------------------------------- @@ -270,8 +419,7 @@ class BeliefPropagation: - Relation variables (CONTRADICTION/EQUIVALENCE) participate fully. - BPDiagnostics always collected (full belief history). - Parameters - ---------- + Args: damping: α in bp.md §4. Default 0.5. Range (0, 1]. 1.0 = fully replace old message (fast, may oscillate). @@ -289,6 +437,16 @@ def __init__( max_iterations: int = 100, convergence_threshold: float = 1e-6, ) -> None: + """Initialize loopy BP with damping and convergence controls. + + Args: + damping: Message damping factor in ``(0, 1]``. + max_iterations: Maximum number of synchronous BP sweeps. + convergence_threshold: Stop when the maximum belief change falls below this value. + + Raises: + ValueError: If ``damping`` is outside ``(0, 1]``. + """ if not (0.0 < damping <= 1.0): raise ValueError(f"damping must be in (0, 1], got {damping}") self._damping = damping @@ -300,17 +458,13 @@ def run(self, graph: FactorGraph) -> BPResult: Always returns a BPResult with full diagnostics (never None). - Parameters - ---------- + Args: graph: A validated FactorGraph. Variables referenced by factors must be registered. Cromwell clamping is enforced at graph construction. - Returns - ------- - BPResult - .beliefs: dict[str, float] — posterior P(x=1) per variable. - .diagnostics: BPDiagnostics — full run record. + Returns: + A BPResult containing posterior ``P(x=1)`` beliefs and full run diagnostics. """ diag = BPDiagnostics() @@ -319,99 +473,57 @@ def run(self, graph: FactorGraph) -> BPResult: diag.converged = True return BPResult(beliefs={}, diagnostics=diag) - # --- Edge case: no factors — beliefs = priors --- + # --- Edge case: no factors — beliefs = unary factors or neutral measure --- if not graph.factors: diag.converged = True - beliefs = dict(graph.variables) - for vid, p in beliefs.items(): + initial_beliefs = _unfactored_beliefs(graph) + for vid, p in initial_beliefs.items(): diag.belief_history[vid] = [p] - return BPResult(beliefs=beliefs, diagnostics=diag) + return BPResult(beliefs=initial_beliefs, diagnostics=diag) # --- Build reverse index: var -> list of factor indices --- var_to_factors = graph.get_var_to_factors() - # --- Initialize priors as 2-vectors --- - priors: dict[str, Msg] = {vid: _prior_to_msg(pi) for vid, pi in graph.variables.items()} + # --- Initialize unary factors as 2-vectors --- + priors = _graph_prior_messages(graph) # --- Initialize all messages to uniform [0.5, 0.5] --- # f2v_msgs[(fi, vid)] = message from factor fi to variable vid # v2f_msgs[(vid, fi)] = message from variable vid to factor fi - f2v_msgs: dict[tuple[int, str], Msg] = {} - v2f_msgs: dict[tuple[str, int], Msg] = {} + f2v_msgs, v2f_msgs = _initial_message_maps(graph) - for fi, factor in enumerate(graph.factors): - for vid in factor.all_vars: - if vid in graph.variables: - f2v_msgs[(fi, vid)] = _uniform_msg() - v2f_msgs[(vid, fi)] = _uniform_msg() - - # --- Compute initial beliefs from priors only --- - prev_beliefs: dict[str, float] = {} - for vid, pi in graph.variables.items(): - prev_beliefs[vid] = pi - diag.belief_history[vid] = [pi] + # --- Compute initial beliefs from unary factors only --- + prev_beliefs = _initialize_belief_history(graph, diag) max_change = 0.0 # --- Main BP loop --- for iteration in range(self._max_iter): # Step 1: Compute all variable→factor messages (synchronous) - new_v2f: dict[tuple[str, int], Msg] = {} - for vid, fi in v2f_msgs: - new_v2f[(vid, fi)] = _compute_v2f( - var=vid, - factor_idx=fi, - prior_msg=priors[vid], - var_to_factors=var_to_factors, - f2v_msgs=f2v_msgs, - ) + new_v2f = _compute_all_v2f(v2f_msgs, priors, var_to_factors, f2v_msgs) # Step 2: Compute all factor→variable messages (synchronous) - new_f2v: dict[tuple[int, str], Msg] = {} - for fi, vid in f2v_msgs: - factor = graph.factors[fi] - new_f2v[(fi, vid)] = _compute_f2v( - factor_idx=fi, - target_var=vid, - factor=factor, - v2f_msgs=new_v2f, # use freshly computed v2f - ) + new_f2v = _compute_all_f2v(graph, f2v_msgs, new_v2f) # Step 3: Damp and normalize both sets of messages - for key in f2v_msgs: - blended = self._damping * new_f2v[key] + (1.0 - self._damping) * f2v_msgs[key] - f2v_msgs[key] = _normalize(blended) - - for key in v2f_msgs: - blended = self._damping * new_v2f[key] + (1.0 - self._damping) * v2f_msgs[key] - v2f_msgs[key] = _normalize(blended) + _damp_f2v_messages(f2v_msgs, new_f2v, self._damping) + _damp_v2f_messages(v2f_msgs, new_v2f, self._damping) # Step 4: Compute beliefs - beliefs: dict[str, float] = {} - for vid in graph.variables: - b = priors[vid].copy() - for fi in var_to_factors[vid]: - incoming = f2v_msgs.get((fi, vid)) - if incoming is not None: - b = b * incoming - b = _normalize(b) - beliefs[vid] = float(b[1]) - diag.belief_history[vid].append(beliefs[vid]) + beliefs = _compute_beliefs(graph, priors, var_to_factors, f2v_msgs, diag) # Step 5: Check convergence max_change = max(abs(beliefs[vid] - prev_beliefs[vid]) for vid in beliefs) prev_beliefs = beliefs if max_change < self._threshold: - diag.converged = True - diag.iterations_run = iteration + 1 - diag.max_change_at_stop = max_change - diag.compute_direction_changes() + _complete_diagnostics( + diag, converged=True, iterations_run=iteration + 1, max_change=max_change + ) return BPResult(beliefs=beliefs, diagnostics=diag) # Did not converge within max_iterations - diag.converged = False - diag.iterations_run = self._max_iter - diag.max_change_at_stop = max_change - diag.compute_direction_changes() + _complete_diagnostics( + diag, converged=False, iterations_run=self._max_iter, max_change=max_change + ) return BPResult(beliefs=prev_beliefs, diagnostics=diag) diff --git a/gaia/engine/bp/contraction.py b/gaia/engine/bp/contraction.py new file mode 100644 index 000000000..b8b177112 --- /dev/null +++ b/gaia/engine/bp/contraction.py @@ -0,0 +1,530 @@ +"""Tensor-contraction-based CPT computation for Gaia IR strategies. + +Replaces O(2^k × BP) brute-force folding in ``fold_composite_to_cpt`` and +``compute_coarse_cpts`` with exact variable elimination. + +Design: + - ``factor_to_tensor``: Factor → dense ndarray + axis labels + - ``contract_to_cpt``: einsum-based variable elimination with explicit unary factors + - ``strategy_cpt``: recursive layer-by-layer CPT for a Strategy, cached by + strategy_id per call + +Every explicit non-free unary factor is applied exactly once, at the layer +where it is marginalized. Variables without unary factors are summed with the +base counting measure, matching ``gaia.engine.bp.exact.exact_inference``. + +Spec: github.com/SiliconEinstein/Gaia/issues/357 +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import numpy as np +from numpy.typing import NDArray + +from gaia.engine.bp.factor_graph import Factor, FactorType +from gaia.engine.ir.strategy import Strategy + +_HIGH: float = 1.0 +_LOW: float = 0.0 +type FloatArray = NDArray[np.float64] +type StrategyCpt = tuple[FloatArray, list[str]] +type TensorBuilder = Callable[[Factor, list[str], tuple[int, ...]], StrategyCpt] + +__all__ = [ + "contract_to_cpt", + "cpt_tensor_to_list", + "factor_to_tensor", + "strategy_cpt", +] + + +# Sentinel used by ``strategy_cpt`` to detect cycles while the recursion is +# in progress. When a composite is first visited, we write this sentinel to +# the cache before recursing into its sub-strategies; if the recursion hits +# the same strategy_id again before it completes, we raise instead of looping +# forever. +class _InProgress: + """Sentinel type for cycle detection in strategy CPT recursion.""" + + +_IN_PROGRESS = _InProgress() +type StrategyCptCacheValue = StrategyCpt | _InProgress + + +def _float_array(values: object) -> FloatArray: + """Return a float64 ndarray while preserving runtime numpy semantics.""" + return np.asarray(values, dtype=np.float64) + + +def _implication_tensor(_f: Factor, axes: list[str], shape: tuple[int, ...]) -> StrategyCpt: + """Build the tensor for a ternary implication helper.""" + t = np.empty(shape, dtype=np.float64) + for a in range(2): + for b in range(2): + for h in range(2): + if h == 1: + t[a, b, h] = _LOW if (a == 1 and b == 0) else _HIGH + else: + t[a, b, h] = _HIGH if (a == 1 and b == 0) else _LOW + return t, axes + + +def _conjunction_tensor(_f: Factor, axes: list[str], shape: tuple[int, ...]) -> StrategyCpt: + """Build the tensor for a conjunction factor.""" + grids = np.indices(shape) + inputs_all_one = grids[:-1].all(axis=0) + conclusion = grids[-1].astype(bool) + return np.where(conclusion == inputs_all_one, _HIGH, _LOW).astype(np.float64), axes + + +def _disjunction_tensor(_f: Factor, axes: list[str], shape: tuple[int, ...]) -> StrategyCpt: + """Build the tensor for a disjunction factor.""" + grids = np.indices(shape) + inputs_any_one = grids[:-1].any(axis=0) + conclusion = grids[-1].astype(bool) + return np.where(conclusion == inputs_any_one, _HIGH, _LOW).astype(np.float64), axes + + +def _equivalence_tensor(_f: Factor, axes: list[str], shape: tuple[int, ...]) -> StrategyCpt: + """Build the tensor for an equivalence factor.""" + grids = np.indices(shape) + target = grids[0] == grids[1] + return np.where(grids[2].astype(bool) == target, _HIGH, _LOW).astype(np.float64), axes + + +def _contradiction_tensor(_f: Factor, axes: list[str], shape: tuple[int, ...]) -> StrategyCpt: + """Build the tensor for a contradiction factor.""" + grids = np.indices(shape) + target = ~((grids[0] == 1) & (grids[1] == 1)) + return np.where(grids[2].astype(bool) == target, _HIGH, _LOW).astype(np.float64), axes + + +def _negation_tensor(_f: Factor, axes: list[str], shape: tuple[int, ...]) -> StrategyCpt: + """Build the tensor for a negation factor.""" + grids = np.indices(shape) + target = grids[0] == 0 + return np.where(grids[1].astype(bool) == target, _HIGH, _LOW).astype(np.float64), axes + + +def _complement_tensor(_f: Factor, axes: list[str], shape: tuple[int, ...]) -> StrategyCpt: + """Build the tensor for a complement factor.""" + grids = np.indices(shape) + target = grids[0] != grids[1] + return np.where(grids[2].astype(bool) == target, _HIGH, _LOW).astype(np.float64), axes + + +def _soft_entailment_tensor(f: Factor, axes: list[str], shape: tuple[int, ...]) -> StrategyCpt: + """Build the tensor for a soft-entailment factor.""" + if f.p1 is None or f.p2 is None: + raise ValueError(f"SOFT_ENTAILMENT {f.factor_id!r} missing p1/p2") + t = np.empty(shape, dtype=np.float64) + t[0, 0] = f.p2 + t[0, 1] = 1.0 - f.p2 + t[1, 0] = 1.0 - f.p1 + t[1, 1] = f.p1 + return t, axes + + +def _conditional_tensor(f: Factor, axes: list[str], shape: tuple[int, ...]) -> StrategyCpt: + """Build the tensor for a full conditional CPT.""" + if f.cpt is None: + raise ValueError(f"CONDITIONAL {f.factor_id!r} missing cpt") + k = len(f.variables) + expected = 1 << k + if len(f.cpt) != expected: + raise ValueError(f"CONDITIONAL {f.factor_id!r}: cpt length {len(f.cpt)} != 2^k={expected}") + cpt_arr = np.asarray(f.cpt, dtype=np.float64) + grids = np.indices(shape) + prem_idx = np.zeros(shape, dtype=np.int64) + for bit in range(k): + prem_idx |= grids[bit].astype(np.int64) << bit + p = cpt_arr[prem_idx] + conclusion = grids[-1] + return np.where(conclusion == 1, p, 1.0 - p), axes + + +def _deductive_implication_tensor( + _f: Factor, axes: list[str], shape: tuple[int, ...] +) -> StrategyCpt: + """Build normalized hard deduction tensor: P(B|A), MaxEnt for ¬A.""" + grids = np.indices(shape) + antecedent = grids[0] + conclusion = grids[1] + return np.where( + antecedent == 1, + np.where(conclusion == 1, _HIGH, _LOW), + 0.5, + ).astype(np.float64), axes + + +def _pairwise_tensor(f: Factor, axes: list[str], _shape: tuple[int, ...]) -> StrategyCpt: + """Build the tensor for a pairwise potential.""" + if f.cpt is None: + raise ValueError(f"PAIRWISE_POTENTIAL {f.factor_id!r} missing cpt") + if len(f.cpt) != 4: + raise ValueError(f"PAIRWISE_POTENTIAL {f.factor_id!r}: cpt length {len(f.cpt)} != 4") + return np.asarray(f.cpt, dtype=np.float64).reshape((2, 2), order="F"), axes + + +_TENSOR_BUILDERS: dict[FactorType, TensorBuilder] = { + FactorType.IMPLICATION: _implication_tensor, + FactorType.CONJUNCTION: _conjunction_tensor, + FactorType.DISJUNCTION: _disjunction_tensor, + FactorType.EQUIVALENCE: _equivalence_tensor, + FactorType.CONTRADICTION: _contradiction_tensor, + FactorType.NEGATION: _negation_tensor, + FactorType.COMPLEMENT: _complement_tensor, + FactorType.SOFT_ENTAILMENT: _soft_entailment_tensor, + FactorType.CONDITIONAL: _conditional_tensor, + FactorType.PAIRWISE_POTENTIAL: _pairwise_tensor, + FactorType.DEDUCTIVE_IMPLICATION: _deductive_implication_tensor, +} + + +def factor_to_tensor(f: Factor) -> StrategyCpt: + """Build a dense tensor representation of a Factor. + + Shape: ``(2,) * (len(f.variables) + 1)``. + Axis order: ``f.variables`` in order, then ``f.conclusion``. + + Deterministic factors use strict ``_HIGH``/``_LOW`` values (1.0 / 0.0) so + they match the semantics of ``gaia.engine.bp.potentials`` exactly. + Parametric factors (SOFT_ENTAILMENT, CONDITIONAL) use their stored + parameters. + """ + axes = [*f.variables, f.conclusion] + shape = (2,) * len(axes) + try: + builder = _TENSOR_BUILDERS[f.factor_type] + except KeyError as err: + raise ValueError(f"Unknown FactorType: {f.factor_type!r}") from err + return builder(f, axes, shape) + + +def _collect_tensor_variables(tensors: list[StrategyCpt]) -> list[str]: + """Collect distinct tensor variable names in first-seen order.""" + all_vars: list[str] = [] + seen: set[str] = set() + for _, axes in tensors: + for variable in axes: + if variable not in seen: + seen.add(variable) + all_vars.append(variable) + return all_vars + + +def _build_contract_operands( + tensors: list[StrategyCpt], + free_vars: list[str], + unary_priors: dict[str, float], +) -> tuple[list[np.ndarray], list[list[str]], list[str]]: + """Build factor, unary, and degenerate operands for contraction.""" + all_vars = _collect_tensor_variables(tensors) + seen = set(all_vars) + free_set = set(free_vars) + + operands: list[np.ndarray] = [] + operand_axes: list[list[str]] = [] + for tensor, axes in tensors: + operands.append(np.asarray(tensor, dtype=np.float64)) + operand_axes.append(list(axes)) + + for variable in all_vars: + if variable in free_set: + continue + if variable in unary_priors: + pi = unary_priors[variable] + operands.append(np.array([1.0 - pi, pi], dtype=np.float64)) + operand_axes.append([variable]) + + for variable in free_vars: + if variable not in seen: + operands.append(np.array([0.5, 0.5], dtype=np.float64)) + operand_axes.append([variable]) + seen.add(variable) + all_vars.append(variable) + + return operands, operand_axes, all_vars + + +def _build_contract_args( + operands: list[np.ndarray], + operand_axes: list[list[str]], + all_vars: list[str], + free_vars: list[str], +) -> list[object]: + """Build opt_einsum's alternating operand/index argument list.""" + var_to_idx = {variable: index for index, variable in enumerate(all_vars)} + args: list[object] = [] + for operand, axes in zip(operands, operand_axes, strict=True): + args.append(operand) + args.append([var_to_idx[variable] for variable in axes]) + args.append([var_to_idx[variable] for variable in free_vars]) + return args + + +def _ascii_einsum_subscripts(einsum_str: str) -> str: + """Remap opt_einsum's non-ASCII symbols to numpy-compatible ASCII.""" + special = [char for char in dict.fromkeys(einsum_str) if char not in "->,"] + if not any(ord(char) > 127 for char in special): + return einsum_str + ascii52 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + mapping = {char: ascii52[index] for index, char in enumerate(special)} + return "".join(mapping.get(char, char) for char in einsum_str) + + +def _execute_contract_path(operands: list[np.ndarray], path_info: Any) -> np.ndarray: + """Execute opt_einsum's pairwise contraction path with per-step rescaling.""" + working: list[np.ndarray] = list(operands) + for step in path_info.contraction_list: + inds = step[0] + einsum_str = _ascii_einsum_subscripts(step[2]) + popped = [working[index] for index in inds] + for index in sorted(inds, reverse=True): + working.pop(index) + + result = np.einsum(einsum_str, *popped) + max_value = float(result.max()) + if max_value > 0: + result = result / max_value + + working.append(result) + + return working[0] + + +def _normalize_contracted_joint(joint: np.ndarray) -> FloatArray: + """Normalize a contracted joint along the conclusion axis.""" + totals = joint.sum(axis=-1, keepdims=True) + if np.any(totals <= 0): + raise ValueError( + "contract_to_cpt: zero partition function encountered; " + "graph may have contradictory deterministic factors." + ) + return _float_array(joint / totals) + + +def contract_to_cpt( + tensors: list[StrategyCpt], + free_vars: list[str], + unary_priors: dict[str, float], +) -> FloatArray: + """Contract a list of factor tensors down to a conditional CPT tensor. + + Uses ``opt_einsum.contract_path`` to plan an optimal contraction order, + then executes each pairwise step manually with per-step rescaling. + Rescaling divides each intermediate tensor by its max, keeping values in + ``[0, 1]`` and preventing raw-float64 underflow on deep graphs. The + final CPT is a ratio (``joint / sum_along_conclusion``), so rescaling + intermediates by any positive constant preserves the result exactly. + + Because each pairwise step involves at most two operands whose combined + axes are small, ``numpy.einsum`` has no trouble with the 52-symbol + alphabet at any individual step — even when the global variable count + exceeds 52. + + Args: + tensors: + List of ``(ndarray, axis_var_ids)`` pairs. The ndarray has one axis + per name in ``axis_var_ids`` (in order); each axis has size 2. + free_vars: + Variables that remain as axes in the output, in output order. + Typically ``[*premises, conclusion]``. The last entry is the + conclusion and is the axis along which the output is normalized. + A free variable that does not appear in any input tensor is handled + as a degenerate constant axis (uniform contribution). + unary_priors: + Explicit unary factors ``[1-π, π]`` to apply to marginalized variables. + Non-free variables omitted from this mapping are summed with the base + counting measure, not assigned an implicit ``π=0.5`` prior. + + Returns: + ndarray of shape ``(2,) * len(free_vars)`` giving + ``P(conclusion | premises)``. The last axis is normalized so that + ``T[..., 0] + T[..., 1] == 1``. + + Raises: + ValueError: If ``free_vars`` is empty, or if the normalized joint is + zero for some premise assignment even after per-step rescaling + (indicates contradictory deterministic factors). + """ + import opt_einsum as oe + + if not free_vars: + raise ValueError("free_vars must be non-empty (need at least a conclusion axis)") + + # Build the full operand list: + # 1) Original factor tensors + # 2) Explicit unary-factor tensors for non-free variables + # 3) Degenerate uniform tensors for free variables not in any input + # (legitimate case: CompositeStrategy with unused interface premises) + operands, operand_axes, all_vars = _build_contract_operands(tensors, free_vars, unary_priors) + args = _build_contract_args(operands, operand_axes, all_vars, free_vars) + + # Let opt_einsum plan an optimal contraction order. ``contract_path`` + # returns ``(path, PathInfo)`` where ``PathInfo.contraction_list`` has + # the per-step subscript strings we need to execute ourselves. + _, path_info = oe.contract_path(*args, optimize="greedy") + + # Execute the path step by step. Each step contracts exactly two + # operands via a small np.einsum call (well within the 52-symbol + # alphabet) and the result is rescaled to prevent underflow. + joint = _execute_contract_path(operands, path_info) + return _normalize_contracted_joint(joint) + + +def cpt_tensor_to_list( + tensor: FloatArray, + axes: list[str], + premises: list[str], + conclusion: str, +) -> list[float]: + """Flatten a normalized CPT tensor to the bit-indexed list format. + + ``tensor`` must have shape ``(2,) * len(axes)`` and be normalized + along the conclusion axis. The output has length ``2 ** len(premises)`` + and is indexed by ``sum(v_i << i for i, v_i in enumerate(premises))``. + Bit 0 corresponds to the first premise (matching the existing + ``fold_composite_to_cpt`` convention and ``FactorType.CONDITIONAL``). + """ + k = len(premises) + target_order = [*premises, conclusion] + perm = [axes.index(name) for name in target_order] + t = np.transpose(tensor, perm) + out: list[float] = [] + for assignment in range(1 << k): + idx = (*(((assignment >> bit) & 1) for bit in range(k)), 1) + out.append(float(t[idx])) + return out + + +def strategy_cpt( + s: Strategy, + strat_by_id: dict[str, Strategy], + strat_params: dict[str, list[float]], + var_priors: dict[str, float], + namespace: str, + package_name: str, + cache: dict[str, StrategyCptCacheValue], +) -> StrategyCpt: + """Compute the effective CPT tensor of a single Gaia IR strategy. + + Layer-by-layer variable elimination: + - Leaf strategies (INFER, NOISY_AND, FormalStrategy, auto-formalized named + strategies): build a mini FactorGraph via the existing ``_lower_strategy`` + dispatch, convert its factors to tensors, and contract them with unary + priors from the mini fg's ``variables`` dict. + - CompositeStrategy: recursion (implemented in Task 5). + + The returned tuple is ``(cpt_tensor, axes)`` where axes = + ``[*s.premises, s.conclusion]``. + + ``cache`` is mutated: keyed by ``strategy_id``, values are + ``(cpt_tensor, axes)`` pairs. Callers pass a fresh dict per top-level + invocation to scope the cache to that call. + + ``var_priors`` is forwarded to ``_lower_strategy`` so that it can honor + explicit unary factors on claim variables (e.g., when called from + ``compute_coarse_cpts`` with the global factor graph's unary factors). + Pass ``{}`` for isolated composite folding. + + Note: + The ``cache`` is keyed by ``s.strategy_id``, which encodes + ``(scope, type, premises, conclusion)``. It does NOT encode + ``var_priors`` or ``strat_params``. Callers MUST pass a fresh + ``cache`` dict for each top-level invocation; reusing a cache + across calls with different unary factors or strat_params will return + stale results for ``FormalStrategy`` and auto-formalized leaves + whose internal helper claims have non-default priors. + """ + from gaia.engine.bp.factor_graph import FactorGraph + from gaia.engine.bp.lowering import _lower_strategy + from gaia.engine.ir.strategy import CompositeStrategy + + if s.strategy_id is None: + raise ValueError("strategy_cpt requires a strategy_id") + if s.conclusion is None: + raise ValueError(f"strategy_cpt requires a conclusion for {s.strategy_id!r}") + strategy_id = s.strategy_id + conclusion = s.conclusion + + cached = cache.get(strategy_id) + if isinstance(cached, _InProgress): + raise ValueError( + f"strategy_cpt: cycle detected — strategy_id {strategy_id!r} " + "is its own ancestor in the composite recursion." + ) + if cached is not None: + return cached + + if isinstance(s, CompositeStrategy): + # Mark this composite as in-progress so recursive calls detect cycles. + cache[strategy_id] = _IN_PROGRESS + child_tensors: list[StrategyCpt] = [] + for sid in s.sub_strategies: + sub = strat_by_id.get(sid) + if sub is None: + raise KeyError( + f"CompositeStrategy {strategy_id!r} references missing strategy_id {sid!r}" + ) + sub_tensor, sub_axes = strategy_cpt( + sub, + strat_by_id, + strat_params, + var_priors, + namespace, + package_name, + cache, + ) + child_tensors.append((sub_tensor, sub_axes)) + + free = [*s.premises, conclusion] + free_set = set(free) + + # Bridge variables: any child axis that isn't a composite free var. + # Only explicit unary factors are applied at this layer. Internal + # helper claims marginalized inside a child's CPT do NOT appear in any + # child's axes and are correctly skipped here. + bridges: dict[str, float] = {} + for _, axes in child_tensors: + for v in axes: + if v not in free_set and v in var_priors and v not in bridges: + bridges[v] = var_priors[v] + + cpt_tensor = contract_to_cpt(child_tensors, free_vars=free, unary_priors=bridges) + result = (cpt_tensor, free) + cache[strategy_id] = result + return result + + # Leaf: build a mini FactorGraph via the existing _lower_strategy dispatch. + mini = FactorGraph() + ctr = [0] + claim_ids: set[str] = set() + _lower_strategy( + mini, + s, + strat_by_id, + var_priors, + strat_params, + {}, + expand_formal=True, + infer_degraded=False, + ctr=ctr, + claim_ids=claim_ids, + namespace=namespace, + package_name=package_name, + ) + + tensors = [factor_to_tensor(f) for f in mini.factors] + free = [*s.premises, conclusion] + free_set = set(free) + # Explicit unary factors for variables in the mini fg that are NOT free axes. + non_free = {v: p for v, p in mini.unary_factors.items() if v not in free_set} + + cpt_tensor = contract_to_cpt(tensors, free_vars=free, unary_priors=non_free) + result = (cpt_tensor, free) + cache[strategy_id] = result + return result diff --git a/gaia/engine/bp/engine.py b/gaia/engine/bp/engine.py new file mode 100644 index 000000000..002998f4e --- /dev/null +++ b/gaia/engine/bp/engine.py @@ -0,0 +1,265 @@ +"""Unified inference engine — automatically selects the best algorithm. + +Exposes a single InferenceEngine.run() method that chooses among: + + - JunctionTreeInference: exact, O(n * 2^w), best for treewidth ≤ JT_MAX_TREEWIDTH + - TRWBeliefPropagation: bounded approximate, default for n ≤ MF_NODE_LIMIT + - MeanFieldVI: fast approximate, for n > MF_NODE_LIMIT + +Decision thresholds (tunable): + JT_MAX_TREEWIDTH = 20 — JT is exact and fast up to treewidth 20 + MF_NODE_LIMIT = 2000 — Mean Field for very large graphs + +Usage: + from gaia.engine.bp.engine import InferenceEngine + + engine = InferenceEngine() + result = engine.run(graph) # auto-select + result = engine.run(graph, method="jt") # force JT + result = engine.run(graph, method="trw_bp") # force TRW-BP + result = engine.run(graph, method="mean_field") # force Mean Field + result = engine.run(graph, method="exact") # force brute-force (small graphs only) +""" + +from __future__ import annotations + +import logging +import time +import warnings +from dataclasses import dataclass +from typing import Literal + +from gaia.engine.bp.exact import exact_inference +from gaia.engine.bp.factor_graph import FactorGraph +from gaia.engine.bp.junction_tree import JunctionTreeInference, jt_treewidth +from gaia.engine.bp.mean_field import MeanFieldVI, MFDiagnostics, MFResult +from gaia.engine.bp.trw_bp import TRWBeliefPropagation, TRWDiagnostics, TRWResult + +__all__ = ["EngineConfig", "InferenceEngine", "InferenceResult", "MethodChoice"] + +logger = logging.getLogger(__name__) + +MethodChoice = Literal["auto", "jt", "trw_bp", "mean_field", "exact"] + +# 算法路由阈值 +JT_MAX_TREEWIDTH: int = 20 # JT 精确推断上限 +MF_NODE_LIMIT: int = 2000 # 超过此节点数用 Mean Field +EXACT_MAX_VARS: int = 26 # 暴力枚举上限(2^26 ≈ 67M 状态) + + +@dataclass +class EngineConfig: + """InferenceEngine 的配置参数。. + + Attributes: + jt_max_treewidth: + treewidth ≤ 此值时使用 JT(精确)。 + mf_node_limit: + 节点数 > 此值时使用 Mean Field VI。 + trw_damping: + TRW-BP 阻尼系数。 + trw_max_iter: + TRW-BP 最大迭代次数。 + trw_threshold: + TRW-BP 收敛阈值。 + mf_max_iter: + Mean Field 最大迭代次数。 + exact_max_vars: + 暴力枚举最大变量数。 + """ + + jt_max_treewidth: int = JT_MAX_TREEWIDTH + mf_node_limit: int = MF_NODE_LIMIT + trw_damping: float = 0.5 + trw_max_iter: int = 200 + trw_threshold: float = 1e-8 + mf_max_iter: int = 500 + exact_max_vars: int = EXACT_MAX_VARS + + +@dataclass +class InferenceResult: + """InferenceEngine 的返回值,包含推断结果和算法元数据。. + + Attributes: + result: + 底层算法的结果(TRWResult 或 MFResult)。 + method_used: + 实际使用的算法:'jt', 'trw_bp', 'mean_field', 或 'exact'。 + treewidth: + 因子图的估计树宽(未计算时为 -1)。 + elapsed_ms: + 推断耗时(毫秒)。 + is_exact: + True 表示算法保证返回精确边缘概率。 + """ + + result: TRWResult | MFResult + method_used: str = "unknown" + treewidth: int = -1 + elapsed_ms: float = 0.0 + is_exact: bool = False + + @property + def beliefs(self) -> dict[str, float]: + """快捷访问 beliefs 字典。.""" + return self.result.beliefs + + @property + def diagnostics(self) -> TRWDiagnostics | MFDiagnostics: + """快捷访问 diagnostics。.""" + return self.result.diagnostics + + +class InferenceEngine: + """统一推断引擎,自动选择最优算法。. + + 自动路由策略(method='auto'): + 1. n > mf_node_limit → Mean Field VI(大图快速近似) + 2. treewidth ≤ jt_max_treewidth → JT(精确) + 3. 其他 → TRW-BP(有界近似) + + Args: + config: + EngineConfig,控制路由阈值和算法参数。 + """ + + def __init__(self, config: EngineConfig | None = None) -> None: + """Initialize the inference engine with optional configuration.""" + self._config = config or EngineConfig() + cfg = self._config + self._jt = JunctionTreeInference() + self._trw = TRWBeliefPropagation( + damping=cfg.trw_damping, + max_iterations=cfg.trw_max_iter, + convergence_threshold=cfg.trw_threshold, + ) + self._mf = MeanFieldVI(max_iterations=cfg.mf_max_iter) + + def run( + self, + graph: FactorGraph, + method: MethodChoice = "auto", + ) -> InferenceResult: + """在 graph 上运行推断。. + + Args: + graph: + 已 lower 好的 FactorGraph。 + method: + 'auto'(默认):按 n 和 treewidth 自动选择。 + 'jt':强制 JT(精确,treewidth ≤ 20)。 + 'trw_bp':强制 TRW-BP。 + 'mean_field':强制 Mean Field VI。 + 'exact':强制暴力枚举(仅适用于小图)。 + + Returns: + InferenceResult,包含边缘概率、算法元数据和耗时。 + """ + cfg = self._config + t0 = time.perf_counter() + result: TRWResult | MFResult + + if method == "exact": + n = len(graph.variables) + if n > cfg.exact_max_vars: + raise ValueError( + f"图有 {n} 个变量,超过暴力枚举上限 {cfg.exact_max_vars}。" + "请使用 method='jt' 进行精确推断。" + ) + beliefs, _Z = exact_inference(graph) + diag = TRWDiagnostics() + diag.converged = True + for v, b in beliefs.items(): + diag.belief_history[v] = [b] + result = TRWResult(beliefs=beliefs, diagnostics=diag) + elapsed = (time.perf_counter() - t0) * 1000 + logger.info("InferenceEngine: exact, %d vars, %.1fms", n, elapsed) + return InferenceResult( + result=result, + method_used="exact", + treewidth=-1, + elapsed_ms=elapsed, + is_exact=True, + ) + + if method == "auto": + n = len(graph.variables) + if n > cfg.mf_node_limit: + warnings.warn( + "Mean Field VI fallback " + f"(n > {cfg.mf_node_limit}) for {n} variables. " + "This large-graph path is approximate and not production-grade; " + "use method='trw_bp' when belief values need higher accuracy.", + UserWarning, + stacklevel=2, + ) + method = "mean_field" + else: + tw = jt_treewidth(graph) + method = "jt" if tw <= cfg.jt_max_treewidth else "trw_bp" + + if method == "jt": + tw = jt_treewidth(graph) + result = self._jt.run(graph) + elapsed = (time.perf_counter() - t0) * 1000 + logger.info("InferenceEngine: JT (exact), treewidth=%d, %.1fms", tw, elapsed) + return InferenceResult( + result=result, + method_used="jt", + treewidth=tw, + elapsed_ms=elapsed, + is_exact=True, + ) + + if method == "trw_bp": + tw = jt_treewidth(graph) if len(graph.variables) <= cfg.mf_node_limit else -1 + result = self._trw.run(graph) + elapsed = (time.perf_counter() - t0) * 1000 + logger.info("InferenceEngine: TRW-BP, treewidth=%d, %.1fms", tw, elapsed) + return InferenceResult( + result=result, + method_used="trw_bp", + treewidth=tw, + elapsed_ms=elapsed, + is_exact=False, + ) + + if method == "mean_field": + result = self._mf.run(graph) + elapsed = (time.perf_counter() - t0) * 1000 + logger.info( + "InferenceEngine: Mean Field, %d vars, %.1fms", len(graph.variables), elapsed + ) + return InferenceResult( + result=result, + method_used="mean_field", + treewidth=-1, + elapsed_ms=elapsed, + is_exact=False, + ) + + raise ValueError( + f"method 必须是 'auto', 'jt', 'trw_bp', 'mean_field', 或 'exact';收到 {method!r}" + ) + + def benchmark(self, graph: FactorGraph) -> dict[str, dict[str, object]]: + """运行所有可行算法并返回对比结果。.""" + results: dict[str, dict[str, object]] = {} + for m in ("jt", "trw_bp", "mean_field"): + r = self.run(graph, method=m) + results[m] = { + "beliefs": r.beliefs, + "elapsed_ms": r.elapsed_ms, + "is_exact": r.is_exact, + "treewidth": r.treewidth, + } + if len(graph.variables) <= self._config.exact_max_vars: + r = self.run(graph, method="exact") + results["exact"] = { + "beliefs": r.beliefs, + "elapsed_ms": r.elapsed_ms, + "is_exact": True, + "treewidth": -1, + } + return results diff --git a/gaia/bp/exact.py b/gaia/engine/bp/exact.py similarity index 56% rename from gaia/bp/exact.py rename to gaia/engine/bp/exact.py index d4012ffda..4c3655614 100644 --- a/gaia/bp/exact.py +++ b/gaia/engine/bp/exact.py @@ -4,21 +4,81 @@ import numpy as np -from gaia.bp.factor_graph import CROMWELL_EPS, Factor, FactorGraph, FactorType +from gaia.engine.bp.factor_graph import Factor, FactorGraph, FactorType -__all__ = ["exact_inference", "comparison_table"] +__all__ = ["comparison_table", "exact_inference", "exact_joint_over"] CHUNK_BITS = 20 -def _factor_log_potentials( +def _enumerate_log_joint( + graph: FactorGraph, +) -> tuple[list[str], dict[str, int], np.ndarray]: + var_ids = sorted(graph.variables.keys()) + n = len(var_ids) + + if n > 26: + raise ValueError( + f"Exact inference requires 2^n enumeration. " + f"n={n} is too large (max 26). Use BP instead." + ) + + var_idx = {v: i for i, v in enumerate(var_ids)} + N = 1 << n + + chunk_size = min(N, 1 << CHUNK_BITS) + all_log_joints = np.empty(N, dtype=np.float64) + unary_idxs: list[tuple[int, float]] = [ + (var_idx[v], p) for v, p in graph.unary_factors.items() if v in var_idx + ] + hard_idxs: list[tuple[int, int]] = [ + (var_idx[v], val) for v, val in graph.hard_evidence.items() if v in var_idx + ] + + for chunk_start in range(0, N, chunk_size): + chunk_end = min(chunk_start + chunk_size, N) + cs = chunk_end - chunk_start + + arange = np.arange(chunk_start, chunk_end, dtype=np.int64) + states = np.empty((cs, n), dtype=np.int8) + for i in range(n): + states[:, i] = (arange >> i) & 1 + + log_j = np.zeros(cs, dtype=np.float64) + for i, p in unary_idxs: + log_j += np.where(states[:, i] == 1, np.log(p), np.log(1.0 - p)) + for i, val in hard_idxs: + log_j = log_j + np.where(states[:, i] == val, 0.0, -np.inf) + + for factor in graph.factors: + log_j += _factor_log_potentials(factor, states, var_idx) + + all_log_joints[chunk_start:chunk_end] = log_j + + return var_ids, var_idx, all_log_joints + + +def _shifted_joint(log_joints: np.ndarray) -> tuple[np.ndarray, float]: + log_max = log_joints.max() + if not np.isfinite(log_max): + raise RuntimeError( + "exact_inference: factor graph has zero partition function (Z=0). " + "All assignments are forbidden by deterministic factors — " + "the asserted information set is logically inconsistent." + ) + joint = np.exp(log_joints - log_max) + z_shifted = joint.sum() + return joint, float(z_shifted) + + +def _factor_log_potentials( # noqa: C901 factor: Factor, states: np.ndarray, var_idx: dict[str, int], ) -> np.ndarray: cs = states.shape[0] - h = 1.0 - CROMWELL_EPS - lo = CROMWELL_EPS + h_log = 0.0 + lo_log = -np.inf ft = factor.factor_type vids = factor.variables concl = factor.conclusion @@ -32,10 +92,9 @@ def _factor_log_potentials( hv = states[:, h_idx] # H=1: standard implication (A=1,B=0 forbidden) # H=0: complement (A=1,B=0 is the only HIGH row) - std_impl = np.where((a == 1) & (b == 0), lo, h) - comp = np.where((a == 1) & (b == 0), h, lo) - pot = np.where(hv == 1, std_impl, comp) - return np.log(pot) + std_impl = np.where((a == 1) & (b == 0), lo_log, h_log) + comp = np.where((a == 1) & (b == 0), h_log, lo_log) + return np.where(hv == 1, std_impl, comp) if ft == FactorType.CONJUNCTION: idxs = [var_idx[x] for x in vids] @@ -45,8 +104,7 @@ def _factor_log_potentials( all_one &= states[:, ii] == 1 m = states[:, m_idx] ok = (all_one & (m == 1)) | ((~all_one) & (m == 0)) - pot = np.where(ok, h, lo) - return np.log(pot) + return np.where(ok, h_log, lo_log) if ft == FactorType.DISJUNCTION: idxs = [var_idx[x] for x in vids] @@ -56,8 +114,7 @@ def _factor_log_potentials( any_one |= states[:, ii] == 1 d = states[:, d_idx] ok = (any_one & (d == 1)) | ((~any_one) & (d == 0)) - pot = np.where(ok, h, lo) - return np.log(pot) + return np.where(ok, h_log, lo_log) if ft == FactorType.EQUIVALENCE: a_idx = var_idx[vids[0]] @@ -65,8 +122,7 @@ def _factor_log_potentials( h_idx = var_idx[concl] target = (states[:, a_idx] == states[:, b_idx]).astype(np.int8) ok = states[:, h_idx] == target - pot = np.where(ok, h, lo) - return np.log(pot) + return np.where(ok, h_log, lo_log) if ft == FactorType.CONTRADICTION: a_idx = var_idx[vids[0]] @@ -75,8 +131,14 @@ def _factor_log_potentials( both = (states[:, a_idx] == 1) & (states[:, b_idx] == 1) target = np.where(both, 0, 1).astype(np.int8) ok = states[:, h_idx] == target - pot = np.where(ok, h, lo) - return np.log(pot) + return np.where(ok, h_log, lo_log) + + if ft == FactorType.NEGATION: + a_idx = var_idx[vids[0]] + h_idx = var_idx[concl] + target = 1 - states[:, a_idx] + ok = states[:, h_idx] == target + return np.where(ok, h_log, lo_log) if ft == FactorType.COMPLEMENT: a_idx = var_idx[vids[0]] @@ -85,8 +147,7 @@ def _factor_log_potentials( xor = states[:, a_idx] != states[:, b_idx] target = xor.astype(np.int8) ok = states[:, h_idx] == target - pot = np.where(ok, h, lo) - return np.log(pot) + return np.where(ok, h_log, lo_log) if ft == FactorType.SOFT_ENTAILMENT: assert factor.p1 is not None and factor.p2 is not None @@ -100,7 +161,18 @@ def _factor_log_potentials( np.where(cv == 1, p1, 1.0 - p1), np.where(cv == 0, p2, 1.0 - p2), ) - return np.log(pot) + return np.log(pot) # type: ignore[no-any-return] + + if ft == FactorType.DEDUCTIVE_IMPLICATION: + a_idx = var_idx[vids[0]] + c_idx = var_idx[concl] + a = states[:, a_idx] + cv = states[:, c_idx] + pot = np.where(a == 1, np.where(cv == 1, 1.0, 0.0), 0.5) + out = np.full(cs, -np.inf, dtype=np.float64) + positive = pot > 0.0 + out[positive] = np.log(pot[positive]) + return out if ft == FactorType.CONDITIONAL: assert factor.cpt is not None @@ -113,61 +185,57 @@ def _factor_log_potentials( p_sel = cpt[idx] cv = states[:, c_idx] pot = np.where(cv == 1, p_sel, 1.0 - p_sel) - return np.log(pot) + return np.log(pot) # type: ignore[no-any-return] + + if ft == FactorType.PAIRWISE_POTENTIAL: + assert factor.cpt is not None + a_idx = var_idx[vids[0]] + b_idx = var_idx[concl] + weights = np.array(factor.cpt, dtype=np.float64) + idx = states[:, a_idx].astype(np.int64) | (states[:, b_idx].astype(np.int64) << 1) + return np.log(weights[idx]) raise ValueError(f"Unknown FactorType: {ft}") def exact_inference(graph: FactorGraph) -> tuple[dict[str, float], float]: - var_ids = sorted(graph.variables.keys()) - n = len(var_ids) - - if n > 26: - raise ValueError( - f"Exact inference requires 2^n enumeration. " - f"n={n} is too large (max 26). Use BP instead." - ) - - var_idx = {v: i for i, v in enumerate(var_ids)} - N = 1 << n - - priors = np.array([graph.variables[v] for v in var_ids], dtype=np.float64) - log_p1 = np.log(priors) - log_p0 = np.log(1.0 - priors) - - chunk_size = min(N, 1 << CHUNK_BITS) - all_log_joints = np.empty(N, dtype=np.float64) - - for chunk_start in range(0, N, chunk_size): - chunk_end = min(chunk_start + chunk_size, N) - cs = chunk_end - chunk_start + """Compute exact marginal beliefs via enumeration over joint distribution.""" + var_ids, _, all_log_joints = _enumerate_log_joint(graph) + joint, z_shifted = _shifted_joint(all_log_joints) + log_Z = all_log_joints.max() + np.log(z_shifted) + Z = float(np.exp(log_Z)) - arange = np.arange(chunk_start, chunk_end, dtype=np.int64) - states = np.empty((cs, n), dtype=np.int8) - for i in range(n): - states[:, i] = (arange >> i) & 1 + full_arange = np.arange(len(all_log_joints), dtype=np.int64) + beliefs: dict[str, float] = {} + for i, vid in enumerate(var_ids): + mask = ((full_arange >> i) & 1) == 1 + beliefs[vid] = float(joint[mask].sum() / z_shifted) - log_j = (states * log_p1 + (1 - states) * log_p0).sum(axis=1) + return beliefs, Z - for factor in graph.factors: - log_j += _factor_log_potentials(factor, states, var_idx) - all_log_joints[chunk_start:chunk_end] = log_j +def exact_joint_over(graph: FactorGraph, free_vars: list[str]) -> np.ndarray: + """Return the normalized joint over ``free_vars`` by exact enumeration. - log_max = all_log_joints.max() - joint = np.exp(all_log_joints - log_max) - Z_shifted = joint.sum() + The result is indexed by the bit pattern over ``free_vars`` in order: + index ``sum(v_i << i for i, v_i in enumerate(free_vars))``. + """ + if not free_vars: + return np.array([1.0], dtype=np.float64) - log_Z = log_max + np.log(Z_shifted) - Z = float(np.exp(log_Z)) + _var_ids, var_idx, all_log_joints = _enumerate_log_joint(graph) + missing = [v for v in free_vars if v not in var_idx] + if missing: + raise KeyError(f"exact_joint_over: unknown free vars {missing!r}") - full_arange = np.arange(N, dtype=np.int64) - beliefs: dict[str, float] = {} - for i, vid in enumerate(var_ids): - mask = ((full_arange >> i) & 1) == 1 - beliefs[vid] = float(joint[mask].sum() / Z_shifted) + joint, z_shifted = _shifted_joint(all_log_joints) + full_arange = np.arange(len(all_log_joints), dtype=np.int64) + assignment_idx = np.zeros(len(all_log_joints), dtype=np.int64) + for bit, vid in enumerate(free_vars): + assignment_idx |= ((full_arange >> var_idx[vid]) & 1).astype(np.int64) << bit - return beliefs, Z + probs = np.bincount(assignment_idx, weights=joint, minlength=1 << len(free_vars)) + return probs / z_shifted def comparison_table( @@ -178,6 +246,7 @@ def comparison_table( title: str = "Exact vs BP Comparison", tolerance: float = 0.02, ) -> str: + """Generate comparison table between exact and approximate beliefs.""" var_ids = sorted(graph.variables.keys()) lines = [] @@ -185,7 +254,7 @@ def comparison_table( lines.append(f" {title}") lines.append(f" Total states: {2 ** len(var_ids):,} | Partition function Z = {Z:.6e}") lines.append(f"{'=' * 78}") - header = f" {'Variable':25s} {'Prior':>7} {'Exact':>8} {'BP':>8} {'Diff':>8} Match?" + header = f" {'Variable':25s} {'Unary':>7} {'Exact':>8} {'BP':>8} {'Diff':>8} Match?" lines.append(header) lines.append(" " + "-" * 72) @@ -194,7 +263,7 @@ def comparison_table( max_diff = 0.0 for vid in var_ids: - prior = graph.variables[vid] + prior = graph.unary_factors.get(vid, 0.5) ex = exact_beliefs.get(vid, 0.0) bp = bp_beliefs.get(vid, 0.0) diff = abs(ex - bp) diff --git a/gaia/bp/factor_graph.py b/gaia/engine/bp/factor_graph.py similarity index 52% rename from gaia/bp/factor_graph.py rename to gaia/engine/bp/factor_graph.py index b7de011c3..e774be8a4 100644 --- a/gaia/bp/factor_graph.py +++ b/gaia/engine/bp/factor_graph.py @@ -7,9 +7,10 @@ from __future__ import annotations import logging +from collections.abc import Sequence from dataclasses import dataclass from enum import Enum, auto -from typing import Sequence +from math import isfinite logger = logging.getLogger(__name__) @@ -24,7 +25,10 @@ def _cromwell_clamp(value: float, label: str = "") -> float: class FactorType(Enum): + """Enumeration of factor types in the factor graph.""" + IMPLICATION = auto() + NEGATION = auto() CONJUNCTION = auto() DISJUNCTION = auto() EQUIVALENCE = auto() @@ -32,10 +36,14 @@ class FactorType(Enum): COMPLEMENT = auto() SOFT_ENTAILMENT = auto() CONDITIONAL = auto() + PAIRWISE_POTENTIAL = auto() + DEDUCTIVE_IMPLICATION = auto() @dataclass(frozen=True) class Factor: + """Factor in a factor graph with variables and potential function.""" + factor_id: str factor_type: FactorType variables: list[str] @@ -46,6 +54,7 @@ class Factor: @property def all_vars(self) -> list[str]: + """Return all variables involved in this factor.""" seen: set[str] = set() out: list[str] = [] for v in (*self.variables, self.conclusion): @@ -56,44 +65,115 @@ def all_vars(self) -> list[str]: class FactorGraph: + """Factor graph for probabilistic inference.""" + def __init__(self) -> None: + """Initialize an empty factor graph.""" self.variables: dict[str, float] = {} + self.unary_factors: dict[str, float] = {} + self.hard_evidence: dict[str, int] = {} self.factors: list[Factor] = [] + # V8 audit trail: every class-II likelihood update appends a record + # {"prior_before", "likelihood_ratio", "prior_after"} so the + # full II→IV chain is recoverable from the graph alone (Gaia + # auditability requirement; does not affect inference). + self.posterior_evidence: dict[str, list[dict[str, float]]] = {} + # V9 audit trail: D2 structural deduplications performed during + # lowering. Each entry: {"op", "args", "conclusion", "dropped_count"}. + # Populated by lowering, untouched by inference. + self.dedup_audit: list[dict[str, object]] = [] - def add_variable(self, var_id: str, prior: float) -> None: - self.variables[var_id] = _cromwell_clamp(prior, label=f"variable '{var_id}' prior") + def add_variable(self, var_id: str, prior: float | None = None) -> None: + r"""Register a binary variable, optionally with an explicit unary factor. - def observe(self, var_id: str, value: int) -> None: - """Hard evidence: clamp variable to observed value (07-bp §1.7). - - Implemented by setting prior to near-0 or near-1 (Cromwell-bounded). - This is equivalent to adding a unary delta factor. + ``variables`` records the neutral display/initial measure for every + variable. Only ``unary_factors`` is a Jaynes-style class IV soft prior + (Cromwell ε permitted). Class I logical assertions belong in + ``hard_evidence`` via :meth:`add_evidence`; inference engines treat + those as strict δ constraints. """ + if prior is None: + self.variables.setdefault(var_id, 0.5) + return + clamped = _cromwell_clamp(prior, label=f"variable '{var_id}' unary") + if var_id in self.hard_evidence: + target = float(self.hard_evidence[var_id]) + if abs(clamped - target) > 0.5: + raise ValueError( + f"Variable '{var_id}': soft prior {clamped:g} contradicts " + f"hard evidence={self.hard_evidence[var_id]} (D5)." + ) + return + if var_id in self.unary_factors: + existing = self.unary_factors[var_id] + if abs(existing - clamped) > CROMWELL_EPS: + raise ValueError( + f"Variable '{var_id}': conflicting unary priors " + f"existing={existing:g}, new={clamped:g} (D1 violation)." + ) + self.variables[var_id] = clamped + self.unary_factors[var_id] = clamped + + def add_evidence(self, var_id: str, value: int) -> None: + """Class I hard observation as a strict δ constraint.""" if var_id not in self.variables: raise KeyError(f"Variable '{var_id}' not registered.") if value not in (0, 1): - raise ValueError(f"observe() value must be 0 or 1, got {value}.") - self.variables[var_id] = 1.0 - CROMWELL_EPS if value == 1 else CROMWELL_EPS + raise ValueError(f"add_evidence() value must be 0 or 1, got {value}.") + if var_id in self.hard_evidence and self.hard_evidence[var_id] != value: + raise ValueError( + f"Variable '{var_id}': conflicting hard evidence " + f"{self.hard_evidence[var_id]} vs {value} (D5 violation)." + ) + if var_id in self.unary_factors: + existing = self.unary_factors[var_id] + if (value == 1 and existing < 0.5) or (value == 0 and existing > 0.5): + raise ValueError( + f"Variable '{var_id}': hard evidence={value} contradicts " + f"existing soft prior {existing:g} (D5 violation)." + ) + self.unary_factors.pop(var_id, None) + self.hard_evidence[var_id] = value + self.variables[var_id] = float(value) + + def observe(self, var_id: str, value: int) -> None: + """Hard evidence alias — delegates to :meth:`add_evidence`.""" + self.add_evidence(var_id, value) def add_likelihood( self, var_id: str, likelihood_ratio: float, ) -> None: - """Soft evidence: multiply variable's prior by likelihood ratio (07-bp §1.7). + """Soft evidence (class II): fold likelihood ratio into the class-IV unary. - P_new(x=1) = normalize(π * lr, (1-π) * 1) where lr = P(E|x=1)/P(E|x=0). + P_new(x=1) = normalize(π · lr, (1−π) · 1) where lr = P(E|x=1)/P(E|x=0). + Records the update in posterior_evidence[var_id] for audit. """ if var_id not in self.variables: raise KeyError(f"Variable '{var_id}' not registered.") if likelihood_ratio <= 0: raise ValueError(f"likelihood_ratio must be > 0, got {likelihood_ratio}.") - pi = self.variables[var_id] + if var_id in self.hard_evidence: + raise ValueError( + f"Variable '{var_id}': cannot apply soft likelihood — variable is " + f"already pinned by hard_evidence={self.hard_evidence[var_id]} (D5)." + ) + pi = self.unary_factors.get(var_id, self.variables.get(var_id, 0.5)) odds = pi / (1.0 - pi) * likelihood_ratio new_pi = odds / (1.0 + odds) - self.variables[var_id] = _cromwell_clamp(new_pi, label=f"likelihood '{var_id}'") + clamped = _cromwell_clamp(new_pi, label=f"variable {var_id!r} likelihood-updated unary") + self.variables[var_id] = clamped + self.unary_factors[var_id] = clamped + self.posterior_evidence.setdefault(var_id, []).append( + { + "prior_before": float(pi), + "likelihood_ratio": float(likelihood_ratio), + "prior_after": float(clamped), + } + ) - def add_factor( + def add_factor( # noqa: C901 self, factor_id: str, factor_type: FactorType, @@ -104,6 +184,7 @@ def add_factor( p2: float | None = None, cpt: Sequence[float] | None = None, ) -> None: + """Add a factor to the graph with specified type and variables.""" v_list = list(variables) if conclusion in v_list: raise ValueError( @@ -117,6 +198,7 @@ def add_factor( if ft in ( FactorType.IMPLICATION, + FactorType.NEGATION, FactorType.CONJUNCTION, FactorType.DISJUNCTION, FactorType.EQUIVALENCE, @@ -162,6 +244,38 @@ def add_factor( f"CONDITIONAL '{factor_id}': cpt length must be 2^k = {expected}, " f"got {len(fcpt)}." ) + + elif ft == FactorType.PAIRWISE_POTENTIAL: + if p1 is not None or p2 is not None: + raise ValueError(f"PAIRWISE_POTENTIAL '{factor_id}' must not set p1/p2.") + if len(v_list) != 1: + raise ValueError( + f"PAIRWISE_POTENTIAL '{factor_id}' requires exactly 1 variable plus " + f"the paired conclusion variable, got {len(v_list)} variables." + ) + if cpt is None: + raise ValueError(f"PAIRWISE_POTENTIAL '{factor_id}' requires cpt.") + fcpt = tuple(float(x) for x in cpt) + if len(fcpt) != 4: + raise ValueError( + f"PAIRWISE_POTENTIAL '{factor_id}': cpt length must be 4, got {len(fcpt)}." + ) + if any((not isfinite(x)) or x < 0.0 for x in fcpt): + raise ValueError( + f"PAIRWISE_POTENTIAL '{factor_id}' requires finite non-negative weights." + ) + if sum(fcpt) <= 0.0: + raise ValueError( + f"PAIRWISE_POTENTIAL '{factor_id}' requires at least one positive weight." + ) + elif ft == FactorType.DEDUCTIVE_IMPLICATION: + if p1 is not None or p2 is not None or cpt is not None: + raise ValueError(f"DEDUCTIVE_IMPLICATION '{factor_id}' must not set p1/p2/cpt.") + if len(v_list) != 1: + raise ValueError( + f"DEDUCTIVE_IMPLICATION '{factor_id}' requires exactly 1 antecedent " + f"variable, got {len(v_list)}." + ) else: raise ValueError(f"Unknown FactorType: {ft!r}") @@ -183,6 +297,10 @@ def _validate_deterministic(factor_id: str, ft: FactorType, v_list: list[str]) - raise ValueError( f"IMPLICATION '{factor_id}' requires exactly 2 variables, got {len(v_list)}." ) + if ft == FactorType.NEGATION and len(v_list) != 1: + raise ValueError( + f"NEGATION '{factor_id}' requires exactly 1 variable, got {len(v_list)}." + ) if ft == FactorType.CONJUNCTION and len(v_list) < 2: raise ValueError( f"CONJUNCTION '{factor_id}' requires at least 2 variables, got {len(v_list)}." @@ -191,13 +309,16 @@ def _validate_deterministic(factor_id: str, ft: FactorType, v_list: list[str]) - raise ValueError( f"DISJUNCTION '{factor_id}' requires at least 2 variables, got {len(v_list)}." ) - if ft in (FactorType.EQUIVALENCE, FactorType.CONTRADICTION, FactorType.COMPLEMENT): - if len(v_list) != 2: - raise ValueError( - f"{ft.name} '{factor_id}' requires exactly 2 variables, got {len(v_list)}." - ) + if ( + ft in (FactorType.EQUIVALENCE, FactorType.CONTRADICTION, FactorType.COMPLEMENT) + and len(v_list) != 2 + ): + raise ValueError( + f"{ft.name} '{factor_id}' requires exactly 2 variables, got {len(v_list)}." + ) def get_var_to_factors(self) -> dict[str, list[int]]: + """Return mapping from variable names to factor indices.""" index: dict[str, list[int]] = {vid: [] for vid in self.variables} for fi, factor in enumerate(self.factors): for vid in factor.all_vars: @@ -212,6 +333,7 @@ def get_var_to_factors(self) -> dict[str, list[int]]: return index def validate(self) -> list[str]: + """Validate the factor graph and return list of errors.""" errors: list[str] = [] for fi, factor in enumerate(self.factors): seen: set[str] = set() @@ -229,10 +351,15 @@ def validate(self) -> list[str]: return errors def summary(self) -> str: + """Generate summary string of the factor graph.""" lines = [f"FactorGraph: {len(self.variables)} variables, {len(self.factors)} factors"] lines.append("Variables:") - for vid, prior in sorted(self.variables.items()): - lines.append(f" {vid:30s} prior={prior:.4f}") + for vid, measure in sorted(self.variables.items()): + unary = self.unary_factors.get(vid) + if unary is None: + lines.append(f" {vid:30s} latent_measure={measure:.4f}") + else: + lines.append(f" {vid:30s} unary={unary:.4f}") lines.append("Factors:") for factor in self.factors: extra = "" diff --git a/gaia/bp/junction_tree.py b/gaia/engine/bp/junction_tree.py similarity index 76% rename from gaia/bp/junction_tree.py rename to gaia/engine/bp/junction_tree.py index 7fac420fe..dc85d6e72 100644 --- a/gaia/bp/junction_tree.py +++ b/gaia/engine/bp/junction_tree.py @@ -20,7 +20,8 @@ clique separators, verifying the running intersection property. 5. Assign each factor to exactly one clique that contains all its variables. 6. Initialize each clique's potential as the product of its assigned factors - evaluated over all 2^|clique| joint assignments, multiplied by priors. + evaluated over all 2^|clique| joint assignments, multiplied by explicit + unary factors. 7. Run two-pass message passing (collect + distribute) on the clique tree. 8. Marginalize each variable from the calibrated clique that contains it. @@ -34,7 +35,7 @@ - vs ~58 * 25 * 5 = 7250 operations (loopy BP, but inexact on cyclic graphs) All binary variables (x ∈ {0, 1}). Compatible with FactorGraph from -gaia.bp.factor_graph. +gaia.engine.bp.factor_graph. """ from __future__ import annotations @@ -42,14 +43,15 @@ import logging from itertools import product as cartesian_product - -from gaia.bp.bp import BPDiagnostics, BPResult -from gaia.bp.factor_graph import Factor, FactorGraph -from gaia.bp.potentials import evaluate_potential +from gaia.engine.bp.factor_graph import Factor, FactorGraph +from gaia.engine.bp.potentials import evaluate_potential +from gaia.engine.bp.trw_bp import TRWDiagnostics, TRWResult __all__ = ["JunctionTreeInference", "jt_treewidth"] logger = logging.getLogger(__name__) +type PotentialTable = dict[tuple[int, ...], float] +type JunctionMessages = dict[tuple[int, int], tuple[PotentialTable, list[str]]] # --------------------------------------------------------------------------- @@ -126,7 +128,7 @@ def _triangulate_min_fill( # Record elimination clique: best + its remaining neighbors neighbors_remaining = [n for n in adj[best] if n in remaining] - clique = [best] + sorted(neighbors_remaining) + clique = [best, *sorted(neighbors_remaining)] elim_cliques.append(clique) # Add fill edges (make neighbors a clique in triangulated graph) @@ -219,7 +221,7 @@ def union(x: int, y: int) -> None: parent[px] = py tree_edges: list[tuple[int, int, frozenset[str]]] = [] - for i, j, w, sep in edges: + for i, j, _w, sep in edges: if find(i) != find(j): union(i, j) tree_edges.append((i, j, sep)) @@ -267,7 +269,7 @@ def _compute_clique_potential( clique: frozenset[str], factors: list[Factor], priors: dict[str, float], -) -> dict[tuple[int, ...], float]: +) -> PotentialTable: """Compute the initial (unnormalized) potential table for a clique. The clique potential is: @@ -285,7 +287,7 @@ def _compute_clique_potential( """ var_list = sorted(clique) n = len(var_list) - table: dict[tuple[int, ...], float] = {} + table: PotentialTable = {} for vals in cartesian_product((0, 1), repeat=n): assignment = {v: vals[i] for i, v in enumerate(var_list)} @@ -324,10 +326,10 @@ def _tree_adjacency( def _marginalize( - table: dict[tuple[int, ...], float], + table: PotentialTable, var_list: list[str], keep_vars: frozenset[str], -) -> dict[tuple[int, ...], float]: +) -> PotentialTable: """Marginalize a clique potential table down to keep_vars. Sums over all variables NOT in keep_vars, returning a table indexed @@ -341,7 +343,7 @@ def _marginalize( """ keep_list = sorted(keep_vars) keep_indices = [var_list.index(v) for v in keep_list] - result: dict[tuple[int, ...], float] = {} + result: PotentialTable = {} for vals, pot in table.items(): key = tuple(vals[i] for i in keep_indices) @@ -351,11 +353,11 @@ def _marginalize( def _multiply_tables( - table_a: dict[tuple[int, ...], float], + table_a: PotentialTable, vars_a: list[str], - table_b: dict[tuple[int, ...], float], + table_b: PotentialTable, vars_b: list[str], -) -> tuple[dict[tuple[int, ...], float], list[str]]: +) -> tuple[PotentialTable, list[str]]: """Multiply two factor tables, aligning on shared variables. Returns (product_table, sorted_union_vars). @@ -364,7 +366,7 @@ def _multiply_tables( a_indices = [union_vars.index(v) for v in vars_a] b_indices = [union_vars.index(v) for v in vars_b] - result: dict[tuple[int, ...], float] = {} + result: PotentialTable = {} for vals in cartesian_product((0, 1), repeat=len(union_vars)): a_key = tuple(vals[i] for i in a_indices) b_key = tuple(vals[i] for i in b_indices) @@ -376,11 +378,11 @@ def _multiply_tables( def _divide_tables( - table_a: dict[tuple[int, ...], float], + table_a: PotentialTable, vars_a: list[str], - table_b: dict[tuple[int, ...], float], + table_b: PotentialTable, vars_b: list[str], -) -> tuple[dict[tuple[int, ...], float], list[str]]: +) -> tuple[PotentialTable, list[str]]: """Divide table_a / table_b (aligned on shared variables). Used in Shafer-Shenoy message passing: message(i->j) = @@ -391,7 +393,7 @@ def _divide_tables( a_indices = [union_vars.index(v) for v in vars_a] b_indices = [union_vars.index(v) for v in vars_b] - result: dict[tuple[int, ...], float] = {} + result: PotentialTable = {} for vals in cartesian_product((0, 1), repeat=len(union_vars)): a_key = tuple(vals[i] for i in a_indices) b_key = tuple(vals[i] for i in b_indices) @@ -402,13 +404,114 @@ def _divide_tables( return result, union_vars +def _junction_tree_orders( + tree_adj: dict[int, list[tuple[int, frozenset[str]]]], + n_cliques: int, +) -> tuple[dict[int, int | None], list[int], list[int]]: + """Return parent map plus collect and distribute traversal orders.""" + visited = [False] * n_cliques + post_order: list[int] = [] + parent: dict[int, int | None] = {0: None} + + stack = [0] + while stack: + node = stack[-1] + if not visited[node]: + visited[node] = True + for child, _ in tree_adj[node]: + if not visited[child]: + parent[child] = node + stack.append(child) + else: + stack.pop() + post_order.append(node) + + return parent, post_order, list(reversed(post_order)) + + +def _initial_separator_messages( + tree_adj: dict[int, list[tuple[int, frozenset[str]]]], + n_cliques: int, +) -> JunctionMessages: + """Initialize all directed separator messages to uniform tables.""" + messages: JunctionMessages = {} + for clique_idx in range(n_cliques): + for neighbor, separator in tree_adj[clique_idx]: + sep_list = sorted(separator) + uniform: PotentialTable = dict.fromkeys( + cartesian_product((0, 1), repeat=len(sep_list)), 1.0 + ) + messages[(clique_idx, neighbor)] = (uniform, sep_list) + return messages + + +def _separator_between( + tree_adj: dict[int, list[tuple[int, frozenset[str]]]], + node: int, + parent: int, +) -> frozenset[str]: + """Return the separator between two adjacent junction-tree cliques.""" + for neighbor, separator in tree_adj[node]: + if neighbor == parent: + return separator + raise RuntimeError(f"Junction tree missing separator between {node} and {parent}") + + +def _compute_junction_message( + sender: int, + receiver: int, + separator: frozenset[str], + *, + clique_potentials: list[PotentialTable], + clique_var_lists: list[list[str]], + tree_adj: dict[int, list[tuple[int, frozenset[str]]]], + messages: JunctionMessages, +) -> tuple[PotentialTable, list[str]]: + """Compute one Shafer-Shenoy separator message.""" + table = dict(clique_potentials[sender]) + var_list = list(clique_var_lists[sender]) + + for neighbor, _neighbor_sep in tree_adj[sender]: + if neighbor == receiver: + continue + in_msg, in_vars = messages[(neighbor, sender)] + table, var_list = _multiply_tables(table, var_list, in_msg, in_vars) + + return _marginalize(table, var_list, separator), sorted(separator) + + +def _calibrate_cliques( + clique_potentials: list[PotentialTable], + clique_var_lists: list[list[str]], + tree_adj: dict[int, list[tuple[int, frozenset[str]]]], + messages: JunctionMessages, + n_cliques: int, +) -> list[PotentialTable]: + """Multiply incoming separator messages into each clique potential.""" + calibrated: list[PotentialTable] = [] + for clique_idx in range(n_cliques): + table = dict(clique_potentials[clique_idx]) + var_list = list(clique_var_lists[clique_idx]) + for neighbor, _separator in tree_adj[clique_idx]: + in_msg, in_vars = messages[(neighbor, clique_idx)] + table, var_list = _multiply_tables(table, var_list, in_msg, in_vars) + + target_vars = clique_var_lists[clique_idx] + reindexed: PotentialTable = {} + for vals, pot in table.items(): + key = tuple(vals[var_list.index(variable)] for variable in target_vars) + reindexed[key] = reindexed.get(key, 0.0) + pot + calibrated.append(reindexed) + return calibrated + + def _collect_distribute( cliques: list[frozenset[str]], - clique_potentials: list[dict[tuple[int, ...], float]], + clique_potentials: list[PotentialTable], clique_var_lists: list[list[str]], tree_adj: dict[int, list[tuple[int, frozenset[str]]]], n_cliques: int, -) -> list[dict[tuple[int, ...], float]]: +) -> list[PotentialTable]: """Run collect + distribute (two-pass Shafer-Shenoy) message passing. Uses post-order DFS (collect) then pre-order DFS (distribute), @@ -419,91 +522,50 @@ def _collect_distribute( Returns list of calibrated clique potential tables (same indexing as input). """ + del cliques if n_cliques == 1: return clique_potentials[:] # Build DFS order rooted at 0 - visited = [False] * n_cliques - post_order: list[int] = [] # collect order - parent: dict[int, int | None] = {0: None} - - stack = [0] - while stack: - node = stack[-1] - if not visited[node]: - visited[node] = True - for child, _ in tree_adj[node]: - if not visited[child]: - parent[child] = node - stack.append(child) - else: - stack.pop() - post_order.append(node) - - pre_order = list(reversed(post_order)) + parent, post_order, pre_order = _junction_tree_orders(tree_adj, n_cliques) # Messages: msg[(sender, receiver)] = separator-indexed table # Initially: uniform over separator - messages: dict[tuple[int, int], tuple[dict, list[str]]] = {} - for i, j, sep in [(i, j, sep) for i in range(n_cliques) for j, sep in tree_adj[i]]: - sep_list = sorted(sep) - uniform = {vals: 1.0 for vals in cartesian_product((0, 1), repeat=len(sep_list))} - messages[(i, j)] = (uniform, sep_list) - - # Helper: compute message from clique i to clique j - def compute_message(sender: int, receiver: int, sep: frozenset[str]) -> tuple[dict, list[str]]: - # Start with sender's initial potential - table = dict(clique_potentials[sender]) - var_list = list(clique_var_lists[sender]) - - # Multiply in all incoming messages EXCEPT from receiver - for neighbor, neighbor_sep in tree_adj[sender]: - if neighbor == receiver: - continue - in_msg, in_vars = messages[(neighbor, sender)] - table, var_list = _multiply_tables(table, var_list, in_msg, in_vars) - - # Marginalize down to separator - sep_msg = _marginalize(table, var_list, sep) - return sep_msg, sorted(sep) + messages = _initial_separator_messages(tree_adj, n_cliques) # COLLECT: post-order (leaves to root) for node in post_order: par = parent.get(node) if par is not None: - # Find separator between node and par - sep = None - for neighbor, s in tree_adj[node]: - if neighbor == par: - sep = s - break - msg_table, msg_vars = compute_message(node, par, sep) + sep = _separator_between(tree_adj, node, par) + msg_table, msg_vars = _compute_junction_message( + node, + par, + sep, + clique_potentials=clique_potentials, + clique_var_lists=clique_var_lists, + tree_adj=tree_adj, + messages=messages, + ) messages[(node, par)] = (msg_table, msg_vars) # DISTRIBUTE: pre-order (root to leaves) for node in pre_order: for child, sep in tree_adj[node]: if parent.get(child) == node: - msg_table, msg_vars = compute_message(node, child, sep) + msg_table, msg_vars = _compute_junction_message( + node, + child, + sep, + clique_potentials=clique_potentials, + clique_var_lists=clique_var_lists, + tree_adj=tree_adj, + messages=messages, + ) messages[(node, child)] = (msg_table, msg_vars) # Calibrate: multiply all incoming messages into each clique - calibrated: list[dict[tuple[int, ...], float]] = [] - for i in range(n_cliques): - table = dict(clique_potentials[i]) - var_list = list(clique_var_lists[i]) - for neighbor, sep in tree_adj[i]: - in_msg, in_vars = messages[(neighbor, i)] - table, var_list = _multiply_tables(table, var_list, in_msg, in_vars) - # Re-index to sorted clique variable order - target_vars = clique_var_lists[i] - reindexed: dict[tuple[int, ...], float] = {} - for vals, pot in table.items(): - key = tuple(vals[var_list.index(v)] for v in target_vars) - reindexed[key] = reindexed.get(key, 0.0) + pot - calibrated.append(reindexed) - - return calibrated + return _calibrate_cliques(clique_potentials, clique_var_lists, tree_adj, messages, n_cliques) # --------------------------------------------------------------------------- @@ -513,7 +575,7 @@ def compute_message(sender: int, receiver: int, sep: frozenset[str]) -> tuple[di def _extract_beliefs( cliques: list[frozenset[str]], - calibrated: list[dict[tuple[int, ...], float]], + calibrated: list[PotentialTable], clique_var_lists: list[list[str]], all_variables: set[str], ) -> dict[str, float]: @@ -586,10 +648,10 @@ class JunctionTreeInference: This fixes loopy BP's double-counting error on graphs with short cycles. For Gaia's factor graphs (treewidth ≤ ~15), this is the preferred engine. - Returns the same BPResult interface as BeliefPropagation for drop-in use. + Returns the same TRWResult interface as BeliefPropagation for drop-in use. """ - def run(self, graph: FactorGraph) -> BPResult: + def run(self, graph: FactorGraph) -> TRWResult: """Run exact Junction Tree inference on *graph*. Parameters @@ -598,25 +660,30 @@ def run(self, graph: FactorGraph) -> BPResult: A validated FactorGraph. All variables referenced by factors must be registered. - Returns - ------- - BPResult - .beliefs: dict[str, float] — exact marginal P(v=1) per variable. - .diagnostics: BPDiagnostics recording treewidth and clique count. + Returns: + TRWResult containing exact marginal ``P(v=1)`` beliefs and + diagnostics recording treewidth and clique count. """ - diag = BPDiagnostics() + diag = TRWDiagnostics() if not graph.variables: diag.converged = True - return BPResult(beliefs={}, diagnostics=diag) + return TRWResult(beliefs={}, diagnostics=diag) if not graph.factors: - # No factors: beliefs = priors + # No factors: beliefs are explicit unary factors or neutral MaxEnt. diag.converged = True - beliefs = dict(graph.variables) + + # Priority: hard_evidence > unary_factors > neutral MaxEnt + def _belief0(vid: str) -> float: + if vid in graph.hard_evidence: + return float(graph.hard_evidence[vid]) + return graph.unary_factors.get(vid, 0.5) + + beliefs = {vid: _belief0(vid) for vid in graph.variables} for vid, p in beliefs.items(): diag.belief_history[vid] = [p] - return BPResult(beliefs=beliefs, diagnostics=diag) + return TRWResult(beliefs=beliefs, diagnostics=diag) # Step 1: Moral graph moral_adj = _build_moral_graph(graph) @@ -645,19 +712,23 @@ def run(self, graph: FactorGraph) -> BPResult: factor_assignment = _assign_factors_to_cliques(cliques, graph) # Step 6: Compute clique potentials - # Each variable's prior is applied in exactly one clique — the first - # clique found that contains it. This prevents double-counting priors. - prior_assigned: set[str] = set() - clique_potentials: list[dict[tuple[int, ...], float]] = [] + # Each explicit unary factor is applied in exactly one clique — the + # first clique found that contains it. Variables without unary factors + # contribute the base counting measure. + unary_assigned: set[str] = set() + clique_potentials: list[PotentialTable] = [] for i, clique in enumerate(cliques): var_list = clique_var_lists[i] - # Determine which priors to apply in this clique + # Determine which explicit unary factors to apply in this clique. local_priors: dict[str, float] = {} for v in var_list: - if v not in prior_assigned: - local_priors[v] = graph.variables[v] - prior_assigned.add(v) + if v in graph.hard_evidence and v not in unary_assigned: + local_priors[v] = float(graph.hard_evidence[v]) + unary_assigned.add(v) + elif v in graph.unary_factors and v not in unary_assigned: + local_priors[v] = graph.unary_factors[v] + unary_assigned.add(v) pot_table = _compute_clique_potential(clique, factor_assignment[i], local_priors) clique_potentials.append(pot_table) @@ -685,4 +756,4 @@ def run(self, graph: FactorGraph) -> BPResult: diag.converged = True diag.max_change_at_stop = 0.0 - return BPResult(beliefs=beliefs, diagnostics=diag) + return TRWResult(beliefs=beliefs, diagnostics=diag) diff --git a/gaia/engine/bp/lowering.py b/gaia/engine/bp/lowering.py new file mode 100644 index 000000000..78942f07b --- /dev/null +++ b/gaia/engine/bp/lowering.py @@ -0,0 +1,1051 @@ +"""Lower Gaia IR (LocalCanonicalGraph) to gaia.engine.bp.FactorGraph. + +Spec: docs/foundations/gaia-ir/07-lowering.md +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import replace +from typing import Any + +from gaia.engine.bp.factor_graph import CROMWELL_EPS, FactorGraph, FactorType +from gaia.engine.ir.formalize import formalize_named_strategy +from gaia.engine.ir.graphs import LocalCanonicalGraph +from gaia.engine.ir.knowledge import KnowledgeType, is_structural_expression_helper +from gaia.engine.ir.operator import Operator, OperatorType +from gaia.engine.ir.review import ReviewManifest, ReviewStatus +from gaia.engine.ir.strategy import ( + _FORMAL_STRATEGY_TYPES, + CompositeStrategy, + FormalStrategy, + Strategy, + StrategyType, +) + +# Deduction is a normalized hard implication: A forbids not-B, while not-A +# leaves B at its MaxEnt 0.5 row. This keeps open antecedents at their explicit +# or default boundary prior unless downstream consequences are observed. + +# Support remains the soft implication family. Its warrant prior is still folded +# into an effective P(C|premise) until support is redesigned as a separate +# likelihood-style operator. +_SOFT_IMPLICATION_TYPES = frozenset({StrategyType.SUPPORT}) + +# Operators whose conclusion is a "relation assertion" (the operator +# DECLARES that the relation holds) — their helper claim should be +# asserted as strict Class-I evidence. DISJUNCTION is +# compositional (``h = a OR b`` is a derived value), so its helper +# stays at the neutral 0.5 default and the factor potential drives +# the marginal. +_RELATION_OPS = frozenset( + { + OperatorType.EQUIVALENCE, + OperatorType.CONTRADICTION, + OperatorType.COMPLEMENT, + OperatorType.IMPLICATION, + } +) + +_ASSOCIATE_TOLERANCE = 1e-6 + +_OPERATOR_MAP: dict[OperatorType, FactorType] = { + OperatorType.IMPLICATION: FactorType.IMPLICATION, + OperatorType.NEGATION: FactorType.NEGATION, + OperatorType.CONJUNCTION: FactorType.CONJUNCTION, + OperatorType.DISJUNCTION: FactorType.DISJUNCTION, + OperatorType.EQUIVALENCE: FactorType.EQUIVALENCE, + OperatorType.CONTRADICTION: FactorType.CONTRADICTION, + OperatorType.COMPLEMENT: FactorType.COMPLEMENT, +} + + +_SYMMETRIC_OPS = frozenset( + { + OperatorType.EQUIVALENCE, + OperatorType.CONTRADICTION, + OperatorType.COMPLEMENT, + OperatorType.DISJUNCTION, + OperatorType.CONJUNCTION, + } +) + + +def _canonical_op_key(op: Operator) -> tuple[str, frozenset[str] | tuple[str, ...]]: + """Structural canonical key for D2 duplicate detection. + + V9 (Jaynes D2): two operators sharing this key encode the same + class-I information (up to the operator's known symmetry). L1 + structural enforcement only; deeper semantic equivalence belongs + to Archon / SAT verifiers. + """ + args = frozenset(op.variables) if op.operator in _SYMMETRIC_OPS else tuple(op.variables) + return (op.operator, args) + + +def _dedup_operators( + ops: Sequence[Operator], + *, + dedup_audit: list[dict[str, object]], + context: str, +) -> list[Operator]: + """L1 D2 dedup: drop later operators matching an earlier canonical key. + + * Same key AND same conclusion -> silently drop, record in dedup_audit. + * Same key but DIFFERENT conclusion -> raise ValueError (D1+D2 violation). + """ + seen: dict[tuple[str, frozenset[str] | tuple[str, ...]], tuple[str, int]] = {} + out: list[Operator] = [] + for op in ops: + key = _canonical_op_key(op) + if key in seen: + prev_concl, _prev_idx = seen[key] + if prev_concl == op.conclusion: + dedup_audit.append( + { + "context": context, + "op": str(op.operator), + "args": sorted(op.variables) + if op.operator in _SYMMETRIC_OPS + else list(op.variables), + "conclusion": op.conclusion, + "dropped_index": len(out) + (len(seen) - 1), + } + ) + continue + raise ValueError( + f"D2 violation [{context}]: operator {op.operator.value} over " + f"args=" + f"{sorted(op.variables) if op.operator in _SYMMETRIC_OPS else list(op.variables)} " + f"is declared with two different conclusions: " + f"'{prev_concl}' (first) vs '{op.conclusion}' (duplicate). " + f"The same logical relation cannot assert into two distinct helper claims." + ) + seen[key] = (op.conclusion, len(out)) + out.append(op) + return out + + +def _next_fid(prefix: str, i: list[int]) -> str: + i[0] += 1 + return f"{prefix}_f{i[0]}" + + +def _review_target_allowed( + target_id: str | None, + metadata: dict[str, Any] | None, + review_manifest: ReviewManifest | None, +) -> bool: + if review_manifest is None: + return True + if not metadata or not metadata.get("action_label"): + return True + if not target_id: + return False + return review_manifest.latest_status(target_id) == ReviewStatus.ACCEPTED + + +def _operator_asserts_relation(op: Operator) -> bool: + """Return True when a relation-operator conclusion is an asserted helper.""" + if op.operator not in _RELATION_OPS: + return False + # Formula connectives use the conclusion as the formula truth variable. + # Its authored prior must remain live instead of being pinned as a hard + # relation assertion. + return (op.metadata or {}).get("formula_lowering") != "connective" + + +def _helper_prior_filter_ids(canonical: LocalCanonicalGraph) -> tuple[set[str], set[str]]: + """Return helper IDs whose user/default priors should be ignored.""" + helper_ids = { + k.id for k in canonical.knowledges if k.id and k.label and k.label.startswith("__") + } + expression_helper_ids = { + k.id for k in canonical.knowledges if k.id and is_structural_expression_helper(k) + } + return helper_ids | expression_helper_ids, expression_helper_ids + + +def _metadata_priors( + canonical: LocalCanonicalGraph, + expression_helper_ids: set[str], +) -> dict[str, float]: + """Collect metadata priors except for structural expression helpers.""" + return { + k.id: float(k.metadata["prior"]) + for k in canonical.knowledges + if k.id and k.metadata and "prior" in k.metadata and k.id not in expression_helper_ids + } + + +def _review_allowed_operators( + canonical: LocalCanonicalGraph, + review_manifest: ReviewManifest | None, +) -> list[Operator]: + """Return operators admitted by the optional review manifest.""" + return [ + op + for op in canonical.operators + if _review_target_allowed(op.operator_id, op.metadata, review_manifest) + ] + + +def _relation_conclusion_ids(operators: list[Operator]) -> set[str]: + """Return conclusions whose relation operators assert the helper true.""" + return {op.conclusion for op in operators if _operator_asserts_relation(op)} + + +def _add_claim_variables( + fg: FactorGraph, + canonical: LocalCanonicalGraph, + *, + priors: dict[str, float], + expression_helper_ids: set[str], + relation_concl_ids: set[str], +) -> set[str]: + """Register claim variables with the documented prior precedence rules.""" + claim_ids = {k.id for k in canonical.knowledges if k.type == KnowledgeType.CLAIM and k.id} + for knowledge in canonical.knowledges: + if knowledge.type != KnowledgeType.CLAIM or not knowledge.id: + continue + meta = knowledge.metadata or {} + metadata_prior = meta.get("prior") + if knowledge.id in expression_helper_ids: + fg.add_variable(knowledge.id) + elif knowledge.id in relation_concl_ids: + fg.add_variable(knowledge.id) + fg.add_evidence(knowledge.id, 1) + elif knowledge.id in priors: + fg.add_variable(knowledge.id, priors[knowledge.id]) + elif metadata_prior is not None: + fg.add_variable(knowledge.id, float(metadata_prior)) + else: + fg.add_variable(knowledge.id) + return claim_ids + + +def _lower_operators( + fg: FactorGraph, + operators: list[Operator], + *, + priors: dict[str, float], + claim_ids: set[str], + expression_helper_ids: set[str], + ctr: list[int], +) -> None: + """Lower review-admitted operators to factor-graph factors.""" + for op in operators: + fid = _next_fid("op", ctr) + ft = _OPERATOR_MAP[op.operator] + for vid in op.variables: + _ensure_claim_var(fg, vid, priors, claim_ids) + conclusion = op.conclusion + if conclusion not in fg.variables: + if conclusion in expression_helper_ids: + fg.add_variable(conclusion) + elif _operator_asserts_relation(op): + fg.add_variable(conclusion) + fg.add_evidence(conclusion, 1) + else: + fg.add_variable(conclusion, priors.get(conclusion)) + fg.add_factor(fid, ft, op.variables, conclusion) + + +def _lower_graph_strategies( + fg: FactorGraph, + canonical: LocalCanonicalGraph, + *, + strat_by_id: dict[str, Strategy], + priors: dict[str, float], + strat_params: dict[str, list[float]], + metadata_priors: dict[str, float], + expand_formal: bool, + infer_degraded: bool, + ctr: list[int], + claim_ids: set[str], + review_manifest: ReviewManifest | None, +) -> None: + """Lower review-admitted strategies to factor-graph factors.""" + seen_strategies: set[str] = set() + for strategy in canonical.strategies: + if not _review_target_allowed(strategy.strategy_id, strategy.metadata, review_manifest): + continue + _lower_strategy( + fg, + strategy, + strat_by_id, + priors, + strat_params, + metadata_priors, + expand_formal, + infer_degraded, + ctr, + claim_ids, + canonical.namespace, + canonical.package_name, + seen_strategies=seen_strategies, + review_manifest=review_manifest, + ) + + +def lower_local_graph( + canonical: LocalCanonicalGraph, + *, + node_priors: dict[str, float] | None = None, + strategy_conditional_params: dict[str, list[float]] | None = None, + expand_formal: bool = True, + infer_use_degraded_noisy_and: bool = False, + review_manifest: ReviewManifest | None = None, +) -> FactorGraph: + """Build a FactorGraph from a local canonical Gaia IR graph. + + Parameters + ---------- + canonical: + Local graph with knowledges, operators, strategies. + node_priors: + Optional prior P(claim=1) per Knowledge id (claim nodes only). + strategy_conditional_params: + Maps strategy_id -> conditional_probabilities list (infer: 2^k entries, + noisy_and: 1 entry). + expand_formal: + If True, expand FormalStrategy to deterministic factors. If False, + fold is required but only implemented when no internal variables exist. + infer_use_degraded_noisy_and: + If True, lower ``infer`` with CONJUNCTION+SOFT_ENTAILMENT using only + all-true / all-false CPT entries (information loss for general CPT). + review_manifest: + Optional qualitative ReviewManifest. When present, v6 action-backed + strategies/operators are lowered only after their latest review is + accepted. Legacy IR targets without ``metadata.action_label`` are not + gated. + """ + priors = node_priors or {} + no_user_prior_ids, expression_helper_ids = _helper_prior_filter_ids(canonical) + if no_user_prior_ids: + priors = {k: v for k, v in priors.items() if k not in no_user_prior_ids} + metadata_priors = _metadata_priors(canonical, expression_helper_ids) + strat_params = strategy_conditional_params or {} + fg = FactorGraph() + ctr = [0] + + lowerable_operators = _review_allowed_operators(canonical, review_manifest) + lowerable_operators = _dedup_operators( + lowerable_operators, + dedup_audit=fg.dedup_audit, + context="graph_operators", + ) + claim_ids = _add_claim_variables( + fg, + canonical, + priors=priors, + expression_helper_ids=expression_helper_ids, + relation_concl_ids=_relation_conclusion_ids(lowerable_operators), + ) + + strat_by_id = {s.strategy_id: s for s in canonical.strategies if s.strategy_id} + + _lower_operators( + fg, + lowerable_operators, + priors=priors, + claim_ids=claim_ids, + expression_helper_ids=expression_helper_ids, + ctr=ctr, + ) + _lower_graph_strategies( + fg, + canonical, + strat_by_id=strat_by_id, + priors=priors, + strat_params=strat_params, + metadata_priors=metadata_priors, + expand_formal=expand_formal, + infer_degraded=infer_use_degraded_noisy_and, + ctr=ctr, + claim_ids=claim_ids, + review_manifest=review_manifest, + ) + + return fg + + +def _ensure_claim_var( + fg: FactorGraph, vid: str, priors: dict[str, float], claim_ids: set[str] +) -> None: + del claim_ids + if vid in fg.variables: + return + fg.add_variable(vid, priors.get(vid)) + + +def _assert_hard_relation(fg: FactorGraph, var_id: str) -> None: + """Assert a relation/helper claim as strict Class-I evidence.""" + if var_id not in fg.variables: + fg.add_variable(var_id) + fg.unary_factors.pop(var_id, None) + fg.add_evidence(var_id, 1) + + +def _clamp_probability(value: float) -> float: + return max(CROMWELL_EPS, min(1.0 - CROMWELL_EPS, float(value))) + + +def _resolve_associate_marginal( + *, + variable_id: str, + priors: dict[str, float], + metadata_priors: dict[str, float], + strategy_id: str | None, +) -> float | None: + providers: list[tuple[str, float]] = [] + if variable_id in priors: + providers.append(("node_priors", _clamp_probability(priors[variable_id]))) + if variable_id in metadata_priors: + providers.append(("metadata.prior", _clamp_probability(metadata_priors[variable_id]))) + + if not providers: + return None + + first_source, first_value = providers[0] + for source, value in providers[1:]: + if abs(value - first_value) > _ASSOCIATE_TOLERANCE: + raise ValueError( + f"associate strategy {strategy_id}: conflicting marginal providers for " + f"{variable_id!r}: {first_source}={first_value:g}, {source}={value:g}" + ) + return first_value + + +def _associate_pairwise_weights( + s: Strategy, + priors: dict[str, float], + metadata_priors: dict[str, float], +) -> tuple[str, str, float, float, tuple[float, float, float, float]]: + if len(s.premises) != 2: + raise ValueError(f"associate strategy {s.strategy_id}: requires exactly 2 premises") + if s.p_a_given_b is None or s.p_b_given_a is None: + raise ValueError( + f"associate strategy {s.strategy_id}: requires p_a_given_b and p_b_given_a" + ) + + a, b = s.premises + p_a_given_b = _clamp_probability(s.p_a_given_b) + p_b_given_a = _clamp_probability(s.p_b_given_a) + pi_a = _resolve_associate_marginal( + variable_id=a, + priors=priors, + metadata_priors=metadata_priors, + strategy_id=s.strategy_id, + ) + pi_b = _resolve_associate_marginal( + variable_id=b, + priors=priors, + metadata_priors=metadata_priors, + strategy_id=s.strategy_id, + ) + + if pi_a is None and pi_b is None: + raise ValueError( + f"associate strategy {s.strategy_id}: missing marginal prior for {a!r} or {b!r}" + ) + if pi_a is None: + pi_a = pi_b * p_a_given_b / p_b_given_a # type: ignore[operator] + if pi_b is None: + pi_b = pi_a * p_b_given_a / p_a_given_b + if not (0.0 < pi_a < 1.0 and 0.0 < pi_b < 1.0): + raise ValueError( + f"associate strategy {s.strategy_id}: derived marginals must be in (0,1), " + f"got pi_a={pi_a:g}, pi_b={pi_b:g}" + ) + + p11_from_a = p_b_given_a * pi_a + p11_from_b = p_a_given_b * pi_b + if abs(p11_from_a - p11_from_b) > _ASSOCIATE_TOLERANCE: + raise ValueError( + f"associate strategy {s.strategy_id}: Bayes-inconsistent marginals " + f"(p_b_given_a*pi_a={p11_from_a:g}, p_a_given_b*pi_b={p11_from_b:g})" + ) + + p11 = 0.5 * (p11_from_a + p11_from_b) + p01 = pi_b - p11 + p10 = pi_a - p11 + p00 = 1.0 - pi_a - pi_b + p11 + cells = (p00, p10, p01, p11) + if any(cell < -_ASSOCIATE_TOLERANCE for cell in cells): + raise ValueError( + f"associate strategy {s.strategy_id}: conditionals and marginals imply " + f"negative joint cell(s): {cells!r}" + ) + p00, p10, p01, p11 = (max(0.0, cell) for cell in cells) + + weights = ( + p00 / ((1.0 - pi_a) * (1.0 - pi_b)), + p10 / (pi_a * (1.0 - pi_b)), + p01 / ((1.0 - pi_a) * pi_b), + p11 / (pi_a * pi_b), + ) + return a, b, pi_a, pi_b, weights + + +def fold_composite_to_cpt( + s: CompositeStrategy, + strat_by_id: dict[str, Strategy], + strat_params: dict[str, list[float]], + expand_formal: bool = True, +) -> list[float]: + """Compute the effective CPT of a CompositeStrategy via tensor contraction. + + Layer-by-layer variable elimination: each sub-strategy's CPT is computed + recursively (cached by strategy_id), then child CPTs are contracted along + shared bridge variables. Exact, no BP iterations. + + Returns a list of 2^k floats (k = number of premises), indexed by the + binary encoding of the premise assignment (bit 0 = first premise). + """ + from gaia.engine.bp.contraction import StrategyCptCacheValue, cpt_tensor_to_list, strategy_cpt + + if not expand_formal: + raise NotImplementedError( + "fold_composite_to_cpt with expand_formal=False is not supported " + "by the tensor-contraction path. See " + "docs/foundations/gaia-ir/07-lowering.md §9." + ) + + if s.conclusion is None: + raise ValueError(f"CompositeStrategy {s.strategy_id} requires a conclusion for folding.") + + cache: dict[str, StrategyCptCacheValue] = {} + cpt_tensor, axes = strategy_cpt( + s, + strat_by_id=strat_by_id, + strat_params=strat_params, + var_priors={}, + namespace="", + package_name="", + cache=cache, + ) + return cpt_tensor_to_list(cpt_tensor, axes, list(s.premises), s.conclusion) + + +def _mark_strategy_seen(s: Strategy, seen_strategies: set[str] | None) -> bool: + """Return True when strategy lowering should continue after deduping.""" + if seen_strategies is None or not s.strategy_id: + return True + if s.strategy_id in seen_strategies: + return False + seen_strategies.add(s.strategy_id) + return True + + +def _lower_composite_strategy( + fg: FactorGraph, + s: CompositeStrategy, + strat_by_id: dict[str, Strategy], + priors: dict[str, float], + strat_params: dict[str, list[float]], + metadata_priors: dict[str, float], + expand_formal: bool, + infer_degraded: bool, + ctr: list[int], + claim_ids: set[str], + namespace: str, + package_name: str, + *, + seen_strategies: set[str] | None, + review_manifest: ReviewManifest | None, +) -> None: + """Lower sub-strategies referenced by a CompositeStrategy.""" + for sid in s.sub_strategies: + sub = strat_by_id.get(sid) + if sub is None: + raise KeyError(f"CompositeStrategy references missing strategy_id {sid!r}") + _lower_strategy( + fg, + sub, + strat_by_id, + priors, + strat_params, + metadata_priors, + expand_formal, + infer_degraded, + ctr, + claim_ids, + namespace, + package_name, + seen_strategies=seen_strategies, + review_manifest=review_manifest, + ) + + +def _lower_deduction_implication( + fg: FactorGraph, + s: FormalStrategy, + op: Operator, + fid: str, + priors: dict[str, float], + claim_ids: set[str], +) -> None: + """Lower a deduction implication as normalized hard conditional entailment.""" + del s + antecedent = op.variables[0] + consequent = op.variables[1] + _ensure_claim_var(fg, antecedent, priors, claim_ids) + _ensure_claim_var(fg, consequent, priors, claim_ids) + _assert_hard_relation(fg, op.conclusion) + fg.add_factor( + fid, + FactorType.DEDUCTIVE_IMPLICATION, + [antecedent], + consequent, + ) + + +def _lower_formal_operator_default( + fg: FactorGraph, + op: Operator, + fid: str, + priors: dict[str, float], + claim_ids: set[str], +) -> None: + """Lower a formal operator with its direct factor mapping.""" + fg.add_factor(fid, _OPERATOR_MAP[op.operator], op.variables, op.conclusion) + for vid in op.variables: + _ensure_claim_var(fg, vid, priors, claim_ids) + conclusion = op.conclusion + if _operator_asserts_relation(op): + _assert_hard_relation(fg, conclusion) + elif conclusion not in fg.variables: + fg.add_variable(conclusion, priors.get(conclusion)) + + +def _lower_support_implication( + fg: FactorGraph, + op: Operator, + fid: str, + priors: dict[str, float], + metadata_priors: dict[str, float], + claim_ids: set[str], +) -> None: + """Lower a support implication by marginalizing the helper prior.""" + helper_prior = priors.get(op.conclusion, metadata_priors.get(op.conclusion, 1.0 - CROMWELL_EPS)) + p1_eff = helper_prior * (1.0 - CROMWELL_EPS) + (1.0 - helper_prior) * 0.5 + antecedent = op.variables[0] + consequent = op.variables[1] + _ensure_claim_var(fg, antecedent, priors, claim_ids) + _ensure_claim_var(fg, consequent, priors, claim_ids) + fg.add_factor( + fid, + FactorType.SOFT_ENTAILMENT, + [antecedent], + consequent, + p1=p1_eff, + p2=0.5, + ) + fg.variables.pop(op.conclusion, None) + fg.unary_factors.pop(op.conclusion, None) + + +def _lower_formal_strategy( + fg: FactorGraph, + s: FormalStrategy, + priors: dict[str, float], + metadata_priors: dict[str, float], + expand_formal: bool, + ctr: list[int], + claim_ids: set[str], +) -> None: + """Lower a FormalStrategy by expanding each formal operator.""" + if not expand_formal: + raise NotImplementedError( + "FormalStrategy fold (marginalize to CONDITIONAL) is not implemented yet. " + "See docs/foundations/bp/inference.md and docs/foundations/gaia-ir/07-lowering.md §9." + ) + fe_ops = _dedup_operators( + s.formal_expr.operators, + dedup_audit=fg.dedup_audit, + context=f"formal_strategy:{s.strategy_id}", + ) + for index, op in enumerate(fe_ops): + fid = _next_fid(f"fs_{s.strategy_id}_{index}", ctr) + if s.type == StrategyType.DEDUCTION and op.operator == OperatorType.IMPLICATION: + _lower_deduction_implication(fg, s, op, fid, priors, claim_ids) + elif s.type in _SOFT_IMPLICATION_TYPES and op.operator == OperatorType.IMPLICATION: + _lower_support_implication(fg, op, fid, priors, metadata_priors, claim_ids) + else: + _lower_formal_operator_default(fg, op, fid, priors, claim_ids) + + +def _prepare_leaf_strategy_variables( + fg: FactorGraph, + s: Strategy, + priors: dict[str, float], + claim_ids: set[str], +) -> tuple[str, str]: + """Validate and register leaf strategy variables.""" + if s.conclusion is None: + raise ValueError(f"Leaf strategy {s.strategy_id} requires a conclusion for lowering.") + if s.strategy_id is None: + raise ValueError("Strategy requires a strategy_id for lowering.") + _ensure_claim_var(fg, s.conclusion, priors, claim_ids) + for premise_id in s.premises: + _ensure_claim_var(fg, premise_id, priors, claim_ids) + return s.conclusion, s.strategy_id + + +def _lower_infer_strategy( + fg: FactorGraph, + s: Strategy, + conc: str, + strategy_id: str, + strat_params: dict[str, list[float]], + infer_degraded: bool, + ctr: list[int], +) -> None: + """Lower an ``infer`` strategy to conditional or degraded soft-entailment factors.""" + cpt = ( + s.conditional_probabilities + or strat_params.get(strategy_id) + or [0.5] * (1 << len(s.premises)) + ) + if infer_degraded: + _lower_degraded_infer(fg, s, conc, strategy_id, cpt, ctr) + else: + expected = 1 << len(s.premises) + if len(cpt) != expected: + raise ValueError( + f"infer strategy {s.strategy_id}: expected {expected} CPT entries, got {len(cpt)}" + ) + fg.add_factor(_next_fid("infer", ctr), FactorType.CONDITIONAL, s.premises, conc, cpt=cpt) + + +def _lower_degraded_infer( + fg: FactorGraph, + s: Strategy, + conc: str, + strategy_id: str, + cpt: list[float], + ctr: list[int], +) -> None: + """Lower infer with the legacy degraded noisy-and-compatible path.""" + if len(s.premises) == 1: + fg.add_factor( + _next_fid("infer_deg", ctr), + FactorType.SOFT_ENTAILMENT, + [s.premises[0]], + conc, + p1=float(cpt[1]), + p2=1.0 - float(cpt[0]), + ) + return + full = (1 << len(s.premises)) - 1 + m = f"_m_infer_{strategy_id}" + fg.add_variable(m) + fg.add_factor(_next_fid("infer_conj", ctr), FactorType.CONJUNCTION, s.premises, m) + fg.add_factor( + _next_fid("infer_se", ctr), + FactorType.SOFT_ENTAILMENT, + [m], + conc, + p1=float(cpt[full]), + p2=1.0 - float(cpt[0]), + ) + + +def _lower_noisy_and_strategy( + fg: FactorGraph, + s: Strategy, + conc: str, + strategy_id: str, + strat_params: dict[str, list[float]], + ctr: list[int], +) -> None: + """Lower a deprecated noisy-and strategy.""" + raw = s.conditional_probabilities or strat_params.get(strategy_id) or [0.5] + p = float(raw[0]) + premises = list(s.premises) + if len(premises) == 1: + fg.add_factor( + _next_fid("na", ctr), + FactorType.SOFT_ENTAILMENT, + premises, + conc, + p1=p, + p2=1.0 - CROMWELL_EPS, + ) + return + m = f"_m_na_{strategy_id}" + fg.add_variable(m) + fg.add_factor(_next_fid("na_conj", ctr), FactorType.CONJUNCTION, premises, m) + fg.add_factor( + _next_fid("na_se", ctr), + FactorType.SOFT_ENTAILMENT, + [m], + conc, + p1=p, + p2=1.0 - CROMWELL_EPS, + ) + + +def _lower_associate_strategy( + fg: FactorGraph, + s: Strategy, + conc: str, + priors: dict[str, float], + metadata_priors: dict[str, float], + ctr: list[int], +) -> None: + """Lower an associate strategy as a pairwise potential.""" + a, b, pi_a, pi_b, weights = _associate_pairwise_weights(s, priors, metadata_priors) + fg.variables.pop(conc, None) + fg.unary_factors.pop(conc, None) + fg.add_variable(a, pi_a) + fg.add_variable(b, pi_b) + fg.add_factor(_next_fid("assoc", ctr), FactorType.PAIRWISE_POTENTIAL, [a], b, cpt=weights) + + +def _lower_named_formal_leaf( + fg: FactorGraph, + s: Strategy, + conc: str, + strat_by_id: dict[str, Strategy], + priors: dict[str, float], + strat_params: dict[str, list[float]], + metadata_priors: dict[str, float], + expand_formal: bool, + infer_degraded: bool, + ctr: list[int], + claim_ids: set[str], + namespace: str, + package_name: str, + *, + seen_strategies: set[str] | None, + review_manifest: ReviewManifest | None, +) -> None: + """Auto-formalize and lower a named formal leaf strategy.""" + ns = namespace if s.scope == "local" else None + pkg = package_name if s.scope == "local" else None + result = formalize_named_strategy( + scope=s.scope, + type_=s.type, + premises=list(s.premises), + conclusion=conc, + namespace=ns, + package_name=pkg, + background=s.background, + steps=s.steps, + metadata=s.metadata, + ) + for knowledge in result.knowledges: + if knowledge.id and knowledge.metadata and "prior" in knowledge.metadata: + priors[knowledge.id] = float(knowledge.metadata["prior"]) + for knowledge in result.knowledges: + if knowledge.id: + _ensure_claim_var(fg, knowledge.id, priors, claim_ids) + _lower_strategy( + fg, + result.strategy, + strat_by_id, + priors, + strat_params, + metadata_priors, + expand_formal, + infer_degraded, + ctr, + claim_ids, + namespace, + package_name, + seen_strategies=seen_strategies, + review_manifest=review_manifest, + ) + + +def _lower_leaf_strategy( + fg: FactorGraph, + s: Strategy, + strat_by_id: dict[str, Strategy], + priors: dict[str, float], + strat_params: dict[str, list[float]], + metadata_priors: dict[str, float], + expand_formal: bool, + infer_degraded: bool, + ctr: list[int], + claim_ids: set[str], + namespace: str, + package_name: str, + *, + seen_strategies: set[str] | None, + review_manifest: ReviewManifest | None, +) -> None: + """Lower a non-composite, non-formal strategy.""" + conc, strategy_id = _prepare_leaf_strategy_variables(fg, s, priors, claim_ids) + if s.type == StrategyType.INFER: + _lower_infer_strategy(fg, s, conc, strategy_id, strat_params, infer_degraded, ctr) + return + if s.type == StrategyType.NOISY_AND: + _lower_noisy_and_strategy(fg, s, conc, strategy_id, strat_params, ctr) + return + if s.type == StrategyType.ASSOCIATE: + _lower_associate_strategy(fg, s, conc, priors, metadata_priors, ctr) + return + if s.type in _FORMAL_STRATEGY_TYPES: + _lower_named_formal_leaf( + fg, + s, + conc, + strat_by_id, + priors, + strat_params, + metadata_priors, + expand_formal, + infer_degraded, + ctr, + claim_ids, + namespace, + package_name, + seen_strategies=seen_strategies, + review_manifest=review_manifest, + ) + return + raise NotImplementedError( + f"Leaf strategy type {s.type!r} is deferred in Gaia IR core " + "(docs/foundations/gaia-ir/02-gaia-ir.md §3.3). " + "Supply a pre-formalized FormalStrategy, or use infer/noisy_and/associate." + ) + + +def _lower_strategy( + fg: FactorGraph, + s: Strategy, + strat_by_id: dict[str, Strategy], + priors: dict[str, float], + strat_params: dict[str, list[float]], + metadata_priors: dict[str, float] | None, + expand_formal: bool, + infer_degraded: bool, + ctr: list[int], + claim_ids: set[str], + namespace: str, + package_name: str, + seen_strategies: set[str] | None = None, + review_manifest: ReviewManifest | None = None, +) -> None: + if not _review_target_allowed(s.strategy_id, s.metadata, review_manifest): + return + if not _mark_strategy_seen(s, seen_strategies): + return + metadata_priors = metadata_priors or {} + + if isinstance(s, CompositeStrategy): + _lower_composite_strategy( + fg, + s, + strat_by_id, + priors, + strat_params, + metadata_priors, + expand_formal, + infer_degraded, + ctr, + claim_ids, + namespace, + package_name, + seen_strategies=seen_strategies, + review_manifest=review_manifest, + ) + return + + if isinstance(s, FormalStrategy): + _lower_formal_strategy(fg, s, priors, metadata_priors, expand_formal, ctr, claim_ids) + return + + _lower_leaf_strategy( + fg, + s, + strat_by_id, + priors, + strat_params, + metadata_priors, + expand_formal, + infer_degraded, + ctr, + claim_ids, + namespace, + package_name, + seen_strategies=seen_strategies, + review_manifest=review_manifest, + ) + + +def lower_operator(graph: FactorGraph, op: Operator, factor_id: str) -> None: + """Lower a single IR Operator into one factor (public helper for tests).""" + ft = _OPERATOR_MAP[op.operator] + graph.add_factor(factor_id, ft, op.variables, op.conclusion) + + +def merge_factor_graphs( # noqa: C901 + local_fg: FactorGraph, + dep_graphs: list[tuple[str, FactorGraph, str]], + *, + local_prefix: str, +) -> FactorGraph: + """Merge local and dependency factor graphs for joint inference. + + Parameters + ---------- + local_fg: + The local package's factor graph. + dep_graphs: + List of ``(dep_import_name, dep_factor_graph, dep_qid_prefix)`` + triples. ``dep_qid_prefix`` identifies variables owned by that + dependency, e.g. ``"github:dep_pkg::"``. + local_prefix: + QID prefix for the local package, e.g. ``"github:my_pkg::"``. + Variables starting with this prefix are owned by the local package. + + Returns: + A merged :class:`FactorGraph` where shared QIDs map to a single + variable (dep-owned prior takes precedence for dep nodes) and all + factors coexist with prefixed IDs to avoid collision. + """ + merged = FactorGraph() + + def _copy_variable(source: FactorGraph, var_id: str, *, force: bool = False) -> None: + if var_id in source.unary_factors: + prior = source.unary_factors[var_id] + if force or var_id not in merged.unary_factors: + merged.variables[var_id] = prior + merged.unary_factors[var_id] = prior + else: + if force or var_id not in merged.variables: + merged.variables[var_id] = source.variables.get(var_id, 0.5) + merged.unary_factors.pop(var_id, None) + + # 1. Add dep variables first. Owner dep is authoritative; non-owner references + # are placeholders that must not overwrite the owner prior. + for _dep_name, dep_fg, dep_prefix in dep_graphs: + for var_id in dep_fg.variables: + _copy_variable(dep_fg, var_id, force=var_id.startswith(dep_prefix)) + + # 2. Add local variables — overwrite only for locally-owned nodes + for var_id in local_fg.variables: + if var_id.startswith(local_prefix): + # Local owns this node — always use local prior + _copy_variable(local_fg, var_id, force=True) + elif var_id not in merged.variables: + # New variable only seen locally (e.g. intermediate _m_ vars) + _copy_variable(local_fg, var_id) + # else: dep owns it, dep prior already set — skip + + # 3. Copy dep factors with prefixed IDs + for dep_name, dep_fg, _dep_prefix in dep_graphs: + for factor in dep_fg.factors: + prefixed = replace(factor, factor_id=f"dep_{dep_name}_{factor.factor_id}") + merged.factors.append(prefixed) + + # 4. Copy local factors with prefix + for factor in local_fg.factors: + prefixed = replace(factor, factor_id=f"local_{factor.factor_id}") + merged.factors.append(prefixed) + + return merged diff --git a/gaia/engine/bp/mean_field.py b/gaia/engine/bp/mean_field.py new file mode 100644 index 000000000..01cc10b49 --- /dev/null +++ b/gaia/engine/bp/mean_field.py @@ -0,0 +1,280 @@ +"""Mean Field Variational Inference for binary factor graphs. + +Coordinate Ascent Variational Inference (CAVI) for binary variables. + +Theory +------ +Approximate the true posterior p(x) with a fully factored distribution: + q(x) = prod_i q_i(x_i), q_i(x_i=1) = mu_i in [eps, 1-eps] + +Minimise KL(q || p) equivalently maximise the ELBO: + L(q) = E_q[log p(x)] - E_q[log q(x)] + = E_q[log psi(x)] + H(q) + +CAVI update for variable i (holding all others fixed): + log q_i(x_i) ∝ E_{q_{-i}}[log p(x)] + = sum_f E_{q_{-i}}[log psi_f(x)] + +For binary variables with {0,1} potentials (delta-like), the expectation +reduces to a sum over factor assignments weighted by the current q values. + +Hard evidence (Class I): + Variables in graph.hard_evidence are clamped to strict {0, 1}. They are + excluded from the CAVI update loop. + +Convergence: + ELBO is non-decreasing under CAVI (guaranteed). + We stop when max|delta_mu| < threshold. + +Complexity: O(n * F * 2^k) per sweep, where k = max factor arity. +For Gaia's factors (k <= 6), this is O(n * F * 64) -- linear in n. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np + +from gaia.engine.bp.factor_graph import FactorGraph +from gaia.engine.bp.potentials import evaluate_potential + +__all__ = ["MFDiagnostics", "MFResult", "MeanFieldVI"] + +from itertools import product as cartesian_product + +# Cromwell clamp for mean-field parameters +_MF_EPS = 1e-6 + +# 软化零势能,避免 log(0) = -∞ 导致 CAVI 退化 +# 对硬约束图(IMPLICATION 等)MF 仍是近似,但不会崩溃 +_MF_POT_EPS = 1e-10 + + +def _clamp(mu: float) -> float: + return float(np.clip(mu, _MF_EPS, 1.0 - _MF_EPS)) + + +# --------------------------------------------------------------------------- +# ELBO computation +# --------------------------------------------------------------------------- + + +def _compute_elbo( + graph: FactorGraph, + mu: dict[str, float], + _var_to_factors: dict[str, list[int]], +) -> float: + """Compute the Evidence Lower BOund (ELBO). + + L = E_q[log psi(x)] + H(q) + = sum_f E_q[log psi_f(x)] - sum_i [mu_i log mu_i + (1-mu_i) log(1-mu_i)] + + For binary {0,1} potentials, E_q[log psi_f] is computed by summing + over all 2^k assignments weighted by q. + """ + # Expected log-potential term + elbo = 0.0 + for _fi, factor in enumerate(graph.factors): + all_vars = factor.all_vars + for vals in cartesian_product((0, 1), repeat=len(all_vars)): + assignment = dict(zip(all_vars, vals, strict=True)) + pot = evaluate_potential(factor, assignment) + if pot <= 0.0: + continue + log_pot = np.log(max(pot, _MF_POT_EPS)) + # q-weight for this assignment + weight = 1.0 + for v, val in zip(all_vars, vals, strict=True): + if v not in mu: + continue + weight *= mu[v] if val == 1 else (1.0 - mu[v]) + elbo += weight * log_pot + + # Unary factor contribution: E_q[log psi_i(x_i)] = mu_i*log(p_i) + (1-mu_i)*log(1-p_i) + for vid, p in graph.unary_factors.items(): + if vid not in mu: + continue + p_c = _clamp(float(p)) + m = mu[vid] + elbo += m * np.log(p_c) + (1.0 - m) * np.log(1.0 - p_c) + + # Entropy term: H(q) = -sum_i [mu_i log mu_i + (1-mu_i) log(1-mu_i)] + for _vid, m in mu.items(): + m_c = _clamp(m) + elbo -= m_c * np.log(m_c) + (1.0 - m_c) * np.log(1.0 - m_c) + + return float(elbo) + + +# --------------------------------------------------------------------------- +# CAVI update +# --------------------------------------------------------------------------- + + +def _cavi_update( + var: str, + graph: FactorGraph, + mu: dict[str, float], + var_to_factors: dict[str, list[int]], +) -> float: + """Compute the CAVI update for variable var. + + log q(x_i=1) - log q(x_i=0) = sum_f [E_{q_{-i}}[log psi_f | x_i=1] + - E_{q_{-i}}[log psi_f | x_i=0]] + + Returns the new mu_i = sigma(natural_param). + """ + log_ratio = 0.0 # log q(x_i=1) / q(x_i=0) + + # Unary factor (prior): log psi_unary(x_i=1) - log psi_unary(x_i=0) = logit(p_i) + if var in graph.unary_factors: + p = _clamp(graph.unary_factors[var]) + log_ratio += np.log(p) - np.log(1.0 - p) + + for fi in var_to_factors.get(var, []): + factor = graph.factors[fi] + all_vars = factor.all_vars + other_vars = [v for v in all_vars if v != var] + + # For each value of x_i, compute E_{q_{-i}}[log psi_f] + for x_i, sign in ((1, +1.0), (0, -1.0)): + for other_vals in cartesian_product((0, 1), repeat=len(other_vars)): + assignment = dict(zip(other_vars, other_vals, strict=True)) + assignment[var] = x_i + + pot = evaluate_potential(factor, assignment) + # 软化零势能:log(0)=-∞ 会使 CAVI 退化到极端值 + log_pot = np.log(max(pot, _MF_POT_EPS)) + + # q-weight for other variables + weight = 1.0 + for v, val in zip(other_vars, other_vals, strict=True): + if v not in mu: + continue + weight *= mu[v] if val == 1 else (1.0 - mu[v]) + + log_ratio += sign * weight * log_pot + + # sigmoid(log_ratio) = P(x_i=1) under q + # Numerically stable sigmoid + if log_ratio >= 0: + new_mu = 1.0 / (1.0 + np.exp(-log_ratio)) + else: + e = np.exp(log_ratio) + new_mu = e / (1.0 + e) + + return _clamp(float(new_mu)) + + +# --------------------------------------------------------------------------- +# Diagnostics +# --------------------------------------------------------------------------- + + +@dataclass +class MFDiagnostics: + """Diagnostics from a Mean Field VI run.""" + + converged: bool = False + iterations_run: int = 0 + max_change_at_stop: float = 0.0 + elbo_history: list[float] = field(default_factory=list) + belief_history: dict[str, list[float]] = field(default_factory=dict) + + +@dataclass +class MFResult: + """Return value of MeanFieldVI.run().""" + + beliefs: dict[str, float] + diagnostics: MFDiagnostics + + +# --------------------------------------------------------------------------- +# MeanFieldVI +# --------------------------------------------------------------------------- + + +class MeanFieldVI: + """Coordinate Ascent Variational Inference (CAVI) for binary factor graphs. + + Scales to large graphs (n > 2000) where Junction Tree and TRW-BP are + too expensive. Complexity O(n * F * 2^k) per sweep. + + Parameters + ---------- + max_iterations: + Maximum number of full CAVI sweeps. + convergence_threshold: + Stop when max|delta_mu| < threshold. + track_elbo: + If True, compute and record ELBO after each sweep (adds O(F*2^k) cost). + """ + + def __init__( + self, + max_iterations: int = 500, + convergence_threshold: float = 1e-6, + track_elbo: bool = False, + ) -> None: + """Initialize mean field inference state.""" + self._max_iter = max_iterations + self._threshold = convergence_threshold + self._track_elbo = track_elbo + + def run(self, graph: FactorGraph) -> MFResult: + """Run CAVI on graph and return beliefs + diagnostics.""" + diag = MFDiagnostics() + + if not graph.variables: + return MFResult(beliefs={}, diagnostics=diag) + + var_to_factors = graph.get_var_to_factors() + + # Initialise mu: hard_evidence -> strict delta, others -> prior or 0.5 + mu: dict[str, float] = {} + for vid in graph.variables: + if vid in graph.hard_evidence: + mu[vid] = float(graph.hard_evidence[vid]) + elif vid in graph.unary_factors: + mu[vid] = _clamp(graph.unary_factors[vid]) + else: + mu[vid] = 0.5 + + # Soft variables (updated by CAVI) + soft_vars = [v for v in graph.variables if v not in graph.hard_evidence] + + # Seed belief history + for vid in graph.variables: + diag.belief_history[vid] = [mu[vid]] + + if self._track_elbo: + diag.elbo_history.append(_compute_elbo(graph, mu, var_to_factors)) + + max_change = 0.0 + + for iteration in range(self._max_iter): + max_change = 0.0 + + for vid in soft_vars: + old_mu = mu[vid] + mu[vid] = _cavi_update(vid, graph, mu, var_to_factors) + max_change = max(max_change, abs(mu[vid] - old_mu)) + + for vid in graph.variables: + diag.belief_history[vid].append(mu[vid]) + + if self._track_elbo: + diag.elbo_history.append(_compute_elbo(graph, mu, var_to_factors)) + + if max_change < self._threshold: + diag.converged = True + diag.iterations_run = iteration + 1 + diag.max_change_at_stop = max_change + return MFResult(beliefs=dict(mu), diagnostics=diag) + + diag.converged = False + diag.iterations_run = self._max_iter + diag.max_change_at_stop = max_change + return MFResult(beliefs=dict(mu), diagnostics=diag) diff --git a/gaia/bp/potentials.py b/gaia/engine/bp/potentials.py similarity index 53% rename from gaia/bp/potentials.py rename to gaia/engine/bp/potentials.py index fd1e932a1..0838b810a 100644 --- a/gaia/bp/potentials.py +++ b/gaia/engine/bp/potentials.py @@ -1,77 +1,91 @@ -"""Factor potential functions — theory 06-factor-graphs.md + IR infer CPT.""" +"""Factor potential functions — theory 06-factor-graphs.md + IR infer CPT. + +Jaynes class I (logical assertion) potentials are STRICT delta {0, 1}; +Cromwell ε is reserved for class IV (unary soft evidence) only. + +This file exposes _HIGH / _LOW historical aliases for downstream callers that +import them; they are now the strict deterministic values 1.0 / 0.0. +Soft factors (SOFT_ENTAILMENT / CONDITIONAL / PAIRWISE_POTENTIAL) carry +their own author-supplied probabilities and are unaffected. +""" from __future__ import annotations -from gaia.bp.factor_graph import CROMWELL_EPS, Factor, FactorType +from gaia.engine.bp.factor_graph import Factor, FactorType __all__ = [ - "implication_potential", + "complement_potential", + "conditional_potential", "conjunction_potential", + "contradiction_potential", + "deductive_implication_potential", "disjunction_potential", "equivalence_potential", - "contradiction_potential", - "complement_potential", - "soft_entailment_potential", - "conditional_potential", "evaluate_potential", + "implication_potential", + "negation_potential", + "pairwise_potential", + "soft_entailment_potential", ] Assignment = dict[str, int] -_HIGH = 1.0 - CROMWELL_EPS -_LOW = CROMWELL_EPS +_DELTA_HIGH: float = 1.0 +_DELTA_LOW: float = 0.0 + +_HIGH = _DELTA_HIGH +_LOW = _DELTA_LOW def implication_potential( assignment: Assignment, antecedent: str, consequent: str, helper: str ) -> float: - """Ternary implication with helper claim H. - - H=1 (implication holds): standard A=>B — forbid A=1,B=0. - H=0 (implication fails): complement — forbid A=1,B=0 being HIGH. - """ + """Compute potential for implication factor: A → B.""" a, b, h = assignment[antecedent], assignment[consequent], assignment[helper] if h == 1: - # Standard implication: A=1,B=0 forbidden - return _LOW if (a == 1 and b == 0) else _HIGH - else: - # Complement: A=1,B=0 is the only HIGH row - return _HIGH if (a == 1 and b == 0) else _LOW + return _DELTA_LOW if (a == 1 and b == 0) else _DELTA_HIGH + return _DELTA_HIGH if (a == 1 and b == 0) else _DELTA_LOW def conjunction_potential(assignment: Assignment, inputs: list[str], conclusion: str) -> float: - """M = AND(inputs).""" + """Compute potential for conjunction factor: A ∧ B ∧ ....""" all_one = all(assignment[v] == 1 for v in inputs) m = assignment[conclusion] ok = (all_one and m == 1) or ((not all_one) and m == 0) - return _HIGH if ok else _LOW + return _DELTA_HIGH if ok else _DELTA_LOW + + +def negation_potential(assignment: Assignment, a: str, conclusion: str) -> float: + """Compute potential for negation factor: ¬A.""" + target = 0 if assignment[a] == 1 else 1 + return _DELTA_HIGH if assignment[conclusion] == target else _DELTA_LOW def disjunction_potential(assignment: Assignment, inputs: list[str], conclusion: str) -> float: - """D = OR(inputs).""" + """Compute potential for disjunction factor: A ∨ B ∨ ....""" any_one = any(assignment[v] == 1 for v in inputs) d = assignment[conclusion] ok = (any_one and d == 1) or ((not any_one) and d == 0) - return _HIGH if ok else _LOW + return _DELTA_HIGH if ok else _DELTA_LOW def equivalence_potential(assignment: Assignment, a: str, b: str, conclusion: str) -> float: - """Helper = (A == B).""" + """Compute potential for equivalence factor: A ↔ B.""" target = 1 if assignment[a] == assignment[b] else 0 - return _HIGH if assignment[conclusion] == target else _LOW + return _DELTA_HIGH if assignment[conclusion] == target else _DELTA_LOW def contradiction_potential(assignment: Assignment, a: str, b: str, conclusion: str) -> float: - """Helper = NOT(A AND B) as binary: 0 iff both true.""" + """Compute potential for contradiction factor: A ⊕ B (XOR).""" both_one = assignment[a] == 1 and assignment[b] == 1 target = 0 if both_one else 1 - return _HIGH if assignment[conclusion] == target else _LOW + return _DELTA_HIGH if assignment[conclusion] == target else _DELTA_LOW def complement_potential(assignment: Assignment, a: str, b: str, conclusion: str) -> float: - """Helper = (A XOR B).""" + """Compute potential for complement factor: A + B = 1.""" target = 1 if assignment[a] != assignment[b] else 0 - return _HIGH if assignment[conclusion] == target else _LOW + return _DELTA_HIGH if assignment[conclusion] == target else _DELTA_LOW def soft_entailment_potential( @@ -81,7 +95,7 @@ def soft_entailment_potential( p1: float, p2: float, ) -> float: - """Theory §3.7: ψ on (M,C). Rows normalized per row for M.""" + """Compute soft entailment potential with confidence parameter.""" m = assignment[premise] c = assignment[conclusion] if m == 1: @@ -89,13 +103,26 @@ def soft_entailment_potential( return p2 if c == 0 else (1.0 - p2) +def deductive_implication_potential( + assignment: Assignment, + antecedent: str, + consequent: str, +) -> float: + """Compute normalized hard deduction P(B|A) with MaxEnt row for ¬A.""" + a = assignment[antecedent] + b = assignment[consequent] + if a == 1: + return _DELTA_HIGH if b == 1 else _DELTA_LOW + return 0.5 + + def conditional_potential( assignment: Assignment, premises: list[str], conclusion: str, cpt: tuple[float, ...], ) -> float: - """P(C=1|parents) from CPT; idx = binary encoding in premise order.""" + """Compute conditional probability potential P(B|A).""" idx = 0 for i, v in enumerate(premises): if assignment[v] == 1: @@ -104,37 +131,50 @@ def conditional_potential( return p if assignment[conclusion] == 1 else (1.0 - p) -def evaluate_potential(factor: Factor, assignment: Assignment) -> float: +def pairwise_potential( + assignment: Assignment, + a: str, + b: str, + weights: tuple[float, ...], +) -> float: + """Compute pairwise potential between two variables.""" + idx = assignment[a] | (assignment[b] << 1) + return weights[idx] + + +def evaluate_potential(factor: Factor, assignment: Assignment) -> float: # noqa: C901 + """Evaluate potential function for given factor type and variable assignment.""" ft = factor.factor_type v = factor.variables c = factor.conclusion if ft == FactorType.IMPLICATION: return implication_potential(assignment, v[0], v[1], c) - if ft == FactorType.CONJUNCTION: return conjunction_potential(assignment, v, c) - + if ft == FactorType.NEGATION: + return negation_potential(assignment, v[0], c) if ft == FactorType.DISJUNCTION: return disjunction_potential(assignment, v, c) - if ft == FactorType.EQUIVALENCE: return equivalence_potential(assignment, v[0], v[1], c) - if ft == FactorType.CONTRADICTION: return contradiction_potential(assignment, v[0], v[1], c) - if ft == FactorType.COMPLEMENT: return complement_potential(assignment, v[0], v[1], c) - if ft == FactorType.SOFT_ENTAILMENT: if factor.p1 is None or factor.p2 is None: raise ValueError(f"SOFT_ENTAILMENT '{factor.factor_id}' missing p1/p2.") return soft_entailment_potential(assignment, v[0], c, factor.p1, factor.p2) - + if ft == FactorType.DEDUCTIVE_IMPLICATION: + return deductive_implication_potential(assignment, v[0], c) if ft == FactorType.CONDITIONAL: if factor.cpt is None: raise ValueError(f"CONDITIONAL '{factor.factor_id}' missing cpt.") return conditional_potential(assignment, v, c, factor.cpt) + if ft == FactorType.PAIRWISE_POTENTIAL: + if factor.cpt is None: + raise ValueError(f"PAIRWISE_POTENTIAL '{factor.factor_id}' missing cpt.") + return pairwise_potential(assignment, v[0], c, factor.cpt) raise ValueError(f"Unknown FactorType: {ft!r}") diff --git a/gaia/engine/bp/trw_bp.py b/gaia/engine/bp/trw_bp.py new file mode 100644 index 000000000..aa42c9695 --- /dev/null +++ b/gaia/engine/bp/trw_bp.py @@ -0,0 +1,581 @@ +"""Tree-Reweighted Belief Propagation (TRW-BP) — factor-level formulation. + +Reference: Wainwright, Jaakkola & Willsky (2003/2005). + "Tree-reweighted belief propagation algorithms and approximate ML + estimation by pseudo-moment matching." AISTATS 2003. + "A new class of upper bounds on the log partition function." + IEEE Trans. Inf. Theory 51(7), 2005. + +Factor-level TRW for higher-order factor graphs +------------------------------------------------ +Standard TRW is defined for pairwise MRFs. For Gaia's higher-order factor +graph (factors can connect k variables), we use the factor-level extension: + + Each factor f has weight rho_f in (0, 1]. + rho_f = min(1, (n_soft - 1) / F_soft) + where n_soft = non-hard-evidence variables, F_soft = soft factors. + +Message updates: + msg(v->f) = prior(v) * prod_{f'!=f} msg(f'->v) [standard] + msg(f->v)[x_v] ∝ exp(rho_f * log Σ_{x_{-v}} + exp[log psi(x) + Σ_{v'!=v} log msg(v'->f)[x_{v'}]]) [TRW-weighted] + b(v) ∝ prior(v) * prod_f msg(f->v) [standard] + +The rho_f weighting is equivalent to raising the factor potential to the +power rho_f, which shrinks the influence of each factor and prevents the +double-counting that causes loopy BP bias on cyclic graphs. + +Hard evidence (Class I, Jaynes): + Variables in graph.hard_evidence are strict delta-distributions. + Their v->f messages are always [0,1] or [1,0] and bypass damping. + Factors containing only hard-evidence variables get rho_f = 1.0. + +Schedules: + "synchronous" -- standard parallel sweep (default) + +Residual priority-queue BP is kept in this module for experimentation, but the +public constructor currently rejects schedule="residual" because that path is +not stable enough for user-facing inference. +""" + +from __future__ import annotations + +import heapq +from dataclasses import dataclass, field +from itertools import product as cartesian_product +from typing import cast + +import numpy as np +from numpy.typing import NDArray + +from gaia.engine.bp.factor_graph import FactorGraph +from gaia.engine.bp.potentials import evaluate_potential + +__all__ = ["TRWBeliefPropagation", "TRWDiagnostics", "TRWResult"] + +Msg = NDArray[np.float64] + +_LOG_EPS = np.log(1e-300) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _uniform_msg() -> Msg: + return np.array([0.5, 0.5], dtype=np.float64) + + +def _prior_to_msg(pi: float) -> Msg: + return np.array([1.0 - pi, pi], dtype=np.float64) + + +def _evidence_to_msg(value: int) -> Msg: + return np.array([1.0, 0.0], dtype=np.float64) if value == 0 else np.array([0.0, 1.0]) + + +def _normalize(msg: Msg) -> Msg: + s = float(msg[0] + msg[1]) + if s < 1e-300: # pragma: no cover + raise RuntimeError( + "TRW-BP: zero-sum message -- factor graph has no valid assignment. " + "Check Cromwell constraints and hard_evidence consistency." + ) + return msg / s + + +def _safe_log(msg: Msg) -> Msg: + return np.log(np.maximum(msg, 1e-300)) # type: ignore[no-any-return] + + +def _log_normalize(log_msg: Msg) -> Msg: + log_msg = log_msg - log_msg.max() + msg = np.exp(log_msg) + return msg / msg.sum() # type: ignore[no-any-return] + + +# --------------------------------------------------------------------------- +# Factor weights +# --------------------------------------------------------------------------- + + +def _compute_factor_weights( + graph: FactorGraph, + _var_to_factors: dict[str, list[int]], +) -> dict[int, float]: + """Compute TRW factor appearance probabilities rho_f. + + Uses the uniform hypertree distribution: + rho_f = min(1, (n_soft - 1) / F_soft) + where n_soft = non-hard-evidence variables, + F_soft = factors with at least one soft variable. + + Hard-evidence-only factors get rho_f = 1.0. + On trees (F_soft = n_soft - 1) all rho_f = 1 and TRW = exact BP. + """ + hard = set(graph.hard_evidence.keys()) + n_soft = sum(1 for v in graph.variables if v not in hard) + + soft_factor_ids = [ + fi + for fi, factor in enumerate(graph.factors) + if any(v not in hard for v in factor.all_vars if v in graph.variables) + ] + F_soft = len(soft_factor_ids) + + rho_soft = 1.0 if F_soft == 0 or n_soft <= 1 else min(1.0, (n_soft - 1) / F_soft) + + weights: dict[int, float] = {} + for fi in range(len(graph.factors)): + factor = graph.factors[fi] + all_hard = all(v in hard for v in factor.all_vars if v in graph.variables) + weights[fi] = 1.0 if all_hard else rho_soft + + return weights + + +# --------------------------------------------------------------------------- +# Message computations +# --------------------------------------------------------------------------- + + +def _compute_v2f_trw( + var: str, + factor_idx: int, + prior_msg: Msg, + var_to_factors: dict[str, list[int]], + f2v_msgs: dict[tuple[int, str], Msg], + _rho: dict[int, float], +) -> Msg: + """Variable->factor message (standard sum-product, no rho in v->f). + + In factor-level TRW the v->f message is the standard form: + msg(v->f) = prior(v) * prod_{f'!=f} msg(f'->v) + The rho weighting only enters in the f->v direction. + """ + log_msg = _safe_log(prior_msg) + for fi in var_to_factors[var]: + if fi == factor_idx: + continue + incoming = f2v_msgs.get((fi, var)) + if incoming is not None: + log_msg = log_msg + _safe_log(incoming) + return _log_normalize(log_msg) + + +def _compute_f2v_trw( # type: ignore[no-untyped-def] + factor_idx: int, + target_var: str, + factor, + v2f_msgs: dict[tuple[str, int], Msg], + rho: dict[int, float], +) -> Msg: + """Factor->variable message with factor-level TRW reweighting. + + log msg(f->v)[x_v] ∝ rho_f * log Σ_{x_{-v}} + exp[ log psi(x) + Σ_{v'!=v} log msg(v'->f)[x_{v'}] ] + + Raising the log-sum-exp by rho_f is equivalent to raising the factor + potential to the power rho_f, reducing double-counting on cyclic graphs. + """ + all_vars = factor.all_vars + other_vars = [v for v in all_vars if v != target_var] + rho_f = rho.get(factor_idx, 1.0) + + log_msg_out = np.zeros(2, dtype=np.float64) + + for target_val in (0, 1): + log_terms = [] + for other_vals in cartesian_product((0, 1), repeat=len(other_vars)): + assignment: dict[str, int] = dict(zip(other_vars, other_vals, strict=True)) + assignment[target_var] = target_val + + pot = evaluate_potential(factor, assignment) + if pot <= 0.0: + continue + + log_term = np.log(pot) + for v, val in zip(other_vars, other_vals, strict=True): + v2f = v2f_msgs.get((v, factor_idx)) + if v2f is not None: + log_term += float(_safe_log(v2f)[val]) + + log_terms.append(log_term) + + if log_terms: + max_lt = max(log_terms) + log_msg_out[target_val] = rho_f * ( + max_lt + np.log(sum(np.exp(lt - max_lt) for lt in log_terms)) + ) + else: # pragma: no cover + log_msg_out[target_val] = _LOG_EPS + + return _log_normalize(log_msg_out) + + +def _compute_beliefs_trw( + graph: FactorGraph, + priors: dict[str, Msg], + var_to_factors: dict[str, list[int]], + f2v_msgs: dict[tuple[int, str], Msg], + _rho: dict[int, float], +) -> dict[str, float]: + """Compute beliefs: b(v) ∝ prior(v) * prod_f msg(f->v). + + The rho weighting is baked into f->v messages, so beliefs use the + standard sum-product formula. + """ + beliefs: dict[str, float] = {} + for vid in graph.variables: + log_b = _safe_log(priors[vid]) + for fi in var_to_factors[vid]: + incoming = f2v_msgs.get((fi, vid)) + if incoming is not None: + log_b = log_b + _safe_log(incoming) + b = _log_normalize(log_b) + beliefs[vid] = float(b[1]) + return beliefs + + +# --------------------------------------------------------------------------- +# Diagnostics +# --------------------------------------------------------------------------- + + +@dataclass +class TRWDiagnostics: + """Diagnostics from a TRW-BP run.""" + + converged: bool = False + iterations_run: int = 0 + max_change_at_stop: float = 0.0 + belief_history: dict[str, list[float]] = field(default_factory=dict) + direction_changes: dict[str, int] = field(default_factory=dict) + rho: float = 1.0 # factor weight used (uniform scheme) + treewidth: int | None = None # junction tree width (JT only) + + def compute_direction_changes(self) -> None: + """Compute direction changes in belief updates for oscillation detection.""" + for vid, history in self.belief_history.items(): + changes = 0 + for k in range(2, len(history)): + d_prev = history[k - 1] - history[k - 2] + d_curr = history[k] - history[k - 1] + if d_prev * d_curr < 0: + changes += 1 + self.direction_changes[vid] = changes + + def belief_table(self, variables: list[str] | None = None) -> str: + """返回 belief history 的格式化表格。.""" + vids = variables if variables is not None else sorted(self.belief_history) + if not vids: + return "(no belief history)" + max_iters = max(len(self.belief_history[v]) for v in vids) + header = "Variable".ljust(30) + "".join(f" iter{i:3d}" for i in range(max_iters)) + lines = [header, "-" * len(header)] + for vid in vids: + row = f"{vid:30s}" + for b in self.belief_history[vid]: + row += f" {b:6.4f}" + lines.append(row) + return "\n".join(lines) + + +@dataclass +class TRWResult: + """Return value of TRWBeliefPropagation.run().""" + + beliefs: dict[str, float] + diagnostics: TRWDiagnostics + + +# --------------------------------------------------------------------------- +# TRWBeliefPropagation +# --------------------------------------------------------------------------- + + +class TRWBeliefPropagation: + """Tree-Reweighted Belief Propagation (Wainwright et al. 2003/2005). + + Replaces loopy BP as the default approximate inference algorithm. + Uses factor-level reweighting for higher-order factor graphs. + + Parameters + ---------- + damping: + Message mixing coefficient alpha in (0, 1]. Default 0.5. + max_iterations: + Maximum number of full sweeps. + convergence_threshold: + Stop when max|delta_belief| < threshold. + schedule: + "synchronous" -- standard parallel sweep (default). + "residual" -- currently rejected; residual TRW-BP is not yet stable. + """ + + def __init__( + self, + damping: float = 0.5, + max_iterations: int = 200, + convergence_threshold: float = 1e-6, + schedule: str = "synchronous", + ) -> None: + """Initialize TRW-BP oscillation diagnostic state.""" + if not (0.0 < damping <= 1.0): + raise ValueError(f"damping must be in (0, 1], got {damping}") + if schedule not in ("synchronous",): + raise ValueError( # pragma: no cover + f"schedule must be 'synchronous', got {schedule!r}. " + f"Residual schedule for TRW-BP is not yet stable." + ) + self._damping = damping + self._max_iter = max_iterations + self._threshold = convergence_threshold + self._schedule = schedule + + def run(self, graph: FactorGraph) -> TRWResult: # noqa: C901 + """Run TRW-BP on graph and return beliefs + diagnostics.""" + diag = TRWDiagnostics() + + if not graph.variables: + diag.converged = True + return TRWResult(beliefs={}, diagnostics=diag) + + if not graph.factors: + beliefs = {} + for vid in graph.variables: + if vid in graph.hard_evidence: + beliefs[vid] = float(graph.hard_evidence[vid]) + else: + beliefs[vid] = graph.unary_factors.get(vid, 0.5) + for vid, b in beliefs.items(): + diag.belief_history[vid] = [b] + return TRWResult(beliefs=beliefs, diagnostics=diag) + + var_to_factors = graph.get_var_to_factors() + rho = _compute_factor_weights(graph, var_to_factors) + if rho: + diag.rho = ( + next(v for v in rho.values() if v < 1.0) + if any(v < 1.0 for v in rho.values()) + else 1.0 + ) + + def _prior_for(vid: str) -> Msg: + if vid in graph.hard_evidence: + return _evidence_to_msg(graph.hard_evidence[vid]) + if vid in graph.unary_factors: + return _prior_to_msg(graph.unary_factors[vid]) + return _uniform_msg() + + priors: dict[str, Msg] = {vid: _prior_for(vid) for vid in graph.variables} + + f2v_msgs: dict[tuple[int, str], Msg] = {} + v2f_msgs: dict[tuple[str, int], Msg] = {} + for fi, factor in enumerate(graph.factors): + for vid in factor.all_vars: + if vid in graph.variables: + f2v_msgs[(fi, vid)] = _uniform_msg() + v2f_msgs[(vid, fi)] = _uniform_msg() + + prev_beliefs: dict[str, float] = {} + for vid in graph.variables: + if vid in graph.hard_evidence: + pi = float(graph.hard_evidence[vid]) + else: + pi = graph.unary_factors.get(vid, 0.5) + prev_beliefs[vid] = pi + diag.belief_history[vid] = [pi] + + if self._schedule == "synchronous": + return self._run_synchronous( + graph, diag, priors, var_to_factors, f2v_msgs, v2f_msgs, prev_beliefs, rho + ) + return self._run_residual( + graph, diag, priors, var_to_factors, f2v_msgs, v2f_msgs, prev_beliefs, rho + ) + + def _run_synchronous( + self, + graph: FactorGraph, + diag: TRWDiagnostics, + priors: dict[str, Msg], + var_to_factors: dict[str, list[int]], + f2v_msgs: dict[tuple[int, str], Msg], + v2f_msgs: dict[tuple[str, int], Msg], + prev_beliefs: dict[str, float], + rho: dict[int, float], + ) -> TRWResult: + max_change = 0.0 + + for iteration in range(self._max_iter): + # v2f messages + new_v2f: dict[tuple[str, int], Msg] = {} + for vid, fi in v2f_msgs: + if vid in graph.hard_evidence: + new_v2f[(vid, fi)] = _evidence_to_msg(graph.hard_evidence[vid]) + else: + new_v2f[(vid, fi)] = _compute_v2f_trw( + vid, fi, priors[vid], var_to_factors, f2v_msgs, rho + ) + + # f2v messages (use fresh v2f) + new_f2v: dict[tuple[int, str], Msg] = {} + for fi, vid in f2v_msgs: + factor = graph.factors[fi] + new_f2v[(fi, vid)] = _compute_f2v_trw(fi, vid, factor, new_v2f, rho) + + # Damp + for fkey in f2v_msgs: + blended = self._damping * new_f2v[fkey] + (1.0 - self._damping) * f2v_msgs[fkey] + f2v_msgs[fkey] = _normalize(blended) + + for vkey in v2f_msgs: + vid = vkey[0] + if vid in graph.hard_evidence: + v2f_msgs[vkey] = new_v2f[vkey] + else: + blended = self._damping * new_v2f[vkey] + (1.0 - self._damping) * v2f_msgs[vkey] + v2f_msgs[vkey] = _normalize(blended) + + # Beliefs + convergence + beliefs = _compute_beliefs_trw(graph, priors, var_to_factors, f2v_msgs, rho) + for vid in beliefs: + diag.belief_history[vid].append(beliefs[vid]) + + max_change = max(abs(beliefs[vid] - prev_beliefs[vid]) for vid in beliefs) + prev_beliefs = beliefs + + if max_change < self._threshold: + diag.converged = True + diag.iterations_run = iteration + 1 + diag.max_change_at_stop = max_change + diag.compute_direction_changes() + return TRWResult(beliefs=beliefs, diagnostics=diag) + + diag.converged = False + diag.iterations_run = self._max_iter + diag.max_change_at_stop = max_change + diag.compute_direction_changes() + return TRWResult(beliefs=prev_beliefs, diagnostics=diag) + + def _run_residual( # noqa: C901 + self, + graph: FactorGraph, + diag: TRWDiagnostics, + priors: dict[str, Msg], + var_to_factors: dict[str, list[int]], + f2v_msgs: dict[tuple[int, str], Msg], + v2f_msgs: dict[tuple[str, int], Msg], + prev_beliefs: dict[str, float], + rho: dict[int, float], + ) -> TRWResult: + heap: list[tuple[float, str, tuple[int, str] | tuple[str, int]]] = [] + + # Bootstrap sweep + new_v2f_init: dict[tuple[str, int], Msg] = {} + for vid, fi in v2f_msgs: + if vid in graph.hard_evidence: + new_v2f_init[(vid, fi)] = _evidence_to_msg(graph.hard_evidence[vid]) + else: + new_v2f_init[(vid, fi)] = _compute_v2f_trw( + vid, fi, priors[vid], var_to_factors, f2v_msgs, rho + ) + + new_f2v_init: dict[tuple[int, str], Msg] = {} + for fi, vid in f2v_msgs: + factor = graph.factors[fi] + new_f2v_init[(fi, vid)] = _compute_f2v_trw(fi, vid, factor, new_v2f_init, rho) + + for vkey in list(v2f_msgs.keys()): + vid = vkey[0] + if vid in graph.hard_evidence: + v2f_msgs[vkey] = new_v2f_init[vkey] + heapq.heappush(heap, (-1.0, "v2f", vkey)) + else: + old_msg = v2f_msgs[vkey] + blended = self._damping * new_v2f_init[vkey] + (1.0 - self._damping) * old_msg + v2f_msgs[vkey] = _normalize(blended) + residual = float(np.abs(v2f_msgs[vkey] - old_msg).max()) + heapq.heappush(heap, (-max(residual, 1e-10), "v2f", vkey)) + + for fkey in list(f2v_msgs.keys()): + old_msg = f2v_msgs[fkey] + blended = self._damping * new_f2v_init[fkey] + (1.0 - self._damping) * old_msg + f2v_msgs[fkey] = _normalize(blended) + residual = float(np.abs(f2v_msgs[fkey] - old_msg).max()) + heapq.heappush(heap, (-max(residual, 1e-10), "f2v", fkey)) + + total_updates = 0 + check_interval = max(1, len(f2v_msgs) + len(v2f_msgs)) + max_updates = self._max_iter * check_interval + max_change = 0.0 + + # Update prev_beliefs after bootstrap so first check_interval comparison is valid + prev_beliefs = _compute_beliefs_trw(graph, priors, var_to_factors, f2v_msgs, rho) + for vid in prev_beliefs: + diag.belief_history[vid].append(prev_beliefs[vid]) + + while total_updates < max_updates and heap: + neg_residual, msg_type, key = heapq.heappop(heap) + residual = -neg_residual + + if residual < self._threshold: + diag.converged = True + break + + if msg_type == "f2v": + fkey = cast(tuple[int, str], key) + fi, vid = fkey + factor = graph.factors[fi] + new_msg = _compute_f2v_trw(fi, vid, factor, v2f_msgs, rho) + old_msg = f2v_msgs[fkey] + blended = self._damping * new_msg + (1.0 - self._damping) * old_msg + f2v_msgs[fkey] = _normalize(blended) + new_residual = float(np.abs(f2v_msgs[fkey] - old_msg).max()) + for fi2 in var_to_factors[vid]: + affected = (vid, fi2) + if affected in v2f_msgs and vid not in graph.hard_evidence: + heapq.heappush(heap, (-max(new_residual, 1e-10), "v2f", affected)) + else: + vkey = cast(tuple[str, int], key) + vid, fi = vkey + if vid in graph.hard_evidence: + total_updates += 1 + continue + new_msg = _compute_v2f_trw(vid, fi, priors[vid], var_to_factors, f2v_msgs, rho) + old_msg = v2f_msgs[vkey] + blended = self._damping * new_msg + (1.0 - self._damping) * old_msg + v2f_msgs[vkey] = _normalize(blended) + new_residual = float(np.abs(v2f_msgs[vkey] - old_msg).max()) + factor = graph.factors[fi] + for v in factor.all_vars: + if v in graph.variables: + affected = (fi, v) # type: ignore[assignment] + if affected in f2v_msgs: # type: ignore[comparison-overlap] + heapq.heappush(heap, (-max(new_residual, 1e-10), "f2v", affected)) + + total_updates += 1 + + if total_updates % check_interval == 0: + beliefs = _compute_beliefs_trw(graph, priors, var_to_factors, f2v_msgs, rho) + max_change = max(abs(beliefs[vid] - prev_beliefs[vid]) for vid in beliefs) + for vid in beliefs: + diag.belief_history[vid].append(beliefs[vid]) + prev_beliefs = beliefs + if max_change < self._threshold: + diag.converged = True + break + + if not diag.converged: + # pragma: no cover + beliefs = _compute_beliefs_trw(graph, priors, var_to_factors, f2v_msgs, rho) + max_change = max(abs(beliefs[vid] - prev_beliefs[vid]) for vid in beliefs) + else: + beliefs = prev_beliefs + + diag.iterations_run = total_updates // check_interval + diag.max_change_at_stop = max_change + diag.compute_direction_changes() + return TRWResult(beliefs=beliefs, diagnostics=diag) diff --git a/gaia/inquiry/__init__.py b/gaia/engine/inquiry/__init__.py similarity index 63% rename from gaia/inquiry/__init__.py rename to gaia/engine/inquiry/__init__.py index 733840bca..aed73bdbe 100644 --- a/gaia/inquiry/__init__.py +++ b/gaia/engine/inquiry/__init__.py @@ -1,11 +1,17 @@ -"""gaia.inquiry — spec §10 public surface. +"""gaia.engine.inquiry — spec §10 public surface. Thin wrapper over Gaia. This module does not run its own compiler, validator, or inference engine; it composes the ones already in Gaia. """ -from gaia.inquiry.anchor import SourceAnchor, find_anchors -from gaia.inquiry.diagnostics import ( +from gaia.engine.inquiry.anchor import SourceAnchor, find_anchors +from gaia.engine.inquiry.check_core import ( + HoleEntry, + KnowledgeBreakdown, + analyze_knowledge_breakdown, + find_possible_duplicate_claims, +) +from gaia.engine.inquiry.diagnostics import ( Diagnostic, NextEdit, format_diagnostics_as_next_edits, @@ -13,18 +19,19 @@ from_knowledge_breakdown, from_validation, ) -from gaia.inquiry.diff import ClaimDelta, SemanticDiff, empty_diff -from gaia.inquiry.focus import FocusBinding, resolve_focus_target -from gaia.inquiry.proof_state import ( +from gaia.engine.inquiry.diff import ClaimDelta, SemanticDiff, empty_diff +from gaia.engine.inquiry.focus import FocusBinding, resolve_focus_target +from gaia.engine.inquiry.proof_state import ( HypothesisView, ObligationView, ProofContext, RejectionView, build_proof_context, ) -from gaia.inquiry.render import render_json, render_markdown, to_json_dict -from gaia.inquiry.review import ReviewReport, render_text, resolve_graph, run_review -from gaia.inquiry.state import ( +from gaia.engine.inquiry.render import render_json, render_markdown, to_json_dict +from gaia.engine.inquiry.review import ReviewReport, render_text, resolve_graph, run_review +from gaia.engine.inquiry.review_manifest import load_or_generate_review_manifest +from gaia.engine.inquiry.state import ( STATE_SCHEMA_VERSION, VALID_MODES, VALID_OBLIGATION_KINDS, @@ -49,8 +56,10 @@ "ClaimDelta", "Diagnostic", "FocusBinding", + "HoleEntry", "HypothesisView", "InquiryState", + "KnowledgeBreakdown", "NextEdit", "ObligationView", "ProofContext", @@ -61,26 +70,29 @@ "SyntheticHypothesis", "SyntheticObligation", "SyntheticRejection", + "analyze_knowledge_breakdown", "append_tactic_event", "build_proof_context", "empty_diff", "find_anchors", + "find_possible_duplicate_claims", "format_diagnostics_as_next_edits", "format_diagnostics_as_structured_edits", "from_knowledge_breakdown", "from_validation", "inquiry_dir", - "render_json", - "render_markdown", + "load_or_generate_review_manifest", "load_state", "mint_qid", "pop_focus_frame", "push_focus_frame", "read_tactic_log", + "render_json", + "render_markdown", "render_text", - "to_json_dict", "resolve_focus_target", "resolve_graph", "run_review", "save_state", + "to_json_dict", ] diff --git a/gaia/cli/commands/_classify.py b/gaia/engine/inquiry/_classify.py similarity index 50% rename from gaia/cli/commands/_classify.py rename to gaia/engine/inquiry/_classify.py index 109abf2e6..fc9030d6e 100644 --- a/gaia/cli/commands/_classify.py +++ b/gaia/engine/inquiry/_classify.py @@ -3,6 +3,9 @@ from __future__ import annotations from dataclasses import dataclass, field +from typing import Any + +NOTE_TYPES = frozenset({"note", "setting", "context"}) @dataclass @@ -14,11 +17,26 @@ class KnowledgeClassification: strategy_background: set[str] = field(default_factory=set) operator_conclusions: set[str] = field(default_factory=set) operator_variables: set[str] = field(default_factory=set) + decomposition_wholes: set[str] = field(default_factory=set) -def classify_ir(ir: dict) -> KnowledgeClassification: +def classify_ir(ir: dict[str, Any]) -> KnowledgeClassification: """Classify knowledge nodes by their role in the reasoning graph.""" c = KnowledgeClassification() + + def add_operator_roles(operators: list[dict[str, Any]]) -> None: + for o in operators: + metadata = o.get("metadata") or {} + decomposition = metadata.get("decomposition") + if isinstance(decomposition, dict): + whole = decomposition.get("whole") + if isinstance(whole, str) and whole: + c.decomposition_wholes.add(whole) + if o.get("conclusion"): + c.operator_conclusions.add(o["conclusion"]) + for v in o.get("variables", []): + c.operator_variables.add(v) + for s in ir.get("strategies", []): if s.get("conclusion"): c.strategy_conclusions.add(s["conclusion"]) @@ -26,21 +44,29 @@ def classify_ir(ir: dict) -> KnowledgeClassification: c.strategy_premises.add(p) for b in s.get("background", []): c.strategy_background.add(b) - for o in ir.get("operators", []): - if o.get("conclusion"): - c.operator_conclusions.add(o["conclusion"]) - for v in o.get("variables", []): - c.operator_variables.add(v) + formal_expr = s.get("formal_expr") or {} + add_operator_roles(formal_expr.get("operators", [])) + add_operator_roles(ir.get("operators", [])) return c +def is_note_type(ktype: str) -> bool: + """Return True for v6 notes and legacy non-probabilistic context nodes.""" + return ktype in NOTE_TYPES + + def node_role(kid: str, ktype: str, c: KnowledgeClassification) -> str: - """Return the role of a knowledge node: setting, question, derived, structural, - independent, background, or orphaned.""" - if ktype == "setting": - return "setting" + """Return the role of a knowledge node. + + Roles are note, question, derived, structural, independent, background, + or orphaned. + """ + if is_note_type(ktype): + return "note" if ktype == "question": return "question" + if kid in c.decomposition_wholes: + return "structural" if kid in c.operator_conclusions: return "structural" if kid in c.strategy_conclusions: diff --git a/gaia/inquiry/anchor.py b/gaia/engine/inquiry/anchor.py similarity index 75% rename from gaia/inquiry/anchor.py rename to gaia/engine/inquiry/anchor.py index d335b580d..3e3f9b623 100644 --- a/gaia/inquiry/anchor.py +++ b/gaia/engine/inquiry/anchor.py @@ -1,9 +1,9 @@ -"""Source anchor —— 把 IR 里的 label 反向解析回源代码 (file, line)。 +"""Map IR labels back to package source locations. Gaia 的 Knowledge 只记录 ``module`` 名字,不记录行号。Inquiry 自己用 -``ast`` 扫描 package 源代码,定位 `` = claim(...) / setting(...) / -question(...) / support(...) / operator(...)`` 形式的顶层赋值,将变量名 (或 -显式 ``label="..."`` 关键字) 映射到 (文件路径, 行号, 列号)。 +``ast`` 扫描 package 源代码,定位 `` = claim(...) / derive(...) / +deduction(...)`` 等 DSL 顶层调用,将变量名 (或显式 ``label="..."`` 关键字) +映射到 (文件路径, 行号, 列号)。 只读:仅读取 .py 文件,不解析模块、不导入、不修改任何东西。 """ @@ -13,32 +13,62 @@ import ast from dataclasses import dataclass from pathlib import Path +from typing import Any # Gaia DSL 顶层构造器;这些调用的赋值目标即为该节点 label 的默认值。 _DSL_CALLABLES = { + "abduction", + "analogy", + "associate", + "case_analysis", "claim", - "setting", + "compare", + "composite", + "compose", + "composition", + "complement", + "contradict", + "contradiction", + "context", + "compute", + "deduction", + "depends_on", + "derive", + "disjunction", + "equal", + "elimination", + "equivalence", + "exclusive", + "extrapolation", + "fills", + "induction", + "infer", + "mathematical_induction", + "note", + "noisy_and", + "observe", + "operator", "question", + "setting", "support", - "operator", - "noisy_and", } @dataclass(frozen=True) class SourceAnchor: - """指向 package 内某个 DSL 声明的源位置。""" + """Source location for a DSL declaration inside a package.""" file: str # 相对 package_root 的 POSIX 风格路径 line: int # 1-based column: int # 0-based, 与 ast.AST.col_offset 一致 - def to_dict(self) -> dict: + def to_dict(self) -> dict[str, Any]: + """Return the JSON-compatible anchor payload.""" return {"file": self.file, "line": self.line, "column": self.column} def _label_from_call(node: ast.Call) -> str | None: - """优先用 ``label="..."`` 关键字, 缺省时由调用者用变量名兜底。""" + """Return an explicit ``label=`` value, if the DSL call has one.""" for kw in node.keywords: if ( kw.arg == "label" @@ -77,6 +107,9 @@ def _scan_module(py_file: Path, rel_file: str) -> dict[str, SourceAnchor]: elif isinstance(stmt, ast.AnnAssign) and stmt.value is not None: targets = [stmt.target] value = stmt.value + elif isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call): + targets = [] + value = stmt.value else: continue if not isinstance(value, ast.Call): @@ -95,7 +128,7 @@ def _scan_module(py_file: Path, rel_file: str) -> dict[str, SourceAnchor]: def find_anchors(pkg_path: str | Path) -> dict[str, SourceAnchor]: - """扫描 package 下所有 .py, 返回 label → SourceAnchor 映射。 + """Scan package Python files and return a label-to-anchor map. 重复 label 取首次出现; 排除 .gaia/ 与隐藏目录。 """ diff --git a/gaia/cli/commands/check_core.py b/gaia/engine/inquiry/check_core.py similarity index 83% rename from gaia/cli/commands/check_core.py rename to gaia/engine/inquiry/check_core.py index cf5b5518e..3f1062608 100644 --- a/gaia/cli/commands/check_core.py +++ b/gaia/engine/inquiry/check_core.py @@ -1,7 +1,7 @@ -"""Structured analyzers shared by `gaia check` and `gaia inquiry review`. +"""Structured analyzers shared by `gaia build check` and `gaia inquiry review`. This module is the single source of truth for graph-health / prior-hole / -knowledge-breakdown analysis on a compiled Gaia IR. `gaia check` keeps +knowledge-breakdown analysis on a compiled Gaia IR. `gaia build check` keeps emitting human-readable lines via the wrappers in ``check.py``; `gaia inquiry` consumes the structured dataclasses below directly. @@ -11,11 +11,12 @@ from __future__ import annotations from dataclasses import dataclass, field +from typing import Any -from gaia.cli.commands._classify import KnowledgeClassification, classify_ir, node_role +from gaia.engine.inquiry._classify import KnowledgeClassification, classify_ir, node_role -def get_prior(k: dict) -> float | None: +def get_prior(k: dict[str, Any]) -> float | None: """Return the prior stored in a knowledge node's metadata, or None.""" meta = k.get("metadata") or {} return meta.get("prior") @@ -33,6 +34,7 @@ class HoleEntry: @property def is_hole(self) -> bool: + """Return whether this independent claim is missing a prior.""" return self.prior is None @@ -51,14 +53,16 @@ class KnowledgeBreakdown: @property def holes(self) -> list[HoleEntry]: + """Return independent claims that still need priors.""" return [e for e in self.independent if e.is_hole] @property def covered(self) -> list[HoleEntry]: + """Return independent claims that already have priors.""" return [e for e in self.independent if not e.is_hole] -def analyze_knowledge_breakdown(ir: dict) -> KnowledgeBreakdown: +def analyze_knowledge_breakdown(ir: dict[str, Any]) -> KnowledgeBreakdown: """Walk the IR once and classify every knowledge node by structural role.""" c = classify_ir(ir) out = KnowledgeBreakdown(classification=c) @@ -66,7 +70,7 @@ def analyze_knowledge_breakdown(ir: dict) -> KnowledgeBreakdown: for k in ir.get("knowledges", []): ktype = k.get("type") kid = k["id"] - label = k.get("label", kid.split("::")[-1]) + label = k.get("label") or kid.split("::")[-1] if ktype == "setting": out.settings.append(label) continue @@ -98,7 +102,7 @@ def analyze_knowledge_breakdown(ir: dict) -> KnowledgeBreakdown: return out -def find_possible_duplicate_claims(ir: dict) -> list[tuple[str, str]]: +def find_possible_duplicate_claims(ir: dict[str, Any]) -> list[tuple[str, str]]: """Heuristic: pairs of claims with identical normalized content. Conservative — only exact-match after whitespace collapse. Per spec §8 diff --git a/gaia/inquiry/diagnostics.py b/gaia/engine/inquiry/diagnostics.py similarity index 64% rename from gaia/inquiry/diagnostics.py rename to gaia/engine/inquiry/diagnostics.py index 19c207ee1..31ac296bb 100644 --- a/gaia/inquiry/diagnostics.py +++ b/gaia/engine/inquiry/diagnostics.py @@ -1,7 +1,7 @@ """Spec §15 — Diagnostic layer over Gaia's existing detectors. Inquiry does NOT run its own graph analysis. It translates the outputs of -``gaia.ir.validator.validate_local_graph`` and +``gaia.engine.ir.validator.validate_local_graph`` and ``gaia.cli.commands.check_core.analyze_knowledge_breakdown`` into a uniform ``Diagnostic`` stream, which drives the `graph_health`, `prior_holes`, and `next_edits` sections of the review report. @@ -9,16 +9,19 @@ from __future__ import annotations +from collections import defaultdict, deque from dataclasses import asdict, dataclass, field -from typing import Literal +from pathlib import Path +from typing import Any, Literal -from gaia.cli.commands.check_core import ( +from gaia.engine.inquiry.anchor import SourceAnchor +from gaia.engine.inquiry.check_core import ( HoleEntry, KnowledgeBreakdown, find_possible_duplicate_claims, ) -from gaia.inquiry.anchor import SourceAnchor -from gaia.inquiry.focus import FocusBinding +from gaia.engine.inquiry.focus import FocusBinding +from gaia.engine.ir import ReviewManifest, ReviewStatus Severity = Literal["error", "warning", "info"] DiagnosticKind = Literal[ @@ -36,6 +39,8 @@ "stale_artifact", "focus_low_posterior", "prior_without_justification", + "prior_dissent", + "prior_overridden", "unreviewed_warrant", "rejected_warrant", "blocked_warrant_path", @@ -56,10 +61,11 @@ class Diagnostic: label: str message: str suggested_edit: str = "" - data: dict = field(default_factory=dict) + data: dict[str, Any] = field(default_factory=dict) source_anchor: SourceAnchor | None = None - def to_dict(self) -> dict: + def to_dict(self) -> dict[str, Any]: + """Return the diagnostic as a JSON-compatible dictionary.""" d = asdict(self) if not d["data"]: d.pop("data") @@ -70,7 +76,7 @@ def to_dict(self) -> dict: @dataclass class NextEdit: - """Spec §8.8 / Round A2 — 结构化编辑建议。 + """Spec §8.8 / Round A2 structured edit suggestion. ``text`` 是渲染给人看的 imperative 一行; ``source_anchor`` 在可定位时指向 需要修改的源位置。其余字段复制自产生该 edit 的 Diagnostic。 @@ -83,8 +89,9 @@ class NextEdit: label: str source_anchor: SourceAnchor | None = None - def to_dict(self) -> dict: - d = { + def to_dict(self) -> dict[str, Any]: + """Return the structured edit as a JSON-compatible dictionary.""" + d: dict[str, Any] = { "text": self.text, "kind": self.kind, "severity": self.severity, @@ -96,6 +103,36 @@ def to_dict(self) -> dict: return d +def _strategy_id(strategy: Any) -> str: + return getattr(strategy, "strategy_id", None) or getattr(strategy, "id", None) or "" + + +def _action_label(metadata: dict[str, Any] | None) -> str | None: + label = (metadata or {}).get("action_label") + if not isinstance(label, str) or not label: + return None + if "::action::" in label: + return label.split("::action::", 1)[1] + return label + + +def _strategy_label(strategy: Any, sid: str, metadata: dict[str, Any] | None = None) -> str: + return ( + _action_label(metadata) + or getattr(strategy, "label", None) + or (sid.split("::")[-1] if sid else "") + ) + + +def _manifest_status( + review_manifest: ReviewManifest | None, + target_id: str, +) -> ReviewStatus | None: + if review_manifest is None or not target_id: + return None + return review_manifest.latest_status(target_id) + + def from_validation(warnings: list[str], errors: list[str]) -> list[Diagnostic]: """Lift strings from ``ValidationResult`` into ``Diagnostic`` records.""" out: list[Diagnostic] = [] @@ -130,11 +167,12 @@ def _attach_anchor(d: Diagnostic, anchors: dict[str, SourceAnchor] | None) -> Di def from_knowledge_breakdown( kb: KnowledgeBreakdown, - ir: dict, + ir: dict[str, Any], focus: FocusBinding | None, anchors: dict[str, SourceAnchor] | None = None, ) -> list[Diagnostic]: """Emit diagnostics for prior holes, orphans, background-only, duplicates.""" + _ = focus out: list[Diagnostic] = [] for entry in kb.holes: out.append(_attach_anchor(_prior_hole_diag(entry), anchors)) @@ -147,7 +185,9 @@ def from_knowledge_breakdown( target=label, label=label, message="Claim is not referenced by any strategy or operator.", - suggested_edit=f"Either connect `{label}` to a strategy/operator, or remove it.", + suggested_edit=( + f"Either connect `{label}` to a strategy/operator, or remove it." + ), ), anchors, ) @@ -204,7 +244,7 @@ def _prior_hole_diag(entry: HoleEntry) -> Diagnostic: def detect_stale_artifact( - pkg_path, + pkg_path: str | Path, current_ir_hash: str | None, ) -> list[Diagnostic]: """Compare in-memory ir_hash with on-disk .gaia/ir_hash file. @@ -213,12 +253,10 @@ def detect_stale_artifact( an earlier IR — typically because the agent edited Python after the last review/build. Always non-fatal (warning). """ - from pathlib import Path as _P - out: list[Diagnostic] = [] if current_ir_hash is None: return out - f = _P(pkg_path) / ".gaia" / "ir_hash" + f = Path(pkg_path) / ".gaia" / "ir_hash" if not f.exists(): return out try: @@ -238,7 +276,7 @@ def detect_stale_artifact( f"compiled graph ({current_ir_hash[:12]}...)." ), suggested_edit=( - "Re-run `gaia build` (or the package equivalent) to refresh " + "Re-run `gaia build compile` (or the package equivalent) to refresh " "cached artifacts; otherwise downstream tools may read stale state." ), data={"recorded": recorded, "current": current_ir_hash}, @@ -248,7 +286,7 @@ def detect_stale_artifact( def detect_focus_low_posterior( - belief_report: dict, + belief_report: dict[str, Any], threshold: float = 0.3, ) -> list[Diagnostic]: """Emit when the focus claim's posterior is below ``threshold``. @@ -292,8 +330,8 @@ def detect_focus_low_posterior( def detect_prior_without_justification( - kb, - anchors: dict | None = None, + kb: KnowledgeBreakdown, + anchors: dict[str, SourceAnchor] | None = None, ) -> list[Diagnostic]: """For every covered (non-hole) prior, require a non-empty justification. @@ -325,9 +363,10 @@ def detect_prior_without_justification( def detect_warrant_status( - graph, + graph: Any, rejected_strategy_targets: set[str] | None = None, - anchors: dict | None = None, + anchors: dict[str, SourceAnchor] | None = None, + review_manifest: ReviewManifest | None = None, ) -> list[Diagnostic]: """Walk graph.strategies and emit unreviewed/rejected warrants. @@ -341,10 +380,13 @@ def detect_warrant_status( return out rejected = rejected_strategy_targets or set() for s in getattr(graph, "strategies", []) or []: - sid = getattr(s, "id", "") or "" - label = sid.split("::")[-1] if sid else getattr(s, "label", "") or "" + sid = _strategy_id(s) meta = dict(getattr(s, "metadata", None) or {}) - if sid in rejected or label in rejected: + label = _strategy_label(s, sid, meta) + status = _manifest_status(review_manifest, sid) + if status == ReviewStatus.ACCEPTED: + continue + if status == ReviewStatus.REJECTED or sid in rejected or label in rejected: d = Diagnostic( severity="info", kind="rejected_warrant", @@ -358,8 +400,22 @@ def detect_warrant_status( ) out.append(_attach_anchor(d, anchors)) continue + if status == ReviewStatus.NEEDS_INPUTS: + d = Diagnostic( + severity="warning", + kind="unreviewed_warrant", + target=sid or label, + label=label, + message=f"Strategy `{label}` needs additional review inputs.", + suggested_edit=( + f"Provide the missing review inputs for `{label}` and update " + ".gaia/review_manifest.json." + ), + ) + out.append(_attach_anchor(d, anchors)) + continue judgment = (meta.get("judgment") or "").strip() - if not judgment: + if status == ReviewStatus.UNREVIEWED or (status is None and not judgment): d = Diagnostic( severity="info", kind="unreviewed_warrant", @@ -384,9 +440,9 @@ def detect_warrant_status( def detect_blocked_warrant_path( - graph, + graph: Any, kb: KnowledgeBreakdown, - anchors: dict | None = None, + anchors: dict[str, SourceAnchor] | None = None, ) -> list[Diagnostic]: """A strategy whose premises include unresolved prior holes. @@ -402,8 +458,9 @@ def detect_blocked_warrant_path( if not hole_ids: return out for s in getattr(graph, "strategies", []) or []: - sid = getattr(s, "id", "") or "" - label = sid.split("::")[-1] if sid else "" + sid = _strategy_id(s) + meta = dict(getattr(s, "metadata", None) or {}) + label = _strategy_label(s, sid, meta) premises = list(getattr(s, "premises", None) or []) blocking = sorted(p for p in premises if p in hole_ids) if not blocking: @@ -425,9 +482,9 @@ def detect_blocked_warrant_path( def detect_focus_unsupported( - graph, + graph: Any, focus: FocusBinding | None, - anchors: dict | None = None, + anchors: dict[str, SourceAnchor] | None = None, ) -> list[Diagnostic]: """Focus claim is not referenced anywhere in the graph. @@ -472,7 +529,7 @@ def detect_focus_unsupported( def detect_large_belief_drop( - belief_report: dict, + belief_report: dict[str, Any], threshold: float = 0.3, ) -> list[Diagnostic]: """Posterior dropped meaningfully relative to the baseline snapshot. @@ -521,9 +578,9 @@ def detect_large_belief_drop( def detect_overstrong_strategy_without_provenance( - graph, + graph: Any, strength_threshold: float = 0.8, - anchors: dict | None = None, + anchors: dict[str, SourceAnchor] | None = None, ) -> list[Diagnostic]: """Strategy has neither ``provenance`` nor ``justification`` metadata. @@ -537,7 +594,7 @@ def detect_overstrong_strategy_without_provenance( if graph is None: return out - def _nonempty(v) -> bool: + def _nonempty(v: Any) -> bool: if v is None: return False if isinstance(v, str): @@ -545,9 +602,9 @@ def _nonempty(v) -> bool: return True for s in getattr(graph, "strategies", []) or []: - sid = getattr(s, "id", "") or "" - label = sid.split("::")[-1] if sid else "" + sid = _strategy_id(s) meta = dict(getattr(s, "metadata", None) or {}) + label = _strategy_label(s, sid, meta) if _nonempty(meta.get("provenance")) or _nonempty(meta.get("justification")): continue @@ -569,7 +626,7 @@ def _nonempty(v) -> bool: f"Strategy `{label}` has no `provenance` or `justification` " "recorded in its metadata." ) - data: dict = {"strength_threshold": strength_threshold} + data: dict[str, Any] = {"strength_threshold": strength_threshold} if strength_val is not None: data["strength"] = strength_val d = Diagnostic( @@ -589,10 +646,73 @@ def _nonempty(v) -> bool: return out +def _link_clique(adj: dict[str, set[str]], nodes: list[str]) -> None: + nodes = [n for n in nodes if n] + for i, a in enumerate(nodes): + for b in nodes[i + 1 :]: + adj[a].add(b) + adj[b].add(a) + + +def _strategy_connection_nodes(strategy: Any) -> list[str]: + nodes: list[str] = [] + if getattr(strategy, "conclusion", None): + nodes.append(strategy.conclusion) + nodes.extend(getattr(strategy, "premises", None) or []) + nodes.extend(getattr(strategy, "background", None) or []) + return nodes + + +def _operator_connection_nodes(operator: Any) -> list[str]: + nodes: list[str] = [] + if getattr(operator, "conclusion", None): + nodes.append(operator.conclusion) + nodes.extend(getattr(operator, "variables", None) or []) + return nodes + + +def _graph_connection_adjacency(graph: Any) -> dict[str, set[str]]: + adj: dict[str, set[str]] = defaultdict(set) + for strategy in getattr(graph, "strategies", []) or []: + _link_clique(adj, _strategy_connection_nodes(strategy)) + for operator in getattr(graph, "operators", []) or []: + _link_clique(adj, _operator_connection_nodes(operator)) + return adj + + +def _reachable_claims(adj: dict[str, set[str]], start: str) -> set[str]: + visited: set[str] = {start} + q: deque[str] = deque([start]) + while q: + cur = q.popleft() + for nb in adj.get(cur, ()): + if nb not in visited: + visited.add(nb) + q.append(nb) + return visited + + +def _background_only_claim_ids(graph: Any) -> set[str]: + in_core: set[str] = set() + in_bg: set[str] = set() + for strategy in getattr(graph, "strategies", []) or []: + if getattr(strategy, "conclusion", None): + in_core.add(strategy.conclusion) + for premise in getattr(strategy, "premises", None) or []: + in_core.add(premise) + for background in getattr(strategy, "background", None) or []: + in_bg.add(background) + return in_bg - in_core + + +def _claim_label(knowledge: Any, kid: str) -> str: + return getattr(knowledge, "label", "") or (kid.split("::")[-1] if kid else "") + + def detect_claim_with_evidence_but_no_focus_connection( - graph, + graph: Any, focus: FocusBinding | None, - anchors: dict | None = None, + anchors: dict[str, SourceAnchor] | None = None, ) -> list[Diagnostic]: """Claim cited as background but disconnected from the focus. @@ -608,58 +728,15 @@ def detect_claim_with_evidence_but_no_focus_connection( if not fid: return [] - from collections import defaultdict, deque - - adj: dict[str, set[str]] = defaultdict(set) - - def _link_clique(nodes: list[str]) -> None: - nodes = [n for n in nodes if n] - for i, a in enumerate(nodes): - for b in nodes[i + 1 :]: - adj[a].add(b) - adj[b].add(a) - - for s in getattr(graph, "strategies", []) or []: - clique: list[str] = [] - if getattr(s, "conclusion", None): - clique.append(s.conclusion) - clique.extend(getattr(s, "premises", None) or []) - clique.extend(getattr(s, "background", None) or []) - _link_clique(clique) - for o in getattr(graph, "operators", []) or []: - clique = [] - if getattr(o, "conclusion", None): - clique.append(o.conclusion) - clique.extend(getattr(o, "variables", None) or []) - _link_clique(clique) - - visited: set[str] = {fid} - q: deque = deque([fid]) - while q: - cur = q.popleft() - for nb in adj.get(cur, ()): - if nb not in visited: - visited.add(nb) - q.append(nb) - - in_core: set[str] = set() - in_bg: set[str] = set() - for s in getattr(graph, "strategies", []) or []: - if getattr(s, "conclusion", None): - in_core.add(s.conclusion) - for p in getattr(s, "premises", None) or []: - in_core.add(p) - for b in getattr(s, "background", None) or []: - in_bg.add(b) - bg_only = in_bg - in_core - + visited = _reachable_claims(_graph_connection_adjacency(graph), fid) + bg_only = _background_only_claim_ids(graph) out: list[Diagnostic] = [] focus_label = fid.split("::")[-1] if fid else "" for k in getattr(graph, "knowledges", []) or []: kid = getattr(k, "id", "") if kid not in bg_only or kid in visited: continue - label = getattr(k, "label", "") or (kid.split("::")[-1] if kid else "") + label = _claim_label(k, kid) d = Diagnostic( severity="info", kind="claim_with_evidence_but_no_focus_connection", @@ -678,11 +755,166 @@ def _link_clique(nodes: list[str]) -> None: return out +PRIOR_DISSENT_THRESHOLD: float = 0.2 +"""Default absolute spread threshold for the ``prior_dissent`` diagnostic. + +When multiple PriorRecords for the same claim differ by more than this in +absolute value, ``detect_prior_dissent`` emits a warning so the author can +review the disagreement instead of silently accepting the resolution winner. +""" + + +def detect_prior_dissent( + ir: dict[str, Any], + *, + threshold: float = PRIOR_DISSENT_THRESHOLD, + anchors: dict[str, SourceAnchor] | None = None, +) -> list[Diagnostic]: + """Emit warnings when multiple prior sources disagree above ``threshold``. + + Walks every claim Knowledge in the IR. When ``metadata['prior_records']`` + contains two or more records and the spread (max − min of values) exceeds + ``threshold``, emits a single warning summarising every contributing + record so the author can inspect the disagreement before relying on the + resolution winner. + """ + out: list[Diagnostic] = [] + for k in ir.get("knowledges", []) or []: + if k.get("type") != "claim": + continue + metadata = k.get("metadata") or {} + records = metadata.get("prior_records") or [] + if not isinstance(records, list) or len(records) < 2: + continue + values = [float(r["value"]) for r in records if isinstance(r, dict) and "value" in r] + if len(values) < 2: + continue + spread = max(values) - min(values) + if spread <= threshold: + continue + kid = k.get("id", "") + label = k.get("label") or (kid.split("::")[-1] if kid else "") + # Sorted listing keeps diagnostic output deterministic for snapshot tests. + sorted_records = sorted( + records, + key=lambda r: (str(r.get("source_id", "")), str(r.get("created_at", ""))), + ) + record_lines = "\n".join( + f" - {float(r['value']):.3f} (source: {r.get('source_id', '?')}; " + f"justification: {str(r.get('justification', ''))[:80]})" + for r in sorted_records + if isinstance(r, dict) and "value" in r + ) + d = Diagnostic( + severity="warning", + kind="prior_dissent", + target=kid or label, + label=label, + message=( + f"Claim `{label}` has {len(values)} prior records spanning " + f"{min(values):.3f}–{max(values):.3f} (spread {spread:.3f} > " + f"{threshold:.2f}):\n{record_lines}" + ), + suggested_edit=( + f"Review the disagreeing prior sources for `{label}`. The resolution " + "policy will pick one winner, but a spread this large suggests the " + "sources are answering different questions or one is miscalibrated." + ), + data={ + "spread": spread, + "min_value": min(values), + "max_value": max(values), + "n_records": len(values), + "threshold": threshold, + }, + ) + out.append(_attach_anchor(d, anchors)) + return out + + +def detect_prior_overridden( + ir: dict[str, Any], + *, + anchors: dict[str, SourceAnchor] | None = None, +) -> list[Diagnostic]: + """Emit info diagnostic when ResolutionPolicy picks one record over others. + + Walks every claim Knowledge in the IR. When ``metadata['prior_records']`` + contains more than one record, the resolution winner has already been + written to ``metadata['prior']``; this detector surfaces the overridden + records so engine output / agent suggestions / reviewer estimates that + were ignored remain visible to the author. + + Severity is ``info`` — being overridden is the expected behaviour of the + explicit-priority policy, but the author should know it is happening. + """ + out: list[Diagnostic] = [] + for k in ir.get("knowledges", []) or []: + if k.get("type") != "claim": + continue + metadata = k.get("metadata") or {} + records = metadata.get("prior_records") or [] + if not isinstance(records, list) or len(records) < 2: + continue + winning_value = metadata.get("prior") + if winning_value is None: + continue + try: + winning_value_f = float(winning_value) + except (TypeError, ValueError): + continue + # A record is "overridden" when its value differs from the winner — we + # do not attempt to recover the exact winning record (timestamps may + # tie). Same-value records are not flagged because the choice between + # them is observationally indistinguishable. + overridden = [ + r + for r in records + if isinstance(r, dict) + and "value" in r + and abs(float(r["value"]) - winning_value_f) > 1e-9 + ] + if not overridden: + continue + kid = k.get("id", "") + label = k.get("label") or (kid.split("::")[-1] if kid else "") + sorted_overridden = sorted( + overridden, + key=lambda r: (str(r.get("source_id", "")), str(r.get("created_at", ""))), + ) + record_lines = "\n".join( + f" - {float(r['value']):.3f} (source: {r.get('source_id', '?')})" + for r in sorted_overridden + ) + d = Diagnostic( + severity="info", + kind="prior_overridden", + target=kid or label, + label=label, + message=( + f"Claim `{label}` prior {winning_value_f:.3f} was selected by the " + f"resolution policy; {len(overridden)} other record(s) were " + f"overridden:\n{record_lines}" + ), + suggested_edit=( + f"If an overridden prior should win for `{label}`, adjust the " + "RESOLUTION_POLICY priority_order in priors.py, or remove the " + "stale register_prior() call from the losing source." + ), + data={ + "winning_value": winning_value_f, + "n_overridden": len(overridden), + }, + ) + out.append(_attach_anchor(d, anchors)) + return out + + _PRIO = {"error": 0, "warning": 1, "info": 2} def format_diagnostics_as_next_edits(diags: list[Diagnostic]) -> list[str]: - """Spec §8 `Next edits` — 文本形式 (向后兼容 Step 2 的 str 列表)。 + """Format diagnostics as the spec §8 text ``Next edits`` list. 若 diagnostic 带 ``source_anchor``, 追加 ``(file:line)`` 到末尾, 便于人眼直接定位源行。 @@ -702,7 +934,7 @@ def format_diagnostics_as_next_edits(diags: list[Diagnostic]) -> list[str]: def format_diagnostics_as_structured_edits(diags: list[Diagnostic]) -> list[NextEdit]: - """Round A2 — structured NextEdit 列表, 与文本版 dedup 语义一致。""" + """Format diagnostics as Round A2 structured ``NextEdit`` records.""" seen: set[str] = set() out: list[NextEdit] = [] for d in sorted(diags, key=lambda d: _PRIO.get(d.severity, 9)): diff --git a/gaia/inquiry/diff.py b/gaia/engine/inquiry/diff.py similarity index 87% rename from gaia/inquiry/diff.py rename to gaia/engine/inquiry/diff.py index c9706d8bc..e253a36f2 100644 --- a/gaia/inquiry/diff.py +++ b/gaia/engine/inquiry/diff.py @@ -17,7 +17,7 @@ from __future__ import annotations from dataclasses import dataclass, field - +from typing import Any # --------------------------------------------------------------------------- # # Delta record # @@ -33,7 +33,8 @@ class ClaimDelta: before: str after: str - def to_dict(self) -> dict: + def to_dict(self) -> dict[str, str]: + """Return the delta record as a JSON-compatible dictionary.""" return { "label": self.label, "field": self.field, @@ -83,6 +84,7 @@ class SemanticDiff: @property def is_empty(self) -> bool: + """Return whether the diff contains no semantic deltas.""" return not any( ( self.added_claims, @@ -103,7 +105,8 @@ def is_empty(self) -> bool: ) ) - def to_dict(self) -> dict: + def to_dict(self) -> dict[str, Any]: + """Return the full semantic diff as a JSON-compatible dictionary.""" return { "baseline_review_id": self.baseline_review_id, "added_claims": list(self.added_claims), @@ -125,6 +128,7 @@ def to_dict(self) -> dict: def empty_diff() -> SemanticDiff: + """Return an empty semantic diff for report defaults.""" return SemanticDiff() @@ -133,35 +137,39 @@ def empty_diff() -> SemanticDiff: # --------------------------------------------------------------------------- # -def _knowledges_by_type_id(ir: dict, kind: str) -> dict[str, dict]: +def _knowledges_by_type_id(ir: dict[str, Any], kind: str) -> dict[str, dict[str, Any]]: return {k["id"]: k for k in ir.get("knowledges", []) or [] if k.get("type") == kind} -def _strategies_by_id(ir: dict) -> dict[str, dict]: +def _strategies_by_id(ir: dict[str, Any]) -> dict[str, dict[str, Any]]: return {s["id"]: s for s in ir.get("strategies", []) or []} -def _operators_by_id(ir: dict) -> dict[str, dict]: +def _operators_by_id(ir: dict[str, Any]) -> dict[str, dict[str, Any]]: return {o["id"]: o for o in ir.get("operators", []) or []} -def _label(item: dict) -> str: - return item.get("label") or item.get("id", "").split("::")[-1] +def _label(item: dict[str, Any]) -> str: + label = item.get("label") + if isinstance(label, str) and label: + return label + item_id = item.get("id", "") + return str(item_id).split("::")[-1] -def _prior(k: dict): +def _prior(k: dict[str, Any]) -> Any: meta = k.get("metadata") or {} return meta.get("prior") -def _exported(k: dict) -> bool: +def _exported(k: dict[str, Any]) -> bool: if "exported" in k: return bool(k["exported"]) meta = k.get("metadata") or {} return bool(meta.get("exported", False)) -def _fmt(v) -> str: +def _fmt(v: Any) -> str: return "∅" if v is None else str(v) @@ -171,8 +179,8 @@ def _fmt(v) -> str: def compute_semantic_diff( - current_ir: dict | None, - baseline_snapshot: dict | None, + current_ir: dict[str, Any] | None, + baseline_snapshot: dict[str, Any] | None, ) -> SemanticDiff: """Return all 16 §14.2 category deltas between two snapshots.""" if baseline_snapshot is None or current_ir is None: @@ -197,7 +205,7 @@ def compute_semantic_diff( # --------------------------------------------------------------------------- # -def _diff_claims(diff: SemanticDiff, cur: dict, base: dict) -> None: +def _diff_claims(diff: SemanticDiff, cur: dict[str, Any], base: dict[str, Any]) -> None: """added/removed/changed_claims + changed_priors + changed_exports.""" cur_c = _knowledges_by_type_id(cur, "claim") base_c = _knowledges_by_type_id(base, "claim") @@ -229,7 +237,7 @@ def _diff_claims(diff: SemanticDiff, cur: dict, base: dict) -> None: diff.changed_exports.append(ClaimDelta(label, "exported", _fmt(ea), _fmt(eb))) -def _diff_questions(diff: SemanticDiff, cur: dict, base: dict) -> None: +def _diff_questions(diff: SemanticDiff, cur: dict[str, Any], base: dict[str, Any]) -> None: cur_q = _knowledges_by_type_id(cur, "question") base_q = _knowledges_by_type_id(base, "question") for qid in sorted(cur_q.keys() - base_q.keys()): @@ -238,7 +246,7 @@ def _diff_questions(diff: SemanticDiff, cur: dict, base: dict) -> None: diff.removed_questions.append(_label(base_q[qid])) -def _diff_settings(diff: SemanticDiff, cur: dict, base: dict) -> None: +def _diff_settings(diff: SemanticDiff, cur: dict[str, Any], base: dict[str, Any]) -> None: cur_s = _knowledges_by_type_id(cur, "setting") base_s = _knowledges_by_type_id(base, "setting") for sid in sorted(cur_s.keys() - base_s.keys()): @@ -247,7 +255,7 @@ def _diff_settings(diff: SemanticDiff, cur: dict, base: dict) -> None: diff.removed_settings.append(_label(base_s[sid])) -def _diff_strategies(diff: SemanticDiff, cur: dict, base: dict) -> None: +def _diff_strategies(diff: SemanticDiff, cur: dict[str, Any], base: dict[str, Any]) -> None: cur_st = _strategies_by_id(cur) base_st = _strategies_by_id(base) for sid in sorted(cur_st.keys() - base_st.keys()): @@ -274,7 +282,7 @@ def _diff_strategies(diff: SemanticDiff, cur: dict, base: dict) -> None: ) -def _diff_operators(diff: SemanticDiff, cur: dict, base: dict) -> None: +def _diff_operators(diff: SemanticDiff, cur: dict[str, Any], base: dict[str, Any]) -> None: cur_op = _operators_by_id(cur) base_op = _operators_by_id(base) for oid in sorted(cur_op.keys() - base_op.keys()): diff --git a/gaia/inquiry/focus.py b/gaia/engine/inquiry/focus.py similarity index 81% rename from gaia/inquiry/focus.py rename to gaia/engine/inquiry/focus.py index 67db69247..643296cba 100644 --- a/gaia/inquiry/focus.py +++ b/gaia/engine/inquiry/focus.py @@ -3,16 +3,20 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any @dataclass class FocusBinding: + """Resolved inquiry focus target for review and rendering.""" + raw: str | None resolved_id: str | None = None resolved_label: str | None = None kind: str = "freeform" - def to_dict(self) -> dict: + def to_dict(self) -> dict[str, str | None]: + """Return the focus binding as a JSON-compatible dictionary.""" return { "raw": self.raw, "resolved_id": self.resolved_id, @@ -21,7 +25,8 @@ def to_dict(self) -> dict: } -def resolve_focus_target(target: str | None, graph) -> FocusBinding: +def resolve_focus_target(target: str | None, graph: Any) -> FocusBinding: + """Resolve a raw focus selector against graph IDs and labels.""" if target is None: return FocusBinding(raw=None, kind="none") t = str(target).strip() diff --git a/gaia/inquiry/proof_state.py b/gaia/engine/inquiry/proof_state.py similarity index 85% rename from gaia/inquiry/proof_state.py rename to gaia/engine/inquiry/proof_state.py index 4c000e48c..9e5ff6b03 100644 --- a/gaia/inquiry/proof_state.py +++ b/gaia/engine/inquiry/proof_state.py @@ -5,13 +5,15 @@ from dataclasses import asdict, dataclass, field from typing import Any -from gaia.inquiry.state import ( +from gaia.engine.inquiry.state import ( InquiryState, ) @dataclass class ObligationView: + """Display record for an IR or synthetic proof obligation.""" + qid: str target_qid: str | None content: str @@ -22,6 +24,8 @@ class ObligationView: @dataclass class HypothesisView: + """Display record for an IR or synthetic proof hypothesis.""" + qid: str content: str scope_qid: str | None @@ -30,6 +34,8 @@ class HypothesisView: @dataclass class RejectionView: + """Display record for a closed or rejected strategy branch.""" + qid: str target_strategy: str content: str @@ -37,11 +43,14 @@ class RejectionView: @dataclass class ProofContext: + """Merged proof-state view shown in inquiry review reports.""" + obligations: list[ObligationView] = field(default_factory=list) hypotheses: list[HypothesisView] = field(default_factory=list) rejections: list[RejectionView] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: + """Return the proof context as a JSON-compatible dictionary.""" return { "obligations": [asdict(o) for o in self.obligations], "hypotheses": [asdict(h) for h in self.hypotheses], @@ -49,7 +58,8 @@ def to_dict(self) -> dict[str, Any]: } -def build_proof_context(graph, state: InquiryState) -> ProofContext: +def build_proof_context(graph: Any, state: InquiryState) -> ProofContext: + """Build the merged IR and synthetic proof context for a package.""" ctx = ProofContext() # IR side — question() becomes obligation view, setting() becomes hypothesis view. diff --git a/gaia/inquiry/ranking.py b/gaia/engine/inquiry/ranking.py similarity index 93% rename from gaia/inquiry/ranking.py rename to gaia/engine/inquiry/ranking.py index a29db7e36..b27bbacd9 100644 --- a/gaia/inquiry/ranking.py +++ b/gaia/engine/inquiry/ranking.py @@ -20,8 +20,9 @@ from __future__ import annotations -from gaia.inquiry.diagnostics import Diagnostic, NextEdit +from collections.abc import Callable +from gaia.engine.inquiry.diagnostics import Diagnostic, NextEdit # Spec §7 — `kind` priority per mode. Lower number = higher priority. # `severity` is a tiebreaker (error < warning < info). @@ -149,13 +150,14 @@ def supported_modes() -> tuple[str, ...]: + """Return the inquiry ranking modes supported by the priority tables.""" return tuple(_MODE_RANK.keys()) -def _key(mode: str): +def _key(mode: str) -> Callable[[Diagnostic | NextEdit], tuple[int, int, str]]: table = _MODE_RANK.get(mode, _MODE_RANK["auto"]) - def _k(d: Diagnostic | NextEdit): + def _k(d: Diagnostic | NextEdit) -> tuple[int, int, str]: kind_rank = table.get(d.kind, _UNKNOWN_KIND_RANK) sev_rank = _SEVERITY_RANK.get(d.severity, 9) # Stable tiebreak on label so identical (kind, severity) sort deterministically. @@ -174,5 +176,5 @@ def rank_diagnostics(diagnostics: list[Diagnostic], mode: str) -> list[Diagnosti def rank_next_edits(edits: list[NextEdit], mode: str) -> list[NextEdit]: - """Same ranking applied to structured next-edits (kept in lock-step).""" + """Return structured next-edits sorted with the diagnostic priority table.""" return sorted(edits, key=_key(mode)) diff --git a/gaia/engine/inquiry/render.py b/gaia/engine/inquiry/render.py new file mode 100644 index 000000000..0d7ad700b --- /dev/null +++ b/gaia/engine/inquiry/render.py @@ -0,0 +1,536 @@ +"""Spec §8 text renderer + §9.1 JSON serializer for ReviewReport.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from gaia.engine.inquiry.focus import FocusBinding +from gaia.engine.inquiry.proof_state import ProofContext + +if TYPE_CHECKING: + from gaia.engine.inquiry.review import ReviewReport + + +def _append_text_focus(lines: list[str], report: ReviewReport) -> None: + """Append text focus section.""" + lines.append("## Focus") + focus = report.focus + if focus.resolved_id: + lines.append(f" {focus.resolved_label} ({focus.kind}, id={focus.resolved_id})") + elif focus.raw: + lines.append(f" (freeform) {focus.raw}") + else: + lines.append(" (no focus set)") + lines.append(f" mode: {report.mode}") + lines.append("") + + +def _append_text_compile(lines: list[str], report: ReviewReport) -> None: + """Append text compile section.""" + lines.append("## Compile") + lines.append(f" status: {report.compile_status}") + if report.ir_hash: + lines.append(f" ir_hash: {report.ir_hash}") + for key, value in report.counts.items(): + lines.append(f" {key}: {value}") + lines.append("") + + +def _append_text_semantic_diff(lines: list[str], report: ReviewReport) -> None: + """Append text semantic-diff section.""" + lines.append("## Semantic diff") + diff = report.semantic_diff + if diff.baseline_review_id is None: + lines.append(" (no baseline review — run `gaia inquiry review` again to diff)") + elif diff.is_empty: + lines.append(f" baseline: {diff.baseline_review_id}") + lines.append(" (no semantic changes)") + else: + lines.append(f" baseline: {diff.baseline_review_id}") + _append_text_diff_counts(lines, diff) + _append_text_diff_details(lines, diff) + lines.append("") + + +def _append_text_diff_counts(lines: list[str], diff: Any) -> None: + """Append text diff count rows.""" + for tag, items in ( + ("claims", diff.added_claims), + ("questions", diff.added_questions), + ("settings", diff.added_settings), + ("strategies", diff.added_strategies), + ("operators", diff.added_operators), + ): + if items: + lines.append(f" + {len(items)} {tag}") + for tag, items in ( + ("claims", diff.removed_claims), + ("questions", diff.removed_questions), + ("settings", diff.removed_settings), + ("strategies", diff.removed_strategies), + ("operators", diff.removed_operators), + ): + if items: + lines.append(f" - {len(items)} {tag}") + for tag, items in ( + ("changed claims", diff.changed_claims), + ("changed strategies", diff.changed_strategies), + ("changed operators", diff.changed_operators), + ): + if items: + lines.append(f" ~ {len(items)} {tag}") + + +def _append_text_diff_details(lines: list[str], diff: Any) -> None: + """Append text changed-prior/export details.""" + if diff.changed_priors: + lines.append(" changed priors:") + for delta in diff.changed_priors: + lines.append(f" - {delta.label}: {delta.before} → {delta.after}") + if diff.changed_exports: + lines.append(" changed exports:") + for delta in diff.changed_exports: + lines.append(f" - {delta.label}: {delta.before} → {delta.after}") + + +def _append_text_graph_health(lines: list[str], report: ReviewReport) -> None: + """Append text graph-health section.""" + lines.append("## Graph health") + graph_health = report.graph_health + lines.append(f" warnings: {len(graph_health['warnings'])}") + lines.append(f" errors: {len(graph_health['errors'])}") + lines.append(f" orphaned claims: {len(graph_health['orphaned_claims'])}") + lines.append(f" background-only claims: {len(graph_health['background_only_claims'])}") + lines.append(f" independent claims missing priors: {len(graph_health['prior_holes'])}") + lines.append(f" possible duplicate claims: {len(graph_health['possible_duplicates'])}") + for msg in graph_health["errors"]: + lines.append(f" ! {msg}") + for msg in graph_health["warnings"]: + lines.append(f" · {msg}") + lines.append("") + + +def _append_text_inquiry_tree(lines: list[str], report: ReviewReport) -> None: + """Append text inquiry-tree section.""" + lines.append("## Inquiry tree") + inquiry_tree = report.inquiry_tree + lines.append(f" goals: {inquiry_tree['goals']}") + lines.append(f" accepted warrants: {inquiry_tree['accepted_warrants']}") + lines.append(f" unreviewed warrants: {inquiry_tree['unreviewed_warrants']}") + lines.append(f" blocked paths: {inquiry_tree['blocked_paths']}") + lines.append(f" structural holes: {len(inquiry_tree['structural_holes'])}") + lines.append("") + + +def _append_text_prior_holes(lines: list[str], report: ReviewReport) -> None: + """Append text prior-hole section.""" + lines.append("## Prior holes") + if not report.prior_holes: + lines.append(" (all independent claims have priors set)") + else: + for hole in report.prior_holes: + lines.append(f" - {hole['label']}") + preview = hole.get("content", "") + if preview: + lines.append(f" content: {preview}") + lines.append(f" prior: {hole['prior']}") + lines.append("") + + +def _append_text_belief_report(lines: list[str], report: ReviewReport) -> None: + """Append text belief-report section.""" + lines.append("## Belief report") + belief_report = report.belief_report + if not belief_report["ran_inference"]: + lines.append(" (inference skipped)") + else: + _append_text_focus_belief(lines, belief_report) + lines.append(f" total claims with beliefs: {len(belief_report['beliefs'])}") + _append_text_belief_deltas(lines, belief_report) + lines.append("") + + +def _append_text_focus_belief(lines: list[str], belief_report: dict[str, Any]) -> None: + """Append text focus belief row when present.""" + if not belief_report.get("focus"): + return + focus = belief_report["focus"] + if focus.get("delta") is not None: + lines.append( + f" focus {focus['label']}: {focus['before']} → {focus['after']} " + f"(Δ={focus['delta']:+.3f})" + ) + else: + lines.append(f" focus {focus['label']}: {focus['after']:.3f}") + + +def _append_text_belief_deltas(lines: list[str], belief_report: dict[str, Any]) -> None: + """Append largest belief increases/decreases.""" + if belief_report.get("largest_increases"): + lines.append(" largest increases:") + for item in belief_report["largest_increases"]: + lines.append(f" - {item['label']}: {item['before']} → {item['after']}") + if belief_report.get("largest_decreases"): + lines.append(" largest decreases:") + for item in belief_report["largest_decreases"]: + lines.append(f" - {item['label']}: {item['before']} → {item['after']}") + + +def _append_text_proof_state(lines: list[str], report: ReviewReport) -> None: + """Append text proof-state section when populated.""" + proof_context = report.proof_context + if proof_context is None: + return + if not (proof_context.obligations or proof_context.hypotheses or proof_context.rejections): + return + lines.append("## Proof state") + lines.append(f" obligations ({len(proof_context.obligations)}):") + for obligation in proof_context.obligations: + lines.append(f" - [{obligation.diagnostic_kind}] {obligation.content}") + if proof_context.hypotheses: + lines.append(f" hypotheses ({len(proof_context.hypotheses)}):") + for hypothesis in proof_context.hypotheses: + lines.append(f" - {hypothesis.content}") + if proof_context.rejections: + lines.append(f" rejections ({len(proof_context.rejections)}):") + for rejection in proof_context.rejections: + lines.append(f" - {rejection.target_strategy}: {rejection.content}") + lines.append("") + + +def _append_text_next_edits(lines: list[str], report: ReviewReport) -> None: + """Append text next-edits section.""" + lines.append("## Next edits") + if not report.next_edits: + lines.append(" (no suggested edits)") + else: + for index, edit in enumerate(report.next_edits, 1): + lines.append(f" {index}. {edit}") + + +def render_text(report: ReviewReport) -> str: + """Render a review report as the spec §8 plain-text layout.""" + lines: list[str] = [] + lines.append("Gaia Inquiry Review") + lines.append("─" * 20) + lines.append("") + + _append_text_focus(lines, report) + _append_text_compile(lines, report) + _append_text_semantic_diff(lines, report) + _append_text_graph_health(lines, report) + _append_text_inquiry_tree(lines, report) + _append_text_prior_holes(lines, report) + _append_text_belief_report(lines, report) + _append_text_proof_state(lines, report) + _append_text_next_edits(lines, report) + + return "\n".join(lines) + + +def to_json_dict(report: ReviewReport) -> dict[str, Any]: + """Serialize a review report to the spec §9.1 JSON dictionary shape.""" + return { + "review_id": report.review_id, + "created_at": report.created_at, + "path": report.path, + "focus": _focus_to_dict(report.focus), + "mode": report.mode, + "compile": { + "status": report.compile_status, + "ir_hash": report.ir_hash, + "counts": dict(report.counts), + }, + "semantic_diff": report.semantic_diff.to_dict(), + "graph_health": report.graph_health, + "inquiry_tree": report.inquiry_tree, + "prior_holes": list(report.prior_holes), + "belief_report": report.belief_report, + "diagnostics": [d.to_dict() for d in report.diagnostics], + "next_edits": list(report.next_edits), + "next_edits_structured": [e.to_dict() for e in report.next_edits_structured], + "proof_context": _proof_context_to_dict(report.proof_context), + } + + +def _focus_to_dict(f: FocusBinding) -> dict[str, Any]: + return { + "raw": f.raw, + "resolved_id": f.resolved_id, + "resolved_label": f.resolved_label, + "kind": f.kind, + } + + +def _proof_context_to_dict(pc: ProofContext | None) -> dict[str, Any]: + if pc is None: + return {"obligations": [], "hypotheses": [], "rejections": []} + return { + "obligations": [vars(o) for o in pc.obligations], + "hypotheses": [vars(h) for h in pc.hypotheses], + "rejections": [vars(r) for r in pc.rejections], + } + + +def _append_markdown_header(md: list[str], report: ReviewReport) -> None: + """Append Markdown document header and metadata.""" + md.append("# Gaia Inquiry Review") + md.append("") + md.append(f"- **review_id**: `{report.review_id}`") + md.append(f"- **created_at**: `{report.created_at}`") + md.append(f"- **path**: `{report.path}`") + md.append("") + + +def _append_markdown_focus(md: list[str], report: ReviewReport) -> None: + """Append Markdown focus section.""" + md.append("## Focus") + focus = report.focus + if focus.resolved_id: + md.append( + f"- **target**: `{focus.resolved_label}` (`{focus.kind}`, id=`{focus.resolved_id}`)" + ) + elif focus.raw: + md.append(f"- **freeform**: `{focus.raw}`") + else: + md.append("- _no focus set_") + md.append(f"- **mode**: `{report.mode}`") + md.append("") + + +def _append_markdown_compile(md: list[str], report: ReviewReport) -> None: + """Append Markdown compile section.""" + md.append("## Compile") + md.append(f"- status: `{report.compile_status}`") + if report.ir_hash: + md.append(f"- ir_hash: `{report.ir_hash}`") + for key, value in report.counts.items(): + md.append(f"- {key}: {value}") + md.append("") + + +def _append_markdown_semantic_diff(md: list[str], report: ReviewReport) -> None: + """Append Markdown semantic-diff section.""" + md.append("## Semantic diff") + diff = report.semantic_diff + if diff.baseline_review_id is None: + md.append("_no baseline review yet_") + elif diff.is_empty: + md.append(f"baseline: `{diff.baseline_review_id}` — no semantic changes") + else: + md.append(f"baseline: `{diff.baseline_review_id}`") + md.append("") + _append_markdown_diff_items(md, diff) + _append_markdown_delta_items(md, diff) + md.append("") + + +def _append_markdown_diff_items(md: list[str], diff: Any) -> None: + """Append Markdown added/removed ID lists.""" + for heading, items in ( + ("Added claims", diff.added_claims), + ("Removed claims", diff.removed_claims), + ("Added questions", diff.added_questions), + ("Removed questions", diff.removed_questions), + ("Added settings", diff.added_settings), + ("Removed settings", diff.removed_settings), + ("Added strategies", diff.added_strategies), + ("Removed strategies", diff.removed_strategies), + ("Added operators", diff.added_operators), + ("Removed operators", diff.removed_operators), + ): + if items: + md.append(f"**{heading}** ({len(items)})") + for item in items: + md.append(f"- `{item}`") + md.append("") + + +def _append_markdown_delta_items(md: list[str], diff: Any) -> None: + """Append Markdown changed-field delta lists.""" + for heading, deltas in ( + ("Changed claims", diff.changed_claims), + ("Changed strategies", diff.changed_strategies), + ("Changed operators", diff.changed_operators), + ("Changed priors", diff.changed_priors), + ("Changed exports", diff.changed_exports), + ): + if deltas: + md.append(f"**{heading}** ({len(deltas)})") + for delta in deltas: + md.append(f"- `{delta.label}` _{delta.field}_: `{delta.before}` → `{delta.after}`") + md.append("") + + +def _append_markdown_graph_health(md: list[str], report: ReviewReport) -> None: + """Append Markdown graph-health section.""" + md.append("## Graph health") + graph_health = report.graph_health + md.append(f"- warnings: {len(graph_health['warnings'])}") + md.append(f"- errors: {len(graph_health['errors'])}") + md.append(f"- orphaned claims: {len(graph_health['orphaned_claims'])}") + md.append(f"- background-only claims: {len(graph_health['background_only_claims'])}") + md.append(f"- prior holes: {len(graph_health['prior_holes'])}") + md.append(f"- possible duplicates: {len(graph_health['possible_duplicates'])}") + _append_markdown_messages(md, "Errors", graph_health["errors"]) + _append_markdown_messages(md, "Warnings", graph_health["warnings"]) + md.append("") + + +def _append_markdown_messages(md: list[str], heading: str, messages: list[str]) -> None: + """Append a titled Markdown message list when non-empty.""" + if not messages: + return + md.append("") + md.append(f"**{heading}**") + for message in messages: + md.append(f"- {message}") + + +def _append_markdown_inquiry_tree(md: list[str], report: ReviewReport) -> None: + """Append Markdown inquiry-tree section.""" + md.append("## Inquiry tree") + inquiry_tree = report.inquiry_tree + md.append(f"- goals: {inquiry_tree['goals']}") + md.append(f"- accepted warrants: {inquiry_tree['accepted_warrants']}") + md.append(f"- unreviewed warrants: {inquiry_tree['unreviewed_warrants']}") + md.append(f"- blocked paths: {inquiry_tree['blocked_paths']}") + md.append(f"- structural holes: {len(inquiry_tree['structural_holes'])}") + md.append("") + + +def _append_markdown_prior_holes(md: list[str], report: ReviewReport) -> None: + """Append Markdown prior-hole section.""" + md.append("## Prior holes") + if not report.prior_holes: + md.append("_all independent claims have priors set_") + else: + for hole in report.prior_holes: + md.append(f"- **{hole['label']}**") + preview = hole.get("content", "") + if preview: + md.append(f" - content: {preview}") + md.append(f" - prior: `{hole['prior']}`") + md.append("") + + +def _append_markdown_belief_report(md: list[str], report: ReviewReport) -> None: + """Append Markdown belief-report section.""" + md.append("## Belief report") + belief_report = report.belief_report + if not belief_report["ran_inference"]: + md.append("_inference skipped_") + else: + _append_markdown_focus_belief(md, belief_report) + md.append(f"- claims with beliefs: {len(belief_report['beliefs'])}") + _append_markdown_belief_deltas(md, belief_report) + md.append("") + + +def _append_markdown_focus_belief(md: list[str], belief_report: dict[str, Any]) -> None: + """Append Markdown focus belief row when present.""" + if not belief_report.get("focus"): + return + focus = belief_report["focus"] + if focus.get("delta") is not None: + md.append( + f"- focus **{focus['label']}**: {focus['before']} → {focus['after']} " + f"(Δ={focus['delta']:+.3f})" + ) + else: + md.append(f"- focus **{focus['label']}**: {focus['after']:.3f}") + + +def _append_markdown_belief_deltas(md: list[str], belief_report: dict[str, Any]) -> None: + """Append largest Markdown belief increases/decreases.""" + for heading, key in ( + ("Largest increases", "largest_increases"), + ("Largest decreases", "largest_decreases"), + ): + if belief_report.get(key): + md.append("") + md.append(f"**{heading}**") + for item in belief_report[key]: + md.append(f"- `{item['label']}`: {item['before']} → {item['after']}") + + +def _append_markdown_proof_state(md: list[str], report: ReviewReport) -> None: + """Append Markdown proof-state section when populated.""" + proof_context = report.proof_context + if proof_context is None: + return + if not (proof_context.obligations or proof_context.hypotheses or proof_context.rejections): + return + md.append("## Proof state") + _append_markdown_proof_items( + md, + f"**Obligations** ({len(proof_context.obligations)})", + [f"_[{item.diagnostic_kind}]_ {item.content}" for item in proof_context.obligations], + ) + _append_markdown_proof_items( + md, + f"**Hypotheses** ({len(proof_context.hypotheses)})", + [item.content for item in proof_context.hypotheses], + ) + _append_markdown_proof_items( + md, + f"**Rejections** ({len(proof_context.rejections)})", + [f"`{item.target_strategy}`: {item.content}" for item in proof_context.rejections], + ) + md.append("") + + +def _append_markdown_proof_items(md: list[str], heading: str, items: list[str]) -> None: + """Append a proof subsection when non-empty.""" + if not items: + return + if md and md[-1] != "## Proof state": + md.append("") + md.append(heading) + for item in items: + md.append(f"- {item}") + + +def _append_markdown_next_edits(md: list[str], report: ReviewReport) -> None: + """Append Markdown next-edits section.""" + md.append("## Next edits") + if not report.next_edits_structured and not report.next_edits: + md.append("_no suggested edits_") + return + for index, edit in enumerate(report.next_edits_structured, 1): + anchor = "" + if edit.source_anchor is not None: + source_anchor = edit.source_anchor + anchor = f" — `{source_anchor.file}:{source_anchor.line}`" + md.append(f"{index}. _[{edit.kind}/{edit.severity}]_ {edit.text}{anchor}") + if not report.next_edits_structured: + for index, edit_text in enumerate(report.next_edits, 1): + md.append(f"{index}. {edit_text}") + + +def render_markdown(report: ReviewReport) -> str: + """Spec §17.2 Markdown renderer. + + Mirrors the eight-section text layout but uses Markdown headings, bullet + lists, and fenced code blocks for IDs/source anchors. The section names + match render_text exactly so agents can diff outputs. + """ + md: list[str] = [] + _append_markdown_header(md, report) + _append_markdown_focus(md, report) + _append_markdown_compile(md, report) + _append_markdown_semantic_diff(md, report) + _append_markdown_graph_health(md, report) + _append_markdown_inquiry_tree(md, report) + _append_markdown_prior_holes(md, report) + _append_markdown_belief_report(md, report) + _append_markdown_proof_state(md, report) + _append_markdown_next_edits(md, report) + + return "\n".join(md) + + +def render_json(report: ReviewReport) -> str: + """Render a review report as pretty JSON without ASCII escaping.""" + return json.dumps(to_json_dict(report), ensure_ascii=False, indent=2) diff --git a/gaia/inquiry/review.py b/gaia/engine/inquiry/review.py similarity index 67% rename from gaia/inquiry/review.py rename to gaia/engine/inquiry/review.py index a948e4125..cd938d9e1 100644 --- a/gaia/inquiry/review.py +++ b/gaia/engine/inquiry/review.py @@ -8,27 +8,17 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any -from gaia.cli._packages import ( - GaiaCliError, - apply_package_priors, - collect_foreign_node_priors, - compile_loaded_package_artifact, - ensure_package_env, - load_gaia_package, -) -from gaia.cli.commands.check_core import ( +from gaia.engine.inquiry.anchor import find_anchors +from gaia.engine.inquiry.check_core import ( KnowledgeBreakdown, analyze_knowledge_breakdown, find_possible_duplicate_claims, ) -from gaia.ir.validator import validate_local_graph - -from gaia.inquiry.anchor import find_anchors -from gaia.inquiry.diagnostics import ( +from gaia.engine.inquiry.diagnostics import ( Diagnostic, NextEdit, detect_blocked_warrant_path, @@ -37,6 +27,8 @@ detect_focus_unsupported, detect_large_belief_drop, detect_overstrong_strategy_without_provenance, + detect_prior_dissent, + detect_prior_overridden, detect_prior_without_justification, detect_stale_artifact, detect_warrant_status, @@ -44,24 +36,36 @@ from_knowledge_breakdown, from_validation, ) -from gaia.inquiry.diff import SemanticDiff, compute_semantic_diff, empty_diff -from gaia.inquiry.focus import FocusBinding, resolve_focus_target -from gaia.inquiry.proof_state import ProofContext, build_proof_context -from gaia.inquiry.ranking import rank_diagnostics, rank_next_edits -from gaia.inquiry.render import render_markdown as _render_markdown -from gaia.inquiry.render import render_text as _render_text -from gaia.inquiry.render import to_json_dict as _to_json_dict -from gaia.inquiry.snapshot import ( +from gaia.engine.inquiry.diff import SemanticDiff, compute_semantic_diff, empty_diff +from gaia.engine.inquiry.focus import FocusBinding, resolve_focus_target +from gaia.engine.inquiry.proof_state import ProofContext, build_proof_context +from gaia.engine.inquiry.ranking import rank_diagnostics, rank_next_edits +from gaia.engine.inquiry.render import render_markdown as _render_markdown +from gaia.engine.inquiry.render import render_text as _render_text +from gaia.engine.inquiry.render import to_json_dict as _to_json_dict +from gaia.engine.inquiry.review_manifest import load_or_generate_review_manifest +from gaia.engine.inquiry.snapshot import ( load_snapshot, mint_review_id, resolve_baseline, save_snapshot, ) -from gaia.inquiry.state import load_state, save_state +from gaia.engine.inquiry.state import load_state, save_state +from gaia.engine.ir import ReviewManifest, ReviewStatus +from gaia.engine.ir.validator import validate_local_graph +from gaia.engine.packaging import ( + GaiaPackagingError, + apply_package_priors, + collect_foreign_node_priors, + compile_loaded_package_artifact, + ensure_package_env, + load_dependency_compiled_graphs, + load_gaia_package, +) def _utcnow_iso() -> str: - return datetime.now(tz=timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + return datetime.now(tz=UTC).isoformat(timespec="seconds").replace("+00:00", "Z") # --------------------------------------------------------------------------- # @@ -71,6 +75,8 @@ def _utcnow_iso() -> str: @dataclass class ReviewReport: + """Passive container for the eight-section Gaia inquiry review report.""" + review_id: str created_at: str path: str @@ -106,6 +112,7 @@ class ReviewReport: proof_context: ProofContext | None = None def to_json_dict(self) -> dict[str, Any]: + """Return the report in the shared JSON dictionary shape.""" return _to_json_dict(self) @@ -114,14 +121,14 @@ def to_json_dict(self) -> dict[str, Any]: # --------------------------------------------------------------------------- # -def resolve_graph(path: str | Path): +def resolve_graph(path: str | Path) -> Any: """Compile a package and return its LocalCanonicalGraph (or None on failure).""" try: ensure_package_env(Path(path).resolve()) loaded = load_gaia_package(str(path)) apply_package_priors(loaded) compiled = compile_loaded_package_artifact(loaded) - except GaiaCliError: + except GaiaPackagingError: return None except Exception: return None @@ -153,6 +160,8 @@ def run_review( since: str | None = None, strict: bool = False, ) -> ReviewReport: + """Run the inquiry review pipeline and persist a review snapshot.""" + del strict pkg_path = Path(path).resolve() state = load_state(pkg_path) focus_raw = focus_override if focus_override is not None else state.focus @@ -160,6 +169,9 @@ def run_review( warnings: list[str] = [] errors: list[str] = [] graph = None + loaded = None + compiled = None + review_manifest: ReviewManifest | None = None compile_status = "error" ir_hash: str | None = None counts = {"knowledge": 0, "strategies": 0, "operators": 0} @@ -172,7 +184,7 @@ def run_review( compiled = compile_loaded_package_artifact(loaded) graph = compiled.graph compile_status = "ok" - except GaiaCliError as exc: + except GaiaPackagingError as exc: errors.append(f"compile: {exc}") except Exception as exc: # surfaced as report error, not raised errors.append(f"compile: {exc}") @@ -188,29 +200,43 @@ def run_review( warnings.extend(validation.warnings) errors.extend(validation.errors) + if loaded is not None and compiled is not None: + try: + review_manifest = load_or_generate_review_manifest(loaded.pkg_path, compiled) + except GaiaPackagingError as exc: + errors.append(f"review_manifest: {exc}") + focus = resolve_focus_target(focus_raw, graph) # Step 3: knowledge breakdown via check_core (single source of truth). ir_dict = _graph_to_ir_dict(graph) - if ir_dict is not None: - kb = analyze_knowledge_breakdown(ir_dict) - else: - kb = KnowledgeBreakdown() + kb = analyze_knowledge_breakdown(ir_dict) if ir_dict is not None else KnowledgeBreakdown() - graph_health = _build_graph_health(kb, ir_dict, warnings, errors) prior_holes = _build_prior_holes(kb) - inquiry_tree = _build_inquiry_tree(kb, graph) + inquiry_tree = _build_inquiry_tree(kb, graph, review_manifest) # Step 4: semantic diff against baseline snapshot. baseline_id = resolve_baseline(pkg_path, since, state.last_review_id) baseline_snap = load_snapshot(pkg_path, baseline_id) if baseline_id else None semantic_diff = compute_semantic_diff(ir_dict, baseline_snap) - # Step 5: inference via gaia.bp; enrich with baseline belief deltas. - belief_report = _build_belief_report(graph, pkg_path, no_infer, errors, focus) + # Step 5: inference via gaia.engine.bp; enrich with baseline belief deltas. + belief_report = _build_belief_report( + graph, + pkg_path, + no_infer, + errors, + focus, + loaded=loaded, + compiled=compiled, + review_manifest=review_manifest, + depth=depth, + ) if belief_report["ran_inference"] and baseline_snap is not None: _annotate_belief_deltas(belief_report, baseline_snap) + graph_health = _build_graph_health(kb, ir_dict, warnings, errors) + # Step 6: diagnostics — translate validator + breakdown into one stream. anchors = find_anchors(pkg_path) diagnostics: list[Diagnostic] = [] @@ -218,10 +244,19 @@ def run_review( if ir_dict is not None: diagnostics.extend(from_knowledge_breakdown(kb, ir_dict, focus, anchors)) diagnostics.extend(detect_prior_without_justification(kb, anchors)) + diagnostics.extend(detect_prior_dissent(ir_dict, anchors=anchors)) + diagnostics.extend(detect_prior_overridden(ir_dict, anchors=anchors)) diagnostics.extend(detect_stale_artifact(pkg_path, ir_hash)) diagnostics.extend(detect_focus_low_posterior(belief_report)) rejected_targets = {r.target_strategy for r in getattr(state, "synthetic_rejections", []) or []} - diagnostics.extend(detect_warrant_status(graph, rejected_targets, anchors)) + diagnostics.extend( + detect_warrant_status( + graph, + rejected_targets, + anchors, + review_manifest=review_manifest, + ) + ) if graph is not None: if ir_dict is not None: diagnostics.extend(detect_blocked_warrant_path(graph, kb, anchors)) @@ -268,7 +303,7 @@ def run_review( ) # Persist snapshot for future diffs. - save_snapshot( + snapshot_path = save_snapshot( pkg_path, review_id=review_id, created_at=created_at, @@ -276,6 +311,10 @@ def run_review( ir_dict=ir_dict, beliefs=belief_report.get("beliefs", []), ) + actual_review_id = snapshot_path.stem + if actual_review_id != review_id: + review_id = actual_review_id + report.review_id = actual_review_id state.last_review_id = review_id if state.baseline_review_id is None: @@ -286,7 +325,7 @@ def run_review( return report -def _annotate_belief_deltas(belief_report: dict, baseline_snap: dict) -> None: +def _annotate_belief_deltas(belief_report: dict[str, Any], baseline_snap: dict[str, Any]) -> None: """Compute per-claim belief deltas vs baseline; fill focus/largest_*.""" base_by_id = {b["knowledge_id"]: b["belief"] for b in baseline_snap.get("beliefs", [])} deltas: list[tuple[str, str, float, float, float]] = [] @@ -329,7 +368,7 @@ def _annotate_belief_deltas(belief_report: dict, baseline_snap: dict) -> None: # --------------------------------------------------------------------------- # -def _graph_to_ir_dict(graph) -> dict | None: +def _graph_to_ir_dict(graph: Any) -> dict[str, Any] | None: """Convert a LocalCanonicalGraph to the dict shape consumed by check_core. check_core was written against the JSON IR shape. The compiled graph holds @@ -342,7 +381,7 @@ def _graph_to_ir_dict(graph) -> dict | None: knowledges.append( { "id": getattr(k, "id", ""), - "label": getattr(k, "label", ""), + "label": getattr(k, "label", None) or "", "type": _normalize_type(getattr(k, "type", "")), "content": getattr(k, "content", "") or "", "metadata": dict(getattr(k, "metadata", {}) or {}), @@ -353,7 +392,7 @@ def _graph_to_ir_dict(graph) -> dict | None: for s in getattr(graph, "strategies", []) or []: strategies.append( { - "id": getattr(s, "id", ""), + "id": _strategy_id(s), "conclusion": getattr(s, "conclusion", None), "premises": list(getattr(s, "premises", []) or []), "background": list(getattr(s, "background", []) or []), @@ -363,7 +402,7 @@ def _graph_to_ir_dict(graph) -> dict | None: for o in getattr(graph, "operators", []) or []: operators.append( { - "id": getattr(o, "id", ""), + "id": _operator_id(o), "conclusion": getattr(o, "conclusion", None), "variables": list(getattr(o, "variables", []) or []), } @@ -371,6 +410,14 @@ def _graph_to_ir_dict(graph) -> dict | None: return {"knowledges": knowledges, "strategies": strategies, "operators": operators} +def _strategy_id(strategy: Any) -> str: + return getattr(strategy, "strategy_id", None) or getattr(strategy, "id", None) or "" + + +def _operator_id(operator: Any) -> str: + return getattr(operator, "operator_id", None) or getattr(operator, "id", None) or "" + + def _normalize_type(t: Any) -> str: s = str(t) if "." in s: @@ -380,7 +427,7 @@ def _normalize_type(t: Any) -> str: def _build_graph_health( kb: KnowledgeBreakdown, - ir_dict: dict | None, + ir_dict: dict[str, Any] | None, warnings: list[str], errors: list[str], ) -> dict[str, Any]: @@ -410,8 +457,43 @@ def _build_prior_holes(kb: KnowledgeBreakdown) -> list[dict[str, Any]]: return out -def _build_inquiry_tree(kb: KnowledgeBreakdown, graph) -> dict[str, Any]: - n_strategies = len(getattr(graph, "strategies", []) or []) if graph else 0 +def _empty_belief_report() -> dict[str, Any]: + return { + "ran_inference": False, + "beliefs": [], + "focus": None, + "largest_increases": [], + "largest_decreases": [], + } + + +def _strategy_review_status( + strategy: Any, + review_manifest: ReviewManifest | None, +) -> ReviewStatus | None: + sid = _strategy_id(strategy) + if review_manifest is None or not sid: + return None + return review_manifest.latest_status(sid) + + +def _build_inquiry_tree( + kb: KnowledgeBreakdown, + graph: Any, + review_manifest: ReviewManifest | None = None, +) -> dict[str, Any]: + accepted_warrants = 0 + rejected_warrants = 0 + unreviewed_warrants = 0 + if graph is not None: + for s in getattr(graph, "strategies", []) or []: + status = _strategy_review_status(s, review_manifest) + if status == ReviewStatus.ACCEPTED: + accepted_warrants += 1 + elif status == ReviewStatus.REJECTED: + rejected_warrants += 1 + else: + unreviewed_warrants += 1 hole_ids = {h.cid for h in kb.holes} blocked_paths = 0 if graph is not None and hole_ids: @@ -421,67 +503,115 @@ def _build_inquiry_tree(kb: KnowledgeBreakdown, graph) -> dict[str, Any]: blocked_paths += 1 return { "goals": len(kb.questions), - "accepted_warrants": 0, - "unreviewed_warrants": n_strategies, + "accepted_warrants": accepted_warrants, + "unreviewed_warrants": unreviewed_warrants, + "rejected_warrants": rejected_warrants, "blocked_paths": blocked_paths, "structural_holes": list(kb.orphaned), } +def _dependency_factor_graphs(loaded: Any, depth: int) -> list[tuple[str, Any, str]]: + from gaia.engine.bp import lower_local_graph + + dep_factor_graphs: list[tuple[str, Any, str]] = [] + for dep in load_dependency_compiled_graphs(loaded.project_config, depth=depth): + dep_review_manifest = load_or_generate_review_manifest(dep.root, dep) + dep_fg = lower_local_graph(dep.graph, review_manifest=dep_review_manifest) + dep_prefix = f"{dep.graph.namespace}:{dep.graph.package_name}::" + dep_factor_graphs.append((dep.import_name, dep_fg, dep_prefix)) + return dep_factor_graphs + + +def _review_factor_graph( + graph: Any, + pkg_path: Path, + loaded: Any, + review_manifest: ReviewManifest | None, + depth: int, +) -> Any: + from gaia.engine.bp import lower_local_graph, merge_factor_graphs + + if depth != 0: + dep_factor_graphs = _dependency_factor_graphs(loaded, depth) + local_fg = lower_local_graph(graph, review_manifest=review_manifest) + local_prefix = f"{graph.namespace}:{graph.package_name}::" + if dep_factor_graphs: + return merge_factor_graphs(local_fg, dep_factor_graphs, local_prefix=local_prefix) + return local_fg + + foreign = collect_foreign_node_priors(graph, pkg_path) + return lower_local_graph( + graph, + node_priors=foreign or None, + review_manifest=review_manifest, + ) + + +def _run_factor_graph_inference(fg: Any) -> Any: + from gaia.engine.bp.engine import InferenceEngine + + engine = InferenceEngine() + return engine.run(fg) + + +def _append_belief_entries(out: dict[str, Any], graph: Any, result: Any) -> None: + kbyid = {knowledge.id: knowledge for knowledge in graph.knowledges} + for kid, belief in sorted(result.beliefs.items()): + if kid in kbyid: + out["beliefs"].append( + {"knowledge_id": kid, "label": kbyid[kid].label, "belief": belief} + ) + + +def _set_focus_belief(out: dict[str, Any], focus: FocusBinding) -> None: + if not focus.resolved_id: + return + for entry in out["beliefs"]: + if entry["knowledge_id"] == focus.resolved_id: + out["focus"] = { + "knowledge_id": focus.resolved_id, + "label": entry["label"], + "before": None, + "after": entry["belief"], + "delta": None, + } + return + + def _build_belief_report( - graph, + graph: Any, pkg_path: Path, no_infer: bool, errors: list[str], focus: FocusBinding, + *, + loaded: Any = None, + compiled: Any = None, + review_manifest: ReviewManifest | None = None, + depth: int = 0, ) -> dict[str, Any]: - out: dict[str, Any] = { - "ran_inference": False, - "beliefs": [], - "focus": None, - "largest_increases": [], - "largest_decreases": [], - } + out = _empty_belief_report() if graph is None or no_infer: return out + if loaded is None or compiled is None: + return out if errors: return out try: - from gaia.bp import lower_local_graph - from gaia.bp.engine import InferenceEngine - - foreign = collect_foreign_node_priors(graph, pkg_path) - fg = lower_local_graph(graph, node_priors=foreign or None) + fg = _review_factor_graph(graph, pkg_path, loaded, review_manifest, depth) fg_errs = fg.validate() if fg_errs: errors.extend(fg_errs) return out - engine = InferenceEngine() - result = engine.run(fg) + result = _run_factor_graph_inference(fg) except Exception as exc: # pragma: no cover errors.append(f"infer: {exc}") return out out["ran_inference"] = True - kbyid = {k.id: k for k in graph.knowledges} - for kid, belief in sorted(result.bp_result.beliefs.items()): - if kid in kbyid: - out["beliefs"].append( - {"knowledge_id": kid, "label": kbyid[kid].label, "belief": belief} - ) - - if focus.resolved_id: - for entry in out["beliefs"]: - if entry["knowledge_id"] == focus.resolved_id: - out["focus"] = { - "knowledge_id": focus.resolved_id, - "label": entry["label"], - "before": None, - "after": entry["belief"], - "delta": None, - } - break - + _append_belief_entries(out, graph, result) + _set_focus_belief(out, focus) return out diff --git a/gaia/engine/inquiry/review_manifest.py b/gaia/engine/inquiry/review_manifest.py new file mode 100644 index 000000000..5787c45cd --- /dev/null +++ b/gaia/engine/inquiry/review_manifest.py @@ -0,0 +1,102 @@ +"""ReviewManifest loading + merge helpers (engine-side facade entry). + +`load_or_generate_review_manifest` is publicly re-exported via +`gaia.engine.inquiry.__all__`. The other helpers (merge / latest_reviews / +REVIEW_MANIFEST_REL_PATH) are engine-internal helpers used by CLI commands. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from pydantic import ValidationError + +from gaia.engine.ir import Review, ReviewManifest +from gaia.engine.lang.review.manifest import generate_review_manifest +from gaia.engine.packaging import GaiaPackagingError + +REVIEW_MANIFEST_REL_PATH = Path(".gaia") / "review_manifest.json" + + +def _generated_manifest(compiled: Any) -> ReviewManifest: + return getattr(compiled, "review", None) or generate_review_manifest(compiled) + + +def merge_review_manifests( + generated: ReviewManifest, + persisted: ReviewManifest, +) -> ReviewManifest: + """Merge persisted review rounds onto the generated target list. + + Generated entries ensure newly compiled v6 action targets still appear as + unreviewed. Persisted entries preserve manual reviewer decisions for matching + target ids. When a target id changes but the stable action label, target kind, + and audit question are unchanged, persisted rounds are reattached to the new + target id so accepted reviews are not silently dropped by hash churn. + """ + generated_target_ids = {review.target_id for review in generated.reviews} + generated_by_stable_key: dict[tuple[str, str, str], Review] = {} + duplicate_stable_keys: set[tuple[str, str, str]] = set() + generated_by_action_key: dict[tuple[str, str], Review] = {} + duplicate_action_keys: set[tuple[str, str]] = set() + for review in generated.reviews: + key = (review.action_label, review.target_kind, review.audit_question) + if key in generated_by_stable_key: + duplicate_stable_keys.add(key) + else: + generated_by_stable_key[key] = review + action_key = (review.action_label, review.audit_question) + if action_key in generated_by_action_key: + duplicate_action_keys.add(action_key) + else: + generated_by_action_key[action_key] = review + + reviews = list(generated.reviews) + for review in persisted.reviews: + if review.target_id in generated_target_ids: + reviews.append(review) + continue + + key = (review.action_label, review.target_kind, review.audit_question) + generated_review = generated_by_stable_key.get(key) + if generated_review is None or key in duplicate_stable_keys: + action_key = (review.action_label, review.audit_question) + generated_review = generated_by_action_key.get(action_key) + if generated_review is None or action_key in duplicate_action_keys: + continue + reviews.append( + review.model_copy( + update={ + "review_id": generated_review.review_id, + "target_id": generated_review.target_id, + } + ) + ) + return ReviewManifest(reviews=reviews) + + +def latest_reviews(manifest: ReviewManifest) -> list[Review]: + """Return the most recent review per target, ordered by action label.""" + latest: dict[str, Review] = {} + for review in manifest.reviews: + current = latest.get(review.target_id) + if current is None or review.round > current.round: + latest[review.target_id] = review + return sorted(latest.values(), key=lambda review: review.action_label) + + +def load_or_generate_review_manifest(pkg_path: str | Path, compiled: Any) -> ReviewManifest: + """Load `.gaia/review_manifest.json` if present, else generate one from the compiled IR.""" + generated = _generated_manifest(compiled) + path = Path(pkg_path) / REVIEW_MANIFEST_REL_PATH + if not path.exists(): + return generated + + try: + data = json.loads(path.read_text()) + persisted = ReviewManifest.model_validate(data) + except (OSError, json.JSONDecodeError, ValidationError) as exc: + raise GaiaPackagingError(f"Error: {path} is not a valid ReviewManifest: {exc}") from exc + return merge_review_manifests(generated, persisted) diff --git a/gaia/inquiry/snapshot.py b/gaia/engine/inquiry/snapshot.py similarity index 85% rename from gaia/inquiry/snapshot.py rename to gaia/engine/inquiry/snapshot.py index 845c3fb85..9f0b9ba23 100644 --- a/gaia/inquiry/snapshot.py +++ b/gaia/engine/inquiry/snapshot.py @@ -9,16 +9,17 @@ import json import re -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, cast -from gaia.inquiry.state import inquiry_dir +from gaia.engine.inquiry.state import inquiry_dir _SAFE_MODE = re.compile(r"[^A-Za-z0-9._-]+") def reviews_dir(pkg_path: str | Path) -> Path: + """Return the package review snapshot directory, creating it if needed.""" d = inquiry_dir(pkg_path) / "reviews" d.mkdir(parents=True, exist_ok=True) return d @@ -30,7 +31,7 @@ def mint_review_id(ir_hash: str | None, mode: str) -> str: Colons in the ISO timestamp are replaced with dashes so the id is usable as a path component on every platform. """ - ts = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ") + ts = datetime.now(tz=UTC).strftime("%Y-%m-%dT%H-%M-%SZ") raw = ir_hash or "nohash" if ":" in raw: raw = raw.split(":", 1)[1] @@ -45,7 +46,7 @@ def save_snapshot( review_id: str, created_at: str, ir_hash: str | None, - ir_dict: dict | None, + ir_dict: dict[str, Any] | None, beliefs: list[dict[str, Any]], ) -> Path: """Persist the minimal snapshot needed to diff future reviews.""" @@ -71,12 +72,13 @@ def save_snapshot( return path -def load_snapshot(pkg_path: str | Path, review_id: str) -> dict | None: +def load_snapshot(pkg_path: str | Path, review_id: str) -> dict[str, Any] | None: + """Load a persisted review snapshot by id, returning None if unavailable.""" path = reviews_dir(pkg_path) / f"{review_id}.json" if not path.exists(): return None try: - return json.loads(path.read_text(encoding="utf-8")) + return cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8"))) except (OSError, json.JSONDecodeError): return None diff --git a/gaia/inquiry/state.py b/gaia/engine/inquiry/state.py similarity index 86% rename from gaia/inquiry/state.py rename to gaia/engine/inquiry/state.py index ff4c2ddb9..785a71ec8 100644 --- a/gaia/inquiry/state.py +++ b/gaia/engine/inquiry/state.py @@ -18,7 +18,7 @@ import json import uuid from dataclasses import asdict, dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -36,15 +36,18 @@ def _utcnow() -> str: - return datetime.now(tz=timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + return datetime.now(tz=UTC).isoformat(timespec="seconds").replace("+00:00", "Z") def mint_qid(prefix: str) -> str: + """Mint a short synthetic inquiry identifier with the given prefix.""" return f"{prefix}_{uuid.uuid4().hex[:8]}" @dataclass class SyntheticObligation: + """Synthetic obligation tracked outside compiled IR.""" + qid: str target_qid: str content: str @@ -53,6 +56,7 @@ class SyntheticObligation: created_at: str | None = None def __post_init__(self) -> None: + """Fill creation time and validate the obligation kind.""" if self.created_at is None: self.created_at = _utcnow() if self.diagnostic_kind not in VALID_OBLIGATION_KINDS: @@ -64,30 +68,38 @@ def __post_init__(self) -> None: @dataclass class SyntheticHypothesis: + """Synthetic hypothesis tracked outside compiled IR.""" + qid: str content: str scope_qid: str | None = None created_at: str | None = None def __post_init__(self) -> None: + """Fill creation time when the hypothesis is created.""" if self.created_at is None: self.created_at = _utcnow() @dataclass class SyntheticRejection: + """Synthetic record for a rejected strategy branch.""" + qid: str target_strategy: str content: str created_at: str | None = None def __post_init__(self) -> None: + """Fill creation time when the rejection is created.""" if self.created_at is None: self.created_at = _utcnow() @dataclass class InquiryState: + """Mutable Lean-style inquiry state stored under ``.gaia/inquiry``.""" + version: int = STATE_SCHEMA_VERSION focus: str | None = None focus_kind: str | None = None @@ -101,6 +113,7 @@ class InquiryState: synthetic_rejections: list[SyntheticRejection] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: + """Return the persisted state payload as a JSON-compatible dictionary.""" return { "version": self.version, "focus": self.focus, @@ -117,6 +130,7 @@ def to_dict(self) -> dict[str, Any]: def inquiry_dir(pkg_path: str | Path) -> Path: + """Return the package inquiry directory, creating it if needed.""" d = Path(pkg_path).resolve() / ".gaia" / "inquiry" d.mkdir(parents=True, exist_ok=True) return d @@ -131,6 +145,7 @@ def _tactics_path(pkg_path: str | Path) -> Path: def load_state(pkg_path: str | Path) -> InquiryState: + """Load the persisted inquiry state, or return the default empty state.""" p = _state_path(pkg_path) if not p.exists(): return InquiryState() @@ -162,6 +177,7 @@ def load_state(pkg_path: str | Path) -> InquiryState: def save_state(pkg_path: str | Path, state: InquiryState) -> None: + """Persist inquiry state to ``.gaia/inquiry/state.json``.""" p = _state_path(pkg_path) payload = state.to_dict() p.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") @@ -170,6 +186,7 @@ def save_state(pkg_path: str | Path, state: InquiryState) -> None: def append_tactic_event( pkg_path: str | Path, event: str, payload: dict[str, Any] | None = None ) -> None: + """Append one tactic event to the inquiry audit log.""" rec = { "timestamp": _utcnow(), "event": event, @@ -181,6 +198,7 @@ def append_tactic_event( def read_tactic_log(pkg_path: str | Path) -> list[dict[str, Any]]: + """Read the inquiry tactic log as JSON records.""" p = _tactics_path(pkg_path) if not p.exists(): return [] diff --git a/gaia/engine/ir/__init__.py b/gaia/engine/ir/__init__.py new file mode 100644 index 000000000..c7093bc54 --- /dev/null +++ b/gaia/engine/ir/__init__.py @@ -0,0 +1,86 @@ +"""Gaia IR — data models for the Gaia reasoning hypergraph. + +Three entities: Knowledge (propositions), Operator (deterministic constraints), +Strategy (reasoning declarations with three forms). + +Parameterization (probability parameters) acts on LocalCanonicalGraph. + +Spec: docs/foundations/gaia-ir/ +""" + +from gaia.engine.ir.compose import Compose +from gaia.engine.ir.formalize import FormalizationResult, formalize_named_strategy +from gaia.engine.ir.formula import FormulaEdge, FormulaGraph, FormulaNode, formula_node_id +from gaia.engine.ir.graphs import LocalCanonicalGraph +from gaia.engine.ir.knowledge import ( + Knowledge, + KnowledgeType, + PackageRef, + Parameter, + make_qid, +) +from gaia.engine.ir.operator import Operator, OperatorType +from gaia.engine.ir.parameterization import ( + CROMWELL_EPS, + DEFAULT_PRIORITY_ORDER, + ParameterizationSource, + PriorRecord, + ResolutionPolicy, + default_resolution_policy, +) +from gaia.engine.ir.review import Review, ReviewManifest, ReviewStatus +from gaia.engine.ir.schemas import ( + BUILTIN_DISTRIBUTION_KINDS, + BuiltinDistributionKind, + CallableRef, + DistributionLiteral, + DistributionParam, + QuantityLiteral, +) +from gaia.engine.ir.strategy import ( + CompositeStrategy, + FormalExpr, + FormalStrategy, + Step, + Strategy, + StrategyType, +) + +__all__ = [ + "BUILTIN_DISTRIBUTION_KINDS", # Schemas + "CROMWELL_EPS", # Parameterization + "DEFAULT_PRIORITY_ORDER", # Parameterization + "BuiltinDistributionKind", # Schemas + "CallableRef", # Schemas + "Compose", # Compose + "CompositeStrategy", # Strategy + "DistributionLiteral", # Schemas + "DistributionParam", # Schemas + "FormalExpr", # Strategy + "FormalStrategy", # Strategy + "FormalizationResult", # Formalization + "FormulaEdge", # Formula + "FormulaGraph", # Formula + "FormulaNode", # Formula + "Knowledge", # Knowledge + "KnowledgeType", # Knowledge + "LocalCanonicalGraph", # Graphs + "Operator", # Operator + "OperatorType", # Operator + "PackageRef", # Knowledge + "Parameter", # Knowledge + "ParameterizationSource", # Parameterization + "PriorRecord", # Parameterization + "QuantityLiteral", # Schemas + "ResolutionPolicy", # Parameterization + "Review", # Review + "ReviewManifest", # Review + "ReviewStatus", # Review + "Step", # Strategy + "Strategy", # Strategy + "StrategyType", # Strategy + "default_resolution_policy", # Parameterization + "formalize_named_strategy", # Formalization + "formula_node_id", # Formula + "make_qid", # Knowledge +] diff --git a/gaia/engine/ir/coarsen.py b/gaia/engine/ir/coarsen.py new file mode 100644 index 000000000..659b828cd --- /dev/null +++ b/gaia/engine/ir/coarsen.py @@ -0,0 +1,496 @@ +"""Coarsen a Gaia IR to show only leaf premises → exported conclusions. + +All intermediate nodes are folded away. Each multi-hop reasoning chain +becomes a single ``infer`` edge connecting a leaf premise to an exported +conclusion it supports (directly or transitively). +""" + +from __future__ import annotations + +from typing import Any + +_HELPER_LABEL_PREFIXES = ("__", "_anon") + + +def coarsen_ir(ir: dict[str, Any], exported_ids: set[str]) -> dict[str, Any]: + """Produce a coarse-grained IR with leaf premises and exported conclusions. + + Args: + ir: Full compiled IR dict with knowledges, strategies, operators. + exported_ids: Set of knowledge IDs that are exported conclusions. + + Returns: + A new IR dict (same schema) containing only leaf premises + exported + conclusions, connected by ``infer`` strategies representing transitive + reasoning chains. + """ + knowledge_labels = _knowledge_labels(ir) + leaf_ids = _leaf_ids(ir, knowledge_labels) + forward = _build_forward_adjacency(ir) + + edges = _coarse_edges(leaf_ids, exported_ids, forward) + leaf_ids |= _add_orphan_surrogate_edges(ir, exported_ids, forward, edges) + unique_edges = sorted(set(edges)) + + connected_leaves = {src for src, _ in unique_edges} + connected_exports = {dst for _, dst in unique_edges} + keep_ids = connected_leaves | connected_exports + coarse_knowledges = _coarse_knowledges(ir, keep_ids) + coarse_strategies = _coarse_strategies(unique_edges) + coarse_operators = _coarse_operators(ir, keep_ids, coarse_knowledges) + + return { + "package_name": ir.get("package_name", ""), + "namespace": ir.get("namespace", ""), + "knowledges": coarse_knowledges, + "strategies": coarse_strategies, + "operators": coarse_operators, + } + + +def _is_helper_label(label: str) -> bool: + return label.startswith(_HELPER_LABEL_PREFIXES) + + +def _knowledge_labels(ir: dict[str, Any]) -> dict[str, str]: + return {k["id"]: k.get("label") or "" for k in ir["knowledges"]} + + +def _knowledge_types(ir: dict[str, Any]) -> dict[str, str]: + return {k["id"]: k.get("type", "") for k in ir["knowledges"]} + + +def _concluded_ids(ir: dict[str, Any]) -> set[str]: + strat_conclusions = {s["conclusion"] for s in ir["strategies"] if s.get("conclusion")} + op_conclusions = {o["conclusion"] for o in ir["operators"] if o.get("conclusion")} + return strat_conclusions | op_conclusions + + +def _leaf_ids(ir: dict[str, Any], knowledge_labels: dict[str, str]) -> set[str]: + all_concluded = _concluded_ids(ir) + leaf_ids = { + k["id"] + for k in ir["knowledges"] + if not _is_helper_label(k.get("label") or "") + and k["id"] not in all_concluded + and k["type"] == "claim" + } + leaf_ids.update(_induction_interface_premises(ir, knowledge_labels)) + return leaf_ids + + +def _induction_interface_premises( + ir: dict[str, Any], + knowledge_labels: dict[str, str], +) -> set[str]: + premises: set[str] = set() + for strategy in ir["strategies"]: + if strategy.get("type") != "induction": + continue + conclusion = strategy.get("conclusion") + if not conclusion: + continue + for premise in strategy.get("premises", []): + if premise != conclusion and not _is_helper_label(knowledge_labels.get(premise, "")): + premises.add(premise) + return premises + + +def _build_forward_adjacency(ir: dict[str, Any]) -> dict[str, set[str]]: + forward: dict[str, set[str]] = {} + for strategy in ir["strategies"]: + _add_adjacency_edges(forward, strategy.get("premises", []), strategy.get("conclusion")) + for operator in ir["operators"]: + _add_adjacency_edges(forward, operator.get("variables", []), operator.get("conclusion")) + return forward + + +def _build_reverse_adjacency(ir: dict[str, Any]) -> dict[str, set[str]]: + reverse: dict[str, set[str]] = {} + for strategy in ir["strategies"]: + _add_reverse_edges(reverse, strategy.get("conclusion"), strategy.get("premises", [])) + for operator in ir["operators"]: + _add_reverse_edges(reverse, operator.get("conclusion"), operator.get("variables", [])) + return reverse + + +def _add_adjacency_edges( + adjacency: dict[str, set[str]], + sources: list[str], + conclusion: str | None, +) -> None: + if not conclusion: + return + for source in sources: + adjacency.setdefault(source, set()).add(conclusion) + + +def _add_reverse_edges( + reverse: dict[str, set[str]], + conclusion: str | None, + sources: list[str], +) -> None: + if not conclusion: + return + for source in sources: + reverse.setdefault(conclusion, set()).add(source) + + +def _coarse_edges( + leaf_ids: set[str], + exported_ids: set[str], + forward: dict[str, set[str]], +) -> list[tuple[str, str]]: + edges = _reachable_export_edges(leaf_ids, exported_ids, forward) + for exported_id in exported_ids: + starts = forward.get(exported_id, set()) + edges.extend( + _reachable_export_edges( + {exported_id}, + exported_ids, + forward, + starts=starts, + include_self_export=True, + ) + ) + return edges + + +def _reachable_export_edges( + source_ids: set[str], + exported_ids: set[str], + forward: dict[str, set[str]], + *, + starts: set[str] | None = None, + include_self_export: bool = False, +) -> list[tuple[str, str]]: + edges: list[tuple[str, str]] = [] + for source_id in source_ids: + queue = list(starts) if starts is not None else [source_id] + visited: set[str] = set() + while queue: + node = queue.pop(0) + if node in visited: + continue + visited.add(node) + if (include_self_export or node != source_id) and node in exported_ids: + edges.append((source_id, node)) + continue + queue.extend(neighbor for neighbor in forward.get(node, []) if neighbor not in visited) + return edges + + +def _add_orphan_surrogate_edges( + ir: dict[str, Any], + exported_ids: set[str], + forward: dict[str, set[str]], + edges: list[tuple[str, str]], +) -> set[str]: + orphaned_exports = exported_ids - {dst for _, dst in edges} + if not orphaned_exports: + return set() + + reverse = _build_reverse_adjacency(ir) + surrogate_leaves = _surrogate_leaves_for_orphans( + orphaned_exports, + reverse, + _knowledge_labels(ir), + _knowledge_types(ir), + ) + edges.extend(_reachable_export_edges(surrogate_leaves, exported_ids, forward)) + return surrogate_leaves + + +def _surrogate_leaves_for_orphans( + orphaned_exports: set[str], + reverse: dict[str, set[str]], + knowledge_labels: dict[str, str], + knowledge_types: dict[str, str], +) -> set[str]: + surrogate_leaves: set[str] = set() + for orphan in orphaned_exports: + surrogate_leaves.update( + _cycle_breaking_leaves(orphan, reverse, knowledge_labels, knowledge_types) + ) + return surrogate_leaves + + +def _cycle_breaking_leaves( + orphan: str, + reverse: dict[str, set[str]], + knowledge_labels: dict[str, str], + knowledge_types: dict[str, str], +) -> set[str]: + leaves: set[str] = set() + visited: set[str] = set() + queue = list(reverse.get(orphan, [])) + while queue: + node = queue.pop(0) + if node in visited: + continue + visited.add(node) + queue.extend( + _next_reverse_nodes(node, reverse, knowledge_labels, knowledge_types, visited, leaves) + ) + return leaves + + +def _next_reverse_nodes( + node: str, + reverse: dict[str, set[str]], + knowledge_labels: dict[str, str], + knowledge_types: dict[str, str], + visited: set[str], + leaves: set[str], +) -> list[str]: + if _is_helper_label(knowledge_labels.get(node, "")): + return [pred for pred in reverse.get(node, []) if pred not in visited] + if knowledge_types.get(node) != "claim": + return [] + + preds = reverse.get(node, set()) + non_helper_preds = _non_helper_claim_predecessors(preds, knowledge_labels, knowledge_types) + if not non_helper_preds or non_helper_preds <= visited: + leaves.add(node) + return [] + return [pred for pred in preds if pred not in visited] + + +def _non_helper_claim_predecessors( + predecessors: set[str], + knowledge_labels: dict[str, str], + knowledge_types: dict[str, str], +) -> set[str]: + return { + pred + for pred in predecessors + if not _is_helper_label(knowledge_labels.get(pred, "")) + and knowledge_types.get(pred) == "claim" + } + + +def _coarse_knowledges(ir: dict[str, Any], keep_ids: set[str]) -> list[dict[str, Any]]: + return [knowledge for knowledge in ir["knowledges"] if knowledge["id"] in keep_ids] + + +def _coarse_strategies(unique_edges: list[tuple[str, str]]) -> list[dict[str, Any]]: + by_conclusion: dict[str, list[str]] = {} + for source_id, conclusion_id in unique_edges: + if source_id != conclusion_id: + by_conclusion.setdefault(conclusion_id, []).append(source_id) + return [ + {"type": "infer", "premises": sorted(premises), "conclusion": conclusion, "reason": ""} + for conclusion, premises in by_conclusion.items() + ] + + +def _coarse_operators( + ir: dict[str, Any], + keep_ids: set[str], + coarse_knowledges: list[dict[str, Any]], +) -> list[dict[str, Any]]: + knowledge_by_id = {knowledge["id"]: knowledge for knowledge in ir["knowledges"]} + coarse_operators = [] + for operator in ir.get("operators", []): + all_nodes = _operator_nodes(operator) + if all_nodes & keep_ids: + coarse_operators.append(operator) + _pull_operator_knowledges(all_nodes, keep_ids, coarse_knowledges, knowledge_by_id) + return coarse_operators + + +def _operator_nodes(operator: dict[str, Any]) -> set[str]: + all_nodes = set(operator.get("variables", [])) + if conclusion := operator.get("conclusion"): + all_nodes.add(conclusion) + return all_nodes + + +def _pull_operator_knowledges( + all_nodes: set[str], + keep_ids: set[str], + coarse_knowledges: list[dict[str, Any]], + knowledge_by_id: dict[str, dict[str, Any]], +) -> None: + for node_id in all_nodes: + if node_id in keep_ids: + continue + keep_ids.add(node_id) + knowledge = knowledge_by_id.get(node_id) + if knowledge and not (knowledge.get("label", "") or "").startswith("__"): + coarse_knowledges.append(knowledge) + + +def _binary_entropy(p: float) -> float: + """H(Bernoulli(p)) in bits.""" + import math + + if p <= 0 or p >= 1: + return 0.0 + return -(p * math.log2(p) + (1 - p) * math.log2(1 - p)) + + +def mutual_information( + cpt: list[float], + premise_priors: list[float], +) -> float: + """Compute I(premises; conclusion) in bits from a coarse CPT. + + Args: + cpt: CPT of length 2^k, indexed by binary encoding of premise assignment. + premise_priors: Prior probability of each premise being true (length k). + + Returns: + Mutual information in bits. + """ + k = len(premise_priors) + assert len(cpt) == (1 << k) + + # P(C=1) marginal and conditional entropy H(C|P) + p_c1 = 0.0 + h_c_given_p = 0.0 + + for assignment in range(1 << k): + # P(assignment) = product of premise marginals + p_assignment = 1.0 + for bit in range(k): + pi = premise_priors[bit] + if (assignment >> bit) & 1: + p_assignment *= pi + else: + p_assignment *= 1 - pi + + p_c1_given_a = cpt[assignment] + p_c1 += p_assignment * p_c1_given_a + h_c_given_p += p_assignment * _binary_entropy(p_c1_given_a) + + h_c = _binary_entropy(p_c1) + return max(0.0, h_c - h_c_given_p) + + +def compute_coarse_cpts( + ir: dict[str, Any], + coarse: dict[str, Any], + node_priors: dict[str, float] | None = None, + strategy_params: dict[str, list[float]] | None = None, + strategy_indices: set[int] | None = None, +) -> dict[int, list[float]]: + """Compute effective CPTs for coarse infer strategies via tensor contraction. + + Lowers the canonical graph once, precomputes each IR strategy's effective + CPT via ``strategy_cpt`` (sharing a cache across coarse strategies), and + contracts strategy CPTs + operator tensors + unary priors for each coarse + strategy. Exact — no BP iterations. + + Returns a dict mapping strategy index to CPT (list of 2^k floats). + """ + from gaia.engine.bp.contraction import ( + StrategyCptCacheValue, + contract_to_cpt, + cpt_tensor_to_list, + factor_to_tensor, + strategy_cpt, + ) + from gaia.engine.bp.factor_graph import Factor + from gaia.engine.bp.lowering import _OPERATOR_MAP, lower_local_graph + from gaia.engine.ir.graphs import LocalCanonicalGraph + + priors = dict(node_priors or {}) + strat_params = dict(strategy_params or {}) + indices = ( + strategy_indices if strategy_indices is not None else set(range(len(coarse["strategies"]))) + ) + + # Build the canonical graph and lower it once. The lowered fg carries + # every variable's prior (including ones set by _lower_strategy for + # relation-operator conclusions or auto-formalized helper claims). + canon = LocalCanonicalGraph( + **{ + key: ir[key] + for key in ("knowledges", "strategies", "operators", "namespace", "package_name") + } + ) + fg = lower_local_graph( + canon, + node_priors=priors, + strategy_conditional_params=strat_params, + ) + + # Build operator tensors directly from canon.operators. Each operator + # becomes one factor tensor using the same FactorType mapping as + # lower_local_graph's operator pass. + operator_tensors: list[tuple[Any, list[str]]] = [] + for op in canon.operators: + op_factor = Factor( + factor_id=f"op_{op.conclusion}", + factor_type=_OPERATOR_MAP[op.operator], + variables=list(op.variables), + conclusion=op.conclusion, + ) + operator_tensors.append(factor_to_tensor(op_factor)) + + # Precompute every IR strategy's effective CPT once, shared cache. + from gaia.engine.ir.strategy import CompositeStrategy + + strat_by_id = {s.strategy_id: s for s in canon.strategies if s.strategy_id} + cache: dict[str, StrategyCptCacheValue] = {} + strategy_tensors: list[tuple[Any, list[str]]] = [] + for s in canon.strategies: + # CompositeStrategy organizes sub-strategies; its CPT is already a + # contraction of its children's CPTs. Including it as a separate + # tensor would double-count every path through the composite. + # The children themselves are iterated normally below / above. + if isinstance(s, CompositeStrategy): + continue + sub_tensor, sub_axes = strategy_cpt( + s, + strat_by_id=strat_by_id, + strat_params=strat_params, + var_priors=fg.unary_factors, + namespace=canon.namespace, + package_name=canon.package_name, + cache=cache, + ) + strategy_tensors.append((sub_tensor, sub_axes)) + + all_tensors = strategy_tensors + operator_tensors + + # Union of all axis labels touched by any tensor. + all_axes: set[str] = set() + for _, axes in all_tensors: + all_axes.update(axes) + + result: dict[int, list[float]] = {} + + for i, s in enumerate(coarse["strategies"]): + if i not in indices: + continue + coarse_premises = list(s["premises"]) + coarse_conclusion = s["conclusion"] + free = [*coarse_premises, coarse_conclusion] + if len(free) != len(set(free)): + raise ValueError( + f"coarse strategy {i}: conclusion {coarse_conclusion!r} must not also " + "appear in premises" + ) + free_set = set(free) + + # Unary priors for every variable that: + # - appears in at least one collected tensor's axes + # - is not a coarse free variable + # - exists in fg.unary_factors (has an explicit unary factor) + # Helper claims absorbed inside a strategy CPT do NOT appear in + # all_axes and so are correctly skipped here (their priors were + # already applied inside the strategy CPT). + unary_priors = { + v: fg.unary_factors[v] for v in all_axes if v not in free_set and v in fg.unary_factors + } + + cpt_tensor = contract_to_cpt( + all_tensors, + free_vars=free, + unary_priors=unary_priors, + ) + result[i] = cpt_tensor_to_list(cpt_tensor, free, coarse_premises, coarse_conclusion) + + return result diff --git a/gaia/engine/ir/compose.py b/gaia/engine/ir/compose.py new file mode 100644 index 000000000..da094593b --- /dev/null +++ b/gaia/engine/ir/compose.py @@ -0,0 +1,23 @@ +"""Compose — action-level composition DAG records.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class Compose(BaseModel): + """A named DAG of action targets with one public conclusion Claim.""" + + model_config = ConfigDict(extra="forbid") + + compose_id: str + name: str + version: str + inputs: list[str] = [] + background: list[str] = [] + actions: list[str] = [] + warrants: list[str] = [] + conclusion: str + metadata: dict[str, Any] | None = None diff --git a/gaia/ir/formalize.py b/gaia/engine/ir/formalize.py similarity index 87% rename from gaia/ir/formalize.py rename to gaia/engine/ir/formalize.py index b2d27f93b..7b9605b4f 100644 --- a/gaia/ir/formalize.py +++ b/gaia/engine/ir/formalize.py @@ -9,20 +9,22 @@ import hashlib from collections import defaultdict +from collections.abc import Sequence from dataclasses import dataclass from typing import Any -from gaia.ir.knowledge import Knowledge, KnowledgeType, make_qid -from gaia.ir.operator import Operator -from gaia.ir.strategy import ( +from gaia.engine.ir.knowledge import Knowledge, KnowledgeType, make_qid +from gaia.engine.ir.operator import Operator, OperatorType +from gaia.engine.ir.strategy import ( + _FORMAL_STRATEGY_TYPES, FormalExpr, FormalStrategy, Step, StrategyType, - _FORMAL_STRATEGY_TYPES, ) _HELPER_KIND_BY_OPERATOR = { + "negation": "negation_result", "conjunction": "conjunction_result", "disjunction": "disjunction_result", "equivalence": "equivalence_result", @@ -44,6 +46,27 @@ def _sha256_hex(data: str, length: int = 16) -> str: return hashlib.sha256(data.encode()).hexdigest()[:length] +def _required_str(value: str | None) -> str: + """Return a generated identifier that must exist by construction.""" + if value is None: + raise AssertionError("generated formalization nodes must carry concrete ids") + return value + + +def _operator( + *, + operator: OperatorType | str, + variables: Sequence[str | None], + conclusion: str | None, +) -> Operator: + """Construct an Operator while preserving runtime string-to-enum coercion.""" + return Operator( + operator=OperatorType(operator), + variables=[_required_str(variable) for variable in variables], + conclusion=_required_str(conclusion), + ) + + def _generated_claim_id( scope: str, strategy_type: StrategyType, @@ -142,7 +165,7 @@ def add_interface_claim(self, role: str, canonical_name: str, *, anchor: str) -> metadata=metadata, ) self.knowledges.append(knowledge) - self.interface_roles[role].append(knowledge.id) + self.interface_roles[role].append(_required_str(knowledge.id)) return knowledge def add_helper(self, operator_name: str, canonical_name: str) -> Knowledge: @@ -205,7 +228,9 @@ def _any_true_name(variables: list[str]) -> str: return f"any_true({','.join(variables)})" -def _same_truth_name(left: str, right: str) -> str: +def _same_truth_name(left: str | None, right: str | None) -> str: + left = _required_str(left) + right = _required_str(right) return f"same_truth({left},{right})" @@ -221,12 +246,14 @@ def _opposite_truth_name(left: str, right: str) -> str: return f"opposite_truth({left},{right})" -def _implies_name(antecedent: str, consequent: str) -> str: +def _implies_name(antecedent: str | None, consequent: str | None) -> str: + antecedent = _required_str(antecedent) + consequent = _required_str(consequent) return f"implies({antecedent},{consequent})" def _propagate_prior(builder: _TemplateBuilder, helper: Knowledge, key: str = "prior") -> None: - """Copy author-set prior from strategy metadata to the helper claim's metadata.""" + """Copy author-set prior from strategy metadata to a soft helper claim's metadata.""" author_prior = (builder.metadata or {}).get(key) if author_prior is not None: helper.metadata = dict(helper.metadata or {}) @@ -242,9 +269,8 @@ def _build_deduction(builder: _TemplateBuilder) -> list[Operator]: impl_helper = builder.add_helper( "implication", _implies_name(antecedent, builder.conclusion) ) - _propagate_prior(builder, impl_helper) return [ - Operator( + _operator( operator="implication", variables=[antecedent, builder.conclusion], conclusion=impl_helper.id, @@ -254,10 +280,9 @@ def _build_deduction(builder: _TemplateBuilder) -> list[Operator]: impl_helper = builder.add_helper( "implication", _implies_name(conjunction.id, builder.conclusion) ) - _propagate_prior(builder, impl_helper) return [ - Operator(operator="conjunction", variables=builder.premises, conclusion=conjunction.id), - Operator( + _operator(operator="conjunction", variables=builder.premises, conclusion=conjunction.id), + _operator( operator="implication", variables=[conjunction.id, builder.conclusion], conclusion=impl_helper.id, @@ -273,8 +298,8 @@ def _build_mathematical_induction(builder: _TemplateBuilder) -> list[Operator]: "implication", _implies_name(conjunction.id, builder.conclusion) ) return [ - Operator(operator="conjunction", variables=builder.premises, conclusion=conjunction.id), - Operator( + _operator(operator="conjunction", variables=builder.premises, conclusion=conjunction.id), + _operator( operator="implication", variables=[conjunction.id, builder.conclusion], conclusion=impl_helper.id, @@ -285,7 +310,8 @@ def _build_mathematical_induction(builder: _TemplateBuilder) -> list[Operator]: def _build_elimination(builder: _TemplateBuilder) -> list[Operator]: if len(builder.premises) < 3 or len(builder.premises[1:]) % 2 != 0: raise ValueError( - "elimination formalization requires premises=[Exhaustiveness, Candidate1, Evidence1, ...]" + "elimination formalization requires " + "premises=[Exhaustiveness, Candidate1, Evidence1, ...]" ) exhaustiveness = builder.premises[0] builder.interface_roles["exhaustiveness"].append(exhaustiveness) @@ -311,12 +337,12 @@ def _build_elimination(builder: _TemplateBuilder) -> list[Operator]: elimination_gate_inputs = [exhaustiveness] operators = [ - Operator( + _operator( operator="disjunction", variables=[candidate for candidate, _ in candidate_pairs] + [builder.conclusion], conclusion=disjunction.id, ), - Operator( + _operator( operator="equivalence", variables=[disjunction.id, exhaustiveness], conclusion=equivalence.id, @@ -327,27 +353,27 @@ def _build_elimination(builder: _TemplateBuilder) -> list[Operator]: candidate_pairs, contradiction_results, strict=True ): operators.append( - Operator( + _operator( operator="contradiction", variables=[candidate, evidence], conclusion=contradiction.id, ) ) - elimination_gate_inputs.extend([evidence, contradiction.id]) + elimination_gate_inputs.extend([evidence, _required_str(contradiction.id)]) conjunction = builder.add_helper("conjunction", _all_true_name(elimination_gate_inputs)) impl_helper = builder.add_helper( "implication", _implies_name(conjunction.id, builder.conclusion) ) operators.append( - Operator( + _operator( operator="conjunction", variables=elimination_gate_inputs, conclusion=conjunction.id, ) ) operators.append( - Operator( + _operator( operator="implication", variables=[conjunction.id, builder.conclusion], conclusion=impl_helper.id, @@ -378,12 +404,12 @@ def _build_case_analysis(builder: _TemplateBuilder) -> list[Operator]: _same_truth_name(disjunction.id, exhaustiveness), ) operators = [ - Operator( + _operator( operator="disjunction", variables=[case_claim for case_claim, _ in case_pairs], conclusion=disjunction.id, ), - Operator( + _operator( operator="equivalence", variables=[disjunction.id, exhaustiveness], conclusion=equivalence.id, @@ -399,14 +425,14 @@ def _build_case_analysis(builder: _TemplateBuilder) -> list[Operator]: _implies_name(conjunction.id, builder.conclusion), ) operators.append( - Operator( + _operator( operator="conjunction", variables=[case_claim, support], conclusion=conjunction.id, ) ) operators.append( - Operator( + _operator( operator="implication", variables=[conjunction.id, builder.conclusion], conclusion=impl_helper.id, @@ -429,14 +455,17 @@ def _build_abduction(builder: _TemplateBuilder) -> list[Operator]: f"alternative_explanation_for({observation})", anchor=observation, ) - builder.premises.append(alternative_explanation.id) - alternative_explanation_id = alternative_explanation.id + alternative_explanation_id = _required_str(alternative_explanation.id) + builder.premises.append(alternative_explanation_id) else: alternative_explanation_id = builder.premises[1] builder.interface_roles["observation"].append(observation) - if len(builder.premises) == 2 and alternative_explanation_id == builder.premises[1]: - if not builder.interface_roles["alternative_explanation"]: - builder.interface_roles["alternative_explanation"].append(alternative_explanation_id) + if ( + len(builder.premises) == 2 + and alternative_explanation_id == builder.premises[1] + and not builder.interface_roles["alternative_explanation"] + ): + builder.interface_roles["alternative_explanation"].append(alternative_explanation_id) explanation_union = builder.add_helper( "disjunction", _explains_name(observation), @@ -446,12 +475,12 @@ def _build_abduction(builder: _TemplateBuilder) -> list[Operator]: _same_truth_name(explanation_union.id, observation), ) return [ - Operator( + _operator( operator="disjunction", variables=[builder.conclusion, alternative_explanation_id], conclusion=explanation_union.id, ), - Operator( + _operator( operator="equivalence", variables=[explanation_union.id, observation], conclusion=equivalence.id, @@ -467,8 +496,8 @@ def _build_analogy(builder: _TemplateBuilder) -> list[Operator]: "implication", _implies_name(conjunction.id, builder.conclusion) ) return [ - Operator(operator="conjunction", variables=builder.premises, conclusion=conjunction.id), - Operator( + _operator(operator="conjunction", variables=builder.premises, conclusion=conjunction.id), + _operator( operator="implication", variables=[conjunction.id, builder.conclusion], conclusion=impl_helper.id, @@ -484,8 +513,8 @@ def _build_extrapolation(builder: _TemplateBuilder) -> list[Operator]: "implication", _implies_name(conjunction.id, builder.conclusion) ) return [ - Operator(operator="conjunction", variables=builder.premises, conclusion=conjunction.id), - Operator( + _operator(operator="conjunction", variables=builder.premises, conclusion=conjunction.id), + _operator( operator="implication", variables=[conjunction.id, builder.conclusion], conclusion=impl_helper.id, @@ -496,13 +525,13 @@ def _build_extrapolation(builder: _TemplateBuilder) -> list[Operator]: def _build_support(builder: _TemplateBuilder) -> list[Operator]: """Support: conjunction + forward IMPLIES (same structure as deduction).""" if len(builder.premises) == 1: - antecedent = builder.premises[0] + antecedent: str | None = builder.premises[0] ops: list[Operator] = [] else: conj = builder.add_helper("conjunction", _all_true_name(builder.premises)) antecedent = conj.id ops = [ - Operator( + _operator( operator="conjunction", variables=builder.premises, conclusion=conj.id, @@ -513,7 +542,7 @@ def _build_support(builder: _TemplateBuilder) -> list[Operator]: _propagate_prior(builder, h_fwd, key="prior") ops.append( - Operator( + _operator( operator="implication", variables=[antecedent, builder.conclusion], conclusion=h_fwd.id, @@ -540,17 +569,17 @@ def _build_compare(builder: _TemplateBuilder) -> list[Operator]: h_match1 = builder.add_helper("equivalence", _matches_name(pred_h, observation)) h_match2 = builder.add_helper("equivalence", _matches_name(pred_alt, observation)) return [ - Operator( + _operator( operator="equivalence", variables=[pred_h, observation], conclusion=h_match1.id, ), - Operator( + _operator( operator="equivalence", variables=[pred_alt, observation], conclusion=h_match2.id, ), - Operator( + _operator( operator="implication", variables=[h_match2.id, h_match1.id], conclusion=builder.conclusion, diff --git a/gaia/engine/ir/formula.py b/gaia/engine/ir/formula.py new file mode 100644 index 000000000..a6d0d766d --- /dev/null +++ b/gaia/engine/ir/formula.py @@ -0,0 +1,138 @@ +"""Formula graph IR models.""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any, Literal + +from pydantic import BaseModel, model_validator + +FormulaNodeKind = Literal["atom", "op", "quantifier", "term", "variable", "constant"] +FormulaEdgeRole = Literal[ + "operand", + "antecedent", + "consequent", + "left", + "right", + "bound_variable", + "body", + "arg", + "function", +] + + +def _duplicate_node_message( + node_id: str, + existing: tuple[str, dict[str, Any]], + current: tuple[str, dict[str, Any]], +) -> str: + if existing[1] != current[1]: + return f"FormulaNode id '{node_id}' appears with different descriptors" + return f"FormulaNode id '{node_id}' appears with different kind or descriptors" + + +def formula_node_id(descriptor: dict[str, Any]) -> str: + """Return the canonical content-addressed ID for a formula node descriptor.""" + payload = json.dumps( + descriptor, + sort_keys=True, + ensure_ascii=False, + separators=(",", ":"), + ) + return f"fg:{hashlib.sha256(payload.encode()).hexdigest()[:16]}" + + +class FormulaNode(BaseModel): + """Content-addressed formula node.""" + + id: str + kind: FormulaNodeKind + descriptor: dict[str, Any] + + @model_validator(mode="after") + def _validate_id_matches_descriptor(self) -> FormulaNode: + expected = formula_node_id(self.descriptor) + if self.id != expected: + raise ValueError( + f"FormulaNode id '{self.id}' does not match canonical descriptor hash '{expected}'" + ) + return self + + +class FormulaEdge(BaseModel): + """Directed formula edge with a semantic role.""" + + source: str + target: str + role: FormulaEdgeRole + index: int | None = None + + +class FormulaGraph(BaseModel): + """Formula graph attached to a source claim.""" + + source_claim: str + root: str + nodes: list[FormulaNode] + edges: list[FormulaEdge] = [] + + @model_validator(mode="before") + @classmethod + def _validate_raw_duplicate_descriptors(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + + raw_nodes = data.get("nodes", []) + if not isinstance(raw_nodes, list): + return data + + node_signatures: dict[str, tuple[str, dict[str, Any]]] = {} + for node in raw_nodes: + node_id: Any + kind: Any + descriptor: Any + if isinstance(node, FormulaNode): + node_id = node.id + kind = node.kind + descriptor = node.descriptor + elif isinstance(node, dict): + node_id = node.get("id") + kind = node.get("kind") + descriptor = node.get("descriptor") + else: + continue + + if not isinstance(node_id, str) or not isinstance(kind, str): + continue + if not isinstance(descriptor, dict): + continue + + signature = (kind, descriptor) + existing = node_signatures.get(node_id) + if existing is not None and existing != signature: + raise ValueError(_duplicate_node_message(node_id, existing, signature)) + node_signatures[node_id] = signature + + return data + + @model_validator(mode="after") + def _validate_references_and_duplicates(self) -> FormulaGraph: + node_signatures: dict[str, tuple[str, dict[str, Any]]] = {} + for node in self.nodes: + signature = (node.kind, node.descriptor) + existing = node_signatures.get(node.id) + if existing is not None and existing != signature: + raise ValueError(_duplicate_node_message(node.id, existing, signature)) + node_signatures[node.id] = signature + + if self.root not in node_signatures: + raise ValueError(f"root '{self.root}' not found") + + for edge in self.edges: + if edge.source not in node_signatures: + raise ValueError(f"edge source '{edge.source}' not found") + if edge.target not in node_signatures: + raise ValueError(f"edge target '{edge.target}' not found") + + return self diff --git a/gaia/ir/graphs.py b/gaia/engine/ir/graphs.py similarity index 71% rename from gaia/ir/graphs.py rename to gaia/engine/ir/graphs.py index bc492b837..d58779879 100644 --- a/gaia/ir/graphs.py +++ b/gaia/engine/ir/graphs.py @@ -11,9 +11,11 @@ from pydantic import BaseModel, model_validator -from gaia.ir.knowledge import Knowledge, make_qid -from gaia.ir.operator import Operator -from gaia.ir.strategy import CompositeStrategy, FormalStrategy, Strategy +from gaia.engine.ir.compose import Compose +from gaia.engine.ir.formula import FormulaGraph +from gaia.engine.ir.knowledge import Knowledge, make_qid +from gaia.engine.ir.operator import Operator +from gaia.engine.ir.strategy import CompositeStrategy, FormalStrategy, Strategy def _json_sort_key(value: Any) -> str: @@ -62,10 +64,27 @@ def _canonicalize_strategy_dump(data: dict[str, Any]) -> dict[str, Any]: return canonical +def _canonicalize_compose_dump(data: dict[str, Any]) -> dict[str, Any]: + canonical = dict(data) + canonical["inputs"] = sorted(canonical.get("inputs", [])) + canonical["background"] = sorted(canonical.get("background", [])) + canonical["warrants"] = sorted(canonical.get("warrants", [])) + return canonical + + +def _canonicalize_formula_graph_dump(data: dict[str, Any]) -> dict[str, Any]: + canonical = dict(data) + canonical["nodes"] = sorted(canonical.get("nodes", []), key=_json_sort_key) + canonical["edges"] = sorted(canonical.get("edges", []), key=_json_sort_key) + return canonical + + def _canonical_json( knowledges: list[Knowledge], operators: list[Operator], strategies: list[Strategy], + composes: list[Compose], + formula_graphs: list[FormulaGraph] | None = None, ) -> str: """Produce canonical JSON for hashing — independent of insertion order.""" data = { @@ -81,6 +100,17 @@ def _canonical_json( [_canonicalize_strategy_dump(s.model_dump(mode="json")) for s in strategies], key=_json_sort_key, ), + "composes": sorted( + [_canonicalize_compose_dump(c.model_dump(mode="json")) for c in composes], + key=_json_sort_key, + ), + "formula_graphs": sorted( + [ + _canonicalize_formula_graph_dump(fg.model_dump(mode="json")) + for fg in (formula_graphs or []) + ], + key=_json_sort_key, + ), } return json.dumps(data, sort_keys=True, ensure_ascii=False) @@ -99,6 +129,8 @@ class LocalCanonicalGraph(BaseModel): knowledges: list[Knowledge] operators: list[Operator] = [] strategies: list[CompositeStrategy | FormalStrategy | Strategy] = [] + composes: list[Compose] = [] + formula_graphs: list[FormulaGraph] = [] module_order: list[str] | None = None module_titles: dict[str, str] | None = None @@ -110,7 +142,13 @@ def _compute_hash(self) -> LocalCanonicalGraph: k.id = make_qid(self.namespace, self.package_name, k.label) if self.ir_hash is None: - canonical = _canonical_json(self.knowledges, self.operators, self.strategies) + canonical = _canonical_json( + self.knowledges, + self.operators, + self.strategies, + self.composes, + self.formula_graphs, + ) digest = hashlib.sha256(canonical.encode()).hexdigest() self.ir_hash = f"sha256:{digest}" return self diff --git a/gaia/ir/knowledge.py b/gaia/engine/ir/knowledge.py similarity index 50% rename from gaia/ir/knowledge.py rename to gaia/engine/ir/knowledge.py index 569a48315..c6db090c1 100644 --- a/gaia/ir/knowledge.py +++ b/gaia/engine/ir/knowledge.py @@ -29,8 +29,32 @@ class KnowledgeType(StrEnum): """Knowledge types (§1.2).""" CLAIM = "claim" + NOTE = "note" + COMPOSITION = "composition" + # Legacy non-probabilistic types accepted for backwards compatibility. SETTING = "setting" QUESTION = "question" + CONTEXT = "context" + + +STRUCTURAL_EXPRESSION_HELPER_KINDS = frozenset( + { + "negation_result", + "conjunction_result", + "disjunction_result", + } +) + + +def is_structural_expression_helper(knowledge: Knowledge) -> bool: + """Return True for non-reviewable helper claims generated by ~, &, and |.""" + metadata = knowledge.metadata or {} + return ( + knowledge.type == KnowledgeType.CLAIM + and metadata.get("generated") is True + and metadata.get("review") is False + and metadata.get("helper_kind") in STRUCTURAL_EXPRESSION_HELPER_KINDS + ) class Parameter(BaseModel): @@ -38,6 +62,7 @@ class Parameter(BaseModel): name: str type: str + value: Any | None = None class PackageRef(BaseModel): @@ -51,13 +76,22 @@ def _sha256_hex(data: str, length: int = 16) -> str: return hashlib.sha256(data.encode()).hexdigest()[:length] -def _compute_content_hash(type_: str, content: str, parameters: list[Parameter]) -> str: - """Content fingerprint: SHA-256(type + content + sorted(parameters)), no package_id. +def _compute_content_hash( + type_: str, content: str, parameters: list[Parameter], format_: str +) -> str: + """Content fingerprint: SHA-256(type + format + content + sorted(parameters)), no package_id. Same content in different packages produces the same content_hash. Used for canonicalization fast-path (exact match) and curation dedup. """ sorted_params = sorted((p.name, p.type) for p in parameters) + payload = f"{type_}|{format_}|{content}|{sorted_params}" + return _sha256_hex(payload, length=64) + + +def _compute_legacy_content_hash(type_: str, content: str, parameters: list[Parameter]) -> str: + """Pre-format-field content fingerprint accepted for old IR inputs.""" + sorted_params = sorted((p.name, p.type) for p in parameters) payload = f"{type_}|{content}|{sorted_params}" return _sha256_hex(payload, length=64) @@ -72,10 +106,15 @@ class Knowledge(BaseModel): label: str | None = None title: str | None = None type: KnowledgeType + format: str = "markdown" content: str | None = None content_hash: str | None = None parameters: list[Parameter] = [] metadata: dict[str, Any] | None = None + template_name: str | None = None + template_version: str | None = None + sub_knowledge: list[str] | None = None + conclusion: str | None = None # provenance provenance: list[PackageRef] | None = None @@ -91,12 +130,31 @@ def _compute_derived_fields(self) -> Knowledge: if self.id is None and self.label is None: raise ValueError("Knowledge requires at least one of `id` or `label`.") + if self.metadata and "prior" in self.metadata and self.type != KnowledgeType.CLAIM: + raise ValueError("metadata.prior is only valid for claim Knowledge.") + if self.type == KnowledgeType.COMPOSITION: + if not self.template_name: + raise ValueError("Composition requires template_name.") + if not self.template_version: + raise ValueError("Composition requires template_version.") + if not self.sub_knowledge: + raise ValueError("Composition requires sub_knowledge.") + if not self.conclusion: + raise ValueError("Composition requires conclusion.") + # Content_hash is a derived fingerprint and must stay consistent # with the node's actual content. if self.content is not None: - expected_content_hash = _compute_content_hash(self.type, self.content, self.parameters) + expected_content_hash = _compute_content_hash( + self.type, self.content, self.parameters, self.format + ) if self.content_hash is not None and self.content_hash != expected_content_hash: - raise ValueError("content_hash must match the derived content fingerprint") + legacy_content_hash = _compute_legacy_content_hash( + self.type, self.content, self.parameters + ) + format_was_defaulted = "format" not in self.model_fields_set + if not (format_was_defaulted and self.content_hash == legacy_content_hash): + raise ValueError("content_hash must match the derived content fingerprint") self.content_hash = expected_content_hash return self diff --git a/gaia/engine/ir/linearize.py b/gaia/engine/ir/linearize.py new file mode 100644 index 000000000..365fa7895 --- /dev/null +++ b/gaia/engine/ir/linearize.py @@ -0,0 +1,334 @@ +"""Linearize a coarse reasoning graph into a narrative outline. + +Topological sort → layering → connectivity-based grouping → narrative sections. +Grouping uses high-cohesion/low-coupling: nodes sharing premises or conclusions +are grouped together, independent of the Python module structure. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class NarrativeEntry: + """One claim in the narrative outline.""" + + kid: str + label: str + title: str + type: str + exported: bool + prior: float | None + belief: float | None + derived_from: list[str] + supports: list[str] + strategy_type: str + mi_bits: float + + +@dataclass +class NarrativeSection: + """A group of entries forming a narrative section.""" + + title: str + layer: int + entries: list[NarrativeEntry] = field(default_factory=list) + + +def _union_find_group( + nodes: list[str], + edges: list[tuple[str, str]], +) -> list[set[str]]: + """Cluster nodes by connectivity using union-find.""" + parent: dict[str, str] = {n: n for n in nodes} + + def find(x: str) -> str: + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + def union(a: str, b: str) -> None: + ra, rb = find(a), find(b) + if ra != rb: + parent[ra] = rb + + for a, b in edges: + if a in parent and b in parent: + union(a, b) + + groups: dict[str, set[str]] = {} + for n in nodes: + root = find(n) + groups.setdefault(root, set()).add(n) + return list(groups.values()) + + +@dataclass(frozen=True) +class _NarrativeGraph: + """Intermediate graph indexes used for narrative linearization.""" + + kid_to_k: dict[str, dict[str, Any]] + exported_ids: set[str] + forward: dict[str, list[str]] + backward: dict[str, list[str]] + strategy_for_conclusion: dict[str, dict[str, Any]] + strategy_idx_for_conclusion: dict[str, int] + all_kids: set[str] + + +def _narrative_graph_indexes(coarse: dict[str, Any]) -> _NarrativeGraph: + """Build adjacency and strategy lookup indexes for a coarse graph.""" + kid_to_k = {k["id"]: k for k in coarse["knowledges"]} + exported_ids = {k["id"] for k in coarse["knowledges"] if k.get("exported")} + forward: dict[str, list[str]] = {} + backward: dict[str, list[str]] = {} + strategy_for_conclusion: dict[str, dict[str, Any]] = {} + strategy_idx_for_conclusion: dict[str, int] = {} + + for i, strategy in enumerate(coarse["strategies"]): + conclusion = strategy["conclusion"] + strategy_for_conclusion[conclusion] = strategy + strategy_idx_for_conclusion[conclusion] = i + for premise in strategy["premises"]: + forward.setdefault(premise, []).append(conclusion) + backward.setdefault(conclusion, []).append(premise) + + for operator in coarse.get("operators", []): + conclusion = operator.get("conclusion") + for variable in operator.get("variables", []): + if conclusion: + forward.setdefault(variable, []).append(conclusion) + backward.setdefault(conclusion, []).append(variable) + + all_kids = {k["id"] for k in coarse["knowledges"] if not k.get("label", "").startswith("__")} + return _NarrativeGraph( + kid_to_k=kid_to_k, + exported_ids=exported_ids, + forward=forward, + backward=backward, + strategy_for_conclusion=strategy_for_conclusion, + strategy_idx_for_conclusion=strategy_idx_for_conclusion, + all_kids=all_kids, + ) + + +def _narrative_layers(graph: _NarrativeGraph) -> dict[str, int]: + """Assign topological layers to non-helper knowledge ids.""" + in_degree: dict[str, int] = dict.fromkeys(graph.all_kids, 0) + for conclusion, premises in graph.backward.items(): + if conclusion in graph.all_kids: + in_degree[conclusion] = len([p for p in premises if p in graph.all_kids]) + + layers: dict[str, int] = {} + queue = [kid for kid in graph.all_kids if in_degree.get(kid, 0) == 0] + layer = 0 + while queue: + next_queue: list[str] = [] + for kid in queue: + layers[kid] = layer + for kid in queue: + for neighbor in graph.forward.get(kid, []): + if neighbor in graph.all_kids and neighbor not in layers: + in_degree[neighbor] -= 1 + if in_degree[neighbor] <= 0: + next_queue.append(neighbor) + queue = next_queue + layer += 1 + + for kid in graph.all_kids: + if kid not in layers: + layers[kid] = layer + return layers + + +def _narrative_entry( + knowledge: dict[str, Any], + graph: _NarrativeGraph, + beliefs: dict[str, float], + priors: dict[str, float], + mi_map: dict[int, float], +) -> NarrativeEntry | None: + """Build one narrative entry, skipping helpers and non-outline nodes.""" + kid = knowledge["id"] + label = knowledge.get("label", "") + if label.startswith("__") or kid not in graph.all_kids: + return None + + derived_labels: list[str] = [] + strategy_type = "" + mi = 0.0 + if kid in graph.strategy_for_conclusion: + strategy = graph.strategy_for_conclusion[kid] + strategy_type = strategy.get("type", "") + derived_labels = [ + graph.kid_to_k[p].get("label", "?") for p in strategy["premises"] if p in graph.kid_to_k + ] + idx = graph.strategy_idx_for_conclusion.get(kid) + if idx is not None: + mi = mi_map.get(idx, 0.0) + + supports_labels = [ + graph.kid_to_k[c].get("label", "?") + for c in graph.forward.get(kid, []) + if c in graph.kid_to_k + ] + return NarrativeEntry( + kid=kid, + label=label, + title=knowledge.get("title") or label, + type=knowledge.get("type", "claim"), + exported=kid in graph.exported_ids, + prior=priors.get(kid), + belief=beliefs.get(kid), + derived_from=derived_labels, + supports=supports_labels, + strategy_type=strategy_type, + mi_bits=mi, + ) + + +def _narrative_entries_by_kid( + coarse: dict[str, Any], + graph: _NarrativeGraph, + beliefs: dict[str, float], + priors: dict[str, float], + mi_map: dict[int, float], +) -> dict[str, NarrativeEntry]: + """Build narrative entries keyed by knowledge id.""" + entries: dict[str, NarrativeEntry] = {} + for knowledge in coarse["knowledges"]: + entry = _narrative_entry(knowledge, graph, beliefs, priors, mi_map) + if entry is not None: + entries[entry.kid] = entry + return entries + + +def _layer_affinity_edges( + layer_kids: list[str], + graph: _NarrativeGraph, +) -> list[tuple[str, str]]: + """Build connectivity edges among nodes in one narrative layer.""" + affinity_edges: list[tuple[str, str]] = [] + parent_to_children: dict[str, list[str]] = {} + for kid in layer_kids: + for parent in graph.backward.get(kid, []): + parent_to_children.setdefault(parent, []).append(kid) + for children in parent_to_children.values(): + affinity_edges.extend( + (children[i], children[j]) + for i in range(len(children)) + for j in range(i + 1, len(children)) + ) + + child_to_parents: dict[str, list[str]] = {} + for kid in layer_kids: + for child in graph.forward.get(kid, []): + child_to_parents.setdefault(child, []).append(kid) + for parents in child_to_parents.values(): + affinity_edges.extend( + (parents[i], parents[j]) + for i in range(len(parents)) + for j in range(i + 1, len(parents)) + ) + return affinity_edges + + +def _narrative_sections( + graph: _NarrativeGraph, + layers: dict[str, int], + entries_by_kid: dict[str, NarrativeEntry], +) -> list[NarrativeSection]: + """Group narrative entries by layer and shared connectivity.""" + sections: list[NarrativeSection] = [] + max_layer = max(layers.values()) if layers else 0 + for layer in range(max_layer + 1): + layer_kids = [ + kid for kid in graph.all_kids if layers.get(kid) == layer and kid in entries_by_kid + ] + if not layer_kids: + continue + groups = _union_find_group(layer_kids, _layer_affinity_edges(layer_kids, graph)) + for group in sorted( + groups, + key=lambda g: min(entries_by_kid[k].belief or 0 for k in g if k in entries_by_kid), + ): + group_entries = [entries_by_kid[kid] for kid in group if kid in entries_by_kid] + group_entries.sort(key=lambda e: (e.exported, e.belief or 0)) + name_entry = group_entries[-1] if group_entries else None + sections.append( + NarrativeSection( + title=name_entry.title if name_entry else f"Layer {layer}", + layer=layer, + entries=group_entries, + ) + ) + return sections + + +def linearize_narrative( + coarse: dict[str, Any], + beliefs: dict[str, float] | None = None, + priors: dict[str, float] | None = None, + mi_per_strategy: dict[int, float] | None = None, +) -> list[NarrativeSection]: + """Convert a coarse reasoning DAG into a linear narrative outline. + + Algorithm: + 1. Build adjacency from coarse strategies + operators + 2. Topological sort → assign layer to each node + 3. Within each layer, group nodes by shared connectivity + (high cohesion / low coupling — not based on Python modules) + 4. Name each group by its most prominent claim + 5. Merge consecutive groups that are tightly connected + """ + beliefs = beliefs or {} + priors = priors or {} + mi_map = mi_per_strategy or {} + graph = _narrative_graph_indexes(coarse) + layers = _narrative_layers(graph) + entries_by_kid = _narrative_entries_by_kid(coarse, graph, beliefs, priors, mi_map) + return _narrative_sections(graph, layers, entries_by_kid) + + +def render_narrative_outline(sections: list[NarrativeSection]) -> str: + """Render narrative sections as markdown for agent consumption.""" + lines: list[str] = [] + lines.append("# Narrative Outline") + lines.append("") + lines.append( + "Auto-generated from the coarse reasoning graph. " + "Sections are grouped by connectivity (high cohesion, low coupling) " + "and ordered by topological layer. Use this as the backbone for " + "writing narrative summaries." + ) + lines.append("") + + entry_num = 0 + for section in sections: + lines.append(f"## {section.title}") + lines.append("") + for entry in section.entries: + entry_num += 1 + star = " ★" if entry.exported else "" + prior_str = f"{entry.prior:.2f}" if entry.prior is not None else "0.50" + belief_str = f"{entry.belief:.2f}" if entry.belief is not None else "—" + + lines.append( + f"{entry_num}. **{entry.title}{star}** (prior: {prior_str} → belief: {belief_str})" + ) + + if entry.derived_from: + mi_str = f" [{entry.mi_bits:.2f} bits]" if entry.mi_bits > 0 else "" + lines.append( + f" - ← {entry.strategy_type}({', '.join(entry.derived_from)}){mi_str}" + ) + + if entry.supports: + lines.append(f" - → supports: {', '.join(entry.supports)}") + + lines.append("") + + return "\n".join(lines) diff --git a/gaia/engine/ir/logic/__init__.py b/gaia/engine/ir/logic/__init__.py new file mode 100644 index 000000000..a00bf7ccb --- /dev/null +++ b/gaia/engine/ir/logic/__init__.py @@ -0,0 +1,53 @@ +"""Logic backends for compiled Gaia IR. + +Provides solver-backed analysis of the IR's logical structure. Backends use +external libraries (sympy, future Z3/CVC5) while keeping `gaia.engine.ir` +data classes free of solver dependencies. + +Current scope: + propositional — sympy-based analysis of claim-level Operator graphs + (NEGATION/CONJUNCTION/DISJUNCTION/IMPLICATION/EQUIVALENCE/ + CONTRADICTION/COMPLEMENT). Treats Knowledge nodes as atoms; does + not look inside Claim.formula metadata. + diagnostics — FormulaGraph-level inspection for reviewer-facing local + formula issues and conservative cross-claim warnings. + +Future (out of scope for this PR; tracked separately): + predicate — first-order / SMT backends consuming `formula_atom` metadata + for claim-internal predicate / quantifier / arithmetic analysis. + smt — cross-cutting analysis combining Operator graph with claim-internal + formula metadata. + +See docs/specs/2026-05-16-engine-module-reorg-design.md §5 for the three-scope +taxonomy. +""" + +from gaia.engine.ir.logic.diagnostics import ( + DiagnosticCondition, + FormulaDiagnostic, + FormulaDiagnosticReport, + inspect_formula_graphs, +) +from gaia.engine.ir.logic.propositional import ( + are_equivalent, + is_satisfiable, + simplify_proposition, + to_cnf_proposition, + to_dnf_proposition, + to_nnf_proposition, + to_sympy_proposition, +) + +__all__ = [ + "DiagnosticCondition", + "FormulaDiagnostic", + "FormulaDiagnosticReport", + "are_equivalent", + "inspect_formula_graphs", + "is_satisfiable", + "simplify_proposition", + "to_cnf_proposition", + "to_dnf_proposition", + "to_nnf_proposition", + "to_sympy_proposition", +] diff --git a/gaia/engine/ir/logic/diagnostics.py b/gaia/engine/ir/logic/diagnostics.py new file mode 100644 index 000000000..e5c75f812 --- /dev/null +++ b/gaia/engine/ir/logic/diagnostics.py @@ -0,0 +1,475 @@ +"""Formula-graph logic diagnostics for Gaia IR.""" + +from __future__ import annotations + +from dataclasses import dataclass +from itertools import combinations +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field +from sympy import And, Equivalent, Implies, Not, Or, Symbol +from sympy.logic.inference import satisfiable + +from gaia.engine.ir.formula import FormulaGraph, FormulaNode +from gaia.engine.ir.graphs import LocalCanonicalGraph + +FormulaDiagnosticSeverity = Literal["info", "warning", "fatal"] +FormulaDiagnosticScope = Literal["claim", "claim_pair", "package"] +FormulaLogicStrength = Literal["hard", "soft", "mixed", "unknown"] + +DiagnosticConditionKind = Literal[ + "formula_unsat", + "formula_tautology", + "joint_incompatibility", + "entailment_violation", + "redundant_formula", +] +ConditionConfidenceBasis = Literal["hard_logic", "soft_relation", "projection"] + + +@dataclass(frozen=True) +class _ProjectedFormula: + source_claim: str + root: str + expression: Any + atom_ids: frozenset[str] + + +class DiagnosticCondition(BaseModel): + """Machine-readable Boolean event associated with a diagnostic.""" + + model_config = ConfigDict(extra="forbid") + + kind: DiagnosticConditionKind + variables: list[str] = Field(default_factory=list) + expression: dict[str, Any] + confidence_basis: ConditionConfidenceBasis + + +class FormulaDiagnostic(BaseModel): + """One formula-level diagnostic emitted for a compiled graph.""" + + model_config = ConfigDict(extra="forbid") + + code: str + severity: FormulaDiagnosticSeverity + scope: FormulaDiagnosticScope + logic_strength: FormulaLogicStrength + source_claim: str | None = None + related_claims: list[str] = Field(default_factory=list) + formula_nodes: list[str] = Field(default_factory=list) + condition: DiagnosticCondition | None = None + message: str + details: dict[str, Any] = Field(default_factory=dict) + + +class FormulaDiagnosticReport(BaseModel): + """Collection of formula diagnostics.""" + + model_config = ConfigDict(extra="forbid") + + diagnostics: list[FormulaDiagnostic] = Field(default_factory=list) + + @property + def has_fatal(self) -> bool: + """Return whether any diagnostic should block the local claim.""" + return any(diagnostic.severity == "fatal" for diagnostic in self.diagnostics) + + +def formula_graph_to_sympy(formula_graph: FormulaGraph) -> Any | None: + """Project a propositional FormulaGraph root to a SymPy Boolean expression.""" + projected = _project_formula_graph(formula_graph) + if projected is None: + return None + return projected.expression + + +def _project_formula_graph(formula_graph: FormulaGraph) -> _ProjectedFormula | None: + nodes_by_id = {node.id: node for node in formula_graph.nodes} + atom_ids: set[str] = set() + expression = _project_node(formula_graph.root, nodes_by_id, atom_ids) + if expression is None: + return None + return _ProjectedFormula( + source_claim=formula_graph.source_claim, + root=formula_graph.root, + expression=expression, + atom_ids=frozenset(atom_ids), + ) + + +def _project_node( + node_id: str, + nodes_by_id: dict[str, FormulaNode], + atom_ids: set[str], +) -> Any | None: + node = nodes_by_id.get(node_id) + if node is None: + return None + + if node.kind == "atom": + return _project_atom_node(node, atom_ids) + + if node.kind == "op": + return _project_op_node(node, nodes_by_id, atom_ids) + + return None + + +def _project_atom_node(node: FormulaNode, atom_ids: set[str]) -> Any: + symbol_name = _atom_symbol_name(node) + atom_ids.add(symbol_name) + return Symbol(symbol_name) + + +def _atom_symbol_name(node: FormulaNode) -> str: + descriptor = node.descriptor + qid = descriptor.get("qid") + if descriptor.get("kind") == "claim" and isinstance(qid, str): + return qid + return node.id + + +def _project_op_node( + node: FormulaNode, + nodes_by_id: dict[str, FormulaNode], + atom_ids: set[str], +) -> Any | None: + descriptor = node.descriptor + operator = descriptor.get("operator") + children = descriptor.get("children") + if not isinstance(operator, str) or not isinstance(children, list): + return None + if not all(isinstance(child_id, str) for child_id in children): + return None + for child_id in children: + if child_id not in nodes_by_id: + raise ValueError(f"FormulaGraph '{node.id}' references missing child node '{child_id}'") + + child_expressions = [_project_node(child_id, nodes_by_id, atom_ids) for child_id in children] + if any(child_expression is None for child_expression in child_expressions): + return None + + return _build_sympy_operation(operator, child_expressions) + + +def _build_sympy_operation(operator: str, child_expressions: list[Any]) -> Any | None: + if operator == "conjunction": + if len(child_expressions) < 2: + return None + return And(*child_expressions) + if operator == "disjunction": + if len(child_expressions) < 2: + return None + return Or(*child_expressions) + if operator == "negation": + if len(child_expressions) != 1: + return None + return Not(child_expressions[0]) + if operator == "implication": + if len(child_expressions) != 2: + return None + return Implies(child_expressions[0], child_expressions[1]) + if operator == "equivalence": + if len(child_expressions) != 2: + return None + return Equivalent(child_expressions[0], child_expressions[1]) + + return None + + +def inspect_formula_graphs( + graph: LocalCanonicalGraph, + *, + include_pairwise: bool = True, +) -> FormulaDiagnosticReport: + """Inspect formula graphs and return reviewer-facing logic diagnostics.""" + diagnostics: list[FormulaDiagnostic] = [] + projected: list[_ProjectedFormula] = [] + + for formula_graph in graph.formula_graphs: + diagnostics.extend(_redundant_operand_diagnostics(formula_graph)) + try: + projection = _project_formula_graph(formula_graph) + except ValueError as error: + diagnostics.append(_projection_malformed_diagnostic(formula_graph, error)) + continue + if projection is None: + diagnostics.append(_projection_unsupported_diagnostic(formula_graph)) + continue + local_diagnostics = _claim_local_diagnostics(projection) + diagnostics.extend(local_diagnostics) + if _is_pairwise_candidate(local_diagnostics): + projected.append(projection) + + if include_pairwise: + diagnostics.extend(_pairwise_diagnostics(projected)) + + return FormulaDiagnosticReport(diagnostics=diagnostics) + + +def _claim_local_diagnostics(projection: _ProjectedFormula) -> list[FormulaDiagnostic]: + diagnostics: list[FormulaDiagnostic] = [] + if satisfiable(projection.expression) is False: + diagnostics.append( + FormulaDiagnostic( + code="formula_unsat", + severity="fatal", + scope="claim", + logic_strength="hard", + source_claim=projection.source_claim, + formula_nodes=[projection.root], + condition=_condition( + "formula_unsat", + [projection.source_claim], + {"var": projection.source_claim}, + "hard_logic", + ), + message=f"Formula for claim {projection.source_claim!r} is unsatisfiable.", + ) + ) + elif satisfiable(Not(projection.expression)) is False: + diagnostics.append( + FormulaDiagnostic( + code="formula_tautology", + severity="warning", + scope="claim", + logic_strength="hard", + source_claim=projection.source_claim, + formula_nodes=[projection.root], + condition=_condition( + "formula_tautology", + [projection.source_claim], + {"var": projection.source_claim}, + "hard_logic", + ), + message=f"Formula for claim {projection.source_claim!r} is tautological.", + ) + ) + return diagnostics + + +def _redundant_operand_diagnostics(formula_graph: FormulaGraph) -> list[FormulaDiagnostic]: + diagnostics: list[FormulaDiagnostic] = [] + nodes_by_id = {node.id: node for node in formula_graph.nodes} + for node in formula_graph.nodes: + operator = node.descriptor.get("operator") + if node.kind != "op" or operator not in {"conjunction", "disjunction"}: + continue + children = node.descriptor.get("children", []) + if not isinstance(children, list): + continue + repeated = sorted( + {child for child in children if isinstance(child, str) and children.count(child) > 1} + ) + if not repeated: + continue + diagnostics.append( + FormulaDiagnostic( + code="formula_redundant_operand", + severity="info", + scope="claim", + logic_strength="hard", + source_claim=formula_graph.source_claim, + formula_nodes=[node.id, *repeated], + condition=_condition( + "redundant_formula", + [formula_graph.source_claim], + {"var": formula_graph.source_claim}, + "hard_logic", + ), + message=f"Formula for claim {formula_graph.source_claim!r} repeats an operand.", + details={ + "operator": operator, + "repeated_children": [ + _condition_var_for_node(nodes_by_id[child]) + for child in repeated + if child in nodes_by_id + ], + }, + ) + ) + return diagnostics + + +def _projection_unsupported_diagnostic(formula_graph: FormulaGraph) -> FormulaDiagnostic: + return FormulaDiagnostic( + code="formula_projection_unsupported", + severity="info", + scope="claim", + logic_strength="unknown", + source_claim=formula_graph.source_claim, + formula_nodes=[formula_graph.root], + message=( + f"Formula for claim {formula_graph.source_claim!r} is outside the current " + "propositional diagnostics subset." + ), + ) + + +def _projection_malformed_diagnostic( + formula_graph: FormulaGraph, + error: ValueError, +) -> FormulaDiagnostic: + return FormulaDiagnostic( + code="formula_projection_malformed", + severity="warning", + scope="claim", + logic_strength="unknown", + source_claim=formula_graph.source_claim, + formula_nodes=[formula_graph.root], + message=f"Formula for claim {formula_graph.source_claim!r} is malformed.", + details={"error": str(error)}, + ) + + +def _is_pairwise_candidate(local_diagnostics: list[FormulaDiagnostic]) -> bool: + """Exclude locally degenerate formulas from pairwise scans. + + Unsatisfiable formulas would look incompatible with every overlapping + formula, and tautologies would be entailed by every overlap. Those cases are + already reported as claim-local diagnostics, so pairwise would only add + noise. + """ + excluded_codes = {"formula_unsat", "formula_tautology"} + return not any(diagnostic.code in excluded_codes for diagnostic in local_diagnostics) + + +def _condition( + kind: DiagnosticConditionKind, + variables: list[str], + expression: dict[str, Any], + confidence_basis: ConditionConfidenceBasis, +) -> DiagnosticCondition: + return DiagnosticCondition( + kind=kind, + variables=variables, + expression=expression, + confidence_basis=confidence_basis, + ) + + +def _condition_var_for_node(node: FormulaNode) -> str: + if node.kind == "atom": + return _atom_symbol_name(node) + return node.id + + +def _pairwise_diagnostics(projected: list[_ProjectedFormula]) -> list[FormulaDiagnostic]: + diagnostics: list[FormulaDiagnostic] = [] + for left, right in combinations(projected, 2): + if left.atom_ids.isdisjoint(right.atom_ids): + continue + + if satisfiable(And(left.expression, right.expression)) is False: + diagnostics.append(_cross_claim_incompatibility(left, right)) + continue + + left_entails_right = satisfiable(And(left.expression, Not(right.expression))) is False + right_entails_left = satisfiable(And(right.expression, Not(left.expression))) is False + if left_entails_right and right_entails_left: + diagnostics.append(_cross_claim_equivalence(left, right)) + elif left_entails_right: + diagnostics.append(_cross_claim_entailment(left, right)) + elif right_entails_left: + diagnostics.append(_cross_claim_entailment(right, left)) + return diagnostics + + +def _cross_claim_incompatibility( + left: _ProjectedFormula, + right: _ProjectedFormula, +) -> FormulaDiagnostic: + return FormulaDiagnostic( + code="cross_claim_incompatibility", + severity="warning", + scope="claim_pair", + logic_strength="hard", + source_claim=left.source_claim, + related_claims=[right.source_claim], + formula_nodes=[left.root, right.root], + condition=_condition( + "joint_incompatibility", + [left.source_claim, right.source_claim], + _and_event(left.source_claim, right.source_claim), + "hard_logic", + ), + message=( + f"Formula claims {left.source_claim!r} and {right.source_claim!r} cannot both hold." + ), + ) + + +def _cross_claim_entailment( + antecedent: _ProjectedFormula, + consequent: _ProjectedFormula, +) -> FormulaDiagnostic: + return FormulaDiagnostic( + code="cross_claim_entailment", + severity="info", + scope="claim_pair", + logic_strength="hard", + source_claim=antecedent.source_claim, + related_claims=[consequent.source_claim], + formula_nodes=[antecedent.root, consequent.root], + condition=_condition( + "entailment_violation", + [antecedent.source_claim, consequent.source_claim], + { + "op": "and", + "args": [ + {"var": antecedent.source_claim}, + {"op": "not", "arg": {"var": consequent.source_claim}}, + ], + }, + "hard_logic", + ), + message=(f"Formula claim {antecedent.source_claim!r} entails {consequent.source_claim!r}."), + ) + + +def _cross_claim_equivalence( + left: _ProjectedFormula, + right: _ProjectedFormula, +) -> FormulaDiagnostic: + return FormulaDiagnostic( + code="cross_claim_equivalence", + severity="info", + scope="claim_pair", + logic_strength="hard", + source_claim=left.source_claim, + related_claims=[right.source_claim], + formula_nodes=[left.root, right.root], + condition=_condition( + "redundant_formula", + [left.source_claim, right.source_claim], + { + "op": "or", + "args": [ + { + "op": "and", + "args": [ + {"var": left.source_claim}, + {"op": "not", "arg": {"var": right.source_claim}}, + ], + }, + { + "op": "and", + "args": [ + {"var": right.source_claim}, + {"op": "not", "arg": {"var": left.source_claim}}, + ], + }, + ], + }, + "hard_logic", + ), + message=( + f"Formula claims {left.source_claim!r} and {right.source_claim!r} " + "are logically equivalent." + ), + ) + + +def _and_event(left: str, right: str) -> dict[str, Any]: + return {"op": "and", "args": [{"var": left}, {"var": right}]} diff --git a/gaia/engine/ir/logic/propositional.py b/gaia/engine/ir/logic/propositional.py new file mode 100644 index 000000000..a838bf02b --- /dev/null +++ b/gaia/engine/ir/logic/propositional.py @@ -0,0 +1,173 @@ +"""Propositional logic backend for Gaia IR operator graphs.""" + +from __future__ import annotations + +from typing import Any + +from sympy import Symbol +from sympy.logic.boolalg import And, Equivalent, Implies, Not, Or, Xor, to_cnf, to_dnf, to_nnf +from sympy.logic.boolalg import simplify_logic as _sympy_simplify_logic +from sympy.logic.inference import satisfiable + +from gaia.engine.ir.graphs import LocalCanonicalGraph +from gaia.engine.ir.operator import Operator, OperatorType +from gaia.engine.ir.strategy import FormalStrategy + + +def _operator_value(operator: OperatorType | str) -> str: + return str(operator) + + +def _operator_by_conclusion(graph: LocalCanonicalGraph) -> dict[str, Operator]: + operators: dict[str, Operator] = {} + + def add(op: Operator) -> None: + existing = operators.get(op.conclusion) + if existing is not None and existing != op: + raise ValueError( + f"Multiple propositional operators conclude {op.conclusion!r}; " + "cannot expand an unambiguous Boolean expression" + ) + operators[op.conclusion] = op + + for op in graph.operators: + add(op) + for strategy in graph.strategies: + if isinstance(strategy, FormalStrategy): + for op in strategy.formal_expr.operators: + add(op) + return operators + + +def _knowledge_ids(graph: LocalCanonicalGraph) -> set[str]: + return {k.id for k in graph.knowledges if k.id is not None} + + +def _to_sympy( + graph: LocalCanonicalGraph, + knowledge_id: str, + *, + operators: dict[str, Operator], + known_ids: set[str], + cache: dict[str, Any], + stack: set[str], +) -> Any: + if knowledge_id in cache: + return cache[knowledge_id] + + if knowledge_id in stack: + cycle = " -> ".join([*stack, knowledge_id]) + raise ValueError(f"Cycle while expanding propositional operator graph: {cycle}") + + op = operators.get(knowledge_id) + if op is None: + if knowledge_id not in known_ids: + raise KeyError(f"Knowledge id not found in graph: {knowledge_id}") + expr = Symbol(knowledge_id) + cache[knowledge_id] = expr + return expr + + stack.add(knowledge_id) + args = [ + _to_sympy( + graph, + variable, + operators=operators, + known_ids=known_ids, + cache=cache, + stack=stack, + ) + for variable in op.variables + ] + stack.remove(knowledge_id) + + match _operator_value(op.operator): + case OperatorType.NEGATION: + expr = Not(args[0]) + case OperatorType.CONJUNCTION: + expr = And(*args) + case OperatorType.DISJUNCTION: + expr = Or(*args) + case OperatorType.IMPLICATION: + expr = Implies(args[0], args[1]) + case OperatorType.EQUIVALENCE: + expr = Equivalent(args[0], args[1]) + case OperatorType.CONTRADICTION: + expr = Not(And(args[0], args[1])) + case OperatorType.COMPLEMENT: + expr = Xor(args[0], args[1]) + case _: + raise ValueError(f"Unsupported propositional operator: {op.operator!r}") + + cache[knowledge_id] = expr + return expr + + +def to_sympy_proposition(graph: LocalCanonicalGraph, knowledge_id: str) -> Any: + """Expand a Gaia knowledge id into a SymPy Boolean expression. + + Knowledge nodes that are not operator conclusions become atomic symbols. Operator + conclusions are recursively expanded through Gaia's deterministic propositional + operators. The returned SymPy object is a backend representation only; callers + should not persist it in Gaia IR. + """ + return _to_sympy( + graph, + knowledge_id, + operators=_operator_by_conclusion(graph), + known_ids=_knowledge_ids(graph), + cache={}, + stack=set(), + ) + + +def simplify_proposition( + graph: LocalCanonicalGraph, knowledge_id: str, *, force: bool = False +) -> Any: + """Return SymPy's simplified Boolean form for a Gaia proposition.""" + return _sympy_simplify_logic(to_sympy_proposition(graph, knowledge_id), force=force) + + +def to_cnf_proposition( + graph: LocalCanonicalGraph, + knowledge_id: str, + *, + simplify: bool = False, + force: bool = False, +) -> Any: + """Return a CNF SymPy expression for a Gaia proposition.""" + return to_cnf(to_sympy_proposition(graph, knowledge_id), simplify=simplify, force=force) + + +def to_dnf_proposition( + graph: LocalCanonicalGraph, + knowledge_id: str, + *, + simplify: bool = False, + force: bool = False, +) -> Any: + """Return a DNF SymPy expression for a Gaia proposition.""" + return to_dnf(to_sympy_proposition(graph, knowledge_id), simplify=simplify, force=force) + + +def to_nnf_proposition( + graph: LocalCanonicalGraph, knowledge_id: str, *, simplify: bool = True +) -> Any: + """Return a negation-normal-form SymPy expression for a Gaia proposition.""" + return to_nnf(to_sympy_proposition(graph, knowledge_id), simplify=simplify) + + +def are_equivalent( + graph: LocalCanonicalGraph, + left_knowledge_id: str, + right_knowledge_id: str, +) -> bool: + """Return whether two Gaia propositions are logically equivalent.""" + left = to_sympy_proposition(graph, left_knowledge_id) + right = to_sympy_proposition(graph, right_knowledge_id) + return satisfiable(Xor(left, right)) is False + + +def is_satisfiable(graph: LocalCanonicalGraph, knowledge_id: str) -> bool: + """Return whether a Gaia proposition has at least one satisfying assignment.""" + return satisfiable(to_sympy_proposition(graph, knowledge_id)) is not False diff --git a/gaia/engine/ir/operator.py b/gaia/engine/ir/operator.py new file mode 100644 index 000000000..fdbe65f1d --- /dev/null +++ b/gaia/engine/ir/operator.py @@ -0,0 +1,104 @@ +"""Operator — deterministic logical constraints between Knowledge. + +Implements docs/foundations/gaia-ir/gaia-ir.md §2. +""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, model_validator + + +class OperatorType(StrEnum): + """Operator types (§2.2). All are deterministic (ψ ∈ {0,1}, no free parameters).""" + + IMPLICATION = "implication" # A=1 → B must =1 + NEGATION = "negation" # H = ¬A + EQUIVALENCE = "equivalence" # A=B + CONTRADICTION = "contradiction" # ¬(A=1 ∧ B=1) + COMPLEMENT = "complement" # A≠B (XOR) + DISJUNCTION = "disjunction" # ¬(all Aᵢ=0) + CONJUNCTION = "conjunction" # M = A₁ ∧ ... ∧ Aₖ + + +_BINARY_OPERATOR_TYPES = frozenset( + { + OperatorType.EQUIVALENCE, + OperatorType.CONTRADICTION, + OperatorType.COMPLEMENT, + } +) + + +def _validate_scope_and_id(operator: Operator) -> None: + """Validate Operator scope and local ID prefix invariants.""" + if operator.scope not in (None, "local"): + raise ValueError("scope must be one of: None, 'local'") + + if ( + operator.scope == "local" + and operator.operator_id is not None + and not operator.operator_id.startswith("lco_") + ): + raise ValueError("local operators must use an operator_id with lco_ prefix") + + +def _validate_operator_arity(operator: Operator) -> None: + """Validate §2.4 arity constraints for an Operator.""" + variable_count = len(operator.variables) + if operator.operator == OperatorType.IMPLICATION: + if variable_count != 2: + raise ValueError("operator=implication requires exactly 2 variables (inputs)") + return + + if operator.operator == OperatorType.NEGATION: + if variable_count != 1: + raise ValueError("operator=negation requires exactly 1 variable (input)") + return + + if operator.operator == OperatorType.CONJUNCTION: + if variable_count < 2: + raise ValueError("operator=conjunction requires at least 2 variables (inputs)") + return + + if operator.operator in _BINARY_OPERATOR_TYPES: + if variable_count != 2: + raise ValueError(f"operator={operator.operator} requires exactly 2 variables") + return + + if operator.operator == OperatorType.DISJUNCTION and variable_count < 2: + raise ValueError("operator=disjunction requires at least 2 variables") + + +class Operator(BaseModel): + """Deterministic logical constraint between Knowledge nodes. + + Operators have no probability parameters — they encode logical structure. + They can appear standalone (top-level operators array) or embedded in FormalExpr. + """ + + operator_id: str | None = None # lco_ prefix + scope: str | None = None # "local" (None when embedded in FormalExpr) + + operator: OperatorType + variables: list[str] # ordered input Knowledge IDs (conclusion never appears here) + conclusion: str # output Knowledge ID (separate from variables for all types) + + metadata: dict[str, Any] | None = None + + @model_validator(mode="after") + def _validate_invariants(self) -> Operator: + _validate_scope_and_id(self) + + # §2.4: conclusion must NEVER appear in variables (inputs-only separation) + if self.conclusion in self.variables: + raise ValueError( + f"conclusion '{self.conclusion}' must not appear in variables " + f"(variables are inputs only)" + ) + + _validate_operator_arity(self) + + return self diff --git a/gaia/engine/ir/parameterization.py b/gaia/engine/ir/parameterization.py new file mode 100644 index 000000000..7dcd8699d --- /dev/null +++ b/gaia/engine/ir/parameterization.py @@ -0,0 +1,211 @@ +"""Parameterization — claim prior records for Gaia IR graphs. + +Implements docs/foundations/gaia-ir/06-parameterization.md. + +Multi-source PriorRecord is the canonical representation: each Claim may have +multiple PriorRecords from different sources (``user_priors`` for the author, +``continuous_inference`` for engine-derived values, ``reviewer_*`` for human +reviewer overrides, etc.). At compile time, ``ResolutionPolicy.resolve()`` +picks the winning record per claim — the winning value is written into +``Knowledge.metadata["prior"]`` for downstream BP / render / brief consumers, +while every record (winner and losers) remains in ``metadata["prior_records"]`` +for audit, ``gaia build check --hole`` display, and the ``prior_dissent`` / +``prior_overridden`` diagnostics. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any, Literal + +from pydantic import BaseModel, model_validator + +CROMWELL_EPS: float = 1e-3 +"""Cromwell's rule epsilon — all probabilities clamped to [EPS, 1-EPS].""" + + +def _clamp(value: float) -> float: + return max(CROMWELL_EPS, min(1 - CROMWELL_EPS, value)) + + +DEFAULT_PRIORITY_ORDER: tuple[str, ...] = ( + "calibration_*", # Historical calibration outputs (future feature) + "user_priors", # Explicit register_prior() with default source + "reviewer_*", # Human reviewer overrides + "continuous_inference", # #581 continuous parameter inference engine + "evidence_factor_*", # #560 EvidenceFactor-derived priors + "agent_*", # LLM agent automated suggestions + "claim_inline", # claim(prior=X) shortcut — lowest deliberate tier + "*", # Catch-all (latest record wins for unmatched sources) +) +"""Default priority order for the ``explicit_priority`` resolution strategy. + +The ranking embodies two principles: + +1. **Explicit deliberation outranks shortcuts.** Any prior set via an explicit + ``register_prior()`` call (``"user_priors"`` and beyond) wins over a + ``claim(prior=X)`` inline shortcut, which sits at the second-to-last tier. + The inline form is convenient for first-pass guesses but its justification + is an auto-generated placeholder, so a properly documented ``register_prior`` + call should always be able to override it. +2. **Author intent outranks engine output, except for retrospective + calibration.** Calibration based on real outcome data sits above + ``user_priors`` because it incorporates evidence the author may not have + had at write-time. The author's hand-written ``register_prior`` comes next, + ahead of reviewer overrides, engine outputs (``continuous_inference``, + ``evidence_factor_*``), automated agent suggestions, the ``claim_inline`` + shortcut, and finally the catch-all. + +Authors may override this ranking per-package by exporting a custom +``RESOLUTION_POLICY`` in ``priors.py``. + +Wildcards: ``"*"`` matches any source_id (catch-all); ``"prefix_*"`` matches +any source_id starting with ``prefix_``. Wildcards may only appear at the end +of a pattern. +""" + + +def _matches(source_id: str, pattern: str) -> bool: + """Match a source_id against a priority-order pattern. + + Patterns: exact match (``"user_priors"``), trailing wildcard + (``"reviewer_*"``), or universal wildcard (``"*"``). Wildcards in any + other position raise ValueError. + """ + if pattern == "*": + return True + if "*" in pattern: + if not pattern.endswith("*") or pattern.count("*") != 1: + raise ValueError( + f"Invalid priority_order pattern {pattern!r}: wildcards may only " + "appear at the end (e.g. 'reviewer_*')." + ) + return source_id.startswith(pattern[:-1]) + return source_id == pattern + + +class PriorRecord(BaseModel): + """Prior probability for a claim Knowledge. + + Only type=claim Knowledge has PriorRecord. Values are Cromwell-clamped. + Multiple records for the same knowledge_id may exist from different sources. + """ + + knowledge_id: str + value: float + source_id: str + justification: str = "" + created_at: datetime = None # type: ignore[assignment] + + def model_post_init(self, __context: Any) -> None: + """Set default timestamp and apply Cromwell clamping after validation.""" + if self.created_at is None: + object.__setattr__(self, "created_at", datetime.now(UTC)) + object.__setattr__(self, "value", _clamp(self.value)) + + +class ParameterizationSource(BaseModel): + """Metadata about the model/policy that produced a batch of records.""" + + source_id: str + model: str + policy: str | None = None + config: dict[str, Any] | None = None + created_at: datetime + + +class ResolutionPolicy(BaseModel): + """Policy for resolving multiple parameterization records before BP runs. + + Strategies: + + - ``"explicit_priority"`` (default): rank records by ``priority_order`` + pattern matching, with most-recent record winning within each pattern + group. Patterns support trailing wildcards (``"reviewer_*"``) and a + catch-all (``"*"``). When ``priority_order`` is omitted, + :data:`DEFAULT_PRIORITY_ORDER` is used. + - ``"latest"``: pick the most recent record per Knowledge/Strategy by + ``created_at`` timestamp, source-agnostic. + - ``"source"``: use only records matching the configured ``source_id``, + latest within that source. + + ``prior_cutoff`` filters records to those created at or before the given + timestamp, enabling reproducible BP runs against a historical snapshot of + the claim-prior layer. + """ + + strategy: Literal["explicit_priority", "latest", "source"] = "explicit_priority" + source_id: str | None = None + priority_order: list[str] | None = None + prior_cutoff: datetime | None = None + + @model_validator(mode="after") + def _validate_strategy_inputs(self) -> ResolutionPolicy: + if self.strategy == "source" and self.source_id is None: + raise ValueError("strategy='source' requires source_id to be set") + if self.priority_order is not None: + for pattern in self.priority_order: + # Trigger pattern validation early so misspellings fail at + # policy-construction time rather than at resolve time. + _matches("__probe__", pattern) + return self + + def resolve(self, records: list[PriorRecord]) -> PriorRecord | None: + """Pick the winning PriorRecord under this policy. + + Returns ``None`` when no record passes the filter (e.g. empty input, + cutoff filters everything out, or a ``"source"`` strategy whose target + ``source_id`` is absent). + + Algorithm: + + 1. Filter by ``prior_cutoff`` if set. + 2. Dispatch on ``strategy``: + + - ``"latest"``: return the record with the most recent ``created_at``. + - ``"source"``: filter to records matching ``self.source_id``, + return the latest within that subset (or None if subset empty). + - ``"explicit_priority"``: walk ``priority_order`` (or + :data:`DEFAULT_PRIORITY_ORDER`) and for each pattern, find all + matching records; the first non-empty pattern wins, with the + most recent record breaking ties within that pattern. If no + pattern matches any record, fall back to global recency. + """ + candidates = list(records) + if self.prior_cutoff is not None: + cutoff = self.prior_cutoff + candidates = [r for r in candidates if r.created_at <= cutoff] + if not candidates: + return None + + if self.strategy == "latest": + return max(candidates, key=lambda r: r.created_at) + + if self.strategy == "source": + assert self.source_id is not None # validator-enforced + matching = [r for r in candidates if r.source_id == self.source_id] + if not matching: + return None + return max(matching, key=lambda r: r.created_at) + + if self.strategy == "explicit_priority": + order = self.priority_order or list(DEFAULT_PRIORITY_ORDER) + for pattern in order: + matching = [r for r in candidates if _matches(r.source_id, pattern)] + if matching: + return max(matching, key=lambda r: r.created_at) + # No pattern matched (DEFAULT_PRIORITY_ORDER includes "*" so this + # is unreachable in practice; covered for custom orders without + # a catch-all). + return max(candidates, key=lambda r: r.created_at) + + raise ValueError(f"Unknown ResolutionPolicy strategy: {self.strategy!r}") + + +def default_resolution_policy() -> ResolutionPolicy: + """Return the package-default ResolutionPolicy. + + Equivalent to ``ResolutionPolicy()`` but more discoverable. Use this when + a package's ``priors.py`` does not export a custom ``RESOLUTION_POLICY``. + """ + return ResolutionPolicy() diff --git a/gaia/engine/ir/review.py b/gaia/engine/ir/review.py new file mode 100644 index 000000000..3fb52951b --- /dev/null +++ b/gaia/engine/ir/review.py @@ -0,0 +1,48 @@ +"""ReviewManifest — qualitative package-level review layer for Gaia IR v6.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Literal + +from pydantic import BaseModel, ConfigDict + + +class ReviewStatus(StrEnum): + """Lifecycle states for qualitative review records.""" + + UNREVIEWED = "unreviewed" + ACCEPTED = "accepted" + REJECTED = "rejected" + NEEDS_INPUTS = "needs_inputs" + + +class Review(BaseModel): + """Qualitative review record for a compiled action, strategy, or operator target.""" + + model_config = ConfigDict(extra="forbid") + + review_id: str + action_label: str + target_kind: Literal["action", "strategy", "operator", "knowledge", "compose"] + target_id: str + status: ReviewStatus + audit_question: str + reviewer_notes: str | None = None + timestamp: str | None = None + round: int = 1 + + +class ReviewManifest(BaseModel): + """Collection of qualitative review records.""" + + model_config = ConfigDict(extra="forbid") + + reviews: list[Review] = [] + + def latest_status(self, target_id: str) -> ReviewStatus | None: + """Return the most recent review status for a target, if one exists.""" + relevant = [review for review in self.reviews if review.target_id == target_id] + if not relevant: + return None + return max(relevant, key=lambda review: review.round).status diff --git a/gaia/engine/ir/schemas.py b/gaia/engine/ir/schemas.py new file mode 100644 index 000000000..82799e00c --- /dev/null +++ b/gaia/engine/ir/schemas.py @@ -0,0 +1,85 @@ +"""Shared Gaia IR schema carriers for scientific parameters.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, model_validator + +BuiltinDistributionKind = Literal[ + "normal", + "lognormal", + "student_t", + "cauchy", + "binomial", + "poisson", + "exponential", + "beta", +] + +DistributionKind = Literal[ + "normal", + "lognormal", + "student_t", + "cauchy", + "binomial", + "poisson", + "exponential", + "beta", + "custom", +] + +BUILTIN_DISTRIBUTION_KINDS = frozenset( + { + "normal", + "lognormal", + "student_t", + "cauchy", + "binomial", + "poisson", + "exponential", + "beta", + } +) + + +class QuantityLiteral(BaseModel): + """JSON-native IR carrier for unit-bearing scalar values.""" + + schema_version: Literal["gaia.quantity_literal.v1"] = "gaia.quantity_literal.v1" + value: float + unit: str + + +class CallableRef(BaseModel): + """Provenance pointer for a callable, not a runtime execution pointer.""" + + schema_version: Literal["gaia.callable_ref.v1"] = "gaia.callable_ref.v1" + name: str + version: str | None = None + signature: str | None = None + source_hash: str | None = None + purity: Literal["pure", "impure", "unknown"] = "unknown" + + +DistributionParam = QuantityLiteral | float | int + + +class DistributionLiteral(BaseModel): + """JSON-native distribution declaration for IR and adapter boundaries.""" + + schema_version: Literal["gaia.distribution.v1"] = "gaia.distribution.v1" + kind: DistributionKind + params: dict[str, DistributionParam] + callable_ref: CallableRef | None = None + + @model_validator(mode="after") + def _validate_callable_ref(self) -> DistributionLiteral: + if self.kind == "custom": + if self.callable_ref is None: + raise ValueError("custom distributions require callable_ref") + return self + + if self.callable_ref is not None: + raise ValueError("Built-in distributions must not carry callable_ref") + return self diff --git a/gaia/ir/strategy.py b/gaia/engine/ir/strategy.py similarity index 66% rename from gaia/ir/strategy.py rename to gaia/engine/ir/strategy.py index 916bac555..8afbf5a0f 100644 --- a/gaia/ir/strategy.py +++ b/gaia/engine/ir/strategy.py @@ -12,21 +12,23 @@ import hashlib import json +import math from enum import StrEnum from typing import TYPE_CHECKING, Any -from pydantic import BaseModel, model_validator +from pydantic import BaseModel, ConfigDict, model_validator -from gaia.ir.operator import Operator, OperatorType +from gaia.engine.ir.operator import Operator, OperatorType if TYPE_CHECKING: - from gaia.ir.formalize import FormalizationResult + from gaia.engine.ir.formalize import FormalizationResult class StrategyType(StrEnum): """Strategy types (§3.3). Orthogonal to form (Strategy/Composite/Formal).""" INFER = "infer" # full CPT: 2^k params + ASSOCIATE = "associate" # symmetric pairwise association NOISY_AND = "noisy_and" # ∧ + single param p # Named strategies — deterministic (pure FormalStrategy) @@ -76,12 +78,26 @@ def _compute_strategy_id( conclusion: str | None, structure_hash: str = "", ) -> str: - """Deterministic strategy ID: lcs_{sha256(scope + type + sorted(premises) + conclusion + structure_hash)[:16]}.""" + """Return the deterministic strategy ID. + + The ID is ``lcs_{sha256(scope + type + sorted(premises) + conclusion + structure_hash)[:16]}``. + """ prefix = "lcs_" payload = f"{scope}|{type_}|{sorted(premises)}|{conclusion}|{structure_hash}" return f"{prefix}{_sha256_hex(payload)}" +def _probability(value: float, field_name: str) -> float: + parsed = float(value) + if not math.isfinite(parsed) or not 0.0 <= parsed <= 1.0: + raise ValueError(f"{field_name} must be a probability in [0, 1], got {value!r}") + return parsed + + +def _probability_list(values: list[float], field_name: str) -> list[float]: + return [_probability(value, f"{field_name}[{i}]") for i, value in enumerate(values)] + + _FORMAL_STRATEGY_TYPES = frozenset( { StrategyType.DEDUCTION, @@ -130,12 +146,76 @@ def _canonical_formal_expr(formal_expr: FormalExpr) -> str: return json.dumps(ops, sort_keys=True, separators=(",", ":")) +def _validate_strategy_scope_and_id(strategy: Strategy) -> None: + """Validate local strategy scope and ID prefix.""" + if strategy.scope != "local": + raise ValueError("scope must be 'local'") + + if strategy.strategy_id is not None and not strategy.strategy_id.startswith("lcs_"): + raise ValueError("local strategies must use a strategy_id with lcs_ prefix") + + +def _assign_strategy_id(strategy: Strategy) -> None: + """Assign the deterministic strategy ID when omitted.""" + if strategy.strategy_id is not None: + return + strategy.strategy_id = _compute_strategy_id( + strategy.scope, + strategy.type, + strategy.premises, + strategy.conclusion, + structure_hash=strategy._structure_hash(), + ) + + +def _validate_conditional_probabilities(strategy: Strategy) -> None: + """Validate and normalize strategy conditional probability parameters.""" + if strategy.conditional_probabilities is None: + return + + probabilities = _probability_list( + strategy.conditional_probabilities, + "conditional_probabilities", + ) + if strategy.type == StrategyType.INFER: + expected = 1 << len(strategy.premises) + if len(probabilities) != expected: + raise ValueError( + f"infer strategy with {len(strategy.premises)} premises requires " + f"{expected} conditional_probabilities, got {len(probabilities)}" + ) + elif strategy.type == StrategyType.NOISY_AND and len(probabilities) != 1: + raise ValueError( + f"noisy_and strategy requires 1 conditional_probability, got {len(probabilities)}" + ) + object.__setattr__(strategy, "conditional_probabilities", probabilities) + + +def _validate_associate_parameters(strategy: Strategy) -> None: + """Validate pairwise association parameters for non-composite strategies.""" + if ( + strategy.type != StrategyType.ASSOCIATE + or strategy.__class__.__name__ == "CompositeStrategy" + ): + return + if len(strategy.premises) != 2: + raise ValueError("associate strategy requires exactly 2 premises") + if strategy.conclusion is None: + raise ValueError("associate strategy requires a helper conclusion") + if strategy.p_a_given_b is None or strategy.p_b_given_a is None: + raise ValueError("associate strategy requires p_a_given_b and p_b_given_a") + object.__setattr__(strategy, "p_a_given_b", _probability(strategy.p_a_given_b, "p_a_given_b")) + object.__setattr__(strategy, "p_b_given_a", _probability(strategy.p_b_given_a, "p_b_given_a")) + + class Strategy(BaseModel): """Base strategy — leaf reasoning (single ↝). Can be instantiated directly for basic strategies (infer, noisy_and). """ + model_config = ConfigDict(extra="forbid") + strategy_id: str | None = None scope: str # "local" type: StrategyType @@ -147,6 +227,9 @@ class Strategy(BaseModel): # local layer steps: list[Step] | None = None # reasoning process (local only, None at global) + conditional_probabilities: list[float] | None = None # infer/noisy_and CPT parameters + p_a_given_b: float | None = None + p_b_given_a: float | None = None # traceability metadata: dict[str, Any] | None = None @@ -166,7 +249,7 @@ def formalize( For local scope, ``namespace`` and ``package_name`` are required so that generated intermediate Knowledge IDs use QID format. """ - from gaia.ir.formalize import formalize_named_strategy + from gaia.engine.ir.formalize import formalize_named_strategy if isinstance(self, CompositeStrategy): raise TypeError("CompositeStrategy cannot be directly formalized") @@ -189,21 +272,10 @@ def formalize( @model_validator(mode="after") def _compute_id_and_validate(self) -> Strategy: - if self.scope != "local": - raise ValueError("scope must be 'local'") - - if self.strategy_id is not None: - if not self.strategy_id.startswith("lcs_"): - raise ValueError("local strategies must use a strategy_id with lcs_ prefix") - - if self.strategy_id is None: - self.strategy_id = _compute_strategy_id( - self.scope, - self.type, - self.premises, - self.conclusion, - structure_hash=self._structure_hash(), - ) + _validate_strategy_scope_and_id(self) + _assign_strategy_id(self) + _validate_conditional_probabilities(self) + _validate_associate_parameters(self) return self # No leaf type restriction — per §3.5.1, named strategies (deduction, abduction, diff --git a/gaia/engine/ir/validator.py b/gaia/engine/ir/validator.py new file mode 100644 index 000000000..d97eabb8b --- /dev/null +++ b/gaia/engine/ir/validator.py @@ -0,0 +1,1295 @@ +"""Gaia IR validator — structural validation on every IR update. + +Implements issue #233. Validates Knowledge, Operator, Strategy, and graph-level +invariants as defined in docs/foundations/gaia-ir/gaia-ir.md. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Any + +from gaia.engine.ir.compose import Compose +from gaia.engine.ir.formula import FormulaGraph, formula_node_id +from gaia.engine.ir.graphs import LocalCanonicalGraph, _canonical_json +from gaia.engine.ir.knowledge import ( + Knowledge, + KnowledgeType, + is_qid, + is_structural_expression_helper, +) +from gaia.engine.ir.operator import Operator, OperatorType +from gaia.engine.ir.parameterization import ( + CROMWELL_EPS, + PriorRecord, +) +from gaia.engine.ir.strategy import CompositeStrategy, FormalStrategy, Strategy + + +def _parse_qid(qid: str) -> tuple[str, str, str] | None: + """Parse QID into (namespace, package_name, label). Returns None if not valid QID.""" + parts = qid.split("::", 1) + if len(parts) != 2: + return None + prefix_parts = parts[0].split(":", 1) + if len(prefix_parts) != 2: + return None + return (prefix_parts[0], prefix_parts[1], parts[1]) + + +_STRUCTURAL_HELPER_OPERATOR_TYPES = { + OperatorType.CONJUNCTION, + OperatorType.NEGATION, + OperatorType.DISJUNCTION, + OperatorType.EQUIVALENCE, + OperatorType.CONTRADICTION, + OperatorType.COMPLEMENT, + OperatorType.IMPLICATION, +} + + +@dataclass +class ValidationResult: + """Accumulated structural validation result for Gaia IR objects.""" + + valid: bool = True + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + def error(self, msg: str) -> None: + """Record a validation error and mark the result invalid.""" + self.errors.append(msg) + self.valid = False + + def warn(self, msg: str) -> None: + """Record a non-fatal validation warning.""" + self.warnings.append(msg) + + def merge(self, other: ValidationResult) -> None: + """Merge another validation result into this accumulator.""" + self.errors.extend(other.errors) + self.warnings.extend(other.warnings) + if not other.valid: + self.valid = False + + +# --------------------------------------------------------------------------- +# 1. Knowledge validation +# --------------------------------------------------------------------------- + + +def _validate_knowledge_id_and_uniqueness( + knowledge: Knowledge, + scope: str, + lookup: dict[str, Knowledge], + result: ValidationResult, +) -> None: + """Validate QID shape and duplicate Knowledge IDs.""" + if scope == "local" and knowledge.id and not is_qid(knowledge.id): + result.error( + f"Knowledge '{knowledge.id}': expected QID format " + f"(namespace:package_name::label) in local graph" + ) + + if knowledge.id in lookup: + result.error(f"Knowledge '{knowledge.id}': duplicate ID") + if knowledge.id: + lookup[knowledge.id] = knowledge + + +def _validate_metadata_prior(knowledge: Knowledge, result: ValidationResult) -> None: + """Validate legacy metadata prior shape and Cromwell bounds.""" + metadata = knowledge.metadata or {} + if "prior" not in metadata: + return + + prior = metadata["prior"] + if isinstance(prior, bool) or not isinstance(prior, (int, float)): + result.error( + f"Knowledge '{knowledge.id}': metadata prior must be a number, " + f"got {type(prior).__name__}" + ) + return + + prior_value = float(prior) + if not math.isfinite(prior_value): + result.error(f"Knowledge '{knowledge.id}': metadata prior must be finite") + elif prior_value < CROMWELL_EPS or prior_value > 1 - CROMWELL_EPS: + result.error( + f"Knowledge '{knowledge.id}': metadata prior {prior_value} outside Cromwell bounds " + f"[{CROMWELL_EPS}, {1 - CROMWELL_EPS}]" + ) + + +def _validate_knowledge_node( + knowledge: Knowledge, + scope: str, + lookup: dict[str, Knowledge], + result: ValidationResult, +) -> None: + """Validate one Knowledge node and update the ID lookup.""" + _validate_knowledge_id_and_uniqueness(knowledge, scope, lookup, result) + + if knowledge.type not in set(KnowledgeType): + result.error(f"Knowledge '{knowledge.id}': invalid type '{knowledge.type}'") + + _validate_metadata_prior(knowledge, result) + + if scope == "local" and knowledge.content is None: + result.error(f"Knowledge '{knowledge.id}': local layer requires content") + + +def _validate_local_label_uniqueness( + knowledges: list[Knowledge], + result: ValidationResult, +) -> None: + """Validate local graph label uniqueness.""" + labels = [knowledge.label for knowledge in knowledges if knowledge.label] + if len(labels) == len(set(labels)): + return + + seen: set[str] = set() + for label in labels: + if label in seen: + result.error(f"Knowledge label '{label}': duplicate in local graph") + seen.add(label) + + +def _validate_knowledges( + knowledges: list[Knowledge], + scope: str, + result: ValidationResult, + *, + graph_namespace: str | None = None, + graph_package_name: str | None = None, +) -> dict[str, Knowledge]: + """Validate Knowledge nodes and return id→Knowledge lookup.""" + del graph_namespace, graph_package_name + lookup: dict[str, Knowledge] = {} + + for knowledge in knowledges: + _validate_knowledge_node(knowledge, scope, lookup, result) + + # label uniqueness check for local scope + if scope == "local": + _validate_local_label_uniqueness(knowledges, result) + + # graph namespace is a free-form string (e.g. "github", "paper", "dp") + # — no validation constraint on allowed values. + + return lookup + + +# --------------------------------------------------------------------------- +# 2. Operator validation +# --------------------------------------------------------------------------- + + +def _validate_operators( + operators: list[Operator], + knowledge_lookup: dict[str, Knowledge], + scope: str, + result: ValidationResult, + *, + top_level: bool, +) -> None: + """Validate top-level Operators against the knowledge set.""" + for op in operators: + if top_level and (op.operator_id is None or op.scope is None): + result.error( + "Top-level Operator must set both operator_id and scope " + "(embedded FormalExpr operators may omit them)" + ) + + if top_level and op.operator_id is not None and not op.operator_id.startswith("lco_"): + result.error(f"Operator '{op.operator_id}': expected lco_ prefix in {scope} graph") + + # operator scope must be compatible with graph scope + if op.scope is not None and op.scope != scope: + result.error( + f"Operator '{op.operator_id}': scope '{op.scope}' incompatible with {scope} graph" + ) + + # reference completeness — variables (inputs only) + for var_id in op.variables: + if var_id not in knowledge_lookup: + result.error(f"Operator '{op.operator_id}': variable '{var_id}' not found in graph") + elif knowledge_lookup[var_id].type != KnowledgeType.CLAIM: + result.error( + f"Operator '{op.operator_id}': variable '{var_id}' is " + f"'{knowledge_lookup[var_id].type}', must be claim" + ) + + # conclusion reference completeness (required str, always present) + if op.conclusion not in knowledge_lookup: + result.error( + f"Operator '{op.operator_id}': conclusion '{op.conclusion}' not found in graph" + ) + elif knowledge_lookup[op.conclusion].type != KnowledgeType.CLAIM: + result.error( + f"Operator '{op.operator_id}': conclusion '{op.conclusion}' is " + f"'{knowledge_lookup[op.conclusion].type}', must be claim" + ) + else: + conclusion = knowledge_lookup[op.conclusion] + metadata = conclusion.metadata or {} + if is_structural_expression_helper(conclusion) and "prior" in metadata: + result.error( + f"Knowledge '{op.conclusion}': structural helper claim " + "must not have metadata prior" + ) + + # conclusion must NOT be in variables (belt-and-suspenders, Pydantic also checks) + if op.conclusion in op.variables: + result.error( + f"Operator '{op.operator_id}': conclusion '{op.conclusion}' " + "must not be in variables" + ) + + +# --------------------------------------------------------------------------- +# 3. Strategy validation +# --------------------------------------------------------------------------- + + +def _validate_strategy_premises( + strategy: Strategy, + knowledge_lookup: dict[str, Knowledge], + result: ValidationResult, +) -> None: + """Validate Strategy premise references and claim types.""" + sid = strategy.strategy_id or "" + for pid in strategy.premises: + if pid not in knowledge_lookup: + result.error(f"Strategy '{sid}': premise '{pid}' not found in graph") + elif knowledge_lookup[pid].type != KnowledgeType.CLAIM: + result.error( + f"Strategy '{sid}': premise '{pid}' is " + f"'{knowledge_lookup[pid].type}', must be claim" + ) + + +def _validate_strategy_conclusion( + strategy: Strategy, + knowledge_lookup: dict[str, Knowledge], + result: ValidationResult, +) -> None: + """Validate Strategy conclusion reference, type, and self-loop.""" + sid = strategy.strategy_id or "" + if strategy.conclusion is not None: + if strategy.conclusion not in knowledge_lookup: + result.error(f"Strategy '{sid}': conclusion '{strategy.conclusion}' not found in graph") + elif knowledge_lookup[strategy.conclusion].type != KnowledgeType.CLAIM: + result.error( + f"Strategy '{sid}': conclusion '{strategy.conclusion}' is " + f"'{knowledge_lookup[strategy.conclusion].type}', must be claim" + ) + + if strategy.conclusion is not None and strategy.conclusion in strategy.premises: + result.error(f"Strategy '{sid}': conclusion in premises (self-loop)") + + +def _validate_strategy_background_refs( + strategy: Strategy, + knowledge_lookup: dict[str, Knowledge], + result: ValidationResult, +) -> None: + """Validate Strategy background references when present.""" + if not strategy.background: + return + sid = strategy.strategy_id or "" + for bid in strategy.background: + if bid not in knowledge_lookup: + result.warn(f"Strategy '{sid}': background '{bid}' not found in graph") + + +def _validate_strategy_scope_and_prefix( + strategy: Strategy, + scope: str, + result: ValidationResult, +) -> None: + """Validate Strategy scope compatibility and local ID prefix.""" + sid = strategy.strategy_id or "" + if strategy.scope != scope: + result.error(f"Strategy '{sid}': scope '{strategy.scope}' incompatible with {scope} graph") + if strategy.strategy_id and not strategy.strategy_id.startswith("lcs_"): + result.error(f"Strategy '{sid}': expected lcs_ prefix in {scope} graph") + + +def _validate_form_strategy_operators( + strategy: FormalStrategy, + knowledge_lookup: dict[str, Knowledge], + scope: str, + result: ValidationResult, +) -> None: + """Validate FormalStrategy embedded operators and closure.""" + _validate_operators( + strategy.formal_expr.operators, + knowledge_lookup, + scope, + result, + top_level=False, + ) + _validate_formal_expr_closure(strategy, knowledge_lookup, result) + + +def _validate_strategy( + strategy: Strategy, + knowledge_lookup: dict[str, Knowledge], + scope: str, + result: ValidationResult, + strategy_lookup: dict[str, Strategy] | None = None, +) -> None: + """Validate a single Strategy (any form) against the knowledge set.""" + _validate_strategy_premises(strategy, knowledge_lookup, result) + _validate_strategy_conclusion(strategy, knowledge_lookup, result) + _validate_strategy_background_refs(strategy, knowledge_lookup, result) + _validate_strategy_scope_and_prefix(strategy, scope, result) + + # form-specific validation + if isinstance(strategy, CompositeStrategy): + _validate_composite_sub_strategies(strategy, strategy_lookup, result) + + if isinstance(strategy, FormalStrategy): + _validate_form_strategy_operators(strategy, knowledge_lookup, scope, result) + + +def _validate_composite_sub_strategies( + strategy: CompositeStrategy, + strategy_lookup: dict[str, Strategy] | None, + result: ValidationResult, +) -> None: + """Validate CompositeStrategy sub_strategy references exist.""" + sid = strategy.strategy_id or "" + if strategy_lookup is None: + return + for sub_id in strategy.sub_strategies: + if sub_id not in strategy_lookup: + result.error( + f"CompositeStrategy '{sid}': sub_strategy '{sub_id}' " + "not found as top-level strategy" + ) + + +def _validate_composite_dag( + strategies: list[Strategy], + result: ValidationResult, +) -> None: + """Check that CompositeStrategy sub_strategy references form a DAG (no cycles).""" + # Build adjacency: composite strategy_id -> list of sub_strategy_ids + adj: dict[str, list[str]] = {} + composite_ids: set[str] = set() + for s in strategies: + if isinstance(s, CompositeStrategy) and s.strategy_id: + adj[s.strategy_id] = list(s.sub_strategies) + composite_ids.add(s.strategy_id) + + # DFS cycle detection + WHITE, GRAY, BLACK = 0, 1, 2 + color: dict[str, int] = dict.fromkeys(adj, WHITE) + + def dfs(node: str) -> bool: + """Returns True if cycle found.""" + color[node] = GRAY + for nb in adj.get(node, []): + if nb not in color: + continue # non-composite, leaf — no cycle through it + if color[nb] == GRAY: + result.error(f"CompositeStrategy cycle detected involving '{node}' -> '{nb}'") + return True + if color[nb] == WHITE and dfs(nb): + return True + color[node] = BLACK + return False + + for sid in adj: + if color[sid] == WHITE: + dfs(sid) + + +def _formal_expr_reference_sets(strategy: FormalStrategy) -> tuple[set[str], set[str]]: + """Return full allowed refs and operator conclusions for a FormalExpr.""" + allowed: set[str] = set(strategy.premises) + if strategy.conclusion is not None: + allowed.add(strategy.conclusion) + + operator_conclusions = {op.conclusion for op in strategy.formal_expr.operators} + return allowed | operator_conclusions, operator_conclusions + + +def _validate_formal_expr_references( + strategy: FormalStrategy, + full_allowed: set[str], + result: ValidationResult, +) -> None: + """Validate FormalExpr variables and conclusions are reference-closed.""" + sid = strategy.strategy_id or "" + for op in strategy.formal_expr.operators: + for var_id in op.variables: + if var_id not in full_allowed: + result.error( + f"FormalStrategy '{sid}': operator variable '{var_id}' not in " + f"strategy premises/conclusion or operator conclusions (reference closure)" + ) + if op.conclusion not in full_allowed: + result.error( + f"FormalStrategy '{sid}': operator conclusion '{op.conclusion}' not in " + f"strategy premises/conclusion or operator conclusions (reference closure)" + ) + + +def _validate_formal_expr_dag( + strategy: FormalStrategy, + operator_conclusions: set[str], + result: ValidationResult, +) -> None: + """Validate that FormalExpr operator conclusion dependencies form a DAG.""" + sid = strategy.strategy_id or "" + conclusion_to_deps: dict[str, set[str]] = {} + for op in strategy.formal_expr.operators: + conclusion_to_deps[op.conclusion] = {v for v in op.variables if v in operator_conclusions} + + WHITE, GRAY, BLACK = 0, 1, 2 + color: dict[str, int] = dict.fromkeys(conclusion_to_deps, WHITE) + + def dfs(node: str) -> bool: + color[node] = GRAY + for dep in conclusion_to_deps.get(node, set()): + if dep not in color: + continue + if color[dep] == GRAY: + result.error( + f"FormalStrategy '{sid}': FormalExpr cycle detected " + f"involving '{node}' -> '{dep}'" + ) + return True + if color[dep] == WHITE and dfs(dep): + return True + color[node] = BLACK + return False + + for conclusion in conclusion_to_deps: + if color[conclusion] == WHITE: + dfs(conclusion) + + +def _validate_formal_expr_closure( + strategy: FormalStrategy, + knowledge_lookup: dict[str, Knowledge], + result: ValidationResult, +) -> None: + """Validate FormalExpr reference closure and DAG (§5 of 08-validation.md). + + Each Operator's variables/conclusion must reference one of: + - The FormalStrategy's premises (interface input) + - The FormalStrategy's conclusion (interface output) + - Another Operator's conclusion in the same FormalExpr (internal intermediate) + + Operator conclusion dependencies must form a DAG (no cycles). + """ + del knowledge_lookup + full_allowed, operator_conclusions = _formal_expr_reference_sets(strategy) + _validate_formal_expr_references(strategy, full_allowed, result) + _validate_formal_expr_dag(strategy, operator_conclusions, result) + + +def _collect_private_formal_nodes(strategies: list[Strategy]) -> dict[str, str]: + """Map private FormalExpr node IDs to their owning strategy IDs.""" + private_nodes: dict[str, str] = {} + for strategy in strategies: + if not isinstance(strategy, FormalStrategy): + continue + sid = strategy.strategy_id or "" + own_interface: set[str] = set(strategy.premises) + if strategy.conclusion is not None: + own_interface.add(strategy.conclusion) + for op in strategy.formal_expr.operators: + if op.conclusion not in own_interface: + private_nodes[op.conclusion] = sid + return private_nodes + + +def _validate_strategy_private_refs( + strategy: Strategy, + private_nodes: dict[str, str], + result: ValidationResult, +) -> None: + """Validate one Strategy does not reference another strategy's private nodes.""" + sid = strategy.strategy_id or "" + for pid in strategy.premises: + if pid in private_nodes and private_nodes[pid] != sid: + result.error( + f"Strategy '{sid}': premise '{pid}' is a private internal node " + f"of FormalStrategy '{private_nodes[pid]}'" + ) + if strategy.conclusion is not None and strategy.conclusion in private_nodes: + owner = private_nodes[strategy.conclusion] + if owner != sid: + result.error( + f"Strategy '{sid}': conclusion '{strategy.conclusion}' is a private internal node " + f"of FormalStrategy '{owner}'" + ) + + +def _validate_operator_private_refs( + operator: Operator, + private_nodes: dict[str, str], + result: ValidationResult, +) -> None: + """Validate one top-level Operator does not reference private FormalExpr nodes.""" + oid = operator.operator_id or "" + for var_id in operator.variables: + if var_id in private_nodes: + result.error( + f"Operator '{oid}': variable '{var_id}' is a private internal node " + f"of FormalStrategy '{private_nodes[var_id]}'" + ) + if operator.conclusion in private_nodes: + result.error( + f"Operator '{oid}': conclusion '{operator.conclusion}' is a private internal node " + f"of FormalStrategy '{private_nodes[operator.conclusion]}'" + ) + + +def _validate_private_node_isolation( + strategies: list[Strategy], + operators: list[Operator], + result: ValidationResult, +) -> None: + """Validate that internal FormalExpr nodes are not referenced externally. + + A 'private' node is an operator conclusion in a FormalExpr that is NOT in + the owning FormalStrategy's own premises/conclusion interface. Such nodes + must not be referenced by any other top-level strategy or top-level operator. + """ + private_nodes = _collect_private_formal_nodes(strategies) + + # Check: no other strategy references a private node + for strategy in strategies: + _validate_strategy_private_refs(strategy, private_nodes, result) + + # Check: no top-level operator references a private node + for operator in operators: + _validate_operator_private_refs(operator, private_nodes, result) + + +def _validate_strategies( + strategies: list[Strategy], + operators: list[Operator], + knowledge_lookup: dict[str, Knowledge], + scope: str, + result: ValidationResult, +) -> None: + """Validate all top-level Strategies.""" + seen_ids: set[str] = set() + strategy_lookup: dict[str, Strategy] = {} + + for s in strategies: + if s.strategy_id: + strategy_lookup[s.strategy_id] = s + + for s in strategies: + # uniqueness (top-level only) + if s.strategy_id and s.strategy_id in seen_ids: + result.error(f"Strategy '{s.strategy_id}': duplicate ID") + if s.strategy_id: + seen_ids.add(s.strategy_id) + + _validate_strategy(s, knowledge_lookup, scope, result, strategy_lookup) + + # DAG check for CompositeStrategy references + _validate_composite_dag(strategies, result) + + # Private node isolation check (includes top-level operators) + _validate_private_node_isolation(strategies, operators, result) + + +# --------------------------------------------------------------------------- +# 4. Graph-level validation +# --------------------------------------------------------------------------- + + +def _check_local_id_format(id_: str, context: str, scope: str, result: ValidationResult) -> None: + """Validate a local graph reference uses QID format.""" + if id_ and not is_qid(id_): + result.error( + f"{context} has wrong format for {scope} graph (expected QID namespace:package::label)" + ) + + +def _validate_strategy_id_formats( + strategies: list[Strategy], + scope: str, + result: ValidationResult, +) -> None: + """Validate Strategy premise and conclusion ID formats.""" + for strategy in strategies: + for pid in strategy.premises: + _check_local_id_format( + pid, + f"Strategy '{strategy.strategy_id}': premise '{pid}'", + scope, + result, + ) + if strategy.conclusion: + _check_local_id_format( + strategy.conclusion, + f"Strategy '{strategy.strategy_id}': conclusion '{strategy.conclusion}'", + scope, + result, + ) + + +def _validate_operator_id_formats( + operator: Operator, + context: str, + scope: str, + result: ValidationResult, +) -> None: + """Validate one Operator's variable and conclusion ID formats.""" + for var_id in operator.variables: + _check_local_id_format( + var_id, + f"{context} '{operator.operator_id}': variable '{var_id}'", + scope, + result, + ) + if operator.conclusion: + _check_local_id_format( + operator.conclusion, + f"{context} '{operator.operator_id}': conclusion '{operator.conclusion}'", + scope, + result, + ) + + +def _validate_formal_expr_id_formats( + strategies: list[Strategy], + scope: str, + result: ValidationResult, +) -> None: + """Validate ID formats for FormalExpr-embedded operators.""" + for strategy in strategies: + if not isinstance(strategy, FormalStrategy): + continue + for operator in strategy.formal_expr.operators: + _validate_operator_id_formats( + operator, + f"FormalStrategy '{strategy.strategy_id}' operator", + scope, + result, + ) + + +def _validate_scope_consistency( + knowledge_lookup: dict[str, Knowledge], + operators: list[Operator], + strategies: list[Strategy], + scope: str, + result: ValidationResult, +) -> None: + """Ensure all references use the correct ID format for the scope.""" + del knowledge_lookup + + _validate_strategy_id_formats(strategies, scope, result) + for operator in operators: + _validate_operator_id_formats(operator, "Operator", scope, result) + _validate_formal_expr_id_formats(strategies, scope, result) + + +# --------------------------------------------------------------------------- +# 5. Compose validation +# --------------------------------------------------------------------------- + + +def _compose_validation_indexes( + graph: LocalCanonicalGraph, + knowledge_lookup: dict[str, Knowledge], +) -> tuple[set[str], set[str], dict[str, list[str]]]: + """Build valid compose action targets and compose adjacency indexes.""" + target_ids = set(knowledge_lookup) + target_ids.update(op.operator_id for op in graph.operators if op.operator_id) + target_ids.update(strategy.strategy_id for strategy in graph.strategies if strategy.strategy_id) + compose_ids = {compose.compose_id for compose in graph.composes} + target_ids.update(compose_ids) + compose_edges: dict[str, list[str]] = {compose_id: [] for compose_id in compose_ids} + return target_ids, compose_ids, compose_edges + + +def _validate_compose_reference_fields( + compose: Compose, + knowledge_lookup: dict[str, Knowledge], + result: ValidationResult, +) -> None: + """Validate compose inputs/background/warrants reference Knowledge nodes.""" + for field_name in ("inputs", "background", "warrants"): + for ref in getattr(compose, field_name): + if ref not in knowledge_lookup: + result.error( + f"Compose '{compose.compose_id}': {field_name} reference " + f"'{ref}' not found in graph" + ) + + +def _validate_compose_conclusion( + compose: Compose, + knowledge_lookup: dict[str, Knowledge], + result: ValidationResult, +) -> None: + """Validate compose conclusion exists and references a claim.""" + if compose.conclusion not in knowledge_lookup: + result.error( + f"Compose '{compose.compose_id}': conclusion '{compose.conclusion}' not found in graph" + ) + elif knowledge_lookup[compose.conclusion].type != KnowledgeType.CLAIM: + result.error( + f"Compose '{compose.compose_id}': conclusion '{compose.conclusion}' is " + f"'{knowledge_lookup[compose.conclusion].type}', must be claim" + ) + + +def _validate_compose_action_ref( + compose: Compose, + action_ref: str, + target_ids: set[str], + compose_ids: set[str], + compose_edges: dict[str, list[str]], + result: ValidationResult, +) -> None: + """Validate one compose action target and record compose-to-compose edges.""" + if action_ref not in target_ids: + result.error( + f"Compose '{compose.compose_id}': action target '{action_ref}' not found in graph" + ) + return + if action_ref == compose.compose_id: + result.error(f"Compose '{compose.compose_id}': cannot reference itself as an action") + return + if action_ref in compose_ids: + compose_edges[compose.compose_id].append(action_ref) + + +def _validate_one_compose( + compose: Compose, + knowledge_lookup: dict[str, Knowledge], + target_ids: set[str], + compose_ids: set[str], + compose_edges: dict[str, list[str]], + result: ValidationResult, +) -> None: + """Validate one Compose record and collect nested compose edges.""" + if not compose.compose_id.startswith("lcm_"): + result.error(f"Compose '{compose.compose_id}': expected lcm_ prefix in local graph") + + _validate_compose_reference_fields(compose, knowledge_lookup, result) + _validate_compose_conclusion(compose, knowledge_lookup, result) + for action_ref in compose.actions: + _validate_compose_action_ref( + compose, + action_ref, + target_ids, + compose_ids, + compose_edges, + result, + ) + + +def _validate_compose_dag( + compose_ids: set[str], + compose_edges: dict[str, list[str]], + result: ValidationResult, +) -> None: + """Validate compose-to-compose action references form a DAG.""" + visiting: set[str] = set() + visited: set[str] = set() + path: list[str] = [] + + def visit(compose_id: str) -> None: + if compose_id in visited: + return + if compose_id in visiting: + cycle_start = path.index(compose_id) + cycle = [*path[cycle_start:], compose_id] + result.error(f"Compose DAG contains cycle: {' -> '.join(cycle)}") + return + + visiting.add(compose_id) + path.append(compose_id) + for child_id in compose_edges.get(compose_id, []): + visit(child_id) + path.pop() + visiting.remove(compose_id) + visited.add(compose_id) + + for compose_id in compose_ids: + visit(compose_id) + + +def _validate_composes( + graph: LocalCanonicalGraph, + knowledge_lookup: dict[str, Knowledge], + result: ValidationResult, +) -> None: + """Validate Compose records, references, and nested compose DAGs.""" + target_ids, compose_ids, compose_edges = _compose_validation_indexes(graph, knowledge_lookup) + + for compose in graph.composes: + _validate_one_compose( + compose, + knowledge_lookup, + target_ids, + compose_ids, + compose_edges, + result, + ) + + _validate_compose_dag(compose_ids, compose_edges, result) + + +# --------------------------------------------------------------------------- +# 6. Formula graph validation +# --------------------------------------------------------------------------- + + +def _formula_graph_label(formula_graph: FormulaGraph) -> str: + source_claim = getattr(formula_graph, "source_claim", None) + if isinstance(source_claim, str) and source_claim: + return source_claim + return "" + + +def _validate_formula_descriptor_qids( + value: Any, + *, + formula_graph: FormulaGraph, + knowledge_lookup: dict[str, Knowledge], + result: ValidationResult, +) -> None: + if isinstance(value, dict): + kind = value.get("kind") + qid = value.get("qid") + if kind in {"claim", "knowledge"}: + if not isinstance(qid, str): + result.error( + f"FormulaGraph '{_formula_graph_label(formula_graph)}': " + "descriptor qid must be a string" + ) + else: + knowledge = knowledge_lookup.get(qid) + if knowledge is None: + result.error( + f"FormulaGraph '{_formula_graph_label(formula_graph)}': " + f"descriptor qid '{qid}' not found in graph" + ) + elif knowledge.type != KnowledgeType.CLAIM: + result.error( + f"FormulaGraph '{_formula_graph_label(formula_graph)}': " + f"descriptor qid '{qid}' must reference a claim" + ) + for child in value.values(): + _validate_formula_descriptor_qids( + child, + formula_graph=formula_graph, + knowledge_lookup=knowledge_lookup, + result=result, + ) + return + + if isinstance(value, list): + for child in value: + _validate_formula_descriptor_qids( + child, + formula_graph=formula_graph, + knowledge_lookup=knowledge_lookup, + result=result, + ) + + +def _formula_graph_sequence( + value: Any, + *, + label: str, + field_name: str, + result: ValidationResult, +) -> list[Any]: + if isinstance(value, list): + return value + result.error(f"FormulaGraph '{label}': {field_name} must be a list") + return [] + + +def _validate_formula_graph_source( + formula_graph: FormulaGraph, + *, + label: str, + knowledge_lookup: dict[str, Knowledge], + result: ValidationResult, +) -> None: + source_claim_id = getattr(formula_graph, "source_claim", None) + if not isinstance(source_claim_id, str): + result.error(f"FormulaGraph '{label}': source_claim must be a string") + return + + source_claim = knowledge_lookup.get(source_claim_id) + if source_claim is None: + result.error(f"FormulaGraph '{label}': source_claim '{source_claim_id}' not found in graph") + elif source_claim.type != KnowledgeType.CLAIM: + result.error( + f"FormulaGraph '{label}': source_claim " + f"'{source_claim_id}' is '{source_claim.type}', must be claim" + ) + + +def _validate_formula_node_hash( + *, + node_id: str | None, + descriptor: dict[str, Any], + result: ValidationResult, +) -> None: + display_id = node_id or "" + try: + expected = formula_node_id(descriptor) + except (TypeError, ValueError) as exc: + result.error( + f"FormulaNode '{display_id}': descriptor is not canonical JSON serializable: {exc}" + ) + return + + if node_id != expected: + result.error( + f"FormulaNode '{node_id}' does not match canonical descriptor hash '{expected}'" + ) + + +def _validate_formula_node( + node: Any, + *, + formula_graph: FormulaGraph, + label: str, + knowledge_lookup: dict[str, Knowledge], + result: ValidationResult, +) -> tuple[str, tuple[str, dict[str, Any]]] | None: + node_id = getattr(node, "id", None) + if not isinstance(node_id, str): + result.error(f"FormulaGraph '{label}': FormulaNode id must be a string") + + kind = getattr(node, "kind", None) + if not isinstance(kind, str): + result.error(f"FormulaGraph '{label}': FormulaNode kind must be a string") + + descriptor = getattr(node, "descriptor", None) + if not isinstance(descriptor, dict): + result.error(f"FormulaNode '{node_id or ''}': descriptor must be a dict") + return None + + _validate_formula_node_hash( + node_id=node_id if isinstance(node_id, str) else None, + descriptor=descriptor, + result=result, + ) + _validate_formula_descriptor_qids( + descriptor, + formula_graph=formula_graph, + knowledge_lookup=knowledge_lookup, + result=result, + ) + + if not isinstance(node_id, str) or not isinstance(kind, str): + return None + + return node_id, (kind, descriptor) + + +def _validate_formula_graph_nodes( + formula_graph: FormulaGraph, + *, + label: str, + knowledge_lookup: dict[str, Knowledge], + result: ValidationResult, +) -> dict[str, tuple[str, dict[str, Any]]]: + node_signatures: dict[str, tuple[str, dict[str, Any]]] = {} + nodes = _formula_graph_sequence( + getattr(formula_graph, "nodes", None), + label=label, + field_name="nodes", + result=result, + ) + for node in nodes: + signature_entry = _validate_formula_node( + node, + formula_graph=formula_graph, + label=label, + knowledge_lookup=knowledge_lookup, + result=result, + ) + if signature_entry is None: + continue + node_id, signature = signature_entry + existing = node_signatures.get(node_id) + if existing is not None and existing != signature: + result.error( + f"FormulaGraph '{label}': FormulaNode id '{node_id}' appears with " + "different kind or descriptor" + ) + node_signatures[node_id] = signature + return node_signatures + + +def _validate_formula_graph_root( + formula_graph: FormulaGraph, + *, + label: str, + node_ids: set[str], + result: ValidationResult, +) -> None: + root = getattr(formula_graph, "root", None) + if not isinstance(root, str): + result.error(f"FormulaGraph '{label}': root must be a string") + elif root not in node_ids: + result.error(f"FormulaGraph '{label}': root '{root}' not found in nodes") + + +def _validate_formula_graph_edges( + formula_graph: FormulaGraph, + *, + label: str, + node_ids: set[str], + result: ValidationResult, +) -> None: + edges = _formula_graph_sequence( + getattr(formula_graph, "edges", None), + label=label, + field_name="edges", + result=result, + ) + for edge in edges: + edge_source = getattr(edge, "source", None) + edge_target = getattr(edge, "target", None) + if not isinstance(edge_source, str): + result.error(f"FormulaGraph '{label}': edge source is missing") + elif edge_source not in node_ids: + result.error(f"FormulaGraph '{label}': edge source '{edge_source}' not found in nodes") + if not isinstance(edge_target, str): + result.error(f"FormulaGraph '{label}': edge target is missing") + elif edge_target not in node_ids: + result.error(f"FormulaGraph '{label}': edge target '{edge_target}' not found in nodes") + + +def _validate_formula_graphs( + formula_graphs: list[FormulaGraph], + knowledge_lookup: dict[str, Knowledge], + result: ValidationResult, +) -> None: + """Validate FormulaGraph structure independent of Pydantic construction.""" + if not isinstance(formula_graphs, list): + result.error("LocalCanonicalGraph formula_graphs must be a list") + return + + for formula_graph in formula_graphs: + label = _formula_graph_label(formula_graph) + _validate_formula_graph_source( + formula_graph, + label=label, + knowledge_lookup=knowledge_lookup, + result=result, + ) + node_signatures = _validate_formula_graph_nodes( + formula_graph, + label=label, + knowledge_lookup=knowledge_lookup, + result=result, + ) + node_ids = set(node_signatures) + _validate_formula_graph_root( + formula_graph, + label=label, + node_ids=node_ids, + result=result, + ) + _validate_formula_graph_edges( + formula_graph, + label=label, + node_ids=node_ids, + result=result, + ) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def validate_local_graph(graph: LocalCanonicalGraph) -> ValidationResult: + """Validate a LocalCanonicalGraph.""" + result = ValidationResult() + + knowledge_lookup = _validate_knowledges( + graph.knowledges, + "local", + result, + graph_namespace=graph.namespace, + graph_package_name=graph.package_name, + ) + _validate_operators(graph.operators, knowledge_lookup, "local", result, top_level=True) + _validate_strategies(graph.strategies, graph.operators, knowledge_lookup, "local", result) + _validate_scope_consistency( + knowledge_lookup, graph.operators, graph.strategies, "local", result + ) + _validate_composes(graph, knowledge_lookup, result) + _validate_formula_graphs(graph.formula_graphs, knowledge_lookup, result) + + # hash consistency + if graph.ir_hash is not None: + recomputed = _canonical_json( + graph.knowledges, + graph.operators, + graph.strategies, + graph.composes, + graph.formula_graphs, + ) + import hashlib + + expected = f"sha256:{hashlib.sha256(recomputed.encode()).hexdigest()}" + if graph.ir_hash != expected: + result.error( + f"LocalCanonicalGraph ir_hash mismatch: stored={graph.ir_hash}, computed={expected}" + ) + + return result + + +# --------------------------------------------------------------------------- +# 7. Parameterization completeness (pre-BP) +# --------------------------------------------------------------------------- + + +def _claims_without_prior_requirement(graph: LocalCanonicalGraph) -> tuple[set[str], set[str]]: + """Return prohibited-prior claims and all prior-exempt claims.""" + no_prior_allowed: set[str] = set() + for operator in graph.operators: + if operator.operator in _STRUCTURAL_HELPER_OPERATOR_TYPES: + no_prior_allowed.add(operator.conclusion) + + for strategy in graph.strategies: + if not isinstance(strategy, FormalStrategy): + continue + own_interface: set[str] = set(strategy.premises) + if strategy.conclusion is not None: + own_interface.add(strategy.conclusion) + for operator in strategy.formal_expr.operators: + if operator.conclusion not in own_interface: + no_prior_allowed.add(operator.conclusion) + + strategy_conclusions = { + strategy.conclusion for strategy in graph.strategies if strategy.conclusion is not None + } + return no_prior_allowed, no_prior_allowed | strategy_conclusions + + +def _validate_prior_coverage( + result: ValidationResult, + *, + claim_ids: set[str], + prior_exempt: set[str], + priors: list[PriorRecord], +) -> None: + """Validate that independent claims have PriorRecord coverage.""" + prior_knowledge_ids = {record.knowledge_id for record in priors} + for cid in claim_ids: + if cid in prior_exempt: + continue + if cid not in prior_knowledge_ids: + result.error(f"Claim '{cid}': missing PriorRecord") + + +def _validate_prohibited_priors( + result: ValidationResult, + *, + priors: list[PriorRecord], + no_prior_allowed: set[str], +) -> None: + """Reject PriorRecords on private or structural helper claims.""" + for prior_record in priors: + if prior_record.knowledge_id in no_prior_allowed: + result.error( + f"PriorRecord '{prior_record.knowledge_id}': private or structural helper claim " + f"must not have independent PriorRecord" + ) + + +def _validate_cromwell_bounds( + result: ValidationResult, + *, + priors: list[PriorRecord], +) -> None: + """Validate Cromwell bounds for prior records.""" + for prior_record in priors: + if prior_record.value < CROMWELL_EPS or prior_record.value > 1 - CROMWELL_EPS: + result.error( + f"PriorRecord '{prior_record.knowledge_id}': value {prior_record.value} " + f"outside Cromwell bounds " + f"[{CROMWELL_EPS}, {1 - CROMWELL_EPS}]" + ) + + +def _validate_parameterization_dangling_refs( + result: ValidationResult, + *, + graph: LocalCanonicalGraph, + priors: list[PriorRecord], +) -> None: + """Warn about prior records that reference missing IR objects.""" + all_knowledge_ids = {knowledge.id for knowledge in graph.knowledges if knowledge.id} + for prior_record in priors: + if prior_record.knowledge_id not in all_knowledge_ids: + result.warn( + f"PriorRecord '{prior_record.knowledge_id}': references non-existent Knowledge" + ) + + +def validate_parameterization( + graph: LocalCanonicalGraph, + priors: list[PriorRecord], +) -> ValidationResult: + """Validate parameterization completeness before BP run. + + Checks that every independent claim Knowledge has at least one PriorRecord. + Strategy probability parameters are part of the Strategy IR itself in the + v0.5 contract; this validator does not maintain a separate strategy + parameterization layer. + + Three categories of claims are excluded from PriorRecord requirements: + + 1. **Strategy conclusions** — claims that appear as the conclusion of any + Strategy. Their belief is derived from premises via BP; they do not need + independent priors (but may optionally have them). + 2. **Top-level structural helper claims** — conclusions of top-level Operators + with structural types (conjunction/disjunction/equivalence/contradiction/ + complement). Their truth value is fully determined by the Operator. + These are PROHIBITED from having independent PriorRecords. + 3. **FormalExpr private nodes** — ANY operator conclusion inside a FormalExpr + that is NOT in the owning FormalStrategy's premises/conclusion interface. + Per spec §4 of 04-helper-claims.md, private nodes must not carry + independent PriorRecord regardless of the operator type. + These are PROHIBITED from having independent PriorRecords. + + Generated public interface claims (e.g. abduction's AlternativeExplanationForObs) + are part of the strategy interface, so they remain ordinary claim inputs and + still require PriorRecord. + """ + result = ValidationResult() + + claim_ids = {k.id for k in graph.knowledges if k.type == KnowledgeType.CLAIM and k.id} + no_prior_allowed, prior_exempt = _claims_without_prior_requirement(graph) + + _validate_prior_coverage(result, claim_ids=claim_ids, prior_exempt=prior_exempt, priors=priors) + _validate_prohibited_priors(result, priors=priors, no_prior_allowed=no_prior_allowed) + _validate_cromwell_bounds(result, priors=priors) + _validate_parameterization_dangling_refs( + result, + graph=graph, + priors=priors, + ) + + return result diff --git a/gaia/engine/lang/__init__.py b/gaia/engine/lang/__init__.py new file mode 100644 index 000000000..667100614 --- /dev/null +++ b/gaia/engine/lang/__init__.py @@ -0,0 +1,257 @@ +"""Gaia Lang — Python DSL for knowledge authoring.""" + +import warnings +from importlib import import_module +from typing import Any + +from gaia.engine.lang.dsl import ( + associate, + candidate_relation, + claim, + compose, + composition, + compute, + contradict, + decompose, + depends_on, + derive, + equal, + equals, + exclusive, + exists, + forall, + iff, + implies, + infer, + land, + lnot, + lor, + materialize, + note, + observe, + parameter, + question, + register_prior, +) +from gaia.engine.lang.dsl.bool_expr import BoolExpr, DerivedDistribution +from gaia.engine.lang.formula import ( + ArithOp, + ClaimAtom, + Constant, + Equals, + Exists, + Forall, + Formula, + FunctionApp, + FunctionSymbol, + Greater, + GreaterEqual, + Iff, + Implies, + Land, + Less, + LessEqual, + Lnot, + Lor, + NotEquals, + PredicateSymbol, + Term, + UserPredicate, + is_formula, + is_term, +) +from gaia.engine.lang.formula.primitives import Bool, Nat, PrimitiveType, Probability, Real +from gaia.engine.lang.runtime import ( + Associate, + CandidateRelation, + Claim, + ClaimKind, + Compose, + Composition, + Compute, + Contradict, + Decompose, + DependsOn, + Derive, + Distribution, + Domain, + Equal, + Exclusive, + Infer, + Knowledge, + MaterializationLink, + Note, + Observe, + Question, + RoleOccurrence, + Variable, + roles_for_claim, + roles_for_package, +) +from gaia.engine.lang.runtime.distribution import ( + Beta, + Binomial, + Cauchy, + ChiSquared, + Exponential, + Gamma, + LogNormal, + Normal, + Poisson, + StudentT, +) + +_COMPAT_EXPORTS = frozenset( + { + "Action", + "Context", + "Directed", + "GaiaGraph", + "Operator", + "Reasoning", + "Relation", + "Scaffold", + "Setting", + "Step", + "Strategy", + "Structural", + "Support", + "abduction", + "analogy", + "and_", + "attach_reasoning", + "case_analysis", + "compare", + "complement", + "composite", + "context", + "contradiction", + "deduction", + "disjunction", + "elimination", + "equivalence", + "extrapolation", + "fills", + "induction", + "mathematical_induction", + "noisy_and", + "not_", + "or_", + "setting", + "support", + "validate_no_self_warrant", + } +) + + +def __getattr__(name: str) -> Any: + if name in _COMPAT_EXPORTS: + warnings.warn( + f"gaia.engine.lang.{name} is deprecated. Prefer current v0.5 " + "verbs from gaia.engine.lang; if you must keep this legacy API " + f"during migration, import gaia.engine.lang.compat.{name} explicitly.", + DeprecationWarning, + stacklevel=2, + ) + compat = import_module("gaia.engine.lang.compat") + value = getattr(compat, name) + globals()[name] = value + return value + raise AttributeError(f"module 'gaia.engine.lang' has no attribute {name!r}") + + +__all__ = [ + "ArithOp", + "Associate", + "Beta", + "Binomial", + "Bool", + "BoolExpr", + "CandidateRelation", + "Cauchy", + "ChiSquared", + "Claim", + "ClaimAtom", + "ClaimKind", + "Compose", + "Composition", + "Compute", + "Constant", + "Contradict", + "Decompose", + "DependsOn", + "Derive", + "DerivedDistribution", + "Distribution", + "Domain", + "Equal", + "Equals", + "Exclusive", + "Exists", + "Exponential", + "Forall", + "Formula", + "FunctionApp", + "FunctionSymbol", + "Gamma", + "Greater", + "GreaterEqual", + "Iff", + "Implies", + "Infer", + "Knowledge", + "Land", + "Less", + "LessEqual", + "Lnot", + "LogNormal", + "Lor", + "MaterializationLink", + "Nat", + "Normal", + "NotEquals", + "Note", + "Observe", + "Poisson", + "PredicateSymbol", + "PrimitiveType", + "Probability", + "Question", + "Real", + "RoleOccurrence", + "StudentT", + "Term", + "UserPredicate", + "Variable", + "associate", + "candidate_relation", + "claim", + "compose", + "composition", + "compute", + "contradict", + "decompose", + "depends_on", + "derive", + "equal", + "equals", + "exclusive", + "exists", + "forall", + "iff", + "implies", + "infer", + "is_formula", + "is_term", + "land", + "lnot", + "lor", + "materialize", + "note", + "observe", + "parameter", + "question", + "register_prior", + "roles_for_claim", + "roles_for_package", +] diff --git a/gaia/engine/lang/compat.py b/gaia/engine/lang/compat.py new file mode 100644 index 000000000..6053f38c9 --- /dev/null +++ b/gaia/engine/lang/compat.py @@ -0,0 +1,91 @@ +"""Deprecated v5 compatibility surface for Gaia Lang. + +New packages should use the current v0.5 verbs from :mod:`gaia.engine.lang`, +such as ``note()``, ``derive()``, ``infer()``, and relation verbs. This module +keeps old DSL names available during migration without keeping them in the +recommended top-level ``gaia.engine.lang.__all__`` surface. See +``docs/foundations/gaia-lang/knowledge-and-reasoning.md`` section 7 for the +legacy-verb migration table. +""" + +from gaia.engine.lang.dsl import ( + abduction, + analogy, + and_, + case_analysis, + compare, + complement, + composite, + context, + contradiction, + deduction, + disjunction, + elimination, + equivalence, + extrapolation, + fills, + induction, + mathematical_induction, + noisy_and, + not_, + or_, + setting, + support, +) +from gaia.engine.lang.runtime import ( + Action, + Context, + Directed, + GaiaGraph, + Operator, + Reasoning, + Relation, + Scaffold, + Setting, + Step, + Strategy, + Structural, + Support, + attach_reasoning, + validate_no_self_warrant, +) + +__all__ = [ + "Action", + "Context", + "Directed", + "GaiaGraph", + "Operator", + "Reasoning", + "Relation", + "Scaffold", + "Setting", + "Step", + "Strategy", + "Structural", + "Support", + "abduction", + "analogy", + "and_", + "attach_reasoning", + "case_analysis", + "compare", + "complement", + "composite", + "context", + "contradiction", + "deduction", + "disjunction", + "elimination", + "equivalence", + "extrapolation", + "fills", + "induction", + "mathematical_induction", + "noisy_and", + "not_", + "or_", + "setting", + "support", + "validate_no_self_warrant", +] diff --git a/gaia/engine/lang/compiler/__init__.py b/gaia/engine/lang/compiler/__init__.py new file mode 100644 index 000000000..ab269038d --- /dev/null +++ b/gaia/engine/lang/compiler/__init__.py @@ -0,0 +1,9 @@ +"""Compiler entry points for Gaia Lang packages.""" + +from gaia.engine.lang.compiler.compile import ( + CompiledPackage, + compile_package, + compile_package_artifact, +) + +__all__ = ["CompiledPackage", "compile_package", "compile_package_artifact"] diff --git a/gaia/engine/lang/compiler/compile.py b/gaia/engine/lang/compiler/compile.py new file mode 100644 index 000000000..6b4323669 --- /dev/null +++ b/gaia/engine/lang/compiler/compile.py @@ -0,0 +1,2084 @@ +"""Gaia Lang v5 — compile collected module declarations to Gaia IR v2 JSON.""" + +from __future__ import annotations + +import hashlib +import inspect +import json +import re +from collections.abc import Sequence +from dataclasses import dataclass, field +from types import SimpleNamespace +from typing import Any, cast + +from gaia.engine.ir import ( + Compose as IrCompose, +) +from gaia.engine.ir import ( + CompositeStrategy as IrCompositeStrategy, +) +from gaia.engine.ir import ( + FormalExpr as IrFormalExpr, +) +from gaia.engine.ir import ( + FormalStrategy as IrFormalStrategy, +) +from gaia.engine.ir import ( + Knowledge as IrKnowledge, +) +from gaia.engine.ir import ( + LocalCanonicalGraph, + ReviewManifest, + formalize_named_strategy, + make_qid, +) +from gaia.engine.ir import ( + Operator as IrOperator, +) +from gaia.engine.ir import ( + PackageRef as IrPackageRef, +) +from gaia.engine.ir import ( + Parameter as IrParameter, +) +from gaia.engine.ir import ( + Step as IrStep, +) +from gaia.engine.ir import ( + Strategy as IrStrategy, +) +from gaia.engine.ir.formula import FormulaGraph +from gaia.engine.ir.knowledge import KnowledgeType +from gaia.engine.ir.operator import OperatorType +from gaia.engine.ir.strategy import StrategyType +from gaia.engine.lang.compiler.extensions import ( + ActionLoweringContext, + ActionLoweringResult, + discover_and_register_extensions, + is_registered_action, + lower_registered_actions, +) +from gaia.engine.lang.compiler.lower_formula import lower_claim_formula +from gaia.engine.lang.refs import ( + ReferenceError, + check_collisions, + extract, + resolve, + validate_groups, +) +from gaia.engine.lang.runtime import Claim, Knowledge, Operator +from gaia.engine.lang.runtime.action import ( + Action, + Associate, + CandidateRelation, + Compose, + Compute, + Contradict, + Decompose, + DependsOn, + Equal, + Exclusive, + GaiaGraph, + Observe, + Support, +) +from gaia.engine.lang.runtime.action import ( + Infer as InferAction, +) +from gaia.engine.lang.runtime.nodes import ReasonInput +from gaia.engine.lang.runtime.nodes import Strategy as DslStrategy +from gaia.engine.lang.runtime.package import CollectedPackage +from gaia.engine.lang.runtime.param import UNBOUND +from gaia.unit import is_quantity, to_literal + +_COMPILE_TIME_FORMAL_STRATEGIES = frozenset( + { + "deduction", + "elimination", + "mathematical_induction", + "case_analysis", + "abduction", + "analogy", + "extrapolation", + "support", + "compare", + } +) + + +def _required_id(value: str | None, label: str) -> str: + if value is None: + raise ValueError(f"{label} was not assigned by IR validation") + return value + + +@dataclass +class CompiledPackage: + """Compiled Gaia package plus runtime-object to IR-ID mappings.""" + + graph: LocalCanonicalGraph + knowledge_ids_by_object: dict[int, str] + strategies_by_object: dict[int, IrStrategy] + action_label_map: dict[str, str] = field(default_factory=dict) + target_action_labels_by_id: dict[str, str] = field(default_factory=dict) + formalization_manifest: dict[str, Any] = field( + default_factory=lambda: {"version": 1, "dependencies": [], "materializations": []} + ) + review: ReviewManifest | None = None + + def to_json(self) -> dict[str, Any]: + """Serialize the compiled graph as Gaia IR JSON.""" + return self.graph.model_dump(mode="json", exclude_none=True, serialize_as_any=True) + + +def _content_hash(k: Knowledge) -> str: + """SHA-256(type + format + content + sorted(parameters)).""" + params_str = json.dumps(sorted(k.parameters, key=lambda p: p.get("name", "")), sort_keys=True) + raw = f"{k.type}|{getattr(k, 'format', 'markdown')}|{k.content}|{params_str}" + return hashlib.sha256(raw.encode()).hexdigest() + + +_LABEL_RE = re.compile(r"[^a-z0-9_]") + + +def _normalize_label(label: str) -> str: + normalized = _LABEL_RE.sub("_", label.strip().lower()) + if not normalized: + return "_anon" + if not (normalized[0].isalpha() or normalized[0] == "_"): + normalized = f"_{normalized}" + return normalized + + +def _anonymous_label(k: Knowledge, *, prefix: str = "_anon") -> str: + return f"{prefix}_{_content_hash(k)[:8]}" + + +def _make_qid(namespace: str, package_name: str, label: str) -> str: + return make_qid(namespace, package_name, label) + + +def _make_action_qid(namespace: str, package_name: str, label: str) -> str: + return f"{namespace}:{package_name}::action::{_normalize_label(label)}" + + +def _make_scaffold_qid(namespace: str, package_name: str, label: str) -> str: + return f"{namespace}:{package_name}::scaffold::{_normalize_label(label)}" + + +def _make_materialization_qid(namespace: str, package_name: str, label: str) -> str: + return f"{namespace}:{package_name}::materialization::{_normalize_label(label)}" + + +def _is_local(k: Knowledge, pkg: CollectedPackage) -> bool: + """Check if a Knowledge node belongs to this package (vs imported from another).""" + return k in pkg.knowledge + + +def _is_composition_warrant(k: Knowledge) -> bool: + """Composition warrants are strategy metadata, not IR knowledge nodes.""" + return k.metadata.get("helper_kind") == "composition_validity" + + +def _knowledge_id( + k: Knowledge, + pkg: CollectedPackage, + *, + local_anon_counter: int, +) -> tuple[str, int]: + if _is_local(k, pkg): + label = k.label or f"_anon_{local_anon_counter:03d}" + next_counter = local_anon_counter + int(k.label is None) + return _make_qid(pkg.namespace, pkg.name, label), next_counter + + metadata_qid = k.metadata.get("qid") + if isinstance(metadata_qid, str): + return metadata_qid, local_anon_counter + + owner = k._package + if owner is not None: + foreign_label = k.label or _anonymous_label(k) + return _make_qid(owner.namespace, owner.name, foreign_label), local_anon_counter + + fallback_label = _normalize_label(k.label or _anonymous_label(k)) + return _make_qid("external", "anonymous", fallback_label), local_anon_counter + + +def _metadata_to_ir(value: Any, knowledge_map: dict[int, str]) -> Any: + from gaia.engine.lang.dsl.bool_expr import BoolExpr, DerivedDistribution + from gaia.engine.lang.runtime.distribution import Distribution + from gaia.unit import is_quantity, to_literal + + if is_quantity(value): + # Pint Quantities flow through metadata when authors write predicates + # like ``T_c > q(77, "K")``; convert to the IR-stable QuantityLiteral + # shape so the downstream LocalCanonicalGraph serialization succeeds + # and the audit-side `gaia build check` can render the unit verbatim. + literal = to_literal(value) + return { + "kind": "quantity", + "value": literal.value, + "unit": literal.unit, + } + if isinstance(value, Distribution): + # Inline-serialize the Distribution descriptor so IR consumers can + # render / audit which continuous quantity is being referenced + # without needing to walk back to the original Lang object. + return { + "kind": "distribution", + "label": value.label, + "content": value.content, + "distribution_kind": value.kind, + "params": value.params, + } + if isinstance(value, BoolExpr): + return { + "kind": "bool_expr", + "op": value.op, + "lhs": _metadata_to_ir(value.left, knowledge_map), + "rhs": _metadata_to_ir(value.right, knowledge_map), + } + if isinstance(value, DerivedDistribution): + return { + "kind": "derived_distribution", + "op": value.op, + "lhs": _metadata_to_ir(value.left, knowledge_map), + "rhs": _metadata_to_ir(value.right, knowledge_map), + } + if isinstance(value, Knowledge): + return knowledge_map[id(value)] + if isinstance(value, dict): + return {key: _metadata_to_ir(item, knowledge_map) for key, item in value.items()} + if isinstance(value, list): + return [_metadata_to_ir(item, knowledge_map) for item in value] + if isinstance(value, tuple): + return [_metadata_to_ir(item, knowledge_map) for item in value] + return value + + +def _knowledge_metadata(k: Knowledge, knowledge_map: dict[int, str]) -> dict[str, Any] | None: + metadata = dict(k.metadata) + prior = getattr(k, "prior", None) + if prior is not None and "prior" not in metadata: + # priors.py writes metadata["prior"] before compilation; that parameterization wins. + metadata["prior"] = prior + # Strip per-record `created_at` from prior_records before serialising to + # IR — created_at is wall-clock at register_prior() call time, so leaving + # it in the IR JSON makes ir_hash unstable across runs and breaks the + # `gaia run infer` stale-artifact guard. Resolution has already consumed the + # timestamp (as the recency tiebreaker) by this point; the IR-side records + # only need to carry value/source_id/justification for diagnostics and + # `gaia build check --hole` rendering. + records = metadata.get("prior_records") + if isinstance(records, list): + metadata["prior_records"] = [ + {k: v for k, v in r.items() if k != "created_at"} if isinstance(r, dict) else r + for r in records + ] + metadata = _metadata_to_ir(metadata, knowledge_map) + return metadata or None + + +def _parameter_to_ir(param: dict[str, Any], knowledge_map: dict[int, str]) -> IrParameter: + payload = dict(param) + value = payload.get("value") + if isinstance(value, Knowledge): + payload["value"] = knowledge_map[id(value)] + elif value is UNBOUND: + payload["value"] = None + elif is_quantity(value): + payload["value"] = to_literal(value).model_dump(mode="json") + return IrParameter(**payload) + + +def _knowledge_provenance(k: Knowledge) -> list[IrPackageRef] | None: + if not k.provenance: + return None + return [IrPackageRef(**item) for item in k.provenance] + + +def _metadata_with_reason( + metadata: dict[str, Any], reason: ReasonInput | None +) -> dict[str, Any] | None: + merged = dict(metadata) + if isinstance(reason, str) and reason: + merged["reason"] = reason + return merged or None + + +def _apply_formula_knowledge_updates( + ir_knowledges: list[IrKnowledge], + *, + metadata_updates: dict[str, dict[str, Any]], + parameter_updates: dict[str, list[IrParameter]], +) -> None: + """Merge formula-derived annotations back onto source IR Knowledge nodes.""" + if not metadata_updates and not parameter_updates: + return + + index_by_id = {k.id: i for i, k in enumerate(ir_knowledges) if k.id} + for qid in sorted(set(metadata_updates) | set(parameter_updates)): + try: + index = index_by_id[qid] + except KeyError as exc: + raise ValueError(f"formula lowering referenced unknown Knowledge id {qid!r}") from exc + + ir_k = ir_knowledges[index] + metadata = dict(ir_k.metadata) if ir_k.metadata else {} + metadata.update(metadata_updates.get(qid, {})) + + parameters = list(ir_k.parameters or []) + for param in parameter_updates.get(qid, []): + existing = next((p for p in parameters if p.name == param.name), None) + if existing is None: + parameters.append(param) + continue + if existing.type != param.type or existing.value != param.value: + raise ValueError( + f"formula binding for parameter {param.name!r} conflicts " + f"with existing parameter on {qid}" + ) + + ir_knowledges[index] = ir_k.model_copy( + update={ + "metadata": metadata or None, + "parameters": parameters, + "content_hash": None, + } + ) + + +def _operator_to_ir( + o: Operator, + knowledge_map: dict[int, str], + *, + top_level: bool, +) -> IrOperator: + payload: dict[str, Any] = { + "operator": OperatorType(o.operator), + "variables": [knowledge_map[id(v)] for v in o.variables], + "conclusion": knowledge_map[id(o.conclusion)], + "metadata": _metadata_with_reason(o.metadata, o.reason), + } + if top_level: + payload["operator_id"] = _operator_id(o, knowledge_map) + payload["scope"] = "local" + return IrOperator(**payload) + + +_SYMMETRIC_OPS = frozenset( + {"equivalence", "contradiction", "complement", "disjunction", "conjunction"} +) + + +def _operator_id(o: Operator, knowledge_map: dict[int, str]) -> str: + var_ids = [knowledge_map[id(v)] for v in o.variables] + if o.operator in _SYMMETRIC_OPS: + var_ids = sorted(var_ids) + conclusion_id = knowledge_map[id(o.conclusion)] + raw = f"{o.operator}|{'|'.join(var_ids)}|{conclusion_id}" + return f"lco_{hashlib.sha256(raw.encode()).hexdigest()[:16]}" + + +def _operator_id_from_values(operator: str, variables: list[str], conclusion: str) -> str: + var_ids = list(variables) + if operator in _SYMMETRIC_OPS: + var_ids = sorted(var_ids) + raw = f"{operator}|{'|'.join(var_ids)}|{conclusion}" + return f"lco_{hashlib.sha256(raw.encode()).hexdigest()[:16]}" + + +def _step_ref( + value: Knowledge | str | None, + knowledge_map: dict[int, str], +) -> str | None: + if value is None: + return None + if isinstance(value, Knowledge): + return knowledge_map[id(value)] + if isinstance(value, str): + return value + raise ValueError(f"Unsupported step reference type: {type(value)!r}") + + +def _step_refs( + values: Sequence[Knowledge | str] | None, + knowledge_map: dict[int, str], +) -> list[str] | None: + if not values: + return None + refs = [_step_ref(value, knowledge_map) for value in values] + return [ref for ref in refs if ref is not None] + + +def _compile_reason( + reason: ReasonInput, + knowledge_map: dict[int, str], +) -> list[IrStep] | None: + """Compile a reason (str or list[str | Step]) into IR Steps.""" + if isinstance(reason, str): + return None # simple string goes to metadata.reason, not steps + if not reason: + return None + from gaia.engine.lang.runtime.nodes import Step as DslStep + + ir_steps: list[IrStep] = [] + for entry in reason: + if isinstance(entry, str): + ir_steps.append(IrStep(reasoning=entry)) + elif isinstance(entry, DslStep): + ir_steps.append( + IrStep( + reasoning=entry.reason, + premises=_step_refs(entry.premises, knowledge_map) if entry.premises else None, + ) + ) + else: + raise ValueError(f"Unsupported reason entry type: {type(entry)!r}") + return ir_steps or None + + +def _action_steps(rationale: str) -> list[IrStep] | None: + if not rationale: + return None + return [IrStep(reasoning=rationale)] + + +def _action_label(action: Any, pkg: CollectedPackage, action_index: int) -> str: + label = action.label or f"_anon_action_{action_index:03d}" + return _make_action_qid(pkg.namespace, pkg.name, label) + + +def _action_label_display(action_label: str) -> str: + return action_label.rsplit("::action::", maxsplit=1)[-1] + + +def _record_action_label_target( + action_label_map: dict[str, str], + target_action_labels_by_id: dict[str, str], + action_label: str, + target_id: str | None, +) -> None: + if target_id is None: + return + existing_target_id = action_label_map.get(action_label) + if existing_target_id is not None: + raise ValueError( + f"duplicate action label '{_action_label_display(action_label)}' " + f"targets both {existing_target_id!r} and {target_id!r}" + ) + action_label_map[action_label] = target_id + target_action_labels_by_id[target_id] = action_label + + +def _merge_action_label_targets( + action_label_map: dict[str, str], + target_action_labels_by_id: dict[str, str], + incoming_action_label_map: dict[str, str], + incoming_target_action_labels_by_id: dict[str, str], +) -> None: + for action_label, target_id in incoming_action_label_map.items(): + _record_action_label_target( + action_label_map, + target_action_labels_by_id, + action_label, + target_id, + ) + target_action_labels_by_id.update(incoming_target_action_labels_by_id) + + +def _action_metadata( + action: Any, + pkg: CollectedPackage, + action_index: int, + *, + pattern: str, + extra: dict[str, Any] | None = None, +) -> tuple[str, dict[str, Any]]: + label = _action_label(action, pkg, action_index) + metadata = dict(getattr(action, "metadata", {}) or {}) + metadata["action_label"] = label + metadata["pattern"] = pattern + if extra: + metadata.update(extra) + return label, metadata + + +def _mark_formal_action_reviews(knowledges: list[IrKnowledge]) -> None: + """Mark deterministic helper claims generated for reviewable v6 actions.""" + for knowledge in knowledges: + metadata = dict(knowledge.metadata or {}) + helper_kind = metadata.get("helper_kind") + if helper_kind == "implication_result": + metadata["review"] = True + elif helper_kind == "conjunction_result": + metadata["review"] = False + if metadata != (knowledge.metadata or {}): + knowledge.metadata = metadata + + +def _compute_metadata(fn: Any) -> dict[str, Any]: + if fn is None: + return {} + function_ref = f"{getattr(fn, '__module__', '')}.{getattr(fn, '__qualname__', repr(fn))}" + try: + source = inspect.getsource(fn) + except (OSError, TypeError): + source = repr(fn) + return { + "function_ref": function_ref, + "code_hash": f"sha256:{hashlib.sha256(source.encode()).hexdigest()}", + } + + +def _probability_scalar(value: float | Knowledge | None, *, field_name: str) -> float: + if value is None: + raise TypeError(f"{field_name} must be a probability scalar or Claim") + if not isinstance(value, Knowledge): + return float(value) + + matches: list[int | float] = [] + for param in value.parameters: + param_value = param.get("value") + if param.get("name") == "value" and isinstance(param_value, int | float): + matches.append(param_value) + if len(matches) != 1: + raise ValueError( + f"{field_name} Claim must define exactly one numeric parameter named 'value'" + ) + return float(matches[0]) + + +def _collect_refs_from_text( + text: str | None, + label_table: dict[str, str], + references: dict[str, Any], +) -> tuple[list[str], list[str]]: + """Scan a piece of text and return (knowledge_refs, citation_refs). + + Enforces: + - homogeneous-group rule (raises ReferenceError on mixed groups) + - strict-form errors on unknown keys (raises ReferenceError) + Ignores opportunistic (bare) misses silently. + """ + if not text: + return [], [] + result = extract(text) + + # §3.2: mixed-group check + validate_groups(result.groups, result.markers, label_table, references) + + knowledge_refs: list[str] = [] + citation_refs: list[str] = [] + for marker in result.markers: + kind = resolve(marker.key, label_table, references) + if kind == "knowledge": + knowledge_refs.append(marker.key) + elif kind == "citation": + citation_refs.append(marker.key) + else: # unknown + if marker.strict: + raise ReferenceError( + f"unknown reference key '@{marker.key}' in strict form " + f"(in brackets): it is neither a knowledge label nor a " + f"citation key. add it to the package or references.json, " + f"or use the bare form `@{marker.key}` for opportunistic " + f"handling." + ) + # opportunistic miss → silent literal + + # Dedupe while preserving order + return ( + list(dict.fromkeys(knowledge_refs)), + list(dict.fromkeys(citation_refs)), + ) + + +@dataclass +class _KnowledgeCollection: + """Knowledge closure and formal operator markers collected from a package.""" + + nodes: list[Knowledge] + formal_operators: set[int] + + +@dataclass +class _KnowledgeCollector: + """Collect local and referenced Knowledge nodes before ID assignment.""" + + pkg: CollectedPackage + nodes: list[Knowledge] = field(default_factory=list) + seen: set[int] = field(default_factory=set) + formal_operators: set[int] = field(default_factory=set) + + def collect(self) -> _KnowledgeCollection: + """Return the package Knowledge closure in declaration-preserving order.""" + for knowledge in self.pkg.knowledge: + if _is_composition_warrant(knowledge): + continue + self.register_knowledge(knowledge) + for strategy in self.pkg.strategies: + self.register_strategy_knowledge(strategy) + for operator in self.pkg.operators: + for variable in operator.variables: + self.register_knowledge(variable) + if operator.conclusion is not None: + self.register_knowledge(operator.conclusion) + for action in getattr(self.pkg, "actions", []): + self.register_action_knowledge(action) + return _KnowledgeCollection(nodes=self.nodes, formal_operators=self.formal_operators) + + def register_knowledge(self, knowledge: Knowledge) -> None: + """Register a Knowledge node and any Knowledge-valued parameters it owns.""" + key = id(knowledge) + if key in self.seen: + return + self.nodes.append(knowledge) + self.seen.add(key) + for param in knowledge.parameters: + value = param.get("value") + if isinstance(value, Knowledge): + self.register_knowledge(value) + + def register_strategy_knowledge(self, strategy: Any) -> None: + """Register Knowledge referenced by a legacy/runtime strategy tree.""" + for premise in strategy.premises: + self.register_knowledge(premise) + for background in strategy.background: + self.register_knowledge(background) + if strategy.conclusion is not None: + self.register_knowledge(strategy.conclusion) + if strategy.formal_expr: + for operator in strategy.formal_expr: + self.formal_operators.add(id(operator)) + for variable in operator.variables: + self.register_knowledge(variable) + if operator.conclusion is not None: + self.register_knowledge(operator.conclusion) + for sub_strategy in strategy.sub_strategies: + self.register_strategy_knowledge(sub_strategy) + + def register_action_knowledge(self, action: Any) -> None: + """Register Knowledge referenced by an authoring action.""" + self._register_action_context(action) + if isinstance(action, Compose): + self._register_compose_action(action) + elif isinstance(action, Support | DependsOn): + self._register_support_like_action(action) + elif isinstance(action, CandidateRelation): + self._register_optional_claims(*action.claims) + elif isinstance(action, Equal | Contradict | Exclusive): + self._register_optional_claims(action.a, action.b, action.helper) + elif isinstance(action, Decompose): + self._register_decompose_action(action) + elif isinstance(action, InferAction): + self._register_infer_action(action) + elif isinstance(action, Associate): + self._register_optional_claims(action.a, action.b, action.helper) + + def _register_action_context(self, action: Any) -> None: + for background in getattr(action, "background", []) or []: + self.register_knowledge(background) + for warrant in getattr(action, "warrants", []) or []: + self.register_knowledge(warrant) + + def _register_optional_claims(self, *claims: Knowledge | None) -> None: + for claim in claims: + if claim is not None: + self.register_knowledge(claim) + + def _register_compose_action(self, action: Compose) -> None: + for item in action.inputs: + if isinstance(item, Knowledge): + self.register_knowledge(item) + for child_action in action.actions: + if isinstance(child_action, Action): + self.register_action_knowledge(child_action) + if action.conclusion is not None: + self.register_knowledge(action.conclusion) + + def _register_support_like_action(self, action: Support | DependsOn) -> None: + for given in action.given: + self.register_knowledge(given) + if action.conclusion is not None: + self.register_knowledge(action.conclusion) + + def _register_decompose_action(self, action: Decompose) -> None: + if action.whole is not None: + self.register_knowledge(action.whole) + for part in action.parts: + self.register_knowledge(part) + + def _register_infer_action(self, action: InferAction) -> None: + self._register_optional_claims(action.hypothesis, action.evidence, action.helper) + for given in action.given: + self.register_knowledge(given) + if isinstance(action.p_e_given_h, Knowledge): + self.register_knowledge(action.p_e_given_h) + if isinstance(action.p_e_given_not_h, Knowledge): + self.register_knowledge(action.p_e_given_not_h) + + +@dataclass +class _FormulaLoweringResult: + """Formula-lowering artifacts emitted after action lowering.""" + + knowledges: list[IrKnowledge] + operators: list[IrOperator] + strategies: list[IrStrategy] + formula_graphs: list[FormulaGraph] + + +@dataclass +class _StrategyCompiler: + """Compile runtime strategies while preserving generated helper state.""" + + pkg: CollectedPackage + knowledge_map: dict[int, str] + generated_knowledges: list[IrKnowledge] + compiled_strategies: dict[int, IrStrategy] = field(default_factory=dict) + + def compile_strategy(self, strategy: DslStrategy) -> IrStrategy: + """Compile one strategy, recursively compiling nested strategy references.""" + strategy_key = id(strategy) + if strategy_key in self.compiled_strategies: + return self.compiled_strategies[strategy_key] + + steps = _compile_reason(strategy.reason, self.knowledge_map) + payload = self._strategy_payload(strategy, steps) + ir_strategy = self._strategy_from_payload(strategy, payload, steps) + self.compiled_strategies[strategy_key] = ir_strategy + return ir_strategy + + def _strategy_payload( + self, strategy: DslStrategy, steps: list[IrStep] | None + ) -> dict[str, Any]: + return { + "scope": "local", + "type": StrategyType(strategy.type), + "premises": [self.knowledge_map[id(p)] for p in strategy.premises], + "conclusion": ( + self.knowledge_map[id(strategy.conclusion)] + if strategy.conclusion is not None + else None + ), + "background": [self.knowledge_map[id(b)] for b in strategy.background] or None, + "steps": steps, + "metadata": _metadata_with_reason(strategy.metadata, strategy.reason), + } + + def _strategy_from_payload( + self, + strategy: DslStrategy, + payload: dict[str, Any], + steps: list[IrStep] | None, + ) -> IrStrategy: + if strategy.sub_strategies: + payload["sub_strategies"] = [ + _required_id(self.compile_strategy(sub_strategy).strategy_id, "strategy_id") + for sub_strategy in strategy.sub_strategies + ] + return IrCompositeStrategy(**payload) + if strategy.formal_expr: + payload["formal_expr"] = IrFormalExpr( + operators=[ + _operator_to_ir(op, self.knowledge_map, top_level=False) + for op in strategy.formal_expr + ] + ) + return IrFormalStrategy(**payload) + if strategy.type in _COMPILE_TIME_FORMAL_STRATEGIES: + result = formalize_named_strategy( + scope="local", + type_=strategy.type, + premises=payload["premises"], + conclusion=payload["conclusion"], + namespace=self.pkg.namespace, + package_name=self.pkg.name, + background=payload["background"], + steps=steps, + metadata=payload["metadata"], + ) + self.generated_knowledges.extend(result.knowledges) + return result.strategy + return IrStrategy(**payload) + + +@dataclass +class _ActionCompiler: + """Lower authoring actions into IR strategies, operators, and compose nodes.""" + + pkg: CollectedPackage + knowledge_map: dict[int, str] + ir_knowledges: list[IrKnowledge] + ir_strategies: list[IrStrategy] + generated_knowledges: list[IrKnowledge] + action_label_map: dict[str, str] = field(default_factory=dict) + target_action_labels_by_id: dict[str, str] = field(default_factory=dict) + action_operators: list[IrOperator] = field(default_factory=list) + formula_graphs: list[FormulaGraph] = field(default_factory=list) + action_target_ids_by_object: dict[int, str] = field(default_factory=dict) + formalization_dependencies: list[dict[str, Any]] = field(default_factory=list) + formalization_materializations: list[dict[str, Any]] = field(default_factory=list) + + def compile_non_compose_actions(self) -> None: + """Lower every non-Compose action in package declaration order.""" + for action_index, action in enumerate(getattr(self.pkg, "actions", [])): + if isinstance(action, Compose): + continue + if isinstance(action, DependsOn): + self.formalization_dependencies.append( + self._compile_depends_on_action(action, action_index) + ) + continue + if isinstance(action, CandidateRelation): + self.formalization_dependencies.append( + self._compile_candidate_relation_action(action, action_index) + ) + continue + self._record_lowered_action(action, action_index) + + def compile_compose_actions( + self, + *, + strategy_target_ids_by_object: dict[int, str], + operator_target_ids_by_object: dict[int, str], + ) -> list[IrCompose]: + """Lower Compose actions after child and extension actions have targets.""" + return [ + self._compile_compose_action( + action, + action_index, + strategy_target_ids_by_object=strategy_target_ids_by_object, + operator_target_ids_by_object=operator_target_ids_by_object, + ) + for action_index, action in enumerate(getattr(self.pkg, "actions", [])) + if isinstance(action, Compose) + ] + + def compile_materializations(self) -> None: + """Lower materialization links into the formalization manifest.""" + for link_index, link in enumerate(getattr(self.pkg, "materializations", [])): + self.formalization_materializations.append( + self._compile_materialization_link(link, link_index) + ) + + def _record_action_target(self, action_label: str, target_id: str | None) -> None: + _record_action_label_target( + self.action_label_map, + self.target_action_labels_by_id, + action_label, + target_id, + ) + + def _scaffold_label(self, action: DependsOn | CandidateRelation, action_index: int) -> str: + return action.label or f"_anon_action_{action_index:03d}" + + def _graph_label(self, record: GaiaGraph, action_index: int | None) -> str: + if record.label: + return record.label + if action_index is None: + raise ValueError(f"{type(record).__name__} record requires a label") + return f"_anon_action_{action_index:03d}" + + def _action_index_by_object(self) -> dict[int, int]: + return {id(action): index for index, action in enumerate(getattr(self.pkg, "actions", []))} + + def _graph_ref(self, record: GaiaGraph, action_indices: dict[int, int]) -> str: + action_index = action_indices.get(id(record)) + if isinstance(record, DependsOn | CandidateRelation): + return _make_scaffold_qid( + self.pkg.namespace, + self.pkg.name, + self._scaffold_label(record, action_index if action_index is not None else 0), + ) + return _make_action_qid( + self.pkg.namespace, + self.pkg.name, + self._graph_label(record, action_index), + ) + + def _compile_materialization_link(self, link: Any, link_index: int) -> dict[str, Any]: + action_indices = self._action_index_by_object() + label = link.label or f"_anon_materialization_{link_index:03d}" + return { + "id": _make_materialization_qid(self.pkg.namespace, self.pkg.name, label), + "kind": "materialization", + "label": label, + "scaffold": self._graph_ref(link.scaffold, action_indices), + "by": [self._graph_ref(record, action_indices) for record in link.by], + "rationale": link.rationale, + "metadata": _metadata_to_ir(dict(link.metadata or {}), self.knowledge_map), + } + + def _compile_depends_on_action(self, action: DependsOn, action_index: int) -> dict[str, Any]: + if action.conclusion is None: + raise ValueError("DependsOn action requires a conclusion") + if not action.given: + raise ValueError("DependsOn action requires at least one given Claim") + label = self._scaffold_label(action, action_index) + record: dict[str, Any] = { + "id": _make_scaffold_qid(self.pkg.namespace, self.pkg.name, label), + "kind": "depends_on", + "label": label, + "conclusion": self.knowledge_map[id(action.conclusion)], + "given": [self.knowledge_map[id(given)] for given in action.given], + "rationale": action.rationale, + "status": "unformalized", + "metadata": _metadata_to_ir(dict(action.metadata or {}), self.knowledge_map), + } + background = [self.knowledge_map[id(bg)] for bg in action.background] + if background: + record["background"] = background + return record + + def _compile_candidate_relation_action( + self, + action: CandidateRelation, + action_index: int, + ) -> dict[str, Any]: + if len(action.claims) < 2: + raise ValueError("CandidateRelation action requires at least two claims") + label = self._scaffold_label(action, action_index) + record: dict[str, Any] = { + "id": _make_scaffold_qid(self.pkg.namespace, self.pkg.name, label), + "kind": "candidate_relation", + "label": label, + "pattern": action.pattern, + "claims": [self.knowledge_map[id(claim)] for claim in action.claims], + "rationale": action.rationale, + "status": action.status, + "metadata": _metadata_to_ir(dict(action.metadata or {}), self.knowledge_map), + } + background = [self.knowledge_map[id(bg)] for bg in action.background] + if background: + record["background"] = background + return record + + def _warrant_ids(self, action: Any) -> list[str]: + return [ + self.knowledge_map[id(warrant)] for warrant in getattr(action, "warrants", []) or [] + ] + + def _attach_action_label_to_warrants( + self, + action: Any, + *, + action_label: str, + pattern: str, + ) -> None: + for warrant in getattr(action, "warrants", []) or []: + warrant_id = self.knowledge_map[id(warrant)] + for i, ir_k in enumerate(self.ir_knowledges): + if ir_k.id != warrant_id: + continue + metadata = dict(ir_k.metadata or {}) + metadata.setdefault("review", True) + metadata["action_label"] = action_label + metadata["pattern"] = pattern + self.ir_knowledges[i] = ir_k.model_copy(update={"metadata": metadata}) + break + + def _attach_supported_by_action( + self, + action: Support, + *, + action_label: str, + conclusion_id: str, + background_ids: list[str] | None, + action_metadata: dict[str, Any], + ) -> None: + for i, ir_k in enumerate(self.ir_knowledges): + if ir_k.id != conclusion_id: + continue + knowledge_metadata = dict(ir_k.metadata) if ir_k.metadata else {} + supported_by = list(knowledge_metadata.get("supported_by") or []) + entry = self._supported_by_entry(action, action_label, background_ids, action_metadata) + supported_by.append(entry) + knowledge_metadata["supported_by"] = supported_by + self.ir_knowledges[i] = ir_k.model_copy(update={"metadata": knowledge_metadata}) + return + + def _supported_by_entry( + self, + action: Support, + action_label: str, + background_ids: list[str] | None, + action_metadata: dict[str, Any], + ) -> dict[str, Any]: + entry: dict[str, Any] = {"action_label": action_label, "pattern": "observation"} + if action_metadata.get("warrants"): + entry["warrants"] = action_metadata["warrants"] + if background_ids: + entry["background"] = background_ids + if action.rationale: + entry["rationale"] = action.rationale + source_refs = action_metadata.get("source_refs") + if source_refs: + entry["source_refs"] = source_refs + return entry + + def _compile_support_action(self, action: Support, action_index: int) -> IrStrategy | None: + if action.conclusion is None: + raise ValueError("Support action requires a conclusion") + premise_ids = [self.knowledge_map[id(given)] for given in action.given] + conclusion_id = self.knowledge_map[id(action.conclusion)] + background_ids = [self.knowledge_map[id(bg)] for bg in action.background] or None + pattern = _support_action_pattern(action) + extra = {"compute": _compute_metadata(action.fn)} if isinstance(action, Compute) else None + action_label, metadata = _action_metadata( + action, + self.pkg, + action_index, + pattern=pattern, + extra=extra, + ) + self._prepare_action_warrants( + action, action_label=action_label, pattern=pattern, metadata=metadata + ) + + if isinstance(action, Observe) and not premise_ids: + self._attach_supported_by_action( + action, + action_label=action_label, + conclusion_id=conclusion_id, + background_ids=background_ids, + action_metadata=metadata, + ) + self._record_action_target(action_label, conclusion_id) + return None + + strategy = self._support_strategy( + action, + premise_ids=premise_ids, + conclusion_id=conclusion_id, + background_ids=background_ids, + metadata=metadata, + ) + self._record_action_target(action_label, strategy.strategy_id) + return strategy + + def _prepare_action_warrants( + self, + action: Any, + *, + action_label: str, + pattern: str, + metadata: dict[str, Any], + ) -> None: + warrant_ids = self._warrant_ids(action) + if warrant_ids: + metadata["warrants"] = warrant_ids + self._attach_action_label_to_warrants(action, action_label=action_label, pattern=pattern) + + def _support_strategy( + self, + action: Support, + *, + premise_ids: list[str], + conclusion_id: str, + background_ids: list[str] | None, + metadata: dict[str, Any], + ) -> IrStrategy: + if premise_ids: + result = formalize_named_strategy( + scope="local", + type_="deduction", + premises=premise_ids, + conclusion=conclusion_id, + namespace=self.pkg.namespace, + package_name=self.pkg.name, + background=background_ids, + steps=_action_steps(action.rationale), + metadata=metadata, + ) + _mark_formal_action_reviews(result.knowledges) + self.generated_knowledges.extend(result.knowledges) + return result.strategy + return IrStrategy( + scope="local", + type=StrategyType.DEDUCTION, + premises=[], + conclusion=conclusion_id, + background=background_ids, + steps=_action_steps(action.rationale), + metadata=metadata, + ) + + def _compile_structural_relation_action( + self, + action: Equal | Contradict | Exclusive, + action_index: int, + ) -> IrOperator: + if action.a is None or action.b is None or action.helper is None: + raise ValueError("Structural relation action requires a, b, and helper") + operator, pattern = _structural_action_operator(action) + action_label, metadata = _action_metadata(action, self.pkg, action_index, pattern=pattern) + self._prepare_action_warrants( + action, action_label=action_label, pattern=pattern, metadata=metadata + ) + if action.rationale: + metadata["reason"] = action.rationale + background_ids = [self.knowledge_map[id(bg)] for bg in action.background] + if background_ids: + metadata["background"] = background_ids + variables = [self.knowledge_map[id(action.a)], self.knowledge_map[id(action.b)]] + conclusion = self.knowledge_map[id(action.helper)] + ir_operator = IrOperator( + operator_id=_operator_id_from_values(operator, variables, conclusion), + scope="local", + operator=operator, + variables=variables, + conclusion=conclusion, + metadata=metadata, + ) + self._record_action_target(action_label, ir_operator.operator_id) + return ir_operator + + def _decompose_generated_label(self, action: Decompose, action_index: int, suffix: str) -> str: + action_label = action.label or f"_anon_action_{action_index:03d}" + return f"__decompose_{_normalize_label(action_label)}_{suffix}" + + def _compile_decompose_action(self, action: Decompose, action_index: int) -> IrOperator: + if action.whole is None: + raise ValueError("Decompose action requires a whole Claim") + if not action.parts: + raise ValueError("Decompose action requires at least one part Claim") + if action.formula is None: + raise ValueError("Decompose action requires a formula") + + action_label, metadata = _action_metadata( + action, self.pkg, action_index, pattern="decomposition" + ) + whole_id = self.knowledge_map[id(action.whole)] + part_ids = [self.knowledge_map[id(part)] for part in action.parts] + formula_id = self._emit_decomposition_formula( + action, action_index, action_label, whole_id, part_ids + ) + equivalence_id = self._emit_decomposition_equivalence( + action, action_index, action_label, whole_id, formula_id + ) + metadata["decomposition"] = { + "whole": whole_id, + "parts": part_ids, + "formula_helper": formula_id, + } + if action.rationale: + metadata["reason"] = action.rationale + ir_operator = IrOperator( + operator_id=_operator_id_from_values( + "equivalence", [whole_id, formula_id], equivalence_id + ), + scope="local", + operator=OperatorType.EQUIVALENCE, + variables=[whole_id, formula_id], + conclusion=equivalence_id, + metadata=metadata, + ) + self._record_action_target(action_label, ir_operator.operator_id) + return ir_operator + + def _emit_decomposition_formula( + self, + action: Decompose, + action_index: int, + action_label: str, + whole_id: str, + part_ids: list[str], + ) -> str: + formula_label = self._decompose_generated_label(action, action_index, "formula") + formula_id = _make_qid(self.pkg.namespace, self.pkg.name, formula_label) + formula_proxy = SimpleNamespace( + content=f"Formula decomposition of {whole_id}", formula=action.formula + ) + lowered = lower_claim_formula( + cast(Claim, formula_proxy), + claim_id=formula_id, + namespace=self.pkg.namespace, + package_name=self.pkg.name, + knowledge_map=self.knowledge_map, + ) + formula_metadata = { + "generated": True, + "helper_kind": "decomposition_formula", + "generated_by": action_label, + "source_claim": whole_id, + "decomposition_parts": part_ids, + "review": False, + } + formula_metadata.update(lowered.metadata_updates.get(formula_id, {})) + self.generated_knowledges.append( + IrKnowledge( + id=formula_id, + label=formula_label, + type=KnowledgeType.CLAIM, + content=f"Formula decomposition of {whole_id}", + parameters=lowered.parameter_updates.get(formula_id) or [], + metadata=formula_metadata, + ) + ) + self.generated_knowledges.extend(lowered.knowledges) + self.formula_graphs.extend(lowered.formula_graphs) + self._record_decomposition_lowering(lowered) + return formula_id + + def _record_decomposition_lowering(self, lowered: Any) -> None: + for operator in lowered.operators: + if operator.scope == "local" and operator.operator_id is None: + operator.operator_id = _operator_id_from_values( + str(operator.operator), + operator.variables, + operator.conclusion, + ) + self.action_operators.append(operator) + if lowered.strategies: + self.ir_strategies.extend(lowered.strategies) + + def _emit_decomposition_equivalence( + self, + action: Decompose, + action_index: int, + action_label: str, + whole_id: str, + formula_id: str, + ) -> str: + equivalence_label = self._decompose_generated_label(action, action_index, "equivalence") + equivalence_id = _make_qid(self.pkg.namespace, self.pkg.name, equivalence_label) + self.generated_knowledges.append( + IrKnowledge( + id=equivalence_id, + label=equivalence_label, + type=KnowledgeType.CLAIM, + content=f"{whole_id} is equivalent to its decomposition formula.", + metadata={ + "generated": True, + "helper_kind": "decomposition_equivalence", + "generated_by": action_label, + "source_claim": whole_id, + "formula_helper": formula_id, + "review": False, + }, + ) + ) + return equivalence_id + + def _compile_infer_action(self, action: InferAction, action_index: int) -> IrStrategy: + if action.hypothesis is None or action.evidence is None: + raise ValueError("Infer action requires hypothesis and evidence") + action_label, metadata = _action_metadata( + action, self.pkg, action_index, pattern="inference" + ) + self._prepare_action_warrants( + action, + action_label=action_label, + pattern="inference", + metadata=metadata, + ) + given_ids = [self.knowledge_map[id(given)] for given in action.given] + if given_ids: + metadata["given"] = given_ids + p_e_given_not_h = _probability_scalar(action.p_e_given_not_h, field_name="p_e_given_not_h") + p_e_given_h = _probability_scalar(action.p_e_given_h, field_name="p_e_given_h") + strategy = IrStrategy( + scope="local", + type=StrategyType.INFER, + premises=[self.knowledge_map[id(action.hypothesis)], *given_ids], + conclusion=self.knowledge_map[id(action.evidence)], + background=[self.knowledge_map[id(bg)] for bg in action.background] or None, + steps=_action_steps(action.rationale), + conditional_probabilities=_infer_conditional_probabilities( + p_e_given_h=p_e_given_h, + p_e_given_not_h=p_e_given_not_h, + given_count=len(given_ids), + ), + metadata=metadata, + ) + self._record_action_target(action_label, strategy.strategy_id) + return strategy + + def _compile_associate_action(self, action: Associate, action_index: int) -> IrStrategy: + if action.a is None or action.b is None or action.helper is None: + raise ValueError("Associate action requires a, b, and helper") + action_label, metadata = _action_metadata( + action, self.pkg, action_index, pattern="association" + ) + self._prepare_action_warrants( + action, + action_label=action_label, + pattern="association", + metadata=metadata, + ) + strategy = IrStrategy( + scope="local", + type=StrategyType.ASSOCIATE, + premises=[self.knowledge_map[id(action.a)], self.knowledge_map[id(action.b)]], + conclusion=self.knowledge_map[id(action.helper)], + background=[self.knowledge_map[id(bg)] for bg in action.background] or None, + steps=_action_steps(action.rationale), + p_a_given_b=action.p_a_given_b, + p_b_given_a=action.p_b_given_a, + metadata=metadata, + ) + self._record_action_target(action_label, strategy.strategy_id) + return strategy + + def _record_lowered_action(self, action: Any, action_index: int) -> None: + target = self.compile_action(action, action_index) + if target is None: + action_label = _action_label(action, self.pkg, action_index) + target_id = self.action_label_map.get(action_label) + if target_id is not None: + self.action_target_ids_by_object[id(action)] = target_id + return + if isinstance(target, IrOperator): + self.action_operators.append(target) + self.action_target_ids_by_object[id(action)] = _required_id( + target.operator_id, + "operator_id", + ) + return + self.ir_strategies.append(target) + self.action_target_ids_by_object[id(action)] = _required_id( + target.strategy_id, "strategy_id" + ) + + def compile_action(self, action: Any, action_index: int) -> IrStrategy | IrOperator | None: + """Lower one non-scaffold action into its IR target.""" + if isinstance(action, DependsOn | CandidateRelation): + return None + if is_registered_action(action): + return None + if isinstance(action, Support): + return self._compile_support_action(action, action_index) + if isinstance(action, Equal | Contradict | Exclusive): + return self._compile_structural_relation_action(action, action_index) + if isinstance(action, Decompose): + return self._compile_decompose_action(action, action_index) + if isinstance(action, InferAction): + return self._compile_infer_action(action, action_index) + if isinstance(action, Associate): + return self._compile_associate_action(action, action_index) + if isinstance(action, Compose): + return None + raise ValueError(f"Unsupported action type: {type(action).__name__}") + + def _target_id( + self, + obj: Any, + *, + strategy_target_ids_by_object: dict[int, str], + operator_target_ids_by_object: dict[int, str], + ) -> str: + if isinstance(obj, str): + return obj + key = id(obj) + if key in self.knowledge_map: + return self.knowledge_map[key] + if key in self.action_target_ids_by_object: + return self.action_target_ids_by_object[key] + if key in strategy_target_ids_by_object: + return strategy_target_ids_by_object[key] + if key in operator_target_ids_by_object: + return operator_target_ids_by_object[key] + raise ValueError(f"Compose child target was not compiled: {type(obj).__name__}") + + def _compile_compose_action( + self, + action: Compose, + action_index: int, + *, + strategy_target_ids_by_object: dict[int, str], + operator_target_ids_by_object: dict[int, str], + ) -> IrCompose: + if action.conclusion is None: + raise ValueError("Compose action requires a conclusion") + + def target_id(item: Any) -> str: + return self._target_id( + item, + strategy_target_ids_by_object=strategy_target_ids_by_object, + operator_target_ids_by_object=operator_target_ids_by_object, + ) + + input_refs = [target_id(item) for item in action.inputs] + background_refs = [target_id(item) for item in action.background] + action_refs = [target_id(child) for child in action.actions] + warrant_refs = [target_id(warrant) for warrant in action.warrants] + conclusion_ref = target_id(action.conclusion) + compose_hash = action.structure_hash( + input_refs, + action_refs, + conclusion_ref, + warrant_refs, + background_refs, + ) + compose_id = f"lcm_{compose_hash}" + action_label, metadata = _action_metadata(action, self.pkg, action_index, pattern="compose") + if action.rationale: + metadata["reason"] = action.rationale + if warrant_refs: + metadata["warrants"] = warrant_refs + self._attach_action_label_to_warrants(action, action_label=action_label, pattern="compose") + ir_compose = IrCompose( + compose_id=compose_id, + name=action.name, + version=action.version, + inputs=input_refs, + background=background_refs, + actions=action_refs, + warrants=warrant_refs, + conclusion=conclusion_ref, + metadata=metadata or None, + ) + self._record_action_target(action_label, compose_id) + self.action_target_ids_by_object[id(action)] = compose_id + return ir_compose + + +@dataclass +class _ReferenceScanner: + """Scan reference-bearing text and attach provenance metadata.""" + + pkg: CollectedPackage + references: dict[str, Any] + knowledge_nodes: list[Knowledge] + knowledge_map: dict[int, str] + action_label_map: dict[str, str] + action_labels_by_object: dict[int, str] + ir_knowledges: list[IrKnowledge] + generated_knowledges: list[IrKnowledge] + formula_generated_knowledges: list[IrKnowledge] + extension_lowered_knowledges: list[IrKnowledge] + ir_strategies: list[IrStrategy] + formula_generated_strategies: list[IrStrategy] + extension_strategies: list[IrStrategy] + ir_operators: list[IrOperator] + action_operators: list[IrOperator] + formula_generated_operators: list[IrOperator] + extension_operators: list[IrOperator] + ir_composes: list[IrCompose] + refs_by_knowledge: dict[int, tuple[set[str], set[str]]] = field(default_factory=dict) + + def scan(self) -> list[IrKnowledge]: + """Scan package text and return updated extension-generated knowledge nodes.""" + label_to_id, knowledge_label_ids = self._build_knowledge_label_tables() + label_to_id.update(self._build_action_short_labels(knowledge_label_ids)) + check_collisions(label_to_id, self.references) + self._scan_strategy_references(label_to_id) + self._scan_local_knowledge_content(label_to_id) + action_rationale_refs = self._collect_action_rationale_refs(label_to_id) + return self._apply_reference_metadata(action_rationale_refs) + + def _build_knowledge_label_tables(self) -> tuple[dict[str, str], dict[str, set[str]]]: + label_to_id: dict[str, str] = {} + knowledge_label_ids: dict[str, set[str]] = {} + for knowledge in self.knowledge_nodes: + if knowledge.label: + qid = self.knowledge_map[id(knowledge)] + label_to_id[knowledge.label] = qid + knowledge_label_ids.setdefault(knowledge.label, set()).add(qid) + return label_to_id, knowledge_label_ids + + def _build_action_short_labels( + self, knowledge_label_ids: dict[str, set[str]] + ) -> dict[str, str]: + action_short_labels: dict[str, str] = {} + for action in getattr(self.pkg, "actions", []): + if not action.label: + continue + action_label_qid = self.action_labels_by_object.get(id(action)) + if action_label_qid is None: + continue + target_qid = self.action_label_map.get(action_label_qid) + if target_qid is not None: + action_short_labels[action.label] = self._action_reference_target( + action, target_qid + ) + self._raise_label_collisions(action_short_labels, knowledge_label_ids) + return action_short_labels + + def _action_reference_target(self, action: Action, default_target_qid: str) -> str: + if not action.label: + return default_target_qid + if ( + isinstance(action, Support) + and action.conclusion is not None + and action.conclusion.label == action.label + ): + return self.knowledge_map[id(action.conclusion)] + helper = getattr(action, "helper", None) + if helper is not None and getattr(helper, "label", None) == action.label: + helper_qid = self.knowledge_map.get(id(helper)) + if helper_qid is not None: + return helper_qid + return default_target_qid + + def _raise_label_collisions( + self, + action_short_labels: dict[str, str], + knowledge_label_ids: dict[str, set[str]], + ) -> None: + label_collisions = sorted( + label + for label, target_qid in action_short_labels.items() + if label in knowledge_label_ids and knowledge_label_ids[label] != {target_qid} + ) + if label_collisions: + quoted = ", ".join(f"'{label}'" for label in label_collisions) + raise ValueError( + f"label collision(s) {quoted}: same identifier used as both " + f"a Knowledge label and an Action label. rename one side to disambiguate." + ) + + def _scan_strategy_references(self, label_to_id: dict[str, str]) -> None: + for strategy in self.pkg.strategies: + self._scan_strategy_refs(strategy, label_to_id) + + def _scan_strategy_refs(self, strategy: DslStrategy, label_to_id: dict[str, str]) -> None: + target = strategy.conclusion + target_is_local = target is not None and _is_local(target, self.pkg) + for text in self._strategy_reference_texts(strategy): + if target_is_local and target is not None: + self._accumulate(target, text, label_to_id) + else: + _collect_refs_from_text(text, label_to_id, self.references) + for sub_strategy in strategy.sub_strategies: + self._scan_strategy_refs(sub_strategy, label_to_id) + + def _strategy_reference_texts(self, strategy: DslStrategy) -> list[str]: + from gaia.engine.lang.runtime.nodes import Step as DslStep + + if isinstance(strategy.reason, str): + return [strategy.reason] if strategy.reason else [] + if not isinstance(strategy.reason, list): + return [] + texts: list[str] = [] + for entry in strategy.reason: + if isinstance(entry, str) and entry: + texts.append(entry) + elif isinstance(entry, DslStep) and entry.reason: + texts.append(entry.reason) + return texts + + def _scan_local_knowledge_content(self, label_to_id: dict[str, str]) -> None: + for knowledge in self.knowledge_nodes: + if _is_local(knowledge, self.pkg): + self._accumulate(knowledge, knowledge.content, label_to_id) + + def _accumulate( + self, knowledge: Knowledge, text: str | None, label_to_id: dict[str, str] + ) -> None: + if not text: + return + knowledge_refs, citation_refs = _collect_refs_from_text(text, label_to_id, self.references) + if knowledge_refs or citation_refs: + current = self.refs_by_knowledge.setdefault(id(knowledge), (set(), set())) + current[0].update(knowledge_refs) + current[1].update(citation_refs) + + def _collect_action_rationale_refs( + self, + label_to_id: dict[str, str], + ) -> dict[str, tuple[set[str], set[str]]]: + action_rationale_refs: dict[str, tuple[set[str], set[str]]] = {} + for action in getattr(self.pkg, "actions", []): + target_id = self._action_rationale_target_id(action) + if not action.rationale or target_id is None: + continue + knowledge_refs, citation_refs = _collect_refs_from_text( + action.rationale, + label_to_id, + self.references, + ) + if not knowledge_refs and not citation_refs: + continue + for target_knowledge_id in self._target_knowledge_ids(target_id): + action_rationale_refs[target_knowledge_id] = ( + set(knowledge_refs), + set(citation_refs), + ) + return action_rationale_refs + + def _action_rationale_target_id(self, action: Any) -> str | None: + rationale_action_label = self.action_labels_by_object.get(id(action)) + if rationale_action_label is None: + return None + return self.action_label_map.get(rationale_action_label) + + def _target_knowledge_ids(self, target_id: str) -> list[str]: + strategy_target = self._strategy_target_knowledge_ids(target_id) + if strategy_target: + return strategy_target + operator_target = self._operator_target_knowledge_ids(target_id) + if operator_target: + return operator_target + return [target_id] + + def _strategy_target_knowledge_ids(self, target_id: str) -> list[str]: + for strategy in [ + *self.ir_strategies, + *self.formula_generated_strategies, + *self.extension_strategies, + ]: + if strategy.strategy_id != target_id: + continue + warrants = strategy.metadata.get("warrants", []) if strategy.metadata else [] + if warrants: + return list(warrants) + return [strategy.conclusion] if strategy.conclusion else [] + return [] + + def _operator_target_knowledge_ids(self, target_id: str) -> list[str]: + all_operators = [ + *self.ir_operators, + *self.action_operators, + *self.formula_generated_operators, + *self.extension_operators, + ] + for operator in all_operators: + if operator.operator_id != target_id: + continue + warrants = operator.metadata.get("warrants", []) if operator.metadata else [] + if warrants: + return list(warrants) + return [operator.conclusion] if operator.conclusion else [] + return [] + + def _apply_reference_metadata( + self, + action_rationale_refs: dict[str, tuple[set[str], set[str]]], + ) -> list[IrKnowledge]: + all_ir_knowledges = self._all_ir_knowledges() + self._apply_knowledge_refs(all_ir_knowledges) + self._apply_action_refs(all_ir_knowledges, action_rationale_refs) + return self._replace_ir_knowledge_lists(all_ir_knowledges) + + def _all_ir_knowledges(self) -> list[IrKnowledge]: + return [ + *self.ir_knowledges, + *self.generated_knowledges, + *self.formula_generated_knowledges, + *self.extension_lowered_knowledges, + ] + + def _apply_knowledge_refs(self, all_ir_knowledges: list[IrKnowledge]) -> None: + for knowledge in self.knowledge_nodes: + if not _is_local(knowledge, self.pkg): + continue + refs = self.refs_by_knowledge.get(id(knowledge)) + if not refs or not any(refs): + continue + self._write_provenance(all_ir_knowledges, self.knowledge_map[id(knowledge)], refs) + + def _apply_action_refs( + self, + all_ir_knowledges: list[IrKnowledge], + action_rationale_refs: dict[str, tuple[set[str], set[str]]], + ) -> None: + for target_qid, refs in action_rationale_refs.items(): + self._write_provenance(all_ir_knowledges, target_qid, refs) + + def _write_provenance( + self, + all_ir_knowledges: list[IrKnowledge], + target_qid: str, + refs: tuple[set[str], set[str]], + ) -> None: + knowledge_refs, citation_refs = refs + for i, ir_knowledge in enumerate(all_ir_knowledges): + if ir_knowledge.id != target_qid: + continue + metadata = dict(ir_knowledge.metadata) if ir_knowledge.metadata else {} + gaia_meta = dict(metadata.get("gaia", {})) + provenance: dict[str, Any] = dict(gaia_meta.get("provenance", {})) + if citation_refs: + existing_cites = set(provenance.get("cited_refs", [])) + existing_cites.update(citation_refs) + provenance["cited_refs"] = sorted(existing_cites) + if knowledge_refs: + existing_refs = set(provenance.get("referenced_claims", [])) + existing_refs.update(knowledge_refs) + provenance["referenced_claims"] = sorted(existing_refs) + gaia_meta["provenance"] = provenance + metadata["gaia"] = gaia_meta + all_ir_knowledges[i] = ir_knowledge.model_copy(update={"metadata": metadata}) + break + + def _replace_ir_knowledge_lists( + self, all_ir_knowledges: list[IrKnowledge] + ) -> list[IrKnowledge]: + num_ir = len(self.ir_knowledges) + num_generated = len(self.generated_knowledges) + num_formula = len(self.formula_generated_knowledges) + self.ir_knowledges[:] = all_ir_knowledges[:num_ir] + self.generated_knowledges[:] = all_ir_knowledges[num_ir : num_ir + num_generated] + self.formula_generated_knowledges[:] = all_ir_knowledges[ + num_ir + num_generated : num_ir + num_generated + num_formula + ] + return all_ir_knowledges[num_ir + num_generated + num_formula :] + + +def _assign_knowledge_ids( + pkg: CollectedPackage, knowledge_nodes: list[Knowledge] +) -> dict[int, str]: + knowledge_map: dict[int, str] = {} + local_anon_counter = 0 + for knowledge in knowledge_nodes: + knowledge_id, local_anon_counter = _knowledge_id( + knowledge, + pkg, + local_anon_counter=local_anon_counter, + ) + knowledge_map[id(knowledge)] = knowledge_id + return knowledge_map + + +def _build_ir_knowledges( + pkg: CollectedPackage, + knowledge_nodes: list[Knowledge], + knowledge_map: dict[int, str], +) -> list[IrKnowledge]: + exported_labels: set[str] = getattr(pkg, "_exported_labels", set()) + return [ + IrKnowledge( + id=knowledge_map[id(knowledge)], + label=knowledge.label, + title=getattr(knowledge, "title", None), + type=KnowledgeType(knowledge.type), + format=getattr(knowledge, "format", "markdown"), + content=knowledge.content, + parameters=[_parameter_to_ir(param, knowledge_map) for param in knowledge.parameters], + provenance=_knowledge_provenance(knowledge), + metadata=_knowledge_metadata(knowledge, knowledge_map), + module=getattr(knowledge, "_source_module", None), + declaration_index=getattr(knowledge, "_declaration_index", None), + exported=knowledge.label in exported_labels if knowledge.label else False, + ) + for knowledge in knowledge_nodes + ] + + +def _compile_top_level_operators( + pkg: CollectedPackage, + knowledge_map: dict[int, str], + formal_operators: set[int], +) -> tuple[list[IrOperator], dict[int, str]]: + ir_operators: list[IrOperator] = [] + operator_target_ids_by_object: dict[int, str] = {} + for operator in pkg.operators: + if id(operator) in formal_operators: + continue + ir_operator = _operator_to_ir(operator, knowledge_map, top_level=True) + ir_operators.append(ir_operator) + operator_target_ids_by_object[id(operator)] = _required_id( + ir_operator.operator_id, + "operator_id", + ) + return ir_operators, operator_target_ids_by_object + + +def _compile_package_strategies( + pkg: CollectedPackage, + strategy_compiler: _StrategyCompiler, +) -> tuple[list[IrStrategy], dict[int, str]]: + ir_strategies: list[IrStrategy] = [] + emitted_strategies: set[int] = set() + strategy_target_ids_by_object: dict[int, str] = {} + for strategy in pkg.strategies: + strategy_key = id(strategy) + if strategy_key in emitted_strategies: + continue + ir_strategy = strategy_compiler.compile_strategy(strategy) + ir_strategies.append(ir_strategy) + strategy_target_ids_by_object[strategy_key] = _required_id( + ir_strategy.strategy_id, + "strategy_id", + ) + emitted_strategies.add(strategy_key) + return ir_strategies, strategy_target_ids_by_object + + +def _support_action_pattern(action: Support) -> str: + if isinstance(action, Observe): + return "observation" + if isinstance(action, Compute): + return "computation" + return "derivation" + + +def _structural_action_operator( + action: Equal | Contradict | Exclusive, +) -> tuple[OperatorType, str]: + if isinstance(action, Equal): + return OperatorType.EQUIVALENCE, "equivalence" + if isinstance(action, Contradict): + return OperatorType.CONTRADICTION, "contradiction" + if isinstance(action, Exclusive): + return OperatorType.COMPLEMENT, "exclusive" + raise ValueError(f"Unsupported structural relation action: {type(action).__name__}") + + +def _infer_conditional_probabilities( + *, + p_e_given_h: float, + p_e_given_not_h: float, + given_count: int, +) -> list[float]: + if given_count == 0: + return [p_e_given_not_h, p_e_given_h] + cpt = [0.5] * (1 << (1 + given_count)) + gate_mask = sum(1 << i for i in range(1, 1 + given_count)) + cpt[gate_mask] = p_e_given_not_h + cpt[gate_mask | 1] = p_e_given_h + return cpt + + +def _lower_formula_claims( + pkg: CollectedPackage, + knowledge_nodes: list[Knowledge], + knowledge_map: dict[int, str], + ir_knowledges: list[IrKnowledge], +) -> _FormulaLoweringResult: + result = _FormulaLoweringResult( + knowledges=[], + operators=[], + strategies=[], + formula_graphs=[], + ) + for knowledge in knowledge_nodes: + if not _is_local(knowledge, pkg): + continue + if not isinstance(knowledge, Claim) or getattr(knowledge, "formula", None) is None: + continue + lowered = lower_claim_formula( + knowledge, + claim_id=knowledge_map[id(knowledge)], + namespace=pkg.namespace, + package_name=pkg.name, + knowledge_map=knowledge_map, + ) + result.knowledges.extend(lowered.knowledges) + result.operators.extend(lowered.operators) + result.strategies.extend(lowered.strategies) + result.formula_graphs.extend(lowered.formula_graphs) + _apply_formula_knowledge_updates( + ir_knowledges, + metadata_updates=lowered.metadata_updates, + parameter_updates=lowered.parameter_updates, + ) + return result + + +def _build_action_labels_by_object(pkg: CollectedPackage) -> dict[int, str]: + return { + id(action): _action_label(action, pkg, action_index) + for action_index, action in enumerate(getattr(pkg, "actions", [])) + } + + +def _build_graph( + pkg: CollectedPackage, + *, + ir_knowledges: list[IrKnowledge], + generated_knowledges: list[IrKnowledge], + formula_generated: _FormulaLoweringResult, + extension_lowered: ActionLoweringResult, + extension_lowered_knowledges_updated: list[IrKnowledge], + ir_operators: list[IrOperator], + action_operators: list[IrOperator], + action_formula_graphs: list[FormulaGraph], + ir_strategies: list[IrStrategy], + ir_composes: list[IrCompose], +) -> LocalCanonicalGraph: + module_order = pkg._module_order if pkg._module_order else None + module_titles = getattr(pkg, "_module_titles", None) or None + return LocalCanonicalGraph( + namespace=pkg.namespace, + package_name=pkg.name, + knowledges=[ + *ir_knowledges, + *generated_knowledges, + *formula_generated.knowledges, + *extension_lowered_knowledges_updated, + ], + operators=[ + *ir_operators, + *action_operators, + *formula_generated.operators, + *extension_lowered.operators, + ], + strategies=[ + *ir_strategies, + *formula_generated.strategies, + *extension_lowered.strategies, + ], + composes=ir_composes, + formula_graphs=[*formula_generated.formula_graphs, *action_formula_graphs], + module_order=module_order, + module_titles=module_titles if module_titles else None, + ) + + +def compile_package_artifact( + pkg: CollectedPackage, + *, + references: dict[str, Any] | None = None, +) -> CompiledPackage: + """Compile collected declarations into Gaia IR plus runtime mappings. + + First, predicate / equation lowering registers any CDF-derived predicate + prior records. Then the package's :class:`ResolutionPolicy` resolves all + per-claim ``metadata['prior_records']`` populated by ``register_prior()``, + predicate lowering, or the ``claim(prior=...)`` shortcut. The winning value + is written to ``metadata['prior']`` so downstream BP / render / brief + consumers see a single resolved prior. + """ + if references is None: + references = {} + + from gaia.engine.lang.compiler.distribution_diagnostics import emit_distribution_warnings + from gaia.engine.lang.compiler.predicate_lowering import lower_predicate_priors + + discover_and_register_extensions() + + lower_predicate_priors(pkg) + _resolve_pkg_priors_with_package_policy(pkg) + emit_distribution_warnings(pkg) + + knowledge_collection = _KnowledgeCollector(pkg).collect() + knowledge_map = _assign_knowledge_ids(pkg, knowledge_collection.nodes) + ir_knowledges = _build_ir_knowledges(pkg, knowledge_collection.nodes, knowledge_map) + ir_operators, operator_target_ids_by_object = _compile_top_level_operators( + pkg, + knowledge_map, + knowledge_collection.formal_operators, + ) + + generated_knowledges: list[IrKnowledge] = [] + strategy_compiler = _StrategyCompiler(pkg, knowledge_map, generated_knowledges) + ir_strategies, strategy_target_ids_by_object = _compile_package_strategies( + pkg, + strategy_compiler, + ) + + action_compiler = _ActionCompiler( + pkg=pkg, + knowledge_map=knowledge_map, + ir_knowledges=ir_knowledges, + ir_strategies=ir_strategies, + generated_knowledges=generated_knowledges, + ) + action_compiler.compile_non_compose_actions() + + formula_generated = _lower_formula_claims( + pkg, knowledge_collection.nodes, knowledge_map, ir_knowledges + ) + action_labels_by_object = _build_action_labels_by_object(pkg) + extension_lowered = lower_registered_actions( + ActionLoweringContext( + knowledge_nodes=knowledge_collection.nodes, + actions=tuple(getattr(pkg, "actions", ())), + namespace=pkg.namespace, + package_name=pkg.name, + knowledge_map=knowledge_map, + action_labels_by_object=action_labels_by_object, + existing_operators=[ + *ir_operators, + *action_compiler.action_operators, + *formula_generated.operators, + ], + ) + ) + _apply_formula_knowledge_updates( + ir_knowledges, + metadata_updates=extension_lowered.metadata_updates, + parameter_updates={}, + ) + _merge_action_label_targets( + action_compiler.action_label_map, + action_compiler.target_action_labels_by_id, + extension_lowered.action_label_map, + extension_lowered.target_action_labels_by_id, + ) + action_compiler.action_target_ids_by_object.update( + extension_lowered.action_target_ids_by_object + ) + + ir_composes = action_compiler.compile_compose_actions( + strategy_target_ids_by_object=strategy_target_ids_by_object, + operator_target_ids_by_object=operator_target_ids_by_object, + ) + action_compiler.compile_materializations() + extension_lowered_knowledges_updated = _ReferenceScanner( + pkg=pkg, + references=references, + knowledge_nodes=knowledge_collection.nodes, + knowledge_map=knowledge_map, + action_label_map=action_compiler.action_label_map, + action_labels_by_object=action_labels_by_object, + ir_knowledges=ir_knowledges, + generated_knowledges=generated_knowledges, + formula_generated_knowledges=formula_generated.knowledges, + extension_lowered_knowledges=extension_lowered.knowledges, + ir_strategies=ir_strategies, + formula_generated_strategies=formula_generated.strategies, + extension_strategies=extension_lowered.strategies, + ir_operators=ir_operators, + action_operators=action_compiler.action_operators, + formula_generated_operators=formula_generated.operators, + extension_operators=extension_lowered.operators, + ir_composes=ir_composes, + ).scan() + + graph = _build_graph( + pkg, + ir_knowledges=ir_knowledges, + generated_knowledges=generated_knowledges, + formula_generated=formula_generated, + extension_lowered=extension_lowered, + extension_lowered_knowledges_updated=extension_lowered_knowledges_updated, + ir_operators=ir_operators, + action_operators=action_compiler.action_operators, + action_formula_graphs=action_compiler.formula_graphs, + ir_strategies=ir_strategies, + ir_composes=ir_composes, + ) + compiled = CompiledPackage( + graph=graph, + knowledge_ids_by_object=dict(knowledge_map), + strategies_by_object=dict(strategy_compiler.compiled_strategies), + action_label_map=action_compiler.action_label_map, + target_action_labels_by_id=action_compiler.target_action_labels_by_id, + formalization_manifest={ + "version": 1, + "dependencies": action_compiler.formalization_dependencies, + "materializations": action_compiler.formalization_materializations, + }, + ) + from gaia.engine.lang.review.manifest import generate_review_manifest + + compiled.review = generate_review_manifest(compiled) + return compiled + + +def compile_package( + pkg: CollectedPackage, + *, + references: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Compile collected declarations into LocalCanonicalGraph JSON.""" + return compile_package_artifact(pkg, references=references).to_json() + + +def _resolve_pkg_priors_with_package_policy(pkg: CollectedPackage) -> None: + """Apply the package ResolutionPolicy to every Claim with prior_records. + + The CLI stores any priors.py ``RESOLUTION_POLICY`` on the package before + calling the compiler. Direct in-memory callers usually do not, so they get + the default policy as a safety net. + """ + from gaia.engine.ir import default_resolution_policy + from gaia.engine.lang.dsl.register_prior import resolve_priors_to_metadata + + policy = pkg._resolution_policy or default_resolution_policy() + resolve_priors_to_metadata(pkg.knowledge, policy) diff --git a/gaia/engine/lang/compiler/distribution_diagnostics.py b/gaia/engine/lang/compiler/distribution_diagnostics.py new file mode 100644 index 000000000..1bbd3470e --- /dev/null +++ b/gaia/engine/lang/compiler/distribution_diagnostics.py @@ -0,0 +1,194 @@ +"""Compile-time diagnostics for the continuous-quantity surface. + +Two detectors emit Python warnings during ``compile_package_artifact``: + +1. :class:`DeadContinuousQuantityWarning` — author declared a + :class:`Distribution` (e.g. ``T_c = Normal(...)``) but never referenced it + in any claim's predicate / equation / observation metadata. Catches typos + and forgotten quantities. + +2. :class:`ObservationNotUpdatingPredicateWarning` — author both observed a + distribution and wrote a predicate over the same distribution; the + predicate prior is currently computed from the distribution's *prior* CDF + without incorporating observations. This is a real correctness gap that + the v0.7 posterior-CDF work will close (tracked separately); this warning + surfaces the limitation so authors are not silently misled by a + prior-CDF-only result. + +Both detectors are non-fatal — they emit warnings, never errors. Authors +who intentionally want a unreferenced distribution or a predicate-without- +posterior-update can suppress them with the standard ``warnings.filterwarnings`` +machinery, scoping by category for precision. +""" + +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING, Any + +from gaia.engine.lang.compiler.predicate_lowering import ( + PREDICATE_LOWERING_SOURCE_ID, + PREDICATE_PRIOR_GENERATED_ATTR, +) +from gaia.engine.lang.dsl.bool_expr import BoolExpr, DerivedDistribution +from gaia.engine.lang.runtime.distribution import Distribution +from gaia.engine.lang.runtime.knowledge import Claim + +if TYPE_CHECKING: + from gaia.engine.lang.runtime.package import CollectedPackage + + +class DeadContinuousQuantityWarning(UserWarning): + """A Distribution was declared but never referenced anywhere in the package.""" + + +class ObservationNotUpdatingPredicateWarning(UserWarning): + """Predicate prior was computed from prior CDF without observations. + + A predicate over a Distribution that has recorded observations does not + yet incorporate the observation into its prior — the prior CDF is used + directly. Posterior CDF support is deferred to a future release. + """ + + +def _walk_distributions(value: Any, sink: set[int]) -> None: + """Recursively collect every Distribution id referenced inside ``value``. + + Walks BoolExpr / DerivedDistribution nesting plus dict / list / tuple + containers so the detector sees every distribution mentioned anywhere + in a claim's metadata. + """ + if isinstance(value, Distribution): + sink.add(id(value)) + return + if isinstance(value, (BoolExpr, DerivedDistribution)): + _walk_distributions(value.left, sink) + _walk_distributions(value.right, sink) + return + if isinstance(value, dict): + for v in value.values(): + _walk_distributions(v, sink) + return + if isinstance(value, (list, tuple, set)): + for v in value: + _walk_distributions(v, sink) + return + + +def _referenced_distribution_ids(pkg: CollectedPackage) -> set[int]: + """Set of Distribution object-ids referenced by any claim in the package.""" + referenced: set[int] = set() + for knowledge in pkg.knowledge: + if isinstance(knowledge, Claim): + _walk_distributions(knowledge.metadata, referenced) + return referenced + + +def _observed_distribution_to_obs_claims( + pkg: CollectedPackage, +) -> dict[int, list[Claim]]: + """Map distribution id to the list of observation claims targeting it.""" + out: dict[int, list[Claim]] = {} + for knowledge in pkg.knowledge: + if not isinstance(knowledge, Claim): + continue + observation = (knowledge.metadata or {}).get("observation") + if not isinstance(observation, dict): + continue + target = observation.get("target_distribution") + if isinstance(target, Distribution): + out.setdefault(id(target), []).append(knowledge) + return out + + +def _predicated_distribution_to_pred_claims( + pkg: CollectedPackage, +) -> dict[int, list[Claim]]: + """Map distribution id to the list of predicate claims with it as the LHS.""" + out: dict[int, list[Claim]] = {} + for knowledge in pkg.knowledge: + if not isinstance(knowledge, Claim): + continue + predicate = (knowledge.metadata or {}).get("predicate") + if isinstance(predicate, BoolExpr) and isinstance(predicate.left, Distribution): + prior_was_generated = bool(getattr(knowledge, PREDICATE_PRIOR_GENERATED_ATTR, False)) + if knowledge.prior is not None and not prior_was_generated: + continue + if knowledge.metadata.get("prior_source_id") not in { + None, + PREDICATE_LOWERING_SOURCE_ID, + }: + continue + out.setdefault(id(predicate.left), []).append(knowledge) + return out + + +def detect_dead_distributions(pkg: CollectedPackage) -> list[Distribution]: + """Return Distributions declared on the package but never referenced.""" + referenced = _referenced_distribution_ids(pkg) + return [d for d in pkg.distributions if id(d) not in referenced] + + +def detect_observation_not_updating_predicate( + pkg: CollectedPackage, +) -> list[tuple[Distribution, list[Claim], list[Claim]]]: + """Find (distribution, observations, predicates) triples that share a target. + + For each triple, the predicate prior was computed from the distribution's + prior CDF without incorporating the observation(s) — a known v0.6 + limitation that the posterior-CDF work will close. + """ + observed = _observed_distribution_to_obs_claims(pkg) + predicated = _predicated_distribution_to_pred_claims(pkg) + out: list[tuple[Distribution, list[Claim], list[Claim]]] = [] + for dist_id in observed.keys() & predicated.keys(): + # Recover the Distribution object from any of the observation claims + # (they all share the same target). + sample_obs = observed[dist_id][0] + target = sample_obs.metadata["observation"]["target_distribution"] + out.append((target, observed[dist_id], predicated[dist_id])) + return out + + +def _label_or_content(d: Distribution) -> str: + return d.label or (d.content[:50] + ("..." if len(d.content) > 50 else "")) + + +def _claim_label_or_content(c: Claim) -> str: + return c.label or (c.content[:60] + ("..." if len(c.content) > 60 else "")) + + +def emit_distribution_warnings(pkg: CollectedPackage) -> None: + """Run both detectors and surface their findings via :mod:`warnings`. + + Called from :func:`compile_package_artifact` immediately after predicate + lowering. Warnings surface in pytest output, in the CLI, and through any + standard ``warnings.catch_warnings`` capture. + """ + for dead in detect_dead_distributions(pkg): + warnings.warn( + f"Continuous quantity {_label_or_content(dead)!r} " + f"({dead.kind} distribution) was declared but never referenced " + "in any claim, predicate, equation, or observation. Either " + "reference it from a claim or remove the declaration. " + "If this is intentional, suppress with " + "warnings.filterwarnings('ignore', " + "category=DeadContinuousQuantityWarning).", + DeadContinuousQuantityWarning, + stacklevel=2, + ) + + for target, obs_claims, pred_claims in detect_observation_not_updating_predicate(pkg): + obs_labels = ", ".join(_claim_label_or_content(c) for c in obs_claims) + pred_labels = ", ".join(_claim_label_or_content(c) for c in pred_claims) + warnings.warn( + f"Predicate(s) {{{pred_labels}}} over distribution " + f"{_label_or_content(target)!r} compute their prior from the " + f"prior CDF directly, without incorporating the observation(s) " + f"{{{obs_labels}}}. Posterior-aware CDF is tracked separately for " + "a future release. Until then, either set `prior=` explicitly on " + "the predicate claim to reflect the post-observation belief, or " + "express the inference via gaia.engine.bayes.likelihood().", + ObservationNotUpdatingPredicateWarning, + stacklevel=2, + ) diff --git a/gaia/engine/lang/compiler/extensions.py b/gaia/engine/lang/compiler/extensions.py new file mode 100644 index 000000000..834cfcff6 --- /dev/null +++ b/gaia/engine/lang/compiler/extensions.py @@ -0,0 +1,195 @@ +"""Extension lowering hooks for Gaia Lang compilation.""" + +from __future__ import annotations + +import importlib +from collections.abc import Callable +from dataclasses import dataclass, field, replace +from typing import Any + +from gaia.engine.ir import Knowledge as IrKnowledge +from gaia.engine.ir import Operator as IrOperator +from gaia.engine.ir import Strategy as IrStrategy + +ActionPredicate = Callable[[Any], bool] + + +@dataclass(frozen=True) +class ActionLoweringContext: + """Inputs shared with registered action lowerers.""" + + knowledge_nodes: list[Any] + actions: tuple[Any, ...] + namespace: str + package_name: str + knowledge_map: dict[int, str] + action_labels_by_object: dict[int, str] + existing_operators: list[IrOperator] + + +@dataclass +class ActionLoweringResult: + """IR additions and action-target mappings emitted by extension lowerers.""" + + knowledges: list[IrKnowledge] = field(default_factory=list) + operators: list[IrOperator] = field(default_factory=list) + strategies: list[IrStrategy] = field(default_factory=list) + metadata_updates: dict[str, dict[str, Any]] = field(default_factory=dict) + action_label_map: dict[str, str] = field(default_factory=dict) + target_action_labels_by_id: dict[str, str] = field(default_factory=dict) + action_target_ids_by_object: dict[int, str] = field(default_factory=dict) + + +ActionLowerer = Callable[[ActionLoweringContext], ActionLoweringResult] + + +@dataclass(frozen=True) +class RegisteredActionLowerer: + """Registered compiler extension for one family of runtime actions.""" + + name: str + handles: ActionPredicate + lower: ActionLowerer + + +_ACTION_LOWERERS: dict[str, RegisteredActionLowerer] = {} + +# First-party extensions that own at least one action lowerer. Each entry is +# ``(module_path, register_callable_name)``. The register callable must be +# idempotent (return early if already registered) so re-discovery does not +# trip the duplicate-name guard in :func:`register_action_lowerer`. +# +# Calling the registration helper explicitly (rather than only relying on +# ``importlib.import_module``) lets discovery recover even after a test +# cleared :data:`_ACTION_LOWERERS`, because module-level import side effects +# only run once per interpreter. +# +# Third-party extensions are not listed here: their consumers are responsible +# for importing the extension package (e.g. ``import myorg.gaia_ext``) before +# calling ``compile_package_artifact``. +_FIRST_PARTY_EXTENSIONS: tuple[tuple[str, str], ...] = ( + ("gaia.engine.bayes.compiler", "register_bayes_lowerer"), +) + + +def register_action_lowerer( + name: str, + *, + handles: ActionPredicate, + lower: ActionLowerer, + override: bool = False, +) -> None: + """Register an extension lowerer by stable name. + + Args: + name: Unique extension identifier (e.g. ``"bayes"``). Must be non-empty. + handles: Predicate returning ``True`` for actions this lowerer claims. + lower: The lowering function. + override: If ``True``, replace any existing registration for ``name``. + If ``False`` (default), raise :class:`ValueError` on duplicate name + so accidental double-registration surfaces loudly. + """ + if not name: + raise ValueError("action lowerer name must not be empty") + if name in _ACTION_LOWERERS and not override: + raise ValueError( + f"action lowerer {name!r} already registered; pass override=True to replace it" + ) + _ACTION_LOWERERS[name] = RegisteredActionLowerer(name=name, handles=handles, lower=lower) + + +def registered_action_lowerers() -> tuple[RegisteredActionLowerer, ...]: + """Return registered action lowerers in registration order.""" + return tuple(_ACTION_LOWERERS.values()) + + +def discover_and_register_extensions() -> None: + """Invoke each first-party extension's ``register__lowerer`` helper. + + Run before every compile so registration is order-independent: callers + no longer need to ensure they ``import gaia.engine.bayes`` (or any other + extension) before invoking the compiler. + + Idempotent: each first-party helper short-circuits when its lowerer name + is already in :data:`_ACTION_LOWERERS`, so repeated discovery is free. + Missing extensions (``ImportError`` / ``AttributeError``) are silently + skipped so partial installs or pruned distributions still compile any + packages that do not need those extensions. + """ + for module_name, register_attr in _FIRST_PARTY_EXTENSIONS: + try: + module = importlib.import_module(module_name) + register_callable = getattr(module, register_attr) + except (ImportError, AttributeError): + continue + register_callable() + + +def is_registered_action(action: Any) -> bool: + """Return whether any registered lowerer owns this action.""" + return any(lowerer.handles(action) for lowerer in _ACTION_LOWERERS.values()) + + +def lower_registered_actions(context: ActionLoweringContext) -> ActionLoweringResult: + """Run registered action lowerers and merge their IR additions.""" + combined = ActionLoweringResult() + existing_operators = list(context.existing_operators) + for lowerer in _ACTION_LOWERERS.values(): + if not any(lowerer.handles(action) for action in context.actions): + continue + scoped_context = replace(context, existing_operators=list(existing_operators)) + result = lowerer.lower(scoped_context) + _merge_result(combined, result, context=context, lowerer=lowerer) + existing_operators.extend(result.operators) + return combined + + +def _merge_result( + combined: ActionLoweringResult, + result: ActionLoweringResult, + *, + context: ActionLoweringContext, + lowerer: RegisteredActionLowerer, +) -> None: + combined.knowledges.extend(result.knowledges) + combined.operators.extend(result.operators) + combined.strategies.extend(result.strategies) + combined.metadata_updates.update(result.metadata_updates) + _merge_action_labels(combined, result, lowerer=lowerer) + combined.action_target_ids_by_object.update(result.action_target_ids_by_object) + _record_owned_action_targets(combined, result, context=context, lowerer=lowerer) + + +def _merge_action_labels( + combined: ActionLoweringResult, + result: ActionLoweringResult, + *, + lowerer: RegisteredActionLowerer, +) -> None: + for action_label, target_id in result.action_label_map.items(): + existing = combined.action_label_map.get(action_label) + if existing is not None and existing != target_id: + raise ValueError( + f"extension lowerer {lowerer.name!r} changed action label " + f"{action_label!r} target from {existing!r} to {target_id!r}" + ) + combined.action_label_map[action_label] = target_id + combined.target_action_labels_by_id.update(result.target_action_labels_by_id) + + +def _record_owned_action_targets( + combined: ActionLoweringResult, + result: ActionLoweringResult, + *, + context: ActionLoweringContext, + lowerer: RegisteredActionLowerer, +) -> None: + for action in context.actions: + if not lowerer.handles(action): + continue + action_label = context.action_labels_by_object.get(id(action)) + if action_label is None: + continue + target_id = result.action_label_map.get(action_label) + if target_id is not None: + combined.action_target_ids_by_object[id(action)] = target_id diff --git a/gaia/engine/lang/compiler/lower_formula.py b/gaia/engine/lang/compiler/lower_formula.py new file mode 100644 index 000000000..f3b44d4b5 --- /dev/null +++ b/gaia/engine/lang/compiler/lower_formula.py @@ -0,0 +1,1284 @@ +"""Lower Gaia Lang Formula AST payloads into existing Gaia IR structures. + +Milestone B starts with a deliberately small lowering contract: finite-domain +universal quantification grounds to one directed deduction/implication per +domain member; finite-domain existential quantification grounds to a +disjunction over instances; top-level atom formulas annotate the source Claim +instead of creating duplicate orphan atoms. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass, field +from typing import Any + +from gaia.engine.ir import Knowledge as IrKnowledge +from gaia.engine.ir import Operator as IrOperator +from gaia.engine.ir import Parameter as IrParameter +from gaia.engine.ir import Strategy as IrStrategy +from gaia.engine.ir.formalize import formalize_named_strategy +from gaia.engine.ir.formula import ( + FormulaEdge, + FormulaGraph, + FormulaNode, + FormulaNodeKind, + formula_node_id, +) +from gaia.engine.ir.knowledge import KnowledgeType, make_qid +from gaia.engine.ir.operator import OperatorType +from gaia.engine.lang.formula.connective import Iff, Implies, Land, Lnot, Lor +from gaia.engine.lang.formula.predicate import ( + ClaimAtom, + Equals, + Greater, + GreaterEqual, + Less, + LessEqual, + NotEquals, + UserPredicate, +) +from gaia.engine.lang.formula.primitives import PrimitiveType +from gaia.engine.lang.formula.quantifier import Exists, Forall +from gaia.engine.lang.formula.symbols import PredicateSymbol +from gaia.engine.lang.formula.term import ArithOp, Constant, FunctionApp +from gaia.engine.lang.runtime.domain import Domain +from gaia.engine.lang.runtime.knowledge import Claim +from gaia.engine.lang.runtime.variable import Variable + +_BindingMap = dict[int, dict[str, Any]] + + +@dataclass(frozen=True) +class FormulaLoweringResult: + """IR records and source-claim updates emitted by formula lowering.""" + + knowledges: list[IrKnowledge] = field(default_factory=list) + operators: list[IrOperator] = field(default_factory=list) + strategies: list[IrStrategy] = field(default_factory=list) + metadata_updates: dict[str, dict[str, Any]] = field(default_factory=dict) + parameter_updates: dict[str, list[IrParameter]] = field(default_factory=dict) + formula_graphs: list[FormulaGraph] = field(default_factory=list) + + +def lower_claim_formula( + claim: Claim, + *, + claim_id: str, + namespace: str, + package_name: str, + knowledge_map: dict[int, str], +) -> FormulaLoweringResult: + """Lower the formula attached to a Claim, if Milestone B supports it.""" + formula = getattr(claim, "formula", None) + if formula is None: + return FormulaLoweringResult() + formula_graph = build_formula_graph( + formula, + source_claim_id=claim_id, + knowledge_map=knowledge_map, + ) + if isinstance(formula, Forall): + result = _lower_forall( + claim, + formula, + claim_id=claim_id, + namespace=namespace, + package_name=package_name, + knowledge_map=knowledge_map, + ) + result.formula_graphs.append(formula_graph) + return result + if isinstance(formula, Exists): + result = _lower_exists( + claim, + formula, + claim_id=claim_id, + namespace=namespace, + package_name=package_name, + knowledge_map=knowledge_map, + ) + result.formula_graphs.append(formula_graph) + return result + + operator_name, _ = _connective_operator(formula) + if operator_name is None: + if not _is_atomic_formula(formula): + raise NotImplementedError(f"Unsupported formula lowering: {type(formula).__name__}") + result = _lower_formula_to_claim( + formula, + target_id=claim_id, + namespace=namespace, + package_name=package_name, + knowledge_map=knowledge_map, + ) + result.formula_graphs.append(formula_graph) + return result + + result = _lower_formula_to_claim( + formula, + target_id=claim_id, + namespace=namespace, + package_name=package_name, + knowledge_map=knowledge_map, + ) + result.formula_graphs.append(formula_graph) + return result + + +def canonical_formula_descriptor( + formula: Any, + *, + knowledge_map: dict[int, str], + bindings: _BindingMap | None = None, +) -> dict[str, Any]: + """Return the canonical descriptor used for formula atom nodes.""" + return _formula_descriptor(formula, knowledge_map=knowledge_map, bindings=bindings) + + +def canonical_term_descriptor( + term: Any, + *, + knowledge_map: dict[int, str], + bindings: _BindingMap | None = None, +) -> dict[str, Any]: + """Return the canonical descriptor used for formula term nodes.""" + return _term_descriptor(term, knowledge_map=knowledge_map, bindings=bindings) + + +def build_formula_graph( + formula: Any, + *, + source_claim_id: str, + knowledge_map: dict[int, str], + bindings: _BindingMap | None = None, +) -> FormulaGraph: + """Build the content-addressed formula graph for one source claim.""" + builder = _FormulaGraphBuilder(knowledge_map=knowledge_map, bindings=bindings) + root = builder.formula_node(formula) + return FormulaGraph( + source_claim=source_claim_id, + root=root, + nodes=list(builder.nodes.values()), + edges=builder.edges, + ) + + +class _FormulaGraphBuilder: + def __init__( + self, + *, + knowledge_map: dict[int, str], + bindings: _BindingMap | None = None, + ) -> None: + self.knowledge_map = knowledge_map + self.bindings = bindings + self.nodes: dict[str, FormulaNode] = {} + self.edges: list[FormulaEdge] = [] + self._edge_keys: set[tuple[str, str, str, int | None]] = set() + self._binder_counter = 0 + self._variable_binders: dict[int, list[str]] = {} + + def formula_node(self, formula: Any) -> str: + if _is_atomic_formula(formula): + return self._atomic_formula_node(formula) + + operator_name, children = _connective_operator(formula) + if operator_name is not None: + child_ids = [self.formula_node(child) for child in children] + descriptor = { + "kind": "op", + "operator": str(operator_name), + "children": child_ids, + } + node_id = self._add_node("op", descriptor) + self._add_connective_edges(node_id, operator_name, child_ids) + return node_id + + if isinstance(formula, (Forall, Exists)): + binder_id = self._push_binder(formula.variable) + try: + variable_id = self.term_node(formula.variable) + body_id = self.formula_node(formula.body) + finally: + self._pop_binder(formula.variable) + quantifier = "forall" if isinstance(formula, Forall) else "exists" + descriptor = { + "kind": "quantifier", + "quantifier": quantifier, + "variable": formula.variable.symbol, + "variable_id": variable_id, + "binder": binder_id, + "domain": _domain_name(formula.variable.domain), + "body": body_id, + } + node_id = self._add_node("quantifier", descriptor) + self._add_edge(FormulaEdge(source=node_id, target=variable_id, role="bound_variable")) + self._add_edge(FormulaEdge(source=node_id, target=body_id, role="body")) + return node_id + + raise NotImplementedError(f"Unsupported formula graph: {type(formula).__name__}") + + def term_node(self, term: Any) -> str: + descriptor = self._term_descriptor(term) + if isinstance(term, Variable): + return self._add_node("variable", descriptor) + if isinstance(term, Constant): + return self._add_node("constant", descriptor) + if isinstance(term, FunctionApp): + node_id = self._add_node("term", descriptor) + for index, arg in enumerate(term.args): + self._add_edge( + FormulaEdge( + source=node_id, + target=self.term_node(arg), + role="arg", + index=index, + ) + ) + return node_id + if isinstance(term, ArithOp): + node_id = self._add_node("term", descriptor) + self._add_edge( + FormulaEdge(source=node_id, target=self.term_node(term.left), role="left") + ) + self._add_edge( + FormulaEdge(source=node_id, target=self.term_node(term.right), role="right") + ) + return node_id + if isinstance(term, ClaimAtom): + return self._add_node("atom", descriptor) + return self._add_node("term", descriptor) + + def _atomic_formula_node(self, formula: Any) -> str: + descriptor = self._formula_descriptor(formula) + node_id = self._add_node("atom", descriptor) + if isinstance(formula, UserPredicate): + for index, arg in enumerate(formula.args): + self._add_edge( + FormulaEdge( + source=node_id, + target=self.term_node(arg), + role="arg", + index=index, + ) + ) + binary_terms = _binary_formula_terms(formula) + if binary_terms is not None: + left, right = binary_terms + self._add_edge(FormulaEdge(source=node_id, target=self.term_node(left), role="left")) + self._add_edge(FormulaEdge(source=node_id, target=self.term_node(right), role="right")) + return node_id + + def _add_connective_edges( + self, + node_id: str, + operator_name: OperatorType, + child_ids: list[str], + ) -> None: + if operator_name == OperatorType.IMPLICATION: + self._add_edge(FormulaEdge(source=node_id, target=child_ids[0], role="antecedent")) + self._add_edge(FormulaEdge(source=node_id, target=child_ids[1], role="consequent")) + return + if operator_name == OperatorType.EQUIVALENCE: + self._add_edge(FormulaEdge(source=node_id, target=child_ids[0], role="left")) + self._add_edge(FormulaEdge(source=node_id, target=child_ids[1], role="right")) + return + for index, child_id in enumerate(child_ids): + self._add_edge( + FormulaEdge(source=node_id, target=child_id, role="operand", index=index) + ) + + def _add_node(self, kind: FormulaNodeKind, descriptor: dict[str, Any]) -> str: + node_id = formula_node_id(descriptor) + self.nodes.setdefault(node_id, FormulaNode(id=node_id, kind=kind, descriptor=descriptor)) + return node_id + + def _add_edge(self, edge: FormulaEdge) -> None: + key = (edge.source, edge.target, edge.role, edge.index) + if key in self._edge_keys: + return + self._edge_keys.add(key) + self.edges.append(edge) + + def _push_binder(self, variable: Variable) -> str: + binder_id = f"b{self._binder_counter}" + self._binder_counter += 1 + self._variable_binders.setdefault(id(variable), []).append(binder_id) + return binder_id + + def _pop_binder(self, variable: Variable) -> None: + binders = self._variable_binders[id(variable)] + binders.pop() + if not binders: + del self._variable_binders[id(variable)] + + def _active_binder(self, variable: Variable) -> str | None: + binders = self._variable_binders.get(id(variable)) + if not binders: + return None + return binders[-1] + + def _formula_descriptor(self, formula: Any) -> dict[str, Any]: + if isinstance(formula, UserPredicate): + descriptor = canonical_formula_descriptor( + formula, + knowledge_map=self.knowledge_map, + bindings=self.bindings, + ) + descriptor["args"] = [self._term_descriptor(arg) for arg in formula.args] + return descriptor + binary_terms = _binary_formula_terms(formula) + if binary_terms is not None: + left, right = binary_terms + descriptor = canonical_formula_descriptor( + formula, + knowledge_map=self.knowledge_map, + bindings=self.bindings, + ) + descriptor["left"] = self._term_descriptor(left) + descriptor["right"] = self._term_descriptor(right) + return descriptor + return canonical_formula_descriptor( + formula, + knowledge_map=self.knowledge_map, + bindings=self.bindings, + ) + + def _term_descriptor(self, term: Any) -> dict[str, Any]: + descriptor = canonical_term_descriptor( + term, + knowledge_map=self.knowledge_map, + bindings=self.bindings, + ) + if isinstance(term, Variable): + binder_id = self._active_binder(term) + if binder_id is not None: + descriptor["binder"] = binder_id + return descriptor + if isinstance(term, FunctionApp): + descriptor["args"] = [self._term_descriptor(arg) for arg in term.args] + return descriptor + if isinstance(term, ArithOp): + descriptor["left"] = self._term_descriptor(term.left) + descriptor["right"] = self._term_descriptor(term.right) + return descriptor + return descriptor + + +def _lower_forall( + claim: Claim, + formula: Forall, + *, + claim_id: str, + namespace: str, + package_name: str, + knowledge_map: dict[int, str], +) -> FormulaLoweringResult: + variable = formula.variable + domain = variable.domain + if not isinstance(domain, Domain): + raise ValueError("Forall formula lowering currently requires a finite Domain") + + generated_knowledges: list[IrKnowledge] = [] + generated_operators: list[IrOperator] = [] + generated_strategies: list[IrStrategy] = [] + for value in domain.members: + binding = _quantifier_binding(variable, domain, value, source="forall") + instance_id, instance_label = _forall_instance_id( + namespace=namespace, + package_name=package_name, + source_claim_id=claim_id, + symbol=variable.symbol, + value=value, + ) + generated_knowledges.append( + IrKnowledge( + id=instance_id, + label=instance_label, + type=KnowledgeType.CLAIM, + content=f"{claim.content} [{variable.symbol}={value!r}]", + parameters=[ + IrParameter( + name=variable.symbol, + type=_domain_name(domain), + value=value, + ) + ], + metadata={ + "generated": True, + "generated_kind": "formula_instance", + "formula_lowering": "forall_instance", + "source_claim": claim_id, + "binding": binding, + "visibility": "formula_grounding", + "review": False, + }, + ) + ) + body_result = _lower_formula_to_claim( + formula.body, + target_id=instance_id, + namespace=namespace, + package_name=package_name, + knowledge_map=knowledge_map, + bindings={id(variable): binding}, + ) + _absorb_generated_result( + generated_knowledges, + generated_operators, + generated_strategies, + body_result, + ) + + result = formalize_named_strategy( + scope="local", + type_="deduction", + premises=[claim_id], + conclusion=instance_id, + namespace=namespace, + package_name=package_name, + metadata={ + "formula_lowering": "forall_grounding", + "source_claim": claim_id, + "binding": binding, + }, + ) + generated_knowledges.extend(result.knowledges) + generated_strategies.append(result.strategy) + + return FormulaLoweringResult( + knowledges=generated_knowledges, + operators=generated_operators, + strategies=generated_strategies, + ) + + +def _lower_exists( + claim: Claim, + formula: Exists, + *, + claim_id: str, + namespace: str, + package_name: str, + knowledge_map: dict[int, str], +) -> FormulaLoweringResult: + variable = formula.variable + domain = variable.domain + if not isinstance(domain, Domain): + raise ValueError("Exists formula lowering currently requires a finite Domain") + + generated_knowledges: list[IrKnowledge] = [] + generated_operators: list[IrOperator] = [] + generated_strategies: list[IrStrategy] = [] + instance_ids: list[str] = [] + for value in domain.members: + binding = _quantifier_binding(variable, domain, value, source="exists") + instance_id, instance_label = _exists_instance_id( + namespace=namespace, + package_name=package_name, + source_claim_id=claim_id, + symbol=variable.symbol, + value=value, + ) + instance_ids.append(instance_id) + generated_knowledges.append( + IrKnowledge( + id=instance_id, + label=instance_label, + type=KnowledgeType.CLAIM, + content=f"{claim.content} [{variable.symbol}={value!r}]", + parameters=[ + IrParameter( + name=variable.symbol, + type=_domain_name(domain), + value=value, + ) + ], + metadata={ + "generated": True, + "generated_kind": "formula_instance", + "formula_lowering": "exists_instance", + "source_claim": claim_id, + "binding": binding, + "visibility": "formula_grounding", + "review": False, + }, + ) + ) + body_result = _lower_formula_to_claim( + formula.body, + target_id=instance_id, + namespace=namespace, + package_name=package_name, + knowledge_map=knowledge_map, + bindings={id(variable): binding}, + ) + _absorb_generated_result( + generated_knowledges, + generated_operators, + generated_strategies, + body_result, + ) + + if len(instance_ids) == 1: + alias_result = _equivalence_result( + namespace=namespace, + package_name=package_name, + left_id=claim_id, + right_id=instance_ids[0], + formula_lowering="exists_grounding", + metadata={ + "source_claim": claim_id, + "binding": _quantifier_binding( + variable, + domain, + domain.members[0], + source="exists", + ), + }, + ) + generated_knowledges.extend(alias_result.knowledges) + generated_operators.extend(alias_result.operators) + return FormulaLoweringResult( + knowledges=generated_knowledges, + operators=generated_operators, + strategies=generated_strategies, + ) + + exists_operator = IrOperator( + scope="local", + operator=OperatorType.DISJUNCTION, + variables=instance_ids, + conclusion=claim_id, + metadata={ + "formula_lowering": "exists_grounding", + "source_claim": claim_id, + }, + ) + return FormulaLoweringResult( + knowledges=generated_knowledges, + operators=[*generated_operators, exists_operator], + strategies=generated_strategies, + ) + + +def _lower_formula_to_claim( + formula: Any, + *, + target_id: str, + namespace: str, + package_name: str, + knowledge_map: dict[int, str], + bindings: _BindingMap | None = None, +) -> FormulaLoweringResult: + if isinstance(formula, ClaimAtom): + return _lower_claim_atom_alias( + formula, + target_id=target_id, + namespace=namespace, + package_name=package_name, + knowledge_map=knowledge_map, + ) + + operator_name, _ = _connective_operator(formula) + if operator_name is None: + if not _is_atomic_formula(formula): + raise NotImplementedError(f"Unsupported formula lowering: {type(formula).__name__}") + return FormulaLoweringResult( + metadata_updates={ + target_id: _source_atom_metadata( + formula, + knowledge_map=knowledge_map, + bindings=bindings, + ) + }, + parameter_updates={ + target_id: _binding_parameters(formula, bindings=bindings), + }, + ) + + if _is_binding_conjunction(formula): + formula_bindings = _formula_bindings(formula, bindings=bindings) + return FormulaLoweringResult( + metadata_updates={ + target_id: { + "formula_lowering": "binding_conjunction", + "formula_bindings": formula_bindings, + } + }, + parameter_updates={ + target_id: _binding_parameters(formula, bindings=bindings), + }, + ) + + state = _FormulaState( + namespace=namespace, + package_name=package_name, + source_claim_id=target_id, + knowledge_map=knowledge_map, + bindings=bindings, + ) + state.lower(formula, target_id=target_id) + formula_bindings = _formula_bindings(formula, bindings=bindings) + binding_parameters = _binding_parameters(formula, bindings=bindings) + metadata_updates: dict[str, dict[str, Any]] = {} + parameter_updates: dict[str, list[IrParameter]] = {} + if formula_bindings: + metadata_updates[target_id] = {"formula_bindings": formula_bindings} + if binding_parameters: + parameter_updates[target_id] = binding_parameters + return FormulaLoweringResult( + knowledges=state.knowledges, + operators=state.operators, + strategies=[], + metadata_updates=metadata_updates, + parameter_updates=parameter_updates, + ) + + +class _FormulaState: + def __init__( + self, + *, + namespace: str, + package_name: str, + source_claim_id: str, + knowledge_map: dict[int, str], + bindings: _BindingMap | None = None, + ): + self.namespace = namespace + self.package_name = package_name + self.source_claim_id = source_claim_id + self.knowledge_map = knowledge_map + self.bindings = bindings or {} + self.knowledges: list[IrKnowledge] = [] + self.operators: list[IrOperator] = [] + self.generated_claims_by_key: dict[tuple[str, str], str] = {} + + def lower(self, formula: Any, *, target_id: str | None = None) -> str: + if isinstance(formula, ClaimAtom): + return self._claim_atom_id(formula) + + operator_name, children = _connective_operator(formula) + if operator_name is None: + if not _is_atomic_formula(formula): + raise NotImplementedError(f"Unsupported formula lowering: {type(formula).__name__}") + return self._atom_claim(formula) + + child_ids = [self.lower(child) for child in children] + conclusion = target_id or self._helper_claim(operator_name, child_ids) + self.operators.append( + IrOperator( + scope="local", + operator=operator_name, + variables=child_ids, + conclusion=conclusion, + metadata={"formula_lowering": "connective"}, + ) + ) + return conclusion + + def _claim_atom_id(self, atom: ClaimAtom) -> str: + try: + return self.knowledge_map[id(atom.claim)] + except KeyError as exc: + raise ValueError( + "ClaimAtom references a claim that is not in the compiled package" + ) from exc + + def _atom_claim(self, formula: Any) -> str: + descriptor = canonical_formula_descriptor( + formula, + knowledge_map=self.knowledge_map, + bindings=self.bindings, + ) + node_id = formula_node_id(descriptor) + label, claim_id, created = self._generated_claim("formula_atom", node_id) + if not created: + return claim_id + + metadata = { + "generated": True, + "generated_kind": "formula_atom", + "formula_lowering": "atom", + "formula_atom": descriptor, + "formula_node_id": node_id, + "source_claim": self.source_claim_id, + "review": False, + } + bindings = _formula_bindings(formula, bindings=self.bindings) + if bindings: + metadata["formula_bindings"] = bindings + self.knowledges.append( + IrKnowledge( + id=claim_id, + label=label, + type=KnowledgeType.CLAIM, + content=repr(formula), + metadata=metadata, + ) + ) + return claim_id + + def _helper_claim(self, operator_name: str, child_ids: list[str]) -> str: + operator_label = str(operator_name) + semantic_key = json.dumps( + {"operator": operator_label, "children": child_ids}, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + label, claim_id, created = self._generated_claim( + f"{operator_label}_result", + semantic_key, + ) + if not created: + return claim_id + + self.knowledges.append( + IrKnowledge( + id=claim_id, + label=label, + type=KnowledgeType.CLAIM, + content=f"{operator_label}({', '.join(child_ids)})", + metadata={ + "generated": True, + "generated_kind": "formula_helper", + "helper_kind": f"{operator_label}_result", + "formula_lowering": "connective_helper", + "source_claim": self.source_claim_id, + "review": False, + }, + ) + ) + return claim_id + + def _generated_claim(self, role: str, semantic_key: str) -> tuple[str, str, bool]: + cache_key = (role, semantic_key) + if cache_key in self.generated_claims_by_key: + claim_id = self.generated_claims_by_key[cache_key] + return claim_id.rsplit("::", 1)[-1], claim_id, False + + digest = hashlib.sha256( + "|".join( + [ + self.namespace, + self.package_name, + self.source_claim_id, + role, + semantic_key, + ] + ).encode() + ).hexdigest()[:8] + label = f"__{_safe_label(role)}_{digest}" + claim_id = make_qid(self.namespace, self.package_name, label) + self.generated_claims_by_key[cache_key] = claim_id + return label, claim_id, True + + +def _connective_operator(formula: Any) -> tuple[OperatorType | None, list[Any]]: + if isinstance(formula, Land): + return OperatorType.CONJUNCTION, list(formula.operands) + if isinstance(formula, Lor): + return OperatorType.DISJUNCTION, list(formula.operands) + if isinstance(formula, Lnot): + return OperatorType.NEGATION, [formula.operand] + if isinstance(formula, Implies): + return OperatorType.IMPLICATION, [formula.antecedent, formula.consequent] + if isinstance(formula, Iff): + return OperatorType.EQUIVALENCE, [formula.left, formula.right] + return None, [] + + +def _is_atomic_formula(formula: Any) -> bool: + return isinstance( + formula, + ( + ClaimAtom, + Equals, + Greater, + GreaterEqual, + Less, + LessEqual, + NotEquals, + UserPredicate, + ), + ) + + +def _binary_formula_terms(formula: Any) -> tuple[Any, Any] | None: + if isinstance(formula, (Equals, NotEquals, Greater, GreaterEqual, Less, LessEqual)): + return formula.left, formula.right + return None + + +def _source_atom_metadata( + formula: Any, + *, + knowledge_map: dict[int, str], + bindings: _BindingMap | None = None, +) -> dict[str, Any]: + metadata: dict[str, Any] = { + "formula_lowering": "atom", + "formula_atom": _formula_descriptor( + formula, + knowledge_map=knowledge_map, + bindings=bindings, + ), + } + formula_bindings = _formula_bindings(formula, bindings=bindings) + if formula_bindings: + metadata["formula_bindings"] = formula_bindings + return metadata + + +def _formula_descriptor( + formula: Any, + *, + knowledge_map: dict[int, str], + bindings: _BindingMap | None = None, +) -> dict[str, Any]: + if isinstance(formula, ClaimAtom): + return {"kind": "claim", "qid": _claim_atom_qid(formula, knowledge_map)} + if isinstance(formula, Equals): + return _binary_formula_descriptor( + "equals", + formula.left, + formula.right, + knowledge_map, + bindings, + ) + if isinstance(formula, NotEquals): + return _binary_formula_descriptor( + "not_equals", + formula.left, + formula.right, + knowledge_map, + bindings, + ) + if isinstance(formula, Greater): + return _binary_formula_descriptor( + "greater", + formula.left, + formula.right, + knowledge_map, + bindings, + ) + if isinstance(formula, GreaterEqual): + return _binary_formula_descriptor( + "greater_equal", + formula.left, + formula.right, + knowledge_map, + bindings, + ) + if isinstance(formula, Less): + return _binary_formula_descriptor( + "less", + formula.left, + formula.right, + knowledge_map, + bindings, + ) + if isinstance(formula, LessEqual): + return _binary_formula_descriptor( + "less_equal", + formula.left, + formula.right, + knowledge_map, + bindings, + ) + if isinstance(formula, UserPredicate): + return { + "kind": "predicate", + "symbol": _predicate_symbol_descriptor(formula.symbol), + "args": [ + _term_descriptor(arg, knowledge_map=knowledge_map, bindings=bindings) + for arg in formula.args + ], + } + return {"kind": type(formula).__name__, "repr": repr(formula)} + + +def _binary_formula_descriptor( + kind: str, + left: Any, + right: Any, + knowledge_map: dict[int, str], + bindings: _BindingMap | None, +) -> dict[str, Any]: + return { + "kind": kind, + "left": _term_descriptor(left, knowledge_map=knowledge_map, bindings=bindings), + "right": _term_descriptor(right, knowledge_map=knowledge_map, bindings=bindings), + } + + +def _term_descriptor( + term: Any, + *, + knowledge_map: dict[int, str], + bindings: _BindingMap | None = None, +) -> dict[str, Any]: + if isinstance(term, Variable): + descriptor = { + "kind": "variable", + "symbol": term.symbol, + "domain": _domain_name(term.domain), + } + binding = (bindings or {}).get(id(term)) + if binding is not None: + descriptor["value"] = binding["value"] + descriptor["binding_source"] = binding["source"] + elif term.value is not None: + descriptor["value"] = term.value + return descriptor + if isinstance(term, Constant): + return { + "kind": "constant", + "value": term.value, + "primitive": term.primitive.name, + } + if isinstance(term, FunctionApp): + return { + "kind": "function", + "symbol": term.symbol.name, + "args": [ + _term_descriptor(arg, knowledge_map=knowledge_map, bindings=bindings) + for arg in term.args + ], + "result_domain": _domain_name(term.symbol.result_domain), + } + if isinstance(term, ArithOp): + return { + "kind": "arith", + "op": term.op, + "left": _term_descriptor( + term.left, + knowledge_map=knowledge_map, + bindings=bindings, + ), + "right": _term_descriptor( + term.right, + knowledge_map=knowledge_map, + bindings=bindings, + ), + } + if isinstance(term, ClaimAtom): + return {"kind": "knowledge", "qid": _claim_atom_qid(term, knowledge_map)} + return {"kind": type(term).__name__, "repr": repr(term)} + + +def _predicate_symbol_descriptor(symbol: PredicateSymbol) -> dict[str, Any]: + return { + "name": symbol.name, + "arg_domains": [_domain_name(domain) for domain in symbol.arg_domains], + } + + +def _claim_atom_qid(atom: ClaimAtom, knowledge_map: dict[int, str]) -> str: + try: + return knowledge_map[id(atom.claim)] + except KeyError as exc: + raise ValueError( + "ClaimAtom references a claim that is not in the compiled package" + ) from exc + + +def _formula_bindings( + formula: Any, + *, + bindings: _BindingMap | None = None, +) -> list[dict[str, Any]]: + formula_bindings = [dict(binding) for binding in (bindings or {}).values()] + for variable, constant in _equals_variable_constant_pairs(formula): + equals_binding = { + "symbol": variable.symbol, + "domain": _domain_name(variable.domain), + "value": constant.value, + "source": "formula", + } + if not any(binding["symbol"] == variable.symbol for binding in formula_bindings): + formula_bindings.append(equals_binding) + return formula_bindings + + +def _binding_parameters( + formula: Any, + *, + bindings: _BindingMap | None = None, +) -> list[IrParameter]: + parameters = [ + IrParameter( + name=binding["symbol"], + type=binding["domain"], + value=binding["value"], + ) + for binding in (bindings or {}).values() + ] + for variable, constant in _equals_variable_constant_pairs(formula): + parameter = IrParameter( + name=variable.symbol, + type=_domain_name(variable.domain), + value=constant.value, + ) + if not any(existing.name == parameter.name for existing in parameters): + parameters.append(parameter) + return parameters + + +def _quantifier_binding( + variable: Variable, + domain: Domain, + value: Any, + *, + source: str, +) -> dict[str, Any]: + return { + "symbol": variable.symbol, + "domain": _domain_name(domain), + "value": value, + "source": source, + } + + +def _absorb_generated_result( + knowledges: list[IrKnowledge], + operators: list[IrOperator], + strategies: list[IrStrategy], + result: FormulaLoweringResult, +) -> None: + knowledges.extend(result.knowledges) + operators.extend(result.operators) + strategies.extend(result.strategies) + _apply_knowledge_updates( + knowledges, + metadata_updates=result.metadata_updates, + parameter_updates=result.parameter_updates, + preserve_instance_lowering=True, + ) + + +def _apply_knowledge_updates( + knowledges: list[IrKnowledge], + *, + metadata_updates: dict[str, dict[str, Any]], + parameter_updates: dict[str, list[IrParameter]], + preserve_instance_lowering: bool = False, +) -> None: + if not metadata_updates and not parameter_updates: + return + index_by_id = {k.id: i for i, k in enumerate(knowledges) if k.id} + for qid in sorted(set(metadata_updates) | set(parameter_updates)): + if qid not in index_by_id: + continue + index = index_by_id[qid] + knowledge = knowledges[index] + metadata = dict(knowledge.metadata or {}) + update = dict(metadata_updates.get(qid, {})) + if ( + preserve_instance_lowering + and metadata.get("formula_lowering") in {"forall_instance", "exists_instance"} + and update.get("formula_lowering") == "atom" + ): + update.pop("formula_lowering") + update["formula_body_lowering"] = "atom" + metadata.update(update) + + parameters = list(knowledge.parameters or []) + for param in parameter_updates.get(qid, []): + existing = next((p for p in parameters if p.name == param.name), None) + if existing is None: + parameters.append(param) + continue + if existing.type != param.type or existing.value != param.value: + raise ValueError( + f"formula binding for parameter {param.name!r} conflicts " + f"with existing parameter on {qid}" + ) + + knowledges[index] = knowledge.model_copy( + update={ + "metadata": metadata or None, + "parameters": parameters, + "content_hash": None, + } + ) + return + + +def _lower_claim_atom_alias( + atom: ClaimAtom, + *, + target_id: str, + namespace: str, + package_name: str, + knowledge_map: dict[int, str], +) -> FormulaLoweringResult: + referenced_id = _claim_atom_qid(atom, knowledge_map) + metadata_updates = { + target_id: { + "formula_lowering": "atom", + "formula_atom": {"kind": "claim", "qid": referenced_id}, + "formula_alias": {"qid": referenced_id}, + } + } + if referenced_id == target_id: + return FormulaLoweringResult(metadata_updates=metadata_updates) + + result = _equivalence_result( + namespace=namespace, + package_name=package_name, + left_id=referenced_id, + right_id=target_id, + formula_lowering="claim_atom_alias", + metadata={ + "source_claim": target_id, + "referenced_claim": referenced_id, + }, + ) + return FormulaLoweringResult( + knowledges=result.knowledges, + operators=result.operators, + strategies=result.strategies, + metadata_updates=metadata_updates, + ) + + +def _equivalence_result( + *, + namespace: str, + package_name: str, + left_id: str, + right_id: str, + formula_lowering: str, + metadata: dict[str, Any], +) -> FormulaLoweringResult: + helper_id, helper_label = _equivalence_helper_id( + namespace=namespace, + package_name=package_name, + left_id=left_id, + right_id=right_id, + formula_lowering=formula_lowering, + ) + helper_metadata = { + "generated": True, + "generated_kind": "formula_helper", + "helper_kind": "equivalence_result", + "formula_lowering": formula_lowering, + "review": False, + **metadata, + } + operator_metadata = { + "formula_lowering": formula_lowering, + **metadata, + } + return FormulaLoweringResult( + knowledges=[ + IrKnowledge( + id=helper_id, + label=helper_label, + type=KnowledgeType.CLAIM, + content=f"equivalence({left_id}, {right_id})", + metadata=helper_metadata, + ) + ], + operators=[ + IrOperator( + scope="local", + operator=OperatorType.EQUIVALENCE, + variables=[left_id, right_id], + conclusion=helper_id, + metadata=operator_metadata, + ) + ], + ) + + +def _equals_variable_constant_pair(formula: Any) -> tuple[Variable, Constant] | None: + if not isinstance(formula, Equals): + return None + if isinstance(formula.left, Variable) and isinstance(formula.right, Constant): + return formula.left, formula.right + if isinstance(formula.right, Variable) and isinstance(formula.left, Constant): + return formula.right, formula.left + return None + + +def _equals_variable_constant_pairs(formula: Any) -> list[tuple[Variable, Constant]]: + pair = _equals_variable_constant_pair(formula) + if pair is not None: + return [pair] + if isinstance(formula, Land): + pairs: list[tuple[Variable, Constant]] = [] + for operand in formula.operands: + pairs.extend(_equals_variable_constant_pairs(operand)) + return pairs + return [] + + +def _is_binding_conjunction(formula: Any) -> bool: + return ( + isinstance(formula, Land) + and bool(formula.operands) + and all(_equals_variable_constant_pair(operand) is not None for operand in formula.operands) + ) + + +def _domain_name(domain: PrimitiveType | Domain) -> str: + if isinstance(domain, PrimitiveType): + return domain.name + return domain.label or domain.title or domain.content or "Domain" + + +def _forall_instance_id( + *, + namespace: str, + package_name: str, + source_claim_id: str, + symbol: str, + value: Any, +) -> tuple[str, str]: + payload = f"{source_claim_id}|{symbol}|{value!r}" + digest = hashlib.sha256(payload.encode()).hexdigest()[:8] + label = f"__forall_{_safe_label(symbol)}_{digest}" + return make_qid(namespace, package_name, label), label + + +def _exists_instance_id( + *, + namespace: str, + package_name: str, + source_claim_id: str, + symbol: str, + value: Any, +) -> tuple[str, str]: + payload = f"{source_claim_id}|{symbol}|{value!r}" + digest = hashlib.sha256(payload.encode()).hexdigest()[:8] + label = f"__exists_{_safe_label(symbol)}_{digest}" + return make_qid(namespace, package_name, label), label + + +def _equivalence_helper_id( + *, + namespace: str, + package_name: str, + left_id: str, + right_id: str, + formula_lowering: str, +) -> tuple[str, str]: + payload = "|".join(sorted([left_id, right_id, formula_lowering])) + digest = hashlib.sha256(payload.encode()).hexdigest()[:8] + label = f"__formula_equivalence_{digest}" + return make_qid(namespace, package_name, label), label + + +def _safe_label(value: str) -> str: + normalized = re.sub(r"[^A-Za-z0-9_]+", "_", value.strip()) + normalized = normalized.strip("_") or "x" + if not re.match(r"[A-Za-z_]", normalized): + normalized = f"_{normalized}" + return normalized diff --git a/gaia/engine/lang/compiler/predicate_lowering.py b/gaia/engine/lang/compiler/predicate_lowering.py new file mode 100644 index 000000000..7804a38e7 --- /dev/null +++ b/gaia/engine/lang/compiler/predicate_lowering.py @@ -0,0 +1,306 @@ +"""Lower predicate / equation BoolExpr propositions to prior records. + +When the author writes ``claim("k is fast", k > 1e-2)``, the resulting Claim +carries the BoolExpr in ``metadata['predicate']``. This module walks the +package, computes ``P(k > 1e-2)`` from the underlying Distribution's CDF, +Cromwell-clamps the result, and registers it as a compiler-generated +``prior_records`` entry with ``source_id="continuous_inference"``. The +package-level :class:`ResolutionPolicy` then decides whether that generated +value or an author/reviewer value wins. + +Equation propositions are stored in ``metadata['equation']`` for audit and +future lowering. This module does not infer equation truth from the marginal +distributions of the equation operands; if the author does not provide a +prior, it registers a neutral 0.5 default that can be overridden by any +explicit prior source. + +Observation-aware posterior CDF (Normal-Normal / LogNormal-Normal conjugate +updates triggered by ``observe(distribution, value, error)``) is intentionally +deferred to a follow-up PR. PR1 uses the prior CDF directly; observations +declared on a Distribution are stashed for the future update path but do not +yet shift the predicate prior. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Any + +from gaia.engine.ir.parameterization import CROMWELL_EPS +from gaia.engine.lang.dsl.bool_expr import BoolExpr +from gaia.engine.lang.dsl.register_prior import PRIOR_RECORDS_METADATA_KEY, register_prior +from gaia.engine.lang.runtime.distribution import Distribution +from gaia.engine.lang.runtime.knowledge import Claim + +if TYPE_CHECKING: + from gaia.engine.lang.runtime.package import CollectedPackage + + +PREDICATE_LOWERING_SOURCE_ID: str = "continuous_inference" +"""``source_id`` for CDF-derived predicate priors. + +The default ResolutionPolicy ranks this source above the low-friction +``claim_inline`` shortcut and below explicit author/reviewer sources. +""" + +EQUATION_DEFAULT_SOURCE_ID: str = "equation_default" +"""Low-priority ``source_id`` for neutral equation defaults.""" + +PREDICATE_PRIOR_GENERATED_ATTR = "_gaia_predicate_prior_generated" +"""Private runtime marker distinguishing compiler-generated priors from author overrides.""" + + +def _clamp(value: float) -> float: + return max(CROMWELL_EPS, min(1.0 - CROMWELL_EPS, value)) + + +def _resolve_threshold(value: Any, distribution: Distribution) -> float: + """Coerce a literal threshold to a finite float in the distribution's unit. + + Accepts either a bare numeric scalar or a :class:`gaia.unit.Quantity`. When + the LHS distribution carries a ``metadata['unit']``, the threshold MUST be + a Quantity with a dimensionally-compatible unit (it is converted via + Pint's ``.to()`` to the distribution's unit before extraction). When the + distribution is unitless, the threshold MUST be a bare scalar — passing a + Quantity in that case is a type error since the comparison would be + ill-defined. + + Distributions on the right-hand side of an inequality are not yet + supported — those would require a joint distribution over ``(lhs, rhs)``. + """ + from gaia.unit import is_quantity, ureg + + distribution_unit: str | None = (distribution.metadata or {}).get("unit") + + if isinstance(value, Distribution): + raise NotImplementedError( + "Predicate with a Distribution on both sides is not yet supported. " + "Express the predicate against a numeric threshold (e.g. " + "k > 1e-3); a Distribution-vs-Distribution comparison would " + "require joint marginalisation, which is deferred to a " + "follow-up release." + ) + if is_quantity(value): + if distribution_unit is None: + raise TypeError( + "Predicate threshold is a unit-typed Quantity but the LHS " + f"distribution {distribution.label or distribution.content[:40]!r} " + "is unitless. Pass a bare scalar threshold or attach a unit " + "to the distribution by passing Quantity-typed parameters." + ) + try: + converted = value.to(ureg.parse_units(distribution_unit)) + except Exception as err: # pint raises a variety of subclasses + raise ValueError( + f"Predicate threshold unit {value.units!s} is not compatible " + f"with the LHS distribution unit {distribution_unit!r}: {err}" + ) from err + threshold = float(converted.magnitude) + else: + if distribution_unit is not None: + raise TypeError( + f"Predicate threshold must be a Quantity in {distribution_unit!r} " + f"because the LHS distribution " + f"{distribution.label or distribution.content[:40]!r} carries " + f"that unit; got bare scalar {value!r}." + ) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError( + "Predicate threshold must be a numeric scalar; " + f"got {type(value).__name__}: {value!r}." + ) + threshold = float(value) + if threshold != threshold: # NaN + raise ValueError("Predicate threshold must be finite, got NaN.") + return threshold + + +def _predicate_prior(distribution: Distribution, op: str, threshold: float) -> float: + """Compute ``P(op(X, threshold))`` for ``X ~ distribution``. + + Returns the Cromwell-clamped probability that the predicate evaluates to + true under the underlying distribution's CDF. PR1 uses the prior CDF + directly; observation-aware posterior CDF is deferred. + """ + if distribution.kind in {"binomial", "poisson"}: + if op == ">": + cdf_threshold = math.floor(threshold) + prior = 1.0 - distribution.cdf(cdf_threshold) + elif op == ">=": + cdf_threshold = math.ceil(threshold) - 1 + prior = 1.0 - distribution.cdf(cdf_threshold) + elif op == "<": + cdf_threshold = math.ceil(threshold) - 1 + prior = distribution.cdf(cdf_threshold) + elif op == "<=": + cdf_threshold = math.floor(threshold) + prior = distribution.cdf(cdf_threshold) + else: + raise NotImplementedError( + f"Predicate operator {op!r} is not yet supported for prior " + "lowering. Use one of '>', '>=', '<', '<=' for inequality " + "predicates, or attach the proposition as an equation via " + "the '==' operator (which lowers to metadata['equation'] and " + "expects an explicit `prior=` for the equation's truth claim)." + ) + return _clamp(float(prior)) + + cdf_at = distribution.cdf(threshold) + if op in {">", ">="}: + # P(X > c) = 1 - cdf(c); for continuous distributions strict and + # non-strict inequalities differ by a measure-zero point. + prior = 1.0 - cdf_at + elif op in {"<", "<="}: + prior = cdf_at + else: + raise NotImplementedError( + f"Predicate operator {op!r} is not yet supported for prior " + "lowering. Use one of '>', '>=', '<', '<=' for inequality " + "predicates, or attach the proposition as an equation via " + "the '==' operator (which lowers to metadata['equation'] and " + "expects an explicit `prior=` for the equation's truth claim)." + ) + return _clamp(float(prior)) + + +def _prior_records(claim: Claim) -> list[dict[str, Any]]: + """Return claim prior records when the reserved metadata key is well-formed.""" + records = claim.metadata.get(PRIOR_RECORDS_METADATA_KEY, []) + if not records: + return [] + if not isinstance(records, list): + raise TypeError( + f"Claim {claim.label or claim.content[:40]!r} has " + f"metadata[{PRIOR_RECORDS_METADATA_KEY!r}] of type " + f"{type(records).__name__}, expected list." + ) + return [record for record in records if isinstance(record, dict)] + + +def _has_non_generated_prior_record(claim: Claim) -> bool: + """Whether a claim has any prior record not produced by this lowering pass.""" + return any( + record.get("source_id") != PREDICATE_LOWERING_SOURCE_ID for record in _prior_records(claim) + ) + + +def _upsert_generated_prior_record( + claim: Claim, + value: float, + *, + justification: str, + source_id: str = PREDICATE_LOWERING_SOURCE_ID, +) -> None: + """Register or update this pass's generated prior without duplicating records.""" + records = claim.metadata.get(PRIOR_RECORDS_METADATA_KEY) + if records is not None and not isinstance(records, list): + raise TypeError( + f"Claim {claim.label or claim.content[:40]!r} has " + f"metadata[{PRIOR_RECORDS_METADATA_KEY!r}] of type " + f"{type(records).__name__}, expected list." + ) + if isinstance(records, list): + for record in records: + if isinstance(record, dict) and record.get("source_id") == source_id: + record["value"] = value + record["justification"] = justification + setattr(claim, PREDICATE_PRIOR_GENERATED_ATTR, True) + return + register_prior( + claim, + value, + source_id=source_id, + justification=justification, + ) + setattr(claim, PREDICATE_PRIOR_GENERATED_ATTR, True) + + +def _cdf_derived_prior(expr: BoolExpr) -> float: + if not isinstance(expr.left, Distribution): + raise TypeError( + "Predicate claim left-hand side must be a Distribution; " + f"got {type(expr.left).__name__}. The proposition is " + f"`{expr.left!r} {expr.op} {expr.right!r}`." + ) + threshold = _resolve_threshold(expr.right, expr.left) + return _predicate_prior(expr.left, expr.op, threshold) + + +def _audit_cdf_prior(claim: Claim, cdf_derived: float | None) -> None: + meta = dict(claim.metadata) + audit = dict(meta.get("predicate_audit") or {}) + audit["cdf_derived_prior"] = cdf_derived + meta["predicate_audit"] = audit + claim.metadata = meta + + +def _lower_predicate_claim(claim: Claim) -> None: + """Compute and register the generated prior for a single predicate claim.""" + expr = claim.metadata.get("predicate") + if not isinstance(expr, BoolExpr): + return + cdf_derived = _cdf_derived_prior(expr) + _upsert_generated_prior_record( + claim, + cdf_derived, + justification="CDF-derived predicate prior from distribution CDF.", + ) + if claim.prior is not None or _has_non_generated_prior_record(claim): + _audit_cdf_prior(claim, cdf_derived) + + +def _has_direct_or_registered_prior(claim: Claim) -> bool: + prior_was_generated = bool(getattr(claim, PREDICATE_PRIOR_GENERATED_ATTR, False)) + return (claim.prior is not None and not prior_was_generated) or bool(_prior_records(claim)) + + +def _register_neutral_equation_prior(claim: Claim) -> None: + _upsert_generated_prior_record( + claim, + 0.5, + justification=( + "Neutral default for equation truth claim; equation constraint " + "lowering is not implemented yet." + ), + source_id=EQUATION_DEFAULT_SOURCE_ID, + ) + + +def _lower_equation_claim(claim: Claim) -> None: + """Validate an equation claim; defer prior derivation to follow-up PR. + + PR1 stores the equation expression and uses prior records for the + equation's truth claim. That truth prior is methodologically separate from + the involved distributions — for example "this calibration equation + holds" has a prior reflecting the author's confidence in the law/model, + which is not derivable from the marginal distributions of the operands + alone. + """ + expr = claim.metadata.get("equation") + if not isinstance(expr, BoolExpr): + return + if not _has_direct_or_registered_prior(claim): + # Author asserted an equation but didn't say how much they believe in + # it. Default to the neutral 0.5 — the author can override. + _register_neutral_equation_prior(claim) + + +def lower_predicate_priors(package: CollectedPackage) -> None: + """Walk the package and compute predicate-derived priors in place. + + Mutates each Claim that carries ``metadata['predicate']`` (an inequality + BoolExpr) by registering the CDF-derived prior in ``prior_records``. + Equation claims (``metadata['equation']``) keep author priors or receive + a neutral generated default of 0.5. + + This is invoked at the start of :func:`compile_package_artifact` so the + ResolutionPolicy can collapse all prior records to one resolved + ``metadata['prior']`` before IR emission. + """ + for knowledge in package.knowledge: + if not isinstance(knowledge, Claim): + continue + if "predicate" in knowledge.metadata: + _lower_predicate_claim(knowledge) + elif "equation" in knowledge.metadata: + _lower_equation_claim(knowledge) diff --git a/gaia/engine/lang/dsl/__init__.py b/gaia/engine/lang/dsl/__init__.py new file mode 100644 index 000000000..4596890ce --- /dev/null +++ b/gaia/engine/lang/dsl/__init__.py @@ -0,0 +1,101 @@ +"""Public Gaia Lang DSL helper functions.""" + +from gaia.engine.lang.dsl.associate_verb import associate +from gaia.engine.lang.dsl.decompose import decompose +from gaia.engine.lang.dsl.formula import ( + equals, + exists, + forall, + iff, + implies, + land, + lnot, + lor, +) +from gaia.engine.lang.dsl.infer_verb import infer +from gaia.engine.lang.dsl.knowledge import claim, context, note, question, setting +from gaia.engine.lang.dsl.operators import complement, contradiction, disjunction, equivalence +from gaia.engine.lang.dsl.propositional import and_, not_, or_ +from gaia.engine.lang.dsl.register_prior import ( + DEFAULT_SOURCE_ID, + PRIOR_RECORDS_METADATA_KEY, + get_prior_records, + register_prior, +) +from gaia.engine.lang.dsl.relate import contradict, equal, exclusive +from gaia.engine.lang.dsl.scaffold import candidate_relation, depends_on, materialize +from gaia.engine.lang.dsl.strategies import ( + abduction, + analogy, + case_analysis, + compare, + composite, + deduction, + elimination, + extrapolation, + fills, + induction, + mathematical_induction, + noisy_and, +) +from gaia.engine.lang.dsl.strategies import ( + support as _strategy_support, +) +from gaia.engine.lang.dsl.sugar import parameter +from gaia.engine.lang.dsl.support import compute, derive, observe +from gaia.engine.lang.runtime.composition import compose, composition + +# Importing gaia.engine.lang.dsl.support installs a same-named submodule on this package. +support = _strategy_support + +__all__ = [ + "abduction", + "analogy", + "and_", + "associate", + "candidate_relation", + "case_analysis", + "claim", + "compare", + "complement", + "compose", + "composite", + "composition", + "compute", + "context", + "contradict", + "contradiction", + "decompose", + "deduction", + "depends_on", + "derive", + "disjunction", + "elimination", + "equal", + "equals", + "equivalence", + "exclusive", + "exists", + "extrapolation", + "fills", + "forall", + "iff", + "implies", + "induction", + "infer", + "land", + "lnot", + "lor", + "materialize", + "mathematical_induction", + "noisy_and", + "not_", + "note", + "observe", + "or_", + "parameter", + "question", + "register_prior", + "setting", + "support", +] diff --git a/gaia/engine/lang/dsl/associate_verb.py b/gaia/engine/lang/dsl/associate_verb.py new file mode 100644 index 000000000..cd267a962 --- /dev/null +++ b/gaia/engine/lang/dsl/associate_verb.py @@ -0,0 +1,90 @@ +"""Gaia Lang v6 Associate verb.""" + +from __future__ import annotations + +from gaia.engine.lang.runtime.action import ( + Associate as AssociateAction, +) +from gaia.engine.lang.runtime.action import attach_reasoning, validate_no_self_warrant +from gaia.engine.lang.runtime.knowledge import Claim, Knowledge + +_ASSOCIATE_PATTERNS = frozenset({"equal", "contradict", "exclusive"}) + + +def _claim_ref(claim: Claim) -> str: + if claim.label: + return f"[@{claim.label}]" + return claim.content + + +def associate( + a: Claim, + b: Claim, + *, + p_a_given_b: float, + p_b_given_a: float, + pattern: str | None = None, + background: list[Knowledge] | None = None, + rationale: str = "", + label: str | None = None, +) -> Claim: + """Declare a symmetric probabilistic association. Returns an association helper Claim.""" + if not isinstance(a, Claim): + raise TypeError("associate() a must be a Claim") + if not isinstance(b, Claim): + raise TypeError("associate() b must be a Claim") + _validate_pattern(pattern, p_a_given_b=p_a_given_b, p_b_given_a=p_b_given_a) + + helper = Claim( + f"{_claim_ref(a)} and {_claim_ref(b)} are statistically associated.", + metadata={ + "generated": True, + "helper_kind": "association", + "review": True, + "relation": { + "type": "associate", + "a": a, + "b": b, + "p_a_given_b": p_a_given_b, + "p_b_given_a": p_b_given_a, + "pattern": pattern, + }, + }, + ) + action = AssociateAction( + label=label, + rationale=rationale, + background=list(background or []), + a=a, + b=b, + p_a_given_b=p_a_given_b, + p_b_given_a=p_b_given_a, + pattern=pattern, + helper=helper, + ) + validate_no_self_warrant(action, helper) + attach_reasoning(helper, action) + return helper + + +def _validate_pattern( + pattern: str | None, + *, + p_a_given_b: float, + p_b_given_a: float, +) -> None: + if pattern is None: + return + if pattern not in _ASSOCIATE_PATTERNS: + allowed = ", ".join(sorted(_ASSOCIATE_PATTERNS)) + raise ValueError(f"associate pattern must be one of: {allowed}") + if pattern == "equal" and not (p_a_given_b > 0.5 and p_b_given_a > 0.5): + raise ValueError( + "associate(pattern='equal') requires p_a_given_b > 0.5 and p_b_given_a > " + "0.5. If the conditional is not informative, drop the pattern argument." + ) + if pattern in {"contradict", "exclusive"} and not (p_a_given_b < 0.5 and p_b_given_a < 0.5): + raise ValueError( + f"associate(pattern='{pattern}') requires p_a_given_b < 0.5 and p_b_given_a " + "< 0.5. If the conditional is not informative, drop the pattern argument." + ) diff --git a/gaia/engine/lang/dsl/bool_expr.py b/gaia/engine/lang/dsl/bool_expr.py new file mode 100644 index 000000000..e9a65e34f --- /dev/null +++ b/gaia/engine/lang/dsl/bool_expr.py @@ -0,0 +1,173 @@ +"""BoolExpr — boolean expression over Distribution objects (and constants). + +The dataclasses in this module are produced by Distribution operator overloads +(``k > 1e-3``, ``y == baseline + slope * x``, ``A / B``). +They carry no semantic meaning on their own — they are intermediate values +that ``claim(content, expr)`` accepts as a structured proposition. + +At compile time the BoolExpr is lowered into Claim metadata +(``metadata['predicate']`` or ``metadata['equation']``) which the BP layer +reads to compute inequality predicate priors via the underlying distribution's +CDF. Equation propositions are currently metadata plus an author/default prior; +joint-distribution constraint lowering is future work. + +``BoolExpr.__bool__`` raises with a helpful message — analogous to +:meth:`gaia.engine.lang.runtime.knowledge.Claim.__bool__` — so accidental use in +Python control flow (``if k > 1e-3: ...``) surfaces as a clear error rather +than as silently always-truthy. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + +ComparisonOp = Literal[">", ">=", "<", "<=", "==", "!="] +ArithmeticOp = Literal["+", "-", "*", "/"] + + +@dataclass(eq=False) +class DerivedDistribution: + """Arithmetic combination of distributions / scalars (e.g. ``baseline + slope * x``). + + Used as the right-hand side of an :class:`Equation` proposition. Carries no + runtime sampling logic — it is a syntactic placeholder retained in + equation metadata for audit and future constraint lowering. + + Constants (Python ``int`` / ``float`` / ``Quantity``) and other + DerivedDistributions may appear on either operand position. + """ + + op: ArithmeticOp + left: Any + right: Any + + def __post_init__(self) -> None: + """Validate the arithmetic operator.""" + if self.op not in {"+", "-", "*", "/"}: + raise ValueError( + f"DerivedDistribution op must be one of '+', '-', '*', '/', got {self.op!r}" + ) + + def __hash__(self) -> int: + """Identity-based hash (mirrors Distribution behaviour).""" + return id(self) + + # Arithmetic operators — chain into deeper DerivedDistribution trees. + def __add__(self, other: Any) -> DerivedDistribution: + """Right-side addition, producing ``self + other`` derived expression.""" + return DerivedDistribution("+", self, other) + + def __radd__(self, other: Any) -> DerivedDistribution: + """Reflected addition, producing ``other + self`` derived expression.""" + return DerivedDistribution("+", other, self) + + def __sub__(self, other: Any) -> DerivedDistribution: + """Right-side subtraction producing ``self - other`` derived expression.""" + return DerivedDistribution("-", self, other) + + def __rsub__(self, other: Any) -> DerivedDistribution: + """Reflected subtraction producing ``other - self`` derived expression.""" + return DerivedDistribution("-", other, self) + + def __mul__(self, other: Any) -> DerivedDistribution: + """Right-side multiplication producing ``self * other`` derived expression.""" + return DerivedDistribution("*", self, other) + + def __rmul__(self, other: Any) -> DerivedDistribution: + """Reflected multiplication producing ``other * self`` derived expression.""" + return DerivedDistribution("*", other, self) + + def __truediv__(self, other: Any) -> DerivedDistribution: + """Right-side division producing ``self / other`` derived expression.""" + return DerivedDistribution("/", self, other) + + def __rtruediv__(self, other: Any) -> DerivedDistribution: + """Reflected division producing ``other / self`` derived expression.""" + return DerivedDistribution("/", other, self) + + def __neg__(self) -> DerivedDistribution: + """Unary negation producing ``-self`` derived expression.""" + return DerivedDistribution("-", 0, self) + + # Comparison operators — produce BoolExpr (so ``baseline + slope * x == y`` works). + def __gt__(self, other: Any) -> BoolExpr: + """Greater-than comparison returning a BoolExpr proposition.""" + return BoolExpr(">", self, other) + + def __ge__(self, other: Any) -> BoolExpr: + """Greater-or-equal comparison returning a BoolExpr proposition.""" + return BoolExpr(">=", self, other) + + def __lt__(self, other: Any) -> BoolExpr: + """Less-than comparison returning a BoolExpr proposition.""" + return BoolExpr("<", self, other) + + def __le__(self, other: Any) -> BoolExpr: + """Less-or-equal comparison returning a BoolExpr proposition.""" + return BoolExpr("<=", self, other) + + def __eq__(self, other: Any) -> Any: + """Equation comparison returning a BoolExpr (op ``==``).""" + return BoolExpr("==", self, other) + + def __ne__(self, other: Any) -> Any: + """Inequality comparison returning a BoolExpr (op ``!=``).""" + return BoolExpr("!=", self, other) + + +@dataclass(eq=False) +class BoolExpr: + """Boolean proposition over Distribution objects. + + Created by Distribution comparison operators (``k > 1e-3``, + ``y == baseline + slope * x``). ``claim(content, expr)`` accepts a BoolExpr as the + second argument and lowers it to claim metadata so the compiler can + compute the resulting prior via the underlying distribution's CDF (for + inequality predicates). Equality / equation predicates are preserved in + metadata with author/default priors; constraint lowering is future work. + + The :meth:`__bool__` override raises so accidental Python control-flow use + (``if k > 1e-3: ...``) surfaces immediately rather than silently always + evaluating to True (the dataclass would otherwise be truthy). + """ + + op: ComparisonOp + left: Any + right: Any + + def __post_init__(self) -> None: + """Validate the comparison operator.""" + if self.op not in {">", ">=", "<", "<=", "==", "!="}: + raise ValueError( + f"BoolExpr op must be one of '>', '>=', '<', '<=', '==', '!=', got {self.op!r}" + ) + + def __hash__(self) -> int: + """Identity-based hash (mirrors Distribution / DerivedDistribution).""" + return id(self) + + def __bool__(self) -> bool: + """Reject Python truth-value coercion with a helpful error. + + Mirrors :meth:`Claim.__bool__`. Authors often write ``if k > 1e-3:`` + in scratch code; without this guard, the BoolExpr would be truthy + (non-empty dataclass), masking the bug. The error message points to + the intended use as a claim proposition. + """ + raise TypeError( + "BoolExpr does not have a Python truth value (analogous to numpy " + "or sympy expressions). Use it as the proposition argument to " + 'claim(...): claim("k is fast", k > 1e-2)' + ) + + # Comparison operators on a BoolExpr would be unusual but are defined for + # symmetry — for example ``(k > 1e-3) != True`` should still produce a + # BoolExpr rather than coercing. + def __eq__(self, other: Any) -> Any: + """Equality comparison returning a nested BoolExpr (rare).""" + return BoolExpr("==", self, other) + + def __ne__(self, other: Any) -> Any: + """Inequality comparison returning a nested BoolExpr (rare).""" + return BoolExpr("!=", self, other) diff --git a/gaia/engine/lang/dsl/decompose.py b/gaia/engine/lang/dsl/decompose.py new file mode 100644 index 000000000..16df4c450 --- /dev/null +++ b/gaia/engine/lang/dsl/decompose.py @@ -0,0 +1,105 @@ +"""Gaia Lang structural decomposition verb.""" + +from __future__ import annotations + +from typing import Any + +from gaia.engine.lang.formula.connective import Iff, Implies, Land, Lnot, Lor +from gaia.engine.lang.formula.predicate import ClaimAtom, is_formula +from gaia.engine.lang.runtime.action import ( + Decompose, + attach_reasoning, + validate_no_self_warrant, +) +from gaia.engine.lang.runtime.knowledge import Claim, Knowledge + + +def _claim_atoms(formula: Any) -> tuple[Claim, ...]: + if isinstance(formula, ClaimAtom): + return (formula.claim,) + if isinstance(formula, Land | Lor): + return tuple(claim for operand in formula.operands for claim in _claim_atoms(operand)) + if isinstance(formula, Lnot): + return _claim_atoms(formula.operand) + if isinstance(formula, Implies): + return (*_claim_atoms(formula.antecedent), *_claim_atoms(formula.consequent)) + if isinstance(formula, Iff): + return (*_claim_atoms(formula.left), *_claim_atoms(formula.right)) + return () + + +def _existing_decompose(whole: Claim) -> Decompose | None: + for action in whole.from_actions: + if isinstance(action, Decompose) and action.whole is whole: + return action + return None + + +def _decomposition_reaches(start: Claim, target: Claim, seen: set[int]) -> bool: + if start is target: + return True + start_id = id(start) + if start_id in seen: + return False + seen.add(start_id) + for action in start.from_actions: + if not isinstance(action, Decompose) or action.whole is not start: + continue + if any(_decomposition_reaches(part, target, seen) for part in action.parts): + return True + return False + + +def decompose( + whole: Claim, + *, + parts: tuple[Claim, ...] | list[Claim], + formula: Any, + background: list[Knowledge] | None = None, + rationale: str = "", + label: str | None = None, + metadata: dict[str, Any] | None = None, +) -> Claim: + """Declare ``whole`` equivalent to ``formula`` over atomic ``parts``.""" + if not isinstance(whole, Claim): + raise TypeError("decompose whole must be a Claim") + part_tuple = tuple(parts) + if not part_tuple: + raise ValueError("decompose requires at least one part Claim") + if any(not isinstance(part, Claim) for part in part_tuple): + raise TypeError("decompose parts must be Claims") + if len({id(part) for part in part_tuple}) != len(part_tuple): + raise ValueError("decompose parts must be unique") + if not is_formula(formula): + raise TypeError("decompose formula must be a Formula") + + atom_claims = _claim_atoms(formula) + atom_ids = {id(claim) for claim in atom_claims} + part_ids = {id(part) for part in part_tuple} + if id(whole) in atom_ids: + raise ValueError("decompose formula must not reference the whole claim") + missing = part_ids - atom_ids + if missing: + raise ValueError("every decompose part must appear in the formula") + extra = atom_ids - part_ids + if extra: + raise ValueError("decompose formula may only reference listed parts") + existing = _existing_decompose(whole) + if existing is not None: + suffix = f" by action {existing.label}" if existing.label else "" + raise ValueError(f"decompose: claim is already decomposed{suffix}") + if any(_decomposition_reaches(part, whole, set()) for part in part_tuple): + raise ValueError("decompose would create a decomposition cycle") + + action = Decompose( + label=label, + rationale=rationale, + background=list(background or []), + metadata=dict(metadata or {}), + whole=whole, + parts=part_tuple, + formula=formula, + ) + validate_no_self_warrant(action, whole) + attach_reasoning(whole, action) + return whole diff --git a/gaia/engine/lang/dsl/formula.py b/gaia/engine/lang/dsl/formula.py new file mode 100644 index 000000000..a2d888824 --- /dev/null +++ b/gaia/engine/lang/dsl/formula.py @@ -0,0 +1,55 @@ +"""Milestone B formula helper functions. + +These helpers are intentionally thin: they construct the typed Formula AST +nodes introduced in Milestone A. Compiler lowering decides how those formulas +become IR. +""" + +from __future__ import annotations + +from typing import Any + +from gaia.engine.lang.formula.connective import Iff, Implies, Land, Lnot, Lor +from gaia.engine.lang.formula.predicate import Equals +from gaia.engine.lang.formula.quantifier import Exists, Forall +from gaia.engine.lang.runtime.variable import Variable + + +def forall(variable: Variable, body: Any) -> Forall: + """Create a universal quantifier over a free variable.""" + return Forall(variable=variable, body=body) + + +def exists(variable: Variable, body: Any) -> Exists: + """Create an existential quantifier over a free variable.""" + return Exists(variable=variable, body=body) + + +def land(*operands: Any) -> Land: + """Create a logical conjunction formula.""" + return Land(operands=tuple(operands)) + + +def lor(*operands: Any) -> Lor: + """Create a logical disjunction formula.""" + return Lor(operands=tuple(operands)) + + +def lnot(operand: Any) -> Lnot: + """Create a logical negation formula.""" + return Lnot(operand=operand) + + +def implies(antecedent: Any, consequent: Any) -> Implies: + """Create an implication formula.""" + return Implies(antecedent=antecedent, consequent=consequent) + + +def iff(left: Any, right: Any) -> Iff: + """Create an equivalence formula.""" + return Iff(left=left, right=right) + + +def equals(left: Any, right: Any) -> Equals: + """Create an equality formula.""" + return Equals(left=left, right=right) diff --git a/gaia/engine/lang/dsl/infer_verb.py b/gaia/engine/lang/dsl/infer_verb.py new file mode 100644 index 000000000..df92a314c --- /dev/null +++ b/gaia/engine/lang/dsl/infer_verb.py @@ -0,0 +1,164 @@ +"""Gaia Lang v6 Infer verb.""" + +from __future__ import annotations + +import warnings +from typing import Any, cast + +from gaia.engine.lang.runtime.action import ( + Infer as InferAction, +) +from gaia.engine.lang.runtime.action import attach_reasoning, validate_no_self_warrant +from gaia.engine.lang.runtime.knowledge import Claim, Knowledge +from gaia.engine.lang.runtime.nodes import Strategy + + +def _claim_ref(claim: Claim) -> str: + # infer() returns the evidence Claim, so callers may relabel it after the + # action is created. Keep helper display text independent of mutable labels; + # structured references live in relation metadata. + return claim.content + + +def _as_given_tuple(given: Claim | tuple[Claim, ...] | list[Claim] | None) -> tuple[Claim, ...]: + if given is None: + return () + if isinstance(given, Knowledge): + return (given,) + return tuple(given) + + +def _legacy_infer( + premises: list[Knowledge] | tuple[Knowledge, ...], + args: tuple[Any, ...], + legacy_kwargs: dict[str, Any], +) -> Strategy: + from gaia.engine.lang.dsl.strategies import infer as legacy_infer + + warnings.warn( + "infer([premises], conclusion, ...) is deprecated; use " + "infer(evidence, hypothesis=..., p_e_given_h=..., " + "p_e_given_not_h=...) instead", + DeprecationWarning, + stacklevel=2, + ) + return legacy_infer(list(premises), *args, **legacy_kwargs) + + +def _resolve_evidence(evidence: Claim | str | None) -> Claim | str | None: + if isinstance(evidence, (list, tuple)): + raise TypeError("legacy infer form must be handled before evidence resolution") + return evidence + + +def _validate_infer_claims( + *, + hypothesis: Claim | None, + evidence: Claim | str | None, + p_e_given_h: float | Claim | None, + given: Claim | tuple[Claim, ...] | list[Claim] | None, +) -> tuple[Claim, Claim, tuple[Claim, ...]]: + if hypothesis is None: + raise TypeError("infer() missing required keyword argument: 'hypothesis'") + if evidence is None: + raise TypeError("infer() missing required keyword argument: 'evidence'") + if p_e_given_h is None: + raise TypeError("infer() missing required keyword argument: 'p_e_given_h'") + if isinstance(evidence, str): + evidence = Claim(evidence) + if not isinstance(evidence, Claim): + raise TypeError("infer() evidence must be a Claim or string") + if not isinstance(hypothesis, Claim): + raise TypeError("infer() hypothesis must be a Claim") + given_tuple = _as_given_tuple(given) + if any(not isinstance(item, Claim) for item in given_tuple): + raise TypeError("infer() given entries must be Claims") + return evidence, hypothesis, given_tuple + + +def _infer_relation( + *, + hypothesis: Claim, + evidence: Claim, + given_tuple: tuple[Claim, ...], + p_e_given_h: float | Claim | None, + p_e_given_not_h: float | Claim | None, +) -> dict[str, Any]: + relation: dict[str, Any] = { + "type": "infer", + "hypothesis": hypothesis, + "evidence": evidence, + "p_e_given_h": p_e_given_h, + "p_e_given_not_h": p_e_given_not_h, + } + if given_tuple: + relation["given"] = given_tuple + return relation + + +def infer( + evidence: Claim | str | None = None, + *args: Any, + hypothesis: Claim | None = None, + given: Claim | tuple[Claim, ...] | list[Claim] | None = (), + background: list[Knowledge] | None = None, + p_e_given_h: float | Claim | None = None, + p_e_given_not_h: float | Claim | None = 0.5, + rationale: str = "", + label: str | None = None, + **legacy_kwargs: Any, +) -> Claim | Strategy: + """Bayesian inference. Returns the evidence Claim. + + The canonical v6 shape is ``infer(evidence, hypothesis=..., ...)``. The old + v5 ``infer([premises], conclusion, ...)`` form is preserved as a deprecated + compatibility path. + """ + legacy_evidence = cast(Any, evidence) + if isinstance(legacy_evidence, (list, tuple)): + return _legacy_infer(legacy_evidence, args, legacy_kwargs) + + if args: + raise TypeError("v6 infer() accepts only one positional evidence argument") + if legacy_kwargs: + unexpected = next(iter(legacy_kwargs)) + raise TypeError(f"infer() got an unexpected keyword argument: '{unexpected}'") + evidence = _resolve_evidence(evidence) + evidence, hypothesis, given_tuple = _validate_infer_claims( + hypothesis=hypothesis, + evidence=evidence, + p_e_given_h=p_e_given_h, + given=given, + ) + assert p_e_given_h is not None + relation = _infer_relation( + hypothesis=hypothesis, + evidence=evidence, + given_tuple=given_tuple, + p_e_given_h=p_e_given_h, + p_e_given_not_h=p_e_given_not_h, + ) + helper = Claim( + f"{_claim_ref(evidence)} statistically supports {_claim_ref(hypothesis)}.", + metadata={ + "generated": True, + "helper_kind": "likelihood", + "review": True, + "relation": relation, + }, + ) + action = InferAction( + label=label, + rationale=rationale, + background=list(background or []), + hypothesis=hypothesis, + evidence=evidence, + given=given_tuple, + p_e_given_h=p_e_given_h, + p_e_given_not_h=p_e_given_not_h, + helper=helper, + ) + action.warrants.append(helper) + validate_no_self_warrant(action, evidence) + attach_reasoning(evidence, action) + return evidence diff --git a/gaia/engine/lang/dsl/knowledge.py b/gaia/engine/lang/dsl/knowledge.py new file mode 100644 index 000000000..52f1eb659 --- /dev/null +++ b/gaia/engine/lang/dsl/knowledge.py @@ -0,0 +1,212 @@ +"""Gaia Lang v5/v6 — Knowledge DSL functions.""" + +from __future__ import annotations + +import warnings +from typing import Any + +from gaia.engine.lang.dsl.bool_expr import BoolExpr +from gaia.engine.lang.runtime import Claim, Knowledge, Note, Question +from gaia.engine.lang.runtime.knowledge import ClaimKind + + +def _metadata_with_legacy_kind(metadata: dict[str, Any], legacy_kind: str) -> dict[str, Any]: + flattened = dict(_flatten_metadata(metadata)) + flattened.setdefault("legacy_kind", legacy_kind) + return flattened + + +def _warn_deprecated_note_alias(name: str) -> None: + warnings.warn( + f"{name}() is deprecated for v0.5+ authoring; use note() instead.", + DeprecationWarning, + stacklevel=2, + ) + + +def note( + content: str, + *, + title: str | None = None, + format: str = "markdown", + **metadata: Any, +) -> Note: + """Declare non-probabilistic contextual material.""" + provenance = metadata.pop("provenance", None) + return Note( + content=content.strip(), + format=format, + title=title, + provenance=provenance or [], + metadata=_flatten_metadata(metadata), + ) + + +def context( + content: str, + *, + title: str | None = None, + format: str = "markdown", + **metadata: Any, +) -> Note: + """Deprecated compatibility wrapper for note().""" + _warn_deprecated_note_alias("context") + provenance = metadata.pop("provenance", None) + return Note( + content=content.strip(), + format=format, + title=title, + provenance=provenance or [], + metadata=_metadata_with_legacy_kind(metadata, "context"), + ) + + +def setting( + content: str, + *, + title: str | None = None, + format: str = "markdown", + **metadata: Any, +) -> Note: + """Deprecated compatibility wrapper for note().""" + _warn_deprecated_note_alias("setting") + provenance = metadata.pop("provenance", None) + return Note( + content=content.strip(), + format=format, + title=title, + provenance=provenance or [], + metadata=_metadata_with_legacy_kind(metadata, "setting"), + ) + + +def question( + content: str, + *, + title: str | None = None, + format: str = "markdown", + **metadata: Any, +) -> Question: + """Declare a research question. No probability, no BP participation.""" + provenance = metadata.pop("provenance", None) + targets = metadata.pop("targets", []) + return Question( + content=content.strip(), + format=format, + title=title, + targets=targets, + provenance=provenance or [], + metadata=_flatten_metadata(metadata), + ) + + +def _flatten_metadata(metadata: dict[str, Any]) -> dict[str, Any]: + """Unwrap nested metadata={"metadata": {...}} into a flat dict.""" + if "metadata" in metadata and isinstance(metadata["metadata"], dict) and len(metadata) == 1: + return metadata["metadata"] + return metadata + + +def claim( + content: str, + proposition: BoolExpr | None = None, + *, + title: str | None = None, + format: str = "markdown", + background: list[Knowledge] | None = None, + parameters: list[dict[str, Any]] | None = None, + provenance: list[dict[str, str]] | None = None, + prior: float | None = None, + formula: Any = None, + kind: ClaimKind = ClaimKind.GENERAL, + tolerance: float | None = None, + **metadata: Any, +) -> Claim: + """Declare a scientific assertion. + + Three authoring shapes: + + 1. **Prose claim** — ``claim("Heliocentric model is correct.", prior=0.8)``. + The proposition is conveyed in natural language. The optional ``prior`` + keyword is a low-priority shortcut routed through ``register_prior()`` + with ``source_id="claim_inline"``. + 2. **Predicate claim** — ``claim("Reaction is fast", k > 1e-2)``. The + second positional argument is a :class:`BoolExpr` produced by + comparing a :class:`Distribution` against a constant. The compiler + registers a CDF-derived prior record for inequality predicates. + See :class:`gaia.engine.lang.Distribution` for how to declare the + underlying continuous quantity. + 3. **Formula claim** — ``claim(content, formula=Forall(...))`` for the + predicate-logic surface (unchanged from v0.5). + + The ``tolerance`` keyword applies only when ``proposition`` is an equation + (``lhs == rhs``). PR1 stores equation metadata and a neutral default prior; + equation constraint lowering is deferred. + """ + raw_metadata = _flatten_metadata(metadata) + if proposition is not None: + if not isinstance(proposition, BoolExpr): + raise TypeError( + "claim() second positional argument must be a BoolExpr produced " + "by comparing a Distribution against another value (e.g. " + "k > 1e-2). Got " + f"{type(proposition).__name__}. For prose claims, omit the " + "second argument; for predicate-logic claims, use the " + "`formula=` keyword." + ) + # The full lowering of predicate / equation propositions to claim + # priors happens in `gaia.engine.lang.compiler.compile`; here we just stash + # the BoolExpr on metadata for the compiler to read. + if "predicate" in raw_metadata or "equation" in raw_metadata: + raise TypeError( + "claim() received both a proposition argument and a manually " + "set metadata['predicate'] / metadata['equation'] entry — pick " + "one." + ) + slot = "equation" if proposition.op in {"==", "!="} else "predicate" + raw_metadata = dict(raw_metadata) + raw_metadata[slot] = proposition + if tolerance is not None: + if slot != "equation": + raise TypeError( + "claim(tolerance=...) only applies to equation propositions " + "(``y == baseline + slope * x``); for inequality predicates the " + "prior is exact via CDF integration." + ) + if not isinstance(tolerance, (int, float)) or float(tolerance) <= 0.0: + raise ValueError( + f"claim(tolerance=...) must be a positive number, got {tolerance!r}." + ) + raw_metadata["equation_tolerance"] = float(tolerance) + elif tolerance is not None: + raise TypeError( + "claim(tolerance=...) requires a proposition (equation BoolExpr). " + "It does nothing on a prose or formula claim." + ) + c = Claim( + content=content.strip(), + format=format, + title=title, + background=background or [], + parameters=parameters or [], + provenance=provenance or [], + prior=None, + formula=formula, + kind=kind, + metadata=raw_metadata, + ) + if prior is not None: + # Route through register_prior so the inline value participates in the + # same multi-source PriorRecord pipeline as everything else. The + # "claim_inline" shortcut is intentionally low priority, below + # generated continuous-inference priors and documented register_prior() + # calls. + from gaia.engine.lang.dsl.register_prior import register_prior + + register_prior( + c, + prior, + source_id="claim_inline", + justification="(inline default declared at claim() call site)", + ) + return c diff --git a/gaia/lang/dsl/operators.py b/gaia/engine/lang/dsl/operators.py similarity index 74% rename from gaia/lang/dsl/operators.py rename to gaia/engine/lang/dsl/operators.py index ca914bae5..39211cad5 100644 --- a/gaia/lang/dsl/operators.py +++ b/gaia/engine/lang/dsl/operators.py @@ -3,10 +3,11 @@ from __future__ import annotations import math +import warnings from typing import Any -from gaia.ir.parameterization import CROMWELL_EPS -from gaia.lang.runtime import Knowledge, Operator +from gaia.engine.ir.parameterization import CROMWELL_EPS +from gaia.engine.lang.runtime import Knowledge, Operator def _validate_prior_range(prior: float | None) -> None: @@ -35,17 +36,30 @@ def _validate_reason_prior(reason: str | Any, prior: float | None) -> None: _validate_prior_range(prior) -def _helper_metadata(helper_kind: str, prior: float | None) -> dict: - meta: dict = {"helper_kind": helper_kind} +def _helper_metadata(helper_kind: str, prior: float | None) -> dict[str, Any]: + meta: dict[str, Any] = {"helper_kind": helper_kind} if prior is not None: meta["prior"] = prior return meta +def _warn_deprecated_operator(function_name: str, replacement: str) -> None: + warnings.warn( + f"{function_name}() is deprecated; use {replacement}", + DeprecationWarning, + stacklevel=3, + ) + + def contradiction( a: Knowledge, b: Knowledge, *, reason: str = "", prior: float | None = None ) -> Knowledge: """not(A and B). Creates Operator, returns helper claim.""" + _warn_deprecated_operator( + "contradiction", + "contradict(a, b, rationale=...) for reviewable relations or " + "claim(formula=lnot(land(ClaimAtom(...), ...))) for structural formulas", + ) _validate_reason_prior(reason, prior) helper = Knowledge( content=f"not_both_true({a.label or 'A'}, {b.label or 'B'})", @@ -60,6 +74,11 @@ def equivalence( a: Knowledge, b: Knowledge, *, reason: str = "", prior: float | None = None ) -> Knowledge: """A = B. Creates Operator, returns helper claim.""" + _warn_deprecated_operator( + "equivalence", + "equal(a, b, rationale=...) for reviewable relations or " + "claim(formula=iff(ClaimAtom(...), ClaimAtom(...))) for structural formulas", + ) _validate_reason_prior(reason, prior) helper = Knowledge( content=f"same_truth({a.label or 'A'}, {b.label or 'B'})", @@ -74,6 +93,11 @@ def complement( a: Knowledge, b: Knowledge, *, reason: str = "", prior: float | None = None ) -> Knowledge: """A != B (XOR). Creates Operator, returns helper claim.""" + _warn_deprecated_operator( + "complement", + "exclusive(a, b, rationale=...) for reviewable relations or " + "claim(formula=lnot(iff(ClaimAtom(...), ClaimAtom(...)))) for structural formulas", + ) _validate_reason_prior(reason, prior) helper = Knowledge( content=f"opposite_truth({a.label or 'A'}, {b.label or 'B'})", @@ -86,6 +110,10 @@ def complement( def disjunction(*claims: Knowledge, reason: str = "", prior: float | None = None) -> Knowledge: """At least one true. Creates Operator, returns helper claim.""" + _warn_deprecated_operator( + "disjunction", + "claim(formula=lor(ClaimAtom(...), ...)) for structural formulas", + ) _validate_reason_prior(reason, prior) labels = ", ".join(c.label or f"C{i}" for i, c in enumerate(claims)) helper = Knowledge( diff --git a/gaia/engine/lang/dsl/propositional.py b/gaia/engine/lang/dsl/propositional.py new file mode 100644 index 000000000..ec2b51a02 --- /dev/null +++ b/gaia/engine/lang/dsl/propositional.py @@ -0,0 +1,68 @@ +"""Legacy propositional expression helpers.""" + +from __future__ import annotations + +import warnings + +from gaia.engine.lang.runtime.knowledge import Claim +from gaia.engine.lang.runtime.nodes import Operator + + +def _claim_ref(claim: Claim) -> str: + if claim.label: + return f"[@{claim.label}]" + return claim.content + + +def _expression_helper(content: str, helper_kind: str) -> Claim: + return Claim( + content, + metadata={"generated": True, "helper_kind": helper_kind, "review": False}, + ) + + +def _validate_claims(claims: tuple[Claim, ...], function_name: str) -> None: + for claim in claims: + if not isinstance(claim, Claim): + raise TypeError(f"{function_name}() arguments must be Claim objects") + + +def _warn_deprecated_helper(function_name: str, replacement: str) -> None: + warnings.warn( + f"{function_name}() is deprecated; use {replacement} for structured formula claims", + DeprecationWarning, + stacklevel=3, + ) + + +def not_(claim: Claim) -> Claim: + """Construct the Boolean negation expression ``not claim``.""" + _warn_deprecated_helper("not_", "claim(formula=lnot(ClaimAtom(...)))") + _validate_claims((claim,), "not_") + helper = _expression_helper(f"not({_claim_ref(claim)})", "negation_result") + Operator(operator="negation", variables=[claim], conclusion=helper) + return helper + + +def and_(*claims: Claim) -> Claim: + """Construct a Boolean conjunction expression over two or more Claims.""" + _warn_deprecated_helper("and_", "claim(formula=land(ClaimAtom(...), ...))") + if len(claims) < 2: + raise ValueError("and_() requires at least two claims") + _validate_claims(claims, "and_") + labels = ", ".join(_claim_ref(claim) for claim in claims) + helper = _expression_helper(f"all_true({labels})", "conjunction_result") + Operator(operator="conjunction", variables=list(claims), conclusion=helper) + return helper + + +def or_(*claims: Claim) -> Claim: + """Construct a Boolean disjunction expression over two or more Claims.""" + _warn_deprecated_helper("or_", "claim(formula=lor(ClaimAtom(...), ...))") + if len(claims) < 2: + raise ValueError("or_() requires at least two claims") + _validate_claims(claims, "or_") + labels = ", ".join(_claim_ref(claim) for claim in claims) + helper = _expression_helper(f"any_true({labels})", "disjunction_result") + Operator(operator="disjunction", variables=list(claims), conclusion=helper) + return helper diff --git a/gaia/engine/lang/dsl/register_prior.py b/gaia/engine/lang/dsl/register_prior.py new file mode 100644 index 000000000..9d3cdd6a7 --- /dev/null +++ b/gaia/engine/lang/dsl/register_prior.py @@ -0,0 +1,251 @@ +"""register_prior — explicit prior registration with multi-source support. + +Also exposes :func:`resolve_priors_to_metadata`, the pure-computation step +that walks a sequence of Claim objects, runs the supplied +:class:`gaia.engine.ir.ResolutionPolicy` over each claim's ``prior_records``, and +writes the winner to ``metadata['prior']``, ``metadata['prior_justification']``, +and ``metadata['prior_source_id']``. This step is invoked both by the CLI's +``apply_package_priors`` (with the package-level ``RESOLUTION_POLICY``) and by +``compile_package_artifact`` (idempotently, with a safety-net default policy +for callers that bypass the CLI). + +This is the canonical way to attach a load-bearing prior to a Claim in Gaia +v0.5+. The ``claim(prior=...)`` kwarg remains as a low-priority compatibility +shortcut that internally records a ``source_id="claim_inline"`` PriorRecord. +The legacy ``PRIORS = {...}`` dict in ``priors.py`` is rejected at compile time +with a migration error. + +Multiple priors may be registered for the same Claim from different sources +(``"user_priors"`` for the author, ``"continuous_inference"`` for +``#581``-style engines, ``"reviewer_"`` for human reviewers, etc.). +The compile-time ``ResolutionPolicy`` picks the winner while preserving the +losing records for audit (see ``gaia.engine.ir.parameterization.ResolutionPolicy`` +and the ``prior_dissent`` / ``prior_overridden`` diagnostics). + +Records are stored on ``claim.metadata["prior_records"]`` as a list of dicts +(JSON-friendly so they survive IR serialization). The compile-time pipeline +in ``gaia.cli._packages`` reads this list, applies the ResolutionPolicy, and +writes the winning value/source/justification to metadata for downstream BP / +render / brief consumers — none of which have to change. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from gaia.engine.ir.parameterization import CROMWELL_EPS +from gaia.engine.lang.runtime.knowledge import Claim + +PRIOR_RECORDS_METADATA_KEY = "prior_records" +"""Metadata key under which the per-claim list of PriorRecord dicts is stored.""" + +DEFAULT_SOURCE_ID = "user_priors" +"""Default source_id used when an author calls ``register_prior`` without +specifying one. Engines, reviewers, and agents must pass an explicit +source_id matching their namespace (e.g. ``"continuous_inference"``, +``"reviewer_alice"``, ``"agent_xyz"``).""" + + +def register_prior( + claim: Claim, + value: float, + *, + justification: str, + source_id: str = DEFAULT_SOURCE_ID, + created_at: datetime | None = None, +) -> None: + """Register a prior probability for a claim from a named source. + + This is the canonical (and after v0.5, the only) way to attach a prior to + a Claim. The author writes register_prior calls in ``priors.py`` (auto- + imported by ``gaia build compile``) or anywhere else in the package; engines and + reviewers use the same API with an appropriate ``source_id``. + + Args: + claim: The Claim instance to attach the prior to. + value: Prior probability. Must be inside the Cromwell bounds + ``[CROMWELL_EPS, 1 - CROMWELL_EPS]`` — values outside this range + are rejected with ValueError (no silent clamping; engines writing + extreme values almost always indicate a bug). + justification: Required non-empty rationale string. Empty or + whitespace-only justifications are rejected. + source_id: Source identifier; defaults to ``"user_priors"`` for + author-written priors. Engines, reviewers, and agents must pass + an explicit ``source_id`` so the ResolutionPolicy can rank them. + Common namespaces: ``"user_priors"``, ``"continuous_inference"``, + ``"reviewer_*"``, ``"calibration_*"``, ``"agent_*"``, + ``"evidence_factor_*"``. + created_at: Optional explicit timestamp. Defaults to ``datetime.now(UTC)``. + Provide an explicit value for reproducible package builds or when + registering historical priors. + + Raises: + TypeError: If ``claim`` is not a Claim instance, or ``value`` is not a + numeric scalar (booleans are explicitly rejected to catch mistakes + like ``register_prior(c, True)``). + ValueError: If ``value`` is outside Cromwell bounds, ``source_id`` is + empty/whitespace, or ``justification`` is empty/whitespace. + + Examples: + Author writes in ``priors.py``:: + + from gaia.engine.lang import register_prior + from . import aristotle_model, medium_model + + register_prior(aristotle_model, 0.5, + justification="Neutral before the thought experiment.") + register_prior(medium_model, 0.5, + justification="Neutral before the thought experiment.") + + Reviewer writes alternative priors in ``priors_reviewer_alice.py``:: + + register_prior(aristotle_model, 0.05, + source_id="reviewer_alice", + justification="Tied-body argument is decisive against A.") + """ + if not isinstance(claim, Claim): + raise TypeError( + f"register_prior() claim must be a Claim instance, " + f"got {type(claim).__name__}. Pass the Claim object returned by " + f"claim(), not its label or content string." + ) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError( + f"register_prior() value must be a numeric scalar in " + f"[{CROMWELL_EPS}, {1 - CROMWELL_EPS}], " + f"got {type(value).__name__}: {value!r}." + ) + value_f = float(value) + if value_f != value_f: # NaN check + raise ValueError("register_prior() value must be finite, got NaN.") + if value_f < CROMWELL_EPS or value_f > 1 - CROMWELL_EPS: + label = claim.label or claim.content[:40] + raise ValueError( + f"register_prior({label!r}, value={value_f}) outside Cromwell bounds " + f"[{CROMWELL_EPS}, {1 - CROMWELL_EPS}]. " + f"Values at the boundary almost always indicate a bug or an " + f"observation that should be expressed via observe() instead. " + f"Use observe(claim) to pin a claim to true (1 - CROMWELL_EPS), " + f"or contradict() to pin it to false." + ) + if not isinstance(source_id, str) or not source_id.strip(): + raise ValueError( + f"register_prior() source_id must be a non-empty string, " + f"got {source_id!r}. Use the default 'user_priors' for author-" + f"written priors, or a namespaced id like 'continuous_inference', " + f"'reviewer_alice', 'calibration_2026q2'." + ) + if not isinstance(justification, str) or not justification.strip(): + label = claim.label or claim.content[:40] + raise ValueError( + f"register_prior({label!r}) requires a non-empty justification. " + f"Setting a prior is methodologically heavy; document the source / " + f"reasoning so future reviewers can audit the choice." + ) + + record = { + "value": value_f, + "source_id": source_id.strip(), + "justification": justification.strip(), + "created_at": (created_at or datetime.now(UTC)).isoformat(), + } + + records = claim.metadata.setdefault(PRIOR_RECORDS_METADATA_KEY, []) + if not isinstance(records, list): + raise TypeError( + f"register_prior() found existing claim.metadata[{PRIOR_RECORDS_METADATA_KEY!r}] " + f"of type {type(records).__name__}, expected list. The metadata key is " + f"reserved by register_prior — do not write it directly." + ) + records.append(record) + + +def get_prior_records(claim: Claim) -> list[dict[str, Any]]: + """Return the list of prior records registered on a Claim, or empty list. + + Returned dicts are JSON-shaped: ``value`` (float), ``source_id`` (str), + ``justification`` (str), ``created_at`` (ISO-8601 str). + """ + if not isinstance(claim, Claim): + raise TypeError(f"get_prior_records() expects a Claim, got {type(claim).__name__}.") + records = claim.metadata.get(PRIOR_RECORDS_METADATA_KEY, []) + if not isinstance(records, list): + return [] + return list(records) + + +def resolve_priors_to_metadata( + knowledges: Any, + policy: Any, +) -> None: + """Run ``policy.resolve()`` over every Claim's prior_records in-place. + + Walks the supplied ``knowledges`` iterable (typically + ``CollectedPackage.knowledge``), and for each :class:`Claim` with one or + more dict records under ``metadata['prior_records']`` constructs the + corresponding :class:`gaia.engine.ir.PriorRecord` instances, asks the supplied + :class:`gaia.engine.ir.ResolutionPolicy` for the winner, and writes the winner's + value/justification/source to ``metadata['prior']`` / + ``metadata['prior_justification']`` / ``metadata['prior_source_id']``. All + records (winner and losers) stay in ``prior_records`` for downstream audit, + ``gaia build check --hole`` display, and the ``prior_dissent`` / + ``prior_overridden`` diagnostics. + + Idempotent: re-running the same policy over the same records produces the + same winner because ``prior_records`` is not mutated. + + Raises: + ValueError: If a record has malformed ``created_at`` (not a string or + datetime). + TypeError: If ``metadata['prior_records']`` is not a list. + """ + for knowledge in knowledges: + if not isinstance(knowledge, Claim): + continue + records_data = knowledge.metadata.get(PRIOR_RECORDS_METADATA_KEY) + if not records_data: + continue + if not isinstance(records_data, list): + label = knowledge.label or knowledge.content[:40] + raise TypeError( + f"Claim {label!r} has metadata[{PRIOR_RECORDS_METADATA_KEY!r}] " + f"of type {type(records_data).__name__}, expected list. The " + "metadata key is reserved by register_prior() — do not write " + "it directly." + ) + kid = knowledge.label or knowledge.content[:40] + records = [_record_from_dict(r, knowledge_id=kid) for r in records_data] + winner = policy.resolve(records) + if winner is None: + continue + knowledge.metadata["prior"] = winner.value + knowledge.metadata["prior_justification"] = winner.justification + knowledge.metadata["prior_source_id"] = winner.source_id + + +def _record_from_dict(record_data: dict[str, Any], *, knowledge_id: str) -> Any: + """Convert a register_prior metadata dict into a PriorRecord for resolution.""" + from gaia.engine.ir.parameterization import PriorRecord + + raw_created_at = record_data.get("created_at") + if isinstance(raw_created_at, str): + created_at = datetime.fromisoformat(raw_created_at) + elif isinstance(raw_created_at, datetime): + created_at = raw_created_at + elif raw_created_at is None: + # IR-side records have created_at stripped to keep ir_hash stable; + # use epoch so they sort below freshly-registered records. + created_at = datetime(1970, 1, 1, tzinfo=UTC) + else: + raise ValueError( + f"prior_records[{knowledge_id!r}] entry has malformed created_at " + f"({type(raw_created_at).__name__})." + ) + return PriorRecord( + knowledge_id=knowledge_id, + value=float(record_data["value"]), + source_id=str(record_data["source_id"]), + justification=str(record_data.get("justification", "")), + created_at=created_at, + ) diff --git a/gaia/engine/lang/dsl/relate.py b/gaia/engine/lang/dsl/relate.py new file mode 100644 index 000000000..6521c7fb6 --- /dev/null +++ b/gaia/engine/lang/dsl/relate.py @@ -0,0 +1,96 @@ +"""Gaia Lang v6 structural relation verbs: equal, contradict, exclusive.""" + +from __future__ import annotations + +from gaia.engine.lang.runtime.action import ( + Contradict, + Equal, + Exclusive, + attach_reasoning, + validate_no_self_warrant, +) +from gaia.engine.lang.runtime.knowledge import Claim, Knowledge + + +def _claim_ref(claim: Claim) -> str: + if claim.label: + return f"[@{claim.label}]" + return claim.content + + +def equal( + a: Claim, + b: Claim, + *, + background: list[Knowledge] | None = None, + rationale: str = "", + label: str | None = None, +) -> Claim: + """Declare two Claims equivalent. Returns an equivalence helper Claim.""" + helper = Claim( + f"{_claim_ref(a)} and {_claim_ref(b)} are equivalent.", + metadata={"generated": True, "helper_kind": "equivalence_result", "review": True}, + ) + action = Equal( + label=label, + rationale=rationale, + background=list(background or []), + a=a, + b=b, + helper=helper, + ) + validate_no_self_warrant(action, helper) + attach_reasoning(helper, action) + return helper + + +def contradict( + a: Claim, + b: Claim, + *, + background: list[Knowledge] | None = None, + rationale: str = "", + label: str | None = None, +) -> Claim: + """Declare two Claims contradictory. Returns a contradiction helper Claim.""" + helper = Claim( + f"{_claim_ref(a)} and {_claim_ref(b)} contradict.", + metadata={"generated": True, "helper_kind": "contradiction_result", "review": True}, + ) + action = Contradict( + label=label, + rationale=rationale, + background=list(background or []), + a=a, + b=b, + helper=helper, + ) + validate_no_self_warrant(action, helper) + attach_reasoning(helper, action) + return helper + + +def exclusive( + a: Claim, + b: Claim, + *, + background: list[Knowledge] | None = None, + rationale: str = "", + label: str | None = None, +) -> Claim: + """Declare two Claims as a closed binary partition. Returns an XOR helper Claim.""" + helper = Claim( + f"exactly one of {_claim_ref(a)} and {_claim_ref(b)} is true.", + metadata={"generated": True, "helper_kind": "complement_result", "review": True}, + ) + action = Exclusive( + label=label, + rationale=rationale, + background=list(background or []), + a=a, + b=b, + helper=helper, + ) + validate_no_self_warrant(action, helper) + attach_reasoning(helper, action) + return helper diff --git a/gaia/engine/lang/dsl/scaffold.py b/gaia/engine/lang/dsl/scaffold.py new file mode 100644 index 000000000..a8c28719e --- /dev/null +++ b/gaia/engine/lang/dsl/scaffold.py @@ -0,0 +1,266 @@ +"""Gaia Lang v6 scaffold verbs.""" + +from __future__ import annotations + +from dataclasses import fields, is_dataclass +from typing import TYPE_CHECKING, Any + +from gaia.engine.lang.runtime.action import ( + Associate, + CandidateRelation, + Contradict, + DependsOn, + Equal, + Exclusive, + GaiaGraph, + MaterializationLink, + Scaffold, +) +from gaia.engine.lang.runtime.knowledge import Claim, Knowledge + +if TYPE_CHECKING: + from gaia.engine.lang.runtime.package import CollectedPackage + +_CANDIDATE_RELATION_KINDS = frozenset( + { + "equal", + "contradict", + "exclusive", + } +) + + +def _as_claim_tuple(given: Claim | tuple[Claim, ...] | list[Claim]) -> tuple[Claim, ...]: + if isinstance(given, Knowledge): + return (given,) + return tuple(given) + + +def depends_on( + conclusion: Claim, + *, + given: Claim | tuple[Claim, ...] | list[Claim], + background: list[Knowledge] | None = None, + rationale: str = "", + label: str | None = None, + metadata: dict[str, Any] | None = None, +) -> DependsOn: + """Record unformalized load-bearing dependencies for a Claim.""" + if not isinstance(conclusion, Claim): + raise TypeError("depends_on conclusion must be a Claim") + given_tuple = _as_claim_tuple(given) + if not given_tuple: + raise ValueError("depends_on requires at least one given Claim") + if any(not isinstance(item, Claim) for item in given_tuple): + raise TypeError("depends_on given entries must be Claims") + return DependsOn( + label=label, + rationale=rationale, + background=list(background or []), + metadata=dict(metadata or {}), + conclusion=conclusion, + given=given_tuple, + ) + + +def candidate_relation( + *, + claims: list[Claim] | tuple[Claim, ...], + pattern: str | None = None, + background: list[Knowledge] | None = None, + rationale: str = "", + label: str | None = None, + metadata: dict[str, Any] | None = None, +) -> CandidateRelation: + """Record a hypothesized relation without triggering formal semantics.""" + claim_tuple = tuple(claims) + if len(claim_tuple) < 2: + raise ValueError("candidate_relation requires at least two Claims") + if any(not isinstance(item, Claim) for item in claim_tuple): + raise TypeError("candidate_relation claims entries must be Claims") + if pattern is not None and pattern not in _CANDIDATE_RELATION_KINDS: + allowed = ", ".join(sorted(_CANDIDATE_RELATION_KINDS)) + raise ValueError(f"candidate_relation pattern must be one of: {allowed}") + if pattern == "contradict" and len(claim_tuple) != 2: + raise ValueError('candidate_relation(pattern="contradict") requires exactly two Claims') + return CandidateRelation( + label=label, + rationale=rationale, + background=list(background or []), + metadata=dict(metadata or {}), + claims=claim_tuple, + pattern=pattern, + ) + + +def materialize( + scaffold: Scaffold, + *, + by: GaiaGraph + | Claim + | str + | list[GaiaGraph | Claim | str] + | tuple[GaiaGraph | Claim | str, ...], + rationale: str = "", + label: str | None = None, + metadata: dict[str, Any] | None = None, +) -> MaterializationLink: + """Record a checked link from scaffold to formal graph records.""" + if not isinstance(scaffold, Scaffold): + raise TypeError("materialize scaffold must be a Scaffold") + pkg = _materialization_package(scaffold) + records = _resolve_materializers(scaffold, by) + if not records: + raise ValueError("materialize requires at least one by record") + for record in records: + if isinstance(record, Scaffold): + raise TypeError("materialize by records must not be Scaffold records") + if record._package is not pkg or record not in pkg.actions: + raise ValueError("materialize by records must belong to the scaffold package") + core_claims = _scaffold_core_claims(scaffold) + core_ids = {id(claim) for claim in core_claims} + if not any(_record_references_claims(record, core_ids) for record in records): + raise ValueError("materialize by records must reference at least one scaffold core claims") + _validate_pattern_consistency(scaffold, records) + + link = MaterializationLink( + scaffold=scaffold, + by=records, + label=label, + rationale=rationale, + metadata=dict(metadata or {}), + ) + pkg._register_materialization(link) + return link + + +def _materialization_package(scaffold: Scaffold) -> CollectedPackage: + pkg = scaffold._package + if pkg is None: + from gaia.engine.lang.runtime.knowledge import _current_package + + pkg = _current_package.get() + if pkg is None or scaffold._package is not pkg or scaffold not in pkg.actions: + raise ValueError("materialize requires a scaffold registered in a package") + return pkg + + +def _resolve_materializers( + scaffold: Scaffold, + by: GaiaGraph + | Claim + | str + | list[GaiaGraph | Claim | str] + | tuple[GaiaGraph | Claim | str, ...], +) -> tuple[GaiaGraph, ...]: + items = by if isinstance(by, list | tuple) else (by,) + return tuple(_resolve_materializer(scaffold, item) for item in items) + + +def _resolve_materializer(scaffold: Scaffold, item: GaiaGraph | Claim | str) -> GaiaGraph: + if isinstance(item, GaiaGraph): + return item + if isinstance(item, Claim): + producers = [ + action + for action in item.from_actions + # Defensive: scaffold records should not be claim attachments. + if isinstance(action, GaiaGraph) and not isinstance(action, Scaffold) + ] + if len(producers) == 1: + return producers[0] + if not producers: + raise ValueError("materialize by Claim has no producing graph record") + raise ValueError("materialize by Claim is ambiguous; use a graph label") + if isinstance(item, str): + pkg = scaffold._package + if pkg is None: + from gaia.engine.lang.runtime.knowledge import _current_package + + pkg = _current_package.get() + if pkg is None: + raise ValueError( + "materialize by label requires an active package or registered scaffold" + ) + matches = [action for action in pkg.actions if getattr(action, "label", None) == item] + if len(matches) == 1: + return matches[0] + if not matches: + raise ValueError(f"materialize could not resolve graph label {item!r}") + raise ValueError(f"materialize graph label {item!r} is ambiguous") + raise TypeError( + f"materialize by entries must be GaiaGraph, Claim, or str, got {type(item).__name__}" + ) + + +def _scaffold_core_claims(scaffold: Scaffold) -> tuple[Claim, ...]: + if isinstance(scaffold, DependsOn): + claims = [scaffold.conclusion, *scaffold.given] + return tuple(claim for claim in claims if isinstance(claim, Claim)) + if isinstance(scaffold, CandidateRelation): + return scaffold.claims + raise TypeError(f"{type(scaffold).__name__} does not define scaffold core claims") + + +def _record_references_claims(record: GaiaGraph, claim_ids: set[int]) -> bool: + return any(id(claim) in claim_ids for claim in _iter_record_claims(record)) + + +def _iter_record_claims(value: Any) -> tuple[Claim, ...]: + """Find semantic claim references for materialization core-claim checks. + + Ambient fields such as background, warrants, and metadata do not count as + references that formalize a scaffold obligation. + """ + seen: set[int] = set() + claims: list[Claim] = [] + + def visit(item: Any, *, field_name: str | None = None) -> None: + if field_name in {"background", "warrants", "metadata"}: + return + if isinstance(item, Claim): + key = id(item) + if key not in seen: + seen.add(key) + claims.append(item) + return + if isinstance(item, dict): + for child in item.values(): + visit(child) + return + if isinstance(item, list | tuple | set | frozenset): + for child in item: + visit(child) + return + if is_dataclass(item): + for data_field in fields(item): + if data_field.name.startswith("_"): + continue + visit(getattr(item, data_field.name), field_name=data_field.name) + + visit(value) + return tuple(claims) + + +def _relation_pattern(record: GaiaGraph) -> str | None: + if isinstance(record, Equal): + return "equal" + if isinstance(record, Contradict): + return "contradict" + if isinstance(record, Exclusive): + return "exclusive" + if isinstance(record, Associate): + return record.pattern + return None + + +def _validate_pattern_consistency(scaffold: Scaffold, records: tuple[GaiaGraph, ...]) -> None: + if not isinstance(scaffold, CandidateRelation) or scaffold.pattern is None: + return + for record in records: + pattern = _relation_pattern(record) + if pattern is not None and pattern != scaffold.pattern: + raise ValueError( + f"materialize pattern mismatch: scaffold pattern {scaffold.pattern!r} " + f"cannot be materialized by {pattern!r}" + ) diff --git a/gaia/lang/dsl/strategies.py b/gaia/engine/lang/dsl/strategies.py similarity index 81% rename from gaia/lang/dsl/strategies.py rename to gaia/engine/lang/dsl/strategies.py index 28ebb5151..01e9fbe1d 100644 --- a/gaia/lang/dsl/strategies.py +++ b/gaia/engine/lang/dsl/strategies.py @@ -4,13 +4,16 @@ import warnings from copy import deepcopy -from typing import Literal +from typing import TYPE_CHECKING, Any, Literal -from gaia.lang.runtime import Knowledge, Step, Strategy -from gaia.lang.runtime.nodes import ReasonInput -from gaia.lang.runtime.nodes import _current_package -from gaia.lang.dsl.operators import _validate_reason_prior -from gaia.lang.runtime.package import infer_package_from_callstack +from gaia.engine.lang.dsl.operators import _validate_reason_prior +from gaia.engine.lang.runtime import Knowledge, Step, Strategy +from gaia.engine.lang.runtime.knowledge import _current_package +from gaia.engine.lang.runtime.nodes import ReasonInput +from gaia.engine.lang.runtime.package import infer_package_from_callstack + +if TYPE_CHECKING: + from gaia.engine.lang.runtime.package import CollectedPackage def _validate_step_premises( @@ -31,7 +34,7 @@ def _validate_step_premises( ) -def _authoring_package(): +def _authoring_package() -> CollectedPackage | None: pkg = _current_package.get() if pkg is None: pkg = infer_package_from_callstack() @@ -53,7 +56,7 @@ def _named_strategy( conclusion: Knowledge, background: list[Knowledge] | None = None, reason: ReasonInput = "", - metadata: dict | None = None, + metadata: dict[str, Any] | None = None, ) -> Strategy: _validate_step_premises(reason, premises) strategy = Strategy( @@ -100,7 +103,7 @@ def _leaf_strategy( conclusion: Knowledge, background: list[Knowledge] | None = None, reason: ReasonInput = "", - metadata: dict | None = None, + metadata: dict[str, Any] | None = None, ) -> Strategy: _validate_step_premises(reason, premises) strategy = Strategy( @@ -135,9 +138,11 @@ def noisy_and( background: list[Knowledge] | None = None, reason: ReasonInput = "", ) -> Strategy: - """Deprecated: use support() instead. Bypasses reason+prior validation.""" + """Deprecated compatibility wrapper for old noisy-and packages.""" warnings.warn( - "noisy_and() is deprecated, use support() instead", + "noisy_and() is deprecated for v0.5+ authoring; use derive() for " + "deterministic reasoning or infer()/bayes.likelihood() for " + "probabilistic evidence links.", DeprecationWarning, stacklevel=2, ) @@ -169,10 +174,17 @@ def support( author-specified prior on the implication warrant, making it a soft (probabilistic) version of deduction. """ + warnings.warn( + "support() is deprecated for v6 authoring; use derive() with explicit " + "premise Claims for uncertainty. The v5 prior is preserved for " + "compatibility.", + DeprecationWarning, + stacklevel=2, + ) if len(premises) < 1: raise ValueError("support() requires at least 1 premise") _validate_reason_prior(reason, prior) - metadata: dict = {} + metadata: dict[str, Any] = {} if prior is not None: metadata["prior"] = prior return _named_strategy( @@ -206,7 +218,7 @@ def compare( prior -> confidence for the comparison implication warrant. """ _validate_reason_prior(reason, prior) - metadata: dict = {} + metadata: dict[str, Any] = {} if prior is not None: metadata["prior"] = prior comparison_claim = Knowledge( @@ -303,12 +315,14 @@ def deduction( ) -> Strategy: """Deduction lowered via the canonical IR formalizer at compile time. - prior -> confidence for the implication warrant. + ``prior`` is accepted for legacy source compatibility. Current BP lowering + treats deduction as a strict Jaynes implication constraint; review gates + publication quality, not the local inference preview or proof probability. """ if len(premises) < 1: raise ValueError("deduction() requires at least 1 premise") _validate_reason_prior(reason, prior) - metadata: dict | None = None + metadata: dict[str, Any] | None = None if prior is not None: metadata = {"prior": prior} return _named_strategy( @@ -321,29 +335,29 @@ def deduction( ) -def abduction( - support_h: Strategy, - support_alt: Strategy, - comparison: Strategy, - *, - background: list[Knowledge] | None = None, - reason: ReasonInput = "", -) -> Strategy: - """Ternary hypothesis comparison (IBE). +def _composition_warrant(content: str, reason: ReasonInput) -> Knowledge: + metadata: dict[str, Any] = {"helper_kind": "composition_validity", "generated": True} + if isinstance(reason, str) and reason: + metadata["warrant"] = reason + return Knowledge(content=content, type="claim", metadata=metadata) - Takes two support strategies and a compare strategy. - The compare strategy provides the conclusion (comparison_claim). - Args: - support_h: Support for the primary theory. - support_alt: Support for the alternative theory. - comparison: compare(pred_h, pred_alt, obs) strategy. - background: Optional background knowledge. - reason: Warrant text for the composition validity. +def _unique_premises(strategies: list[Strategy]) -> list[Knowledge]: + all_premises: list[Knowledge] = [] + seen: set[int] = set() + for strategy in strategies: + for premise in strategy.premises: + if id(premise) not in seen: + all_premises.append(premise) + seen.add(id(premise)) + return all_premises - Returns: - CompositeStrategy whose conclusion is ``comparison.conclusion``. - """ + +def _validate_abduction_inputs( + support_h: Strategy, + support_alt: Strategy, + comparison: Strategy, +) -> Knowledge: if not isinstance(support_h, Strategy): raise TypeError("abduction() first arg must be a Strategy") if not isinstance(support_alt, Strategy): @@ -365,27 +379,38 @@ def abduction( raise ValueError("abduction() support_alt must conclude the compared observation") if comparison.conclusion is None: raise ValueError("abduction() compare strategy must have a conclusion") + return comparison.conclusion - # Composition warrant - comp_warrant = Knowledge( - content=(f"abduction_validity({support_h.type}, {support_alt.type}, {comparison.type})"), - type="claim", - metadata={"helper_kind": "composition_validity", "generated": True}, - ) - if isinstance(reason, str) and reason: - comp_warrant.metadata["warrant"] = reason - # Gather unique premises from all three sub-strategies - all_premises: list[Knowledge] = [] - seen: set[int] = set() - for s in [support_h, support_alt, comparison]: - for p in s.premises: - if id(p) not in seen: - all_premises.append(p) - seen.add(id(p)) +def abduction( + support_h: Strategy, + support_alt: Strategy, + comparison: Strategy, + *, + background: list[Knowledge] | None = None, + reason: ReasonInput = "", +) -> Strategy: + """Ternary hypothesis comparison (IBE). - # Conclusion comes from the comparison strategy - conclusion = comparison.conclusion + Takes two support strategies and a compare strategy. + The compare strategy provides the conclusion (comparison_claim). + + Args: + support_h: Support for the primary theory. + support_alt: Support for the alternative theory. + comparison: compare(pred_h, pred_alt, obs) strategy. + background: Optional background knowledge. + reason: Warrant text for the composition validity. + + Returns: + CompositeStrategy whose conclusion is ``comparison.conclusion``. + """ + conclusion = _validate_abduction_inputs(support_h, support_alt, comparison) + comp_warrant = _composition_warrant( + f"abduction_validity({support_h.type}, {support_alt.type}, {comparison.type})", + reason, + ) + all_premises = _unique_premises([support_h, support_alt, comparison]) strategy = Strategy( type="abduction", @@ -512,28 +537,15 @@ def composite( ) -def induction( +def _support_has_law_as_premise(strategy: Strategy, law: Knowledge) -> bool: + return any(p is law for p in strategy.premises) + + +def _validate_induction_inputs( support_1: Strategy, support_2: Strategy, law: Knowledge, - *, - background: list[Knowledge] | None = None, - reason: ReasonInput = "", -) -> Strategy: - """Binary CompositeStrategy: two supports jointly confirm a law. - - Chains via ``induction(prev_induction, new_support, law)``. - - Args: - support_1: First support (FormalStrategy or previous induction). - support_2: Second support (FormalStrategy). - law: The Knowledge being supported. - background: Optional background knowledge. - reason: Warrant text for the composition validity. - - Returns: - CompositeStrategy whose conclusion is *law*. - """ +) -> None: if not isinstance(support_1, Strategy): raise TypeError(f"induction() support_1 must be a Strategy, got {type(support_1).__name__}") if not isinstance(support_2, Strategy): @@ -543,52 +555,70 @@ def induction( if support_2.type != "support": raise TypeError("induction() support_2 must be a support strategy") - # Validate law participation: each support must have law as a *premise* - # (generative direction: law predicts observation). Putting law as the - # conclusion (obs → law) is the wrong direction for induction — the - # observation is the evidence, not the conclusion of the sub-strategy. - # A chained induction must have law as its conclusion. - def _support_has_law_as_premise(s: Strategy) -> bool: - return any(p is law for p in s.premises) - - if support_1.type == "support" and not _support_has_law_as_premise(support_1): + if support_1.type == "support" and not _support_has_law_as_premise(support_1, law): raise ValueError( "induction() support_1 must have the law as a premise " "(generative direction: support([law, ...], obs))" ) if support_1.type == "induction" and support_1.conclusion is not law: raise ValueError("induction() support_1 (previous induction) must conclude the same law") - if not _support_has_law_as_premise(support_2): + if not _support_has_law_as_premise(support_2, law): raise ValueError( "induction() support_2 must have the law as a premise " "(generative direction: support([law, ...], obs))" ) - # Auto-create composition warrant - warrant_metadata: dict = {"helper_kind": "composition_validity", "generated": True} - if isinstance(reason, str) and reason: - warrant_metadata["warrant"] = reason - composition_warrant = Knowledge( - content="Are observations independent? Do they support the same law?", - type="claim", - metadata=warrant_metadata, - ) - # Collect all variables from sub-strategies (excluding law) as composite - # premises. In the generative model (law → obs), observations are the - # sub-strategy *conclusions*, not premises. We must gather both to - # correctly expose all evidence nodes at the composite level. +def _induction_premises( + support_1: Strategy, + support_2: Strategy, + law: Knowledge, +) -> list[Knowledge]: all_premises: list[Knowledge] = [] seen: set[int] = set() - for s in [support_1, support_2]: - for p in s.premises: - if id(p) not in seen and p is not law: - all_premises.append(p) - seen.add(id(p)) - # Sub-strategy conclusions (observations in generative mode) - if s.conclusion is not None and s.conclusion is not law and id(s.conclusion) not in seen: - all_premises.append(s.conclusion) - seen.add(id(s.conclusion)) + for strategy in [support_1, support_2]: + for premise in strategy.premises: + if id(premise) not in seen and premise is not law: + all_premises.append(premise) + seen.add(id(premise)) + if ( + strategy.conclusion is not None + and strategy.conclusion is not law + and id(strategy.conclusion) not in seen + ): + all_premises.append(strategy.conclusion) + seen.add(id(strategy.conclusion)) + return all_premises + + +def induction( + support_1: Strategy, + support_2: Strategy, + law: Knowledge, + *, + background: list[Knowledge] | None = None, + reason: ReasonInput = "", +) -> Strategy: + """Binary CompositeStrategy: two supports jointly confirm a law. + + Chains via ``induction(prev_induction, new_support, law)``. + + Args: + support_1: First support (FormalStrategy or previous induction). + support_2: Second support (FormalStrategy). + law: The Knowledge being supported. + background: Optional background knowledge. + reason: Warrant text for the composition validity. + + Returns: + CompositeStrategy whose conclusion is *law*. + """ + _validate_induction_inputs(support_1, support_2, law) + composition_warrant = _composition_warrant( + "Are observations independent? Do they support the same law?", + reason, + ) + all_premises = _induction_premises(support_1, support_2, law) strategy = Strategy( type="induction", diff --git a/gaia/engine/lang/dsl/sugar.py b/gaia/engine/lang/dsl/sugar.py new file mode 100644 index 000000000..9180c80eb --- /dev/null +++ b/gaia/engine/lang/dsl/sugar.py @@ -0,0 +1,65 @@ +"""Structured Claim sugar for common formula shapes.""" + +from __future__ import annotations + +from typing import Any + +from gaia.engine.lang.dsl.formula import equals +from gaia.engine.lang.dsl.knowledge import claim +from gaia.engine.lang.formula.primitives import PrimitiveType +from gaia.engine.lang.formula.term import Constant +from gaia.engine.lang.runtime import Claim, Knowledge, Variable +from gaia.engine.lang.runtime.knowledge import ClaimKind + + +def parameter( + variable: Variable, + value: Any, + *, + content: str | None = None, + describe: str | None = None, + title: str | None = None, + format: str = "markdown", + background: list[Knowledge] | None = None, + provenance: list[dict[str, str]] | None = None, + prior: float | None = None, + label: str | None = None, + metadata: dict[str, Any] | None = None, +) -> Claim: + """Declare that a primitive Variable takes a concrete value.""" + formula = equals(variable, _constant_for(variable, value)) + result = claim( + _content_text(content, describe, _parameter_content(variable, value)), + title=title, + format=format, + background=background, + provenance=provenance, + prior=prior, + formula=formula, + kind=ClaimKind.PARAMETER, + metadata=metadata or {}, + ) + result.label = label + return result + + +def _content_text(content: str | None, describe: str | None, fallback: str) -> str: + if content is not None and describe is not None: + raise TypeError("Pass either content or describe, not both") + return content if content is not None else describe if describe is not None else fallback + + +def _constant_for(variable: Variable, value: Any) -> Constant: + if not isinstance(variable, Variable): + raise TypeError(f"expected a Variable, got {type(variable).__name__}") + if not isinstance(variable.domain, PrimitiveType): + raise TypeError("structured value sugar currently supports PrimitiveType variables") + if variable.value is not None and variable.value != value: + raise ValueError( + f"Variable {variable.symbol!r} already has value {variable.value!r}, got {value!r}" + ) + return Constant(value, variable.domain) + + +def _parameter_content(variable: Variable, value: Any) -> str: + return f"{variable.symbol} = {value!r}." diff --git a/gaia/engine/lang/dsl/support.py b/gaia/engine/lang/dsl/support.py new file mode 100644 index 000000000..38dd1c619 --- /dev/null +++ b/gaia/engine/lang/dsl/support.py @@ -0,0 +1,454 @@ +"""Gaia Lang v6 Support verbs: derive, observe, compute.""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from functools import wraps +from typing import Any, cast + +from gaia.engine.ir.parameterization import CROMWELL_EPS +from gaia.engine.lang.runtime.action import ( + Compute, + Derive, + Observe, + attach_reasoning, + validate_no_self_warrant, +) +from gaia.engine.lang.runtime.distribution import Distribution +from gaia.engine.lang.runtime.knowledge import Claim, Knowledge + + +def _as_given_tuple(given: Claim | tuple[Claim, ...] | list[Claim] | None) -> tuple[Claim, ...]: + if given is None: + return () + if isinstance(given, Knowledge): + return (given,) + return tuple(given) + + +def _implication_warrant( + action_type: str, + *, + given: tuple[Claim, ...], + conclusion: Claim, + rationale: str, +) -> Claim: + content = f"{action_type} warrants {conclusion.content}" + metadata: dict[str, Any] = { + "generated": True, + "helper_kind": "implication_warrant", + "review": True, + "relation": { + "type": action_type, + "given": given, + "conclusion": conclusion, + }, + } + if rationale: + metadata["warrant"] = rationale + return Claim(content, metadata=metadata) + + +def _pin_observed_claim(conclusion: Claim) -> None: + pinned = 1.0 - CROMWELL_EPS + if conclusion.prior is not None and conclusion.prior != pinned: + raise ValueError( + "zero-premise observe() pins the conclusion to 1 - CROMWELL_EPS; " + "do not combine it with a different Claim.prior" + ) + conclusion.prior = pinned + + +def derive( + conclusion: Claim | str, + *, + given: Claim | tuple[Claim, ...] | list[Claim] | None = (), + background: list[Knowledge] | None = None, + rationale: str = "", + label: str | None = None, +) -> Claim: + """Logical derivation. Returns the conclusion Claim.""" + if isinstance(conclusion, str): + conclusion = Claim(conclusion) + given_tuple = _as_given_tuple(given) + warrant = _implication_warrant( + "derive", + given=given_tuple, + conclusion=conclusion, + rationale=rationale, + ) + action = Derive( + label=label, + rationale=rationale, + background=list(background or []), + warrants=[warrant], + conclusion=conclusion, + given=given_tuple, + ) + validate_no_self_warrant(action, conclusion) + attach_reasoning(conclusion, action) + return conclusion + + +_OBSERVE_VALUE_SENTINEL: Any = object() + + +def observe( + conclusion: Claim | Distribution | str, + *, + value: Any = _OBSERVE_VALUE_SENTINEL, + error: Any = None, + given: Claim | tuple[Claim, ...] | list[Claim] | None = (), + background: list[Knowledge] | None = None, + source_refs: list[str] | None = None, + rationale: str = "", + label: str | None = None, +) -> Claim: + """Empirical observation. + + Two authoring shapes: + + 1. **Discrete claim observation** — ``observe(my_claim)``. A no-premise + observation pins ``my_claim.prior`` to ``1 - CROMWELL_EPS``. Use + ``given=`` to record a conditional observation that does not pin the + conclusion. + 2. **Continuous quantity observation** — ``observe(distribution, + value=v, error=σ)``. Records a measurement event for a + :class:`Distribution`-typed quantity. Returns a freshly minted + :class:`Claim` representing the observation event (pinned to + ``1 - CROMWELL_EPS`` since the measurement was made), with metadata + linking back to the underlying distribution. The compiler reads this + linkage for audit and future posterior-CDF lowering. The current + predicate-prior lowering still uses the Distribution's prior CDF and + emits a warning when an observation targets the same Distribution. + + ``value`` is the measured numeric value; ``error`` is either ``None`` + for a noise-free observation, a scalar interpreted as the Gaussian + additive standard deviation, or a :class:`Distribution` for a + custom noise model. + """ # noqa: RUF002 (sigma symbol used in scientific docstring) + if isinstance(conclusion, Distribution): + if value is _OBSERVE_VALUE_SENTINEL: + raise TypeError( + "observe(distribution, ...) requires `value=` (the measured " + "numeric value). For a discrete claim observation use " + "observe(claim) without value/error." + ) + if given: + raise TypeError( + "observe(distribution, value=..., given=...) is not supported " + "— continuous observations are unconditional measurement " + "events. To express a conditional measurement, observe a " + "Claim wrapping the conditioning premise." + ) + return _observe_continuous( + conclusion, + value=value, + error=error, + background=background, + source_refs=source_refs, + rationale=rationale, + label=label, + ) + + if value is not _OBSERVE_VALUE_SENTINEL or error is not None: + raise TypeError( + "observe(..., value=..., error=...) only applies to Distribution " + "targets. For discrete claim observations omit value/error." + ) + + if isinstance(conclusion, str): + conclusion = Claim(conclusion) + given_tuple = _as_given_tuple(given) + warrant = _implication_warrant( + "observe", + given=given_tuple, + conclusion=conclusion, + rationale=rationale, + ) + action = Observe( + label=label, + rationale=rationale, + background=list(background or []), + warrants=[warrant], + metadata={"source_refs": list(source_refs)} if source_refs else {}, + conclusion=conclusion, + given=given_tuple, + ) + if not given_tuple: + _pin_observed_claim(conclusion) + validate_no_self_warrant(action, conclusion) + attach_reasoning(conclusion, action) + return conclusion + + +def _coerce_observation_scalar( + raw: Any, + *, + target: Distribution, + role: str, +) -> tuple[float, str | None]: + """Coerce ``observe(...)`` ``value=`` or ``error=`` to (magnitude, unit). + + Mirrors the predicate-threshold rules — a unit-typed Distribution requires + a Quantity-typed observation (and the unit must be dimensionally + compatible); a unitless Distribution requires bare scalars. + """ + from gaia.unit import is_quantity, ureg + + distribution_unit: str | None = (target.metadata or {}).get("unit") + if is_quantity(raw): + if distribution_unit is None: + raise TypeError( + f"observe(distribution, {role}=...) is a unit-typed Quantity " + f"but the target distribution " + f"{target.label or target.content[:40]!r} is unitless. Pass a " + f"bare scalar {role} or attach a unit to the distribution by " + f"passing Quantity-typed parameters." + ) + try: + converted = raw.to(ureg.parse_units(distribution_unit)) + except Exception as err: + raise ValueError( + f"observe(distribution, {role}=...) unit {raw.units!s} is not " + f"compatible with the target distribution unit " + f"{distribution_unit!r}: {err}" + ) from err + return float(converted.magnitude), distribution_unit + if isinstance(raw, bool) or not isinstance(raw, (int, float)): + raise TypeError( + f"observe(distribution, {role}=...) must be a numeric scalar or " + f"a gaia.unit.Quantity, got {type(raw).__name__}: {raw!r}." + ) + if distribution_unit is not None: + raise TypeError( + f"observe(distribution, {role}=...) must be a Quantity in " + f"{distribution_unit!r} because the target distribution " + f"{target.label or target.content[:40]!r} carries that unit; got " + f"bare scalar {raw!r}." + ) + return float(raw), None + + +def _validate_noise_distribution_unit(noise: Distribution, *, target: Distribution) -> None: + """Ensure a Distribution-valued noise model uses the target quantity's unit.""" + from gaia.unit import ureg + + target_unit: str | None = (target.metadata or {}).get("unit") + noise_unit: str | None = (noise.metadata or {}).get("unit") + if target_unit is None: + if noise_unit is not None: + raise TypeError( + "observe(distribution, error=) got a unit-typed " + f"noise distribution {noise_unit!r} for unitless target " + f"{target.label or target.content[:40]!r}." + ) + return + if noise_unit is None: + raise TypeError( + "observe(distribution, error=) noise distribution " + f"must carry unit {target_unit!r} because the target distribution " + f"{target.label or target.content[:40]!r} carries that unit." + ) + try: + (1 * ureg.parse_units(noise_unit)).to(ureg.parse_units(target_unit)) + except Exception as err: + raise ValueError( + f"observe(distribution, error=) noise distribution unit " + f"{noise_unit!r} is not compatible with target unit {target_unit!r}: {err}" + ) from err + if noise_unit != target_unit: + raise ValueError( + f"observe(distribution, error=) noise distribution unit " + f"{noise_unit!r} must match target unit {target_unit!r}; pass a noise " + "Distribution already expressed in the target's canonical unit." + ) + + +def _observe_continuous( + target: Distribution, + *, + value: Any, + error: Any, + background: list[Knowledge] | None, + source_refs: list[str] | None, + rationale: str, + label: str | None, +) -> Claim: + """Build the observation Claim for a continuous quantity measurement.""" + coerced_value, value_unit = _coerce_observation_scalar(value, target=target, role="value") + + coerced_error: Any + if error is None: + coerced_error = None + elif isinstance(error, Distribution): + _validate_noise_distribution_unit(error, target=target) + coerced_error = error + else: + coerced_error_scalar, _ = _coerce_observation_scalar(error, target=target, role="error") + if coerced_error_scalar <= 0.0: + raise ValueError( + f"observe(distribution, error=sigma) requires sigma > 0, got {error!r}." + ) + coerced_error = coerced_error_scalar + + label_part = target.label or target.content[:40] + unit_suffix = f" {value_unit}" if value_unit else "" + # Format numerics with :g so ``203.0`` renders as ``203`` and ``0.0015`` + # stays ``0.0015`` (no trailing zeros, no scientific notation for sane + # magnitudes; Python's :g default flips to scientific only at very small + # or very large magnitudes). + value_part = format(coerced_value, "g") + if isinstance(error, Distribution): + error_part = f" with noise {error.kind}" + elif error is None: + error_part = "" + else: + error_part = f" +/- {format(coerced_error, 'g')}{unit_suffix}" + content = f"Observed {label_part} = {value_part}{unit_suffix}{error_part}" + + obs_metadata: dict[str, Any] = { + "observation": { + "target_distribution": target, + "value": coerced_value, + "error": coerced_error, + "unit": value_unit, + "kind": "continuous_observation", + }, + } + if source_refs: + obs_metadata["source_refs"] = list(source_refs) + obs_claim = Claim(content, metadata=obs_metadata) + warrant = _implication_warrant( + "observe", + given=(), + conclusion=obs_claim, + rationale=rationale, + ) + action = Observe( + label=label, + rationale=rationale, + background=list(background or []), + warrants=[warrant], + metadata={"source_refs": list(source_refs)} if source_refs else {}, + conclusion=obs_claim, + given=(), + ) + _pin_observed_claim(obs_claim) + validate_no_self_warrant(action, obs_claim) + attach_reasoning(obs_claim, action) + return obs_claim + + +def _wrap_result(return_type: type[Claim], result_value: Any) -> Claim: + if isinstance(result_value, return_type): + return result_value + return return_type(value=result_value) + + +def _bound_given(sig: inspect.Signature, *args: Any, **kwargs: Any) -> tuple[Claim, ...]: + bound = sig.bind(*args, **kwargs) + bound.apply_defaults() + given: list[Knowledge] = [] + for name, value in bound.arguments.items(): + parameter = sig.parameters[name] + if parameter.kind is inspect.Parameter.VAR_POSITIONAL: + values = value + elif parameter.kind is inspect.Parameter.VAR_KEYWORD: + values = value.values() + else: + values = (value,) + given.extend(item for item in values if isinstance(item, Knowledge)) + return cast(tuple[Claim, ...], tuple(given)) + + +def _compute_call( + conclusion_type: type[Claim], + *, + fn: Callable[..., Any] | None, + given: Claim | tuple[Claim, ...] | list[Claim] | None, + background: list[Knowledge] | None, + rationale: str, + label: str | None, +) -> Claim: + given_tuple = _as_given_tuple(given) + result_value = fn(*given_tuple) if fn is not None else None + conclusion = _wrap_result(conclusion_type, result_value) + warrant = _implication_warrant( + "compute", + given=given_tuple, + conclusion=conclusion, + rationale=rationale, + ) + action = Compute( + label=label, + rationale=rationale, + background=list(background or []), + warrants=[warrant], + conclusion=conclusion, + given=given_tuple, + fn=fn, + ) + validate_no_self_warrant(action, conclusion) + attach_reasoning(conclusion, action) + return conclusion + + +def compute( + conclusion_type: type[Claim] | Callable[..., Any], + *, + fn: Callable[..., Any] | None = None, + given: Claim | tuple[Claim, ...] | list[Claim] | None = (), + background: list[Knowledge] | None = None, + rationale: str = "", + label: str | None = None, +) -> Claim | Callable[..., Claim]: + """Deterministic computation. + + Used either as ``compute(ResultClaim, fn=..., given=...)`` or as ``@compute``. + """ + if callable(conclusion_type) and not inspect.isclass(conclusion_type) and fn is None: + wrapped_fn = conclusion_type + sig = inspect.signature(wrapped_fn) + return_type = sig.return_annotation + if return_type is inspect.Signature.empty: + raise TypeError("@compute requires a Claim return annotation") + + @wraps(wrapped_fn) + def wrapper(*args: Any, **kwargs: Any) -> Claim: + result_value = wrapped_fn(*args, **kwargs) + conclusion = _wrap_result(return_type, result_value) + given_tuple = _bound_given(sig, *args, **kwargs) + action_rationale = inspect.getdoc(wrapped_fn) or "" + warrant = _implication_warrant( + "compute", + given=given_tuple, + conclusion=conclusion, + rationale=action_rationale, + ) + action = Compute( + label=label, + rationale=action_rationale, + background=list(background or []), + warrants=[warrant], + conclusion=conclusion, + given=given_tuple, + fn=wrapped_fn, + ) + validate_no_self_warrant(action, conclusion) + attach_reasoning(conclusion, action) + return conclusion + + return wrapper + + if not inspect.isclass(conclusion_type) or not issubclass(conclusion_type, Claim): + raise TypeError("compute() first argument must be a Claim subclass or decorated function") + return _compute_call( + conclusion_type, + fn=fn, + given=given, + background=background, + rationale=rationale, + label=label, + ) diff --git a/gaia/engine/lang/formula/__init__.py b/gaia/engine/lang/formula/__init__.py new file mode 100644 index 000000000..5611aa3d8 --- /dev/null +++ b/gaia/engine/lang/formula/__init__.py @@ -0,0 +1,64 @@ +"""Gaia Lang Formula AST — typed term, predicate, connective, quantifier nodes.""" + +from gaia.engine.lang.formula.connective import Iff, Implies, Land, Lnot, Lor +from gaia.engine.lang.formula.predicate import ( + ClaimAtom, + Equals, + Formula, + Greater, + GreaterEqual, + Less, + LessEqual, + NotEquals, + UserPredicate, + is_formula, +) +from gaia.engine.lang.formula.primitives import Bool, Nat, PrimitiveType, Probability, Real +from gaia.engine.lang.formula.symbols import FunctionSymbol, PredicateSymbol +from gaia.engine.lang.formula.term import ArithOp, Constant, FunctionApp, Term, is_term + + +# Lazy by design: quantifier imports runtime.variable, while runtime.variable +# imports formula.primitives. Eagerly importing Exists/Forall here closes that +# cycle while formula/__init__.py is still loading. +def __getattr__(name: str) -> object: + if name in {"Exists", "Forall"}: + from gaia.engine.lang.formula.quantifier import Exists, Forall + + exports = {"Exists": Exists, "Forall": Forall} + globals().update(exports) + return exports[name] + raise AttributeError(f"module 'gaia.engine.lang.formula' has no attribute {name!r}") + + +__all__ = [ + "ArithOp", + "Bool", + "ClaimAtom", + "Constant", + "Equals", + "Exists", + "Forall", + "Formula", + "FunctionApp", + "FunctionSymbol", + "Greater", + "GreaterEqual", + "Iff", + "Implies", + "Land", + "Less", + "LessEqual", + "Lnot", + "Lor", + "Nat", + "NotEquals", + "PredicateSymbol", + "PrimitiveType", + "Probability", + "Real", + "Term", + "UserPredicate", + "is_formula", + "is_term", +] diff --git a/gaia/engine/lang/formula/connective.py b/gaia/engine/lang/formula/connective.py new file mode 100644 index 000000000..9521b6b1c --- /dev/null +++ b/gaia/engine/lang/formula/connective.py @@ -0,0 +1,85 @@ +"""Connectives — compound formulas built from sub-formulas.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, ClassVar + +from gaia.engine.lang.formula.predicate import is_formula + + +def _check_formula(name: str, value: object) -> None: + if not is_formula(value): + raise TypeError(f"{name} is not a Formula: {value!r}") + + +@dataclass(frozen=True) +class Land: + """Logical conjunction over two or more Formula operands.""" + + operands: tuple[Any, ...] + __gaia_formula__: ClassVar[bool] = True + + def __post_init__(self) -> None: + """Validate conjunction arity and operand formula markers.""" + if len(self.operands) < 2: + raise ValueError("Land requires at least two operands") + for i, op in enumerate(self.operands): + if not is_formula(op): + raise TypeError(f"Land.operands[{i}] is not a Formula: {op!r}") + + +@dataclass(frozen=True) +class Lor: + """Logical disjunction over two or more Formula operands.""" + + operands: tuple[Any, ...] + __gaia_formula__: ClassVar[bool] = True + + def __post_init__(self) -> None: + """Validate disjunction arity and operand formula markers.""" + if len(self.operands) < 2: + raise ValueError("Lor requires at least two operands") + for i, op in enumerate(self.operands): + if not is_formula(op): + raise TypeError(f"Lor.operands[{i}] is not a Formula: {op!r}") + + +@dataclass(frozen=True) +class Lnot: + """Logical negation of a Formula operand.""" + + operand: Any + __gaia_formula__: ClassVar[bool] = True + + def __post_init__(self) -> None: + """Validate that the operand is a Formula.""" + _check_formula("operand", self.operand) + + +@dataclass(frozen=True) +class Implies: + """Logical implication from antecedent Formula to consequent Formula.""" + + antecedent: Any + consequent: Any + __gaia_formula__: ClassVar[bool] = True + + def __post_init__(self) -> None: + """Validate implication operands as Formula nodes.""" + _check_formula("antecedent", self.antecedent) + _check_formula("consequent", self.consequent) + + +@dataclass(frozen=True) +class Iff: + """Logical equivalence between two Formula operands.""" + + left: Any + right: Any + __gaia_formula__: ClassVar[bool] = True + + def __post_init__(self) -> None: + """Validate equivalence operands as Formula nodes.""" + _check_formula("left", self.left) + _check_formula("right", self.right) diff --git a/gaia/engine/lang/formula/predicate.py b/gaia/engine/lang/formula/predicate.py new file mode 100644 index 000000000..b60461cae --- /dev/null +++ b/gaia/engine/lang/formula/predicate.py @@ -0,0 +1,160 @@ +"""Predicate — atomic formulas (truth-valued expressions over Terms or Claims). + +Spec §3 typed-AST discipline: UserPredicate carries a PredicateSymbol reference +and validates arity + arg domains. Equals/Greater/etc. validate that operands +are Terms. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, ClassVar, Protocol, runtime_checkable + +from gaia.engine.lang.formula.symbols import PredicateSymbol +from gaia.engine.lang.formula.term import _term_domain, is_term +from gaia.engine.lang.runtime.knowledge import Claim + + +@runtime_checkable +class Formula(Protocol): + """Marker protocol — a truth-valued AST node.""" + + __gaia_formula__: bool = True + + +def is_formula(obj: object) -> bool: + """Return whether an object is explicitly tagged as a Formula node.""" + return getattr(obj, "__gaia_formula__", False) is True + + +def _check_term(name: str, value: object) -> None: + if not is_term(value): + raise TypeError(f"{name} is not a Term: {value!r}") + + +@dataclass(frozen=True) +class Equals: + """Term equality formula.""" + + left: Any + right: Any + __gaia_formula__: ClassVar[bool] = True + + def __post_init__(self) -> None: + """Validate equality operands as Term nodes.""" + _check_term("left", self.left) + _check_term("right", self.right) + + +@dataclass(frozen=True) +class NotEquals: + """Term inequality formula.""" + + left: Any + right: Any + __gaia_formula__: ClassVar[bool] = True + + def __post_init__(self) -> None: + """Validate inequality operands as Term nodes.""" + _check_term("left", self.left) + _check_term("right", self.right) + + +@dataclass(frozen=True) +class Greater: + """Greater-than relation over Term operands.""" + + left: Any + right: Any + __gaia_formula__: ClassVar[bool] = True + + def __post_init__(self) -> None: + """Validate greater-than operands as Term nodes.""" + _check_term("left", self.left) + _check_term("right", self.right) + + +@dataclass(frozen=True) +class GreaterEqual: + """Greater-than-or-equal relation over Term operands.""" + + left: Any + right: Any + __gaia_formula__: ClassVar[bool] = True + + def __post_init__(self) -> None: + """Validate greater-than-or-equal operands as Term nodes.""" + _check_term("left", self.left) + _check_term("right", self.right) + + +@dataclass(frozen=True) +class Less: + """Less-than relation over Term operands.""" + + left: Any + right: Any + __gaia_formula__: ClassVar[bool] = True + + def __post_init__(self) -> None: + """Validate less-than operands as Term nodes.""" + _check_term("left", self.left) + _check_term("right", self.right) + + +@dataclass(frozen=True) +class LessEqual: + """Less-than-or-equal relation over Term operands.""" + + left: Any + right: Any + __gaia_formula__: ClassVar[bool] = True + + def __post_init__(self) -> None: + """Validate less-than-or-equal operands as Term nodes.""" + _check_term("left", self.left) + _check_term("right", self.right) + + +@dataclass(frozen=True) +class UserPredicate: + """Application of a user-declared PredicateSymbol to typed Term arguments.""" + + symbol: PredicateSymbol + args: tuple[Any, ...] + __gaia_formula__: ClassVar[bool] = True + + def __post_init__(self) -> None: + """Validate predicate symbol, arity, and argument domains.""" + if not isinstance(self.symbol, PredicateSymbol): + raise TypeError(f"symbol must be a PredicateSymbol, got {type(self.symbol).__name__}") + expected_arity = len(self.symbol.arg_domains) + if len(self.args) != expected_arity: + raise ValueError( + f"UserPredicate arity mismatch: {self.symbol.name} expects " + f"{expected_arity} args, got {len(self.args)}" + ) + for i, (arg, expected_domain) in enumerate( + zip(self.args, self.symbol.arg_domains, strict=True) + ): + if not is_term(arg): + raise TypeError(f"UserPredicate argument {i} is not a Term: {arg!r}") + actual = _term_domain(arg) + if actual is not None and actual is not expected_domain: + raise TypeError( + f"UserPredicate argument {i} domain mismatch: {self.symbol.name} expects " + f"{expected_domain}, got {actual}" + ) + + +@dataclass(frozen=True) +class ClaimAtom: + """A reference to another Claim's truth — the bridge from formula land to claim graph.""" + + claim: Claim + __gaia_formula__: ClassVar[bool] = True + + def __post_init__(self) -> None: + """Validate that the atom wraps a Claim.""" + if not isinstance(self.claim, Claim): + raise TypeError(f"ClaimAtom requires a Claim instance, got {type(self.claim).__name__}") diff --git a/gaia/engine/lang/formula/primitives.py b/gaia/engine/lang/formula/primitives.py new file mode 100644 index 000000000..199b1a555 --- /dev/null +++ b/gaia/engine/lang/formula/primitives.py @@ -0,0 +1,71 @@ +"""Built-in primitive type tokens for Gaia Lang. + +A primitive type is a runtime singleton that knows its name and how to validate +a candidate value. Authors reference the four built-ins (Nat, Real, Probability, +Bool); they do not construct PrimitiveType instances directly. +""" + +from __future__ import annotations + +from collections.abc import Callable + +_SEALED = False + + +class PrimitiveType: + """A built-in typed sort. Construction is sealed once the module finishes loading.""" + + __slots__ = ("_accept", "name") + + def __init__(self, name: str, accept: Callable[[object], bool]) -> None: + """Create a primitive type token before the module is sealed.""" + if _SEALED: + raise TypeError( + "PrimitiveType is sealed. Use the four built-ins: Nat, Real, Probability, Bool." + ) + self.name = name + self._accept = accept + + def accepts(self, value: object) -> bool: + """Return whether ``value`` belongs to this primitive type.""" + return self._accept(value) + + def __repr__(self) -> str: + """Return the primitive type name.""" + return self.name + + def __reduce__(self) -> tuple[Callable[[str], PrimitiveType], tuple[str]]: + """Preserve primitive singleton identity when pickled.""" + return (_lookup_primitive, (self.name,)) + + +def _is_nat(v: object) -> bool: + return isinstance(v, int) and not isinstance(v, bool) and v >= 0 + + +def _is_real(v: object) -> bool: + return isinstance(v, int | float) and not isinstance(v, bool) + + +def _is_probability(v: object) -> bool: + return isinstance(v, int | float) and not isinstance(v, bool) and 0.0 <= v <= 1.0 + + +def _is_bool(v: object) -> bool: + return isinstance(v, bool) + + +Nat = PrimitiveType("Nat", _is_nat) +Real = PrimitiveType("Real", _is_real) +Probability = PrimitiveType("Probability", _is_probability) +Bool = PrimitiveType("Bool", _is_bool) + + +_BY_NAME = {p.name: p for p in (Nat, Real, Probability, Bool)} + + +def _lookup_primitive(name: str) -> PrimitiveType: + return _BY_NAME[name] + + +_SEALED = True diff --git a/gaia/engine/lang/formula/quantifier.py b/gaia/engine/lang/formula/quantifier.py new file mode 100644 index 000000000..abef5978c --- /dev/null +++ b/gaia/engine/lang/formula/quantifier.py @@ -0,0 +1,47 @@ +"""Quantifiers — universal and existential binding of a Variable inside a body Formula.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, ClassVar + +from gaia.engine.lang.formula.predicate import is_formula +from gaia.engine.lang.runtime.variable import Variable + + +def _check(variable: object, body: object) -> None: + if not isinstance(variable, Variable): + raise TypeError(f"variable must be a Variable, got {type(variable).__name__}") + if variable.value is not None: + raise ValueError( + f"variable {variable.symbol!r} is already bound to a value; " + "quantifiers must bind FREE variables" + ) + if not is_formula(body): + raise TypeError(f"body is not a Formula: {body!r}") + + +@dataclass(frozen=True) +class Forall: + """Universal quantifier binding a free Variable in a Formula body.""" + + variable: Variable + body: Any + __gaia_formula__: ClassVar[bool] = True + + def __post_init__(self) -> None: + """Validate the bound variable and body Formula.""" + _check(self.variable, self.body) + + +@dataclass(frozen=True) +class Exists: + """Existential quantifier binding a free Variable in a Formula body.""" + + variable: Variable + body: Any + __gaia_formula__: ClassVar[bool] = True + + def __post_init__(self) -> None: + """Validate the bound variable and body Formula.""" + _check(self.variable, self.body) diff --git a/gaia/engine/lang/formula/symbols.py b/gaia/engine/lang/formula/symbols.py new file mode 100644 index 000000000..d7ee59fdf --- /dev/null +++ b/gaia/engine/lang/formula/symbols.py @@ -0,0 +1,55 @@ +"""FunctionSymbol and PredicateSymbol — typed declarations of user-defined symbols.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from gaia.engine.lang.formula.primitives import PrimitiveType +from gaia.engine.lang.runtime.domain import Domain + + +def _validate_arg_domains(arg_domains: tuple[object, ...]) -> None: + if len(arg_domains) == 0: + raise ValueError( + "arity zero is not allowed; use a Claim for nullary propositions or a " + "Variable for nullary terms" + ) + for i, d in enumerate(arg_domains): + if not isinstance(d, (PrimitiveType, Domain)): + raise TypeError( + f"arg_domain[{i}] must be a PrimitiveType or Domain, got {type(d).__name__}" + ) + + +@dataclass(frozen=True) +class FunctionSymbol: + """Declaration of a user function symbol like ``E: Particle → Real``.""" + + name: str + arg_domains: tuple[PrimitiveType | Domain, ...] + result_domain: PrimitiveType | Domain + + def __post_init__(self) -> None: + """Validate function symbol name, argument domains, and result domain.""" + if not self.name: + raise ValueError("name must be a non-empty string") + _validate_arg_domains(self.arg_domains) + if not isinstance(self.result_domain, (PrimitiveType, Domain)): + raise TypeError( + f"result_domain must be a PrimitiveType or Domain, " + f"got {type(self.result_domain).__name__}" + ) + + +@dataclass(frozen=True) +class PredicateSymbol: + """Declaration of a user predicate symbol like ``Stable: Particle → Bool``.""" + + name: str + arg_domains: tuple[PrimitiveType | Domain, ...] + + def __post_init__(self) -> None: + """Validate predicate symbol name and argument domains.""" + if not self.name: + raise ValueError("name must be a non-empty string") + _validate_arg_domains(self.arg_domains) diff --git a/gaia/engine/lang/formula/term.py b/gaia/engine/lang/formula/term.py new file mode 100644 index 000000000..4f97089df --- /dev/null +++ b/gaia/engine/lang/formula/term.py @@ -0,0 +1,119 @@ +"""Term — value-bearing AST nodes (typed). + +Spec §3 typed-AST discipline: +- Constant.primitive is a PrimitiveType reference; value must be accepted by it. +- FunctionApp.symbol is a FunctionSymbol; arity and arg domains validated. +- ArithOp operands must be Terms; op must be one of {+, -, *, /}. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, ClassVar, Protocol, runtime_checkable + +from gaia.engine.lang.formula.primitives import PrimitiveType +from gaia.engine.lang.formula.symbols import FunctionSymbol +from gaia.engine.lang.runtime.domain import Domain + +_ARITH_OPS = frozenset({"+", "-", "*", "/"}) + + +@runtime_checkable +class Term(Protocol): + """Marker protocol. A Term is a value-bearing expression node.""" + + __gaia_term__: bool = True + + +def is_term(obj: object) -> bool: + """Strict check — only objects explicitly tagged as terms qualify.""" + return getattr(obj, "__gaia_term__", False) is True + + +def _term_domain(t: Any) -> PrimitiveType | Domain | None: + """Best-effort domain inference for a Term (used to validate FunctionApp args). + + Returns None when the domain cannot be statically determined (e.g. raw ArithOp). + """ + if isinstance(t, Constant): + return t.primitive + if hasattr(t, "domain"): # Variable + domain = t.domain + if isinstance(domain, PrimitiveType | Domain): + return domain + if isinstance(t, FunctionApp): + return t.symbol.result_domain + return None # ArithOp — leave to compiler to type-check + + +@dataclass(frozen=True) +class Constant: + """A primitive literal value, validated against its declared PrimitiveType.""" + + value: Any + primitive: PrimitiveType + + __gaia_term__: ClassVar[bool] = True + + def __post_init__(self) -> None: + """Validate the literal against its declared primitive type.""" + if not isinstance(self.primitive, PrimitiveType): + raise TypeError( + f"primitive must be a PrimitiveType, got {type(self.primitive).__name__}" + ) + if not self.primitive.accepts(self.value): + raise ValueError( + f"value {self.value!r} not accepted by primitive type {self.primitive}" + ) + + +@dataclass(frozen=True) +class FunctionApp: + """Application of a FunctionSymbol to a tuple of Term arguments.""" + + symbol: FunctionSymbol + args: tuple[Any, ...] + + __gaia_term__: ClassVar[bool] = True + + def __post_init__(self) -> None: + """Validate function symbol, arity, and argument domains.""" + if not isinstance(self.symbol, FunctionSymbol): + raise TypeError(f"symbol must be a FunctionSymbol, got {type(self.symbol).__name__}") + expected_arity = len(self.symbol.arg_domains) + if len(self.args) != expected_arity: + raise ValueError( + f"FunctionApp arity mismatch: {self.symbol.name} expects " + f"{expected_arity} args, got {len(self.args)}" + ) + for i, (arg, expected_domain) in enumerate( + zip(self.args, self.symbol.arg_domains, strict=True) + ): + if not is_term(arg): + raise TypeError(f"FunctionApp argument {i} is not a Term: {arg!r}") + actual = _term_domain(arg) + if actual is not None and actual is not expected_domain: + raise TypeError( + f"FunctionApp argument {i} domain mismatch: {self.symbol.name} expects " + f"{expected_domain}, got {actual}" + ) + + +@dataclass(frozen=True) +class ArithOp: + """An arithmetic operation between two Terms.""" + + op: str + left: Any + right: Any + + __gaia_term__: ClassVar[bool] = True + + def __post_init__(self) -> None: + """Validate arithmetic operator and operands.""" + if self.op not in _ARITH_OPS: + raise ValueError(f"op must be one of {_ARITH_OPS}, got {self.op!r}") + if not is_term(self.left): + raise TypeError(f"ArithOp.left is not a Term: {self.left!r}") + if not is_term(self.right): + raise TypeError(f"ArithOp.right is not a Term: {self.right!r}") diff --git a/gaia/lang/refs/__init__.py b/gaia/engine/lang/refs/__init__.py similarity index 75% rename from gaia/lang/refs/__init__.py rename to gaia/engine/lang/refs/__init__.py index 9cd9b289e..788170b21 100644 --- a/gaia/lang/refs/__init__.py +++ b/gaia/engine/lang/refs/__init__.py @@ -11,31 +11,30 @@ from __future__ import annotations -from gaia.lang.refs.errors import ReferenceError -from gaia.lang.refs.types import ( +from gaia.engine.lang.refs.errors import ReferenceError +from gaia.engine.lang.refs.extractor import extract +from gaia.engine.lang.refs.loader import load_references +from gaia.engine.lang.refs.resolver import ( + check_collisions, + resolve, + validate_groups, +) +from gaia.engine.lang.refs.types import ( BracketGroup, ExtractionResult, RefKind, RefMarker, ) -from gaia.lang.refs.extractor import extract -from gaia.lang.refs.loader import load_references -from gaia.lang.refs.resolver import ( - check_collisions, - resolve, - validate_groups, -) - __all__ = [ "BracketGroup", "ExtractionResult", "RefKind", "RefMarker", "ReferenceError", - "extract", "check_collisions", + "extract", + "load_references", "resolve", "validate_groups", - "load_references", ] diff --git a/gaia/lang/refs/errors.py b/gaia/engine/lang/refs/errors.py similarity index 88% rename from gaia/lang/refs/errors.py rename to gaia/engine/lang/refs/errors.py index 0c5807c39..2f71b0a24 100644 --- a/gaia/lang/refs/errors.py +++ b/gaia/engine/lang/refs/errors.py @@ -11,6 +11,7 @@ class ReferenceError(Exception): """ def __init__(self, message: str, *, location: str | None = None) -> None: + """Create a reference error with an optional location prefix.""" self.location = location if location: super().__init__(f"{location}: {message}") diff --git a/gaia/lang/refs/extractor.py b/gaia/engine/lang/refs/extractor.py similarity index 98% rename from gaia/lang/refs/extractor.py rename to gaia/engine/lang/refs/extractor.py index 6195e788d..85b94bdb0 100644 --- a/gaia/lang/refs/extractor.py +++ b/gaia/engine/lang/refs/extractor.py @@ -12,7 +12,7 @@ import re -from gaia.lang.refs.types import BracketGroup, ExtractionResult, RefMarker +from gaia.engine.lang.refs.types import BracketGroup, ExtractionResult, RefMarker # Pandoc-compatible citation key: # - starts with letter/digit/underscore diff --git a/gaia/lang/refs/loader.py b/gaia/engine/lang/refs/loader.py similarity index 97% rename from gaia/lang/refs/loader.py rename to gaia/engine/lang/refs/loader.py index 16d8aff97..aef118662 100644 --- a/gaia/lang/refs/loader.py +++ b/gaia/engine/lang/refs/loader.py @@ -11,8 +11,8 @@ from pathlib import Path from typing import Any -from gaia.lang.refs.errors import ReferenceError -from gaia.lang.refs.extractor import CITATION_KEY_RE +from gaia.engine.lang.refs.errors import ReferenceError +from gaia.engine.lang.refs.extractor import CITATION_KEY_RE # CSL 1.0.2 type allowlist. # Source: https://github.com/citation-style-language/schema diff --git a/gaia/lang/refs/resolver.py b/gaia/engine/lang/refs/resolver.py similarity index 96% rename from gaia/lang/refs/resolver.py rename to gaia/engine/lang/refs/resolver.py index 04c0e1de4..710ec0182 100644 --- a/gaia/lang/refs/resolver.py +++ b/gaia/engine/lang/refs/resolver.py @@ -15,8 +15,8 @@ from collections.abc import Iterable from typing import Any -from gaia.lang.refs.errors import ReferenceError -from gaia.lang.refs.types import BracketGroup, RefKind, RefMarker +from gaia.engine.lang.refs.errors import ReferenceError +from gaia.engine.lang.refs.types import BracketGroup, RefKind, RefMarker def resolve( diff --git a/gaia/lang/refs/types.py b/gaia/engine/lang/refs/types.py similarity index 100% rename from gaia/lang/refs/types.py rename to gaia/engine/lang/refs/types.py diff --git a/gaia/engine/lang/review/__init__.py b/gaia/engine/lang/review/__init__.py new file mode 100644 index 000000000..aaeed41fb --- /dev/null +++ b/gaia/engine/lang/review/__init__.py @@ -0,0 +1,6 @@ +"""Review helpers for Gaia Lang v6.""" + +from gaia.engine.lang.review.manifest import generate_review_manifest +from gaia.engine.lang.review.templates import generate_audit_question + +__all__ = ["generate_audit_question", "generate_review_manifest"] diff --git a/gaia/engine/lang/review/manifest.py b/gaia/engine/lang/review/manifest.py new file mode 100644 index 000000000..83378f289 --- /dev/null +++ b/gaia/engine/lang/review/manifest.py @@ -0,0 +1,246 @@ +"""Generate ReviewManifest records from compiled v6 action targets.""" + +from __future__ import annotations + +import hashlib +from typing import Any + +from gaia.engine.ir import Review, ReviewManifest, ReviewStatus +from gaia.engine.lang.review.templates import generate_audit_question + + +def _review_id(target_kind: str, target_id: str) -> str: + digest = hashlib.sha256(f"{target_kind}|{target_id}".encode()).hexdigest()[:12] + return f"rev_{digest}" + + +def _labels_by_id(compiled: Any) -> dict[str, str]: + labels: dict[str, str] = {} + for knowledge in compiled.graph.knowledges: + if not knowledge.id: + continue + if knowledge.label: + labels[knowledge.id] = knowledge.label + else: + labels[knowledge.id] = knowledge.id.split("::")[-1] + return labels + + +def _strategy_action_type(strategy: Any) -> str: + pattern = (strategy.metadata or {}).get("pattern") + if pattern == "observation": + return "observe" + if pattern == "computation": + return "compute" + if pattern == "inference": + return "infer" + return "derive" + + +def _operator_action_type(operator: Any) -> str: + if (operator.metadata or {}).get("pattern") == "decomposition": + return "decompose" + if operator.operator == "equivalence": + return "equal" + if operator.operator == "contradiction": + return "contradict" + if operator.operator == "complement": + return "exclusive" + return str(operator.operator) + + +def _strategy_question(strategy: Any, action_type: str, labels: dict[str, str]) -> str: + if action_type == "infer": + hypothesis = strategy.premises[0] if strategy.premises else "" + metadata = strategy.metadata or {} + given_ids = metadata.get("given") + if not isinstance(given_ids, list): + given_ids = strategy.premises[1:] + given_labels = [labels.get(given_id, given_id) for given_id in given_ids] + given_clause = "" + if given_labels: + given_clause = " given " + ", ".join(f"[@{label}]" for label in given_labels) + return generate_audit_question( + "infer", + hypothesis_label=labels.get(hypothesis, hypothesis), + evidence_label=labels.get(strategy.conclusion, strategy.conclusion or "?"), + given_clause=given_clause, + ) + return generate_audit_question( + action_type, + conclusion_label=labels.get(strategy.conclusion, strategy.conclusion or "?"), + ) + + +def _knowledge_review_info(knowledge: Any) -> tuple[str, str] | None: + metadata = knowledge.metadata or {} + review_target = metadata.get("review_target") + if isinstance(review_target, dict): + action_label = review_target.get("action_label") + pattern = review_target.get("pattern") + if isinstance(action_label, str) and action_label: + if pattern == "prediction": + return action_label, "model" + return action_label, str(pattern or "derive") + return None + + +def _knowledge_supported_by_entries(knowledge: Any) -> list[dict[str, Any]]: + metadata = knowledge.metadata or {} + supported_by = metadata.get("supported_by") + if not isinstance(supported_by, list): + return [] + return [entry for entry in supported_by if isinstance(entry, dict)] + + +def _knowledge_question(knowledge: Any, action_type: str, labels: dict[str, str]) -> str: + knowledge_id = knowledge.id or "" + return generate_audit_question( + action_type, + conclusion_label=labels.get(knowledge_id, knowledge.label or knowledge_id or "?"), + ) + + +def _operator_question(operator: Any, action_type: str, labels: dict[str, str]) -> str: + if action_type == "decompose": + metadata = operator.metadata or {} + decomposition = metadata.get("decomposition") or {} + whole = decomposition.get("whole") or (operator.variables[0] if operator.variables else "") + formula = decomposition.get("formula_helper") or ( + operator.variables[1] if len(operator.variables) > 1 else "" + ) + return generate_audit_question( + "decompose", + whole_label=labels.get(whole, whole), + formula_label=labels.get(formula, formula), + ) + a = operator.variables[0] if operator.variables else "" + b = operator.variables[1] if len(operator.variables) > 1 else "" + return generate_audit_question( + action_type, + a_label=labels.get(a, a), + b_label=labels.get(b, b), + ) + + +def _compose_question(compose: Any, labels: dict[str, str]) -> str: + return generate_audit_question( + "compose", + conclusion_label=labels.get(compose.conclusion, compose.conclusion or "?"), + ) + + +def _knowledge_reviews(knowledge: Any, labels: dict[str, str]) -> list[Review]: + if not knowledge.id: + return [] + + reviews: list[Review] = [] + for entry in _knowledge_supported_by_entries(knowledge): + if entry.get("pattern") != "observation": + continue + action_label = entry.get("action_label") + if not isinstance(action_label, str) or not action_label: + continue + reviews.append( + Review( + review_id=_review_id("action", action_label), + action_label=action_label, + target_kind="action", + target_id=action_label, + status=ReviewStatus.UNREVIEWED, + audit_question=_knowledge_question(knowledge, "observe", labels), + round=1, + ) + ) + + review_info = _knowledge_review_info(knowledge) + if review_info is not None: + action_label, action_type = review_info + reviews.append( + Review( + review_id=_review_id("knowledge", knowledge.id), + action_label=action_label, + target_kind="knowledge", + target_id=knowledge.id, + status=ReviewStatus.UNREVIEWED, + audit_question=_knowledge_question(knowledge, action_type, labels), + round=1, + ) + ) + return reviews + + +def _strategy_review(strategy: Any, labels: dict[str, str]) -> Review | None: + metadata = strategy.metadata or {} + action_label = metadata.get("action_label") + if not action_label or not strategy.strategy_id: + return None + action_type = _strategy_action_type(strategy) + return Review( + review_id=_review_id("strategy", strategy.strategy_id), + action_label=action_label, + target_kind="strategy", + target_id=strategy.strategy_id, + status=ReviewStatus.UNREVIEWED, + audit_question=_strategy_question(strategy, action_type, labels), + round=1, + ) + + +def _operator_review(operator: Any, labels: dict[str, str]) -> Review | None: + metadata = operator.metadata or {} + action_label = metadata.get("action_label") + if not action_label or not operator.operator_id: + return None + action_type = _operator_action_type(operator) + return Review( + review_id=_review_id("operator", operator.operator_id), + action_label=action_label, + target_kind="operator", + target_id=operator.operator_id, + status=ReviewStatus.UNREVIEWED, + audit_question=_operator_question(operator, action_type, labels), + round=1, + ) + + +def _compose_review(compose: Any, labels: dict[str, str]) -> Review | None: + metadata = compose.metadata or {} + action_label = metadata.get("action_label") + if not action_label or not compose.compose_id: + return None + return Review( + review_id=_review_id("compose", compose.compose_id), + action_label=action_label, + target_kind="compose", + target_id=compose.compose_id, + status=ReviewStatus.UNREVIEWED, + audit_question=_compose_question(compose, labels), + round=1, + ) + + +def generate_review_manifest(compiled: Any) -> ReviewManifest: + """Generate unreviewed Review records for each v6 action target.""" + labels = _labels_by_id(compiled) + reviews: list[Review] = [] + + for knowledge in compiled.graph.knowledges: + reviews.extend(_knowledge_reviews(knowledge, labels)) + + for strategy in compiled.graph.strategies: + review = _strategy_review(strategy, labels) + if review is not None: + reviews.append(review) + + for operator in compiled.graph.operators: + review = _operator_review(operator, labels) + if review is not None: + reviews.append(review) + + for compose in getattr(compiled.graph, "composes", []): + review = _compose_review(compose, labels) + if review is not None: + reviews.append(review) + + return ReviewManifest(reviews=reviews) diff --git a/gaia/engine/lang/review/templates.py b/gaia/engine/lang/review/templates.py new file mode 100644 index 000000000..9a769b22f --- /dev/null +++ b/gaia/engine/lang/review/templates.py @@ -0,0 +1,34 @@ +"""Audit-question templates for Gaia Lang v6 review targets.""" + +from __future__ import annotations + + +class _MissingLabelDict(dict[str, object]): + def __missing__(self, key: str) -> str: + return "?" + + +_TEMPLATES = { + "derive": "Do the listed premises suffice to establish [@{conclusion_label}]?", + "observe": "Is the observation of [@{conclusion_label}] reliable under the stated conditions?", + "compute": "Is the computation of [@{conclusion_label}] correctly implemented?", + "model": "Does [@{conclusion_label}] specify a checkable predictive model?", + "infer": ( + "Does [@{hypothesis_label}] predict [@{evidence_label}]{given_clause} at the stated " + "conditional probabilities?" + ), + "equal": "Are [@{a_label}] and [@{b_label}] truly equivalent?", + "contradict": "Do [@{a_label}] and [@{b_label}] truly contradict?", + "exclusive": ( + "Do [@{a_label}] and [@{b_label}] form a closed case split where exactly one is true?" + ), + "decompose": "Does [@{whole_label}] faithfully decompose into [@{formula_label}]?", + "compose": "Does this action DAG correctly establish [@{conclusion_label}]?", +} + + +def generate_audit_question(action_type: str, **labels: object) -> str: + """Render the review audit question for an action type.""" + labels.setdefault("given_clause", "") + template = _TEMPLATES.get(action_type, "Is this reasoning step valid?") + return template.format_map(_MissingLabelDict(labels)) diff --git a/gaia/engine/lang/runtime/__init__.py b/gaia/engine/lang/runtime/__init__.py new file mode 100644 index 000000000..588463d17 --- /dev/null +++ b/gaia/engine/lang/runtime/__init__.py @@ -0,0 +1,93 @@ +"""Runtime dataclasses and helpers backing the Gaia Lang DSL.""" + +from gaia.engine.lang.runtime.action import ( + Action, + Associate, + CandidateRelation, + Compose, + Compute, + Contradict, + Decompose, + DependsOn, + Derive, + Directed, + Equal, + Exclusive, + GaiaGraph, + Infer, + MaterializationLink, + Observe, + Reasoning, + Relation, + Scaffold, + Structural, + Support, + attach_reasoning, + validate_no_self_warrant, +) +from gaia.engine.lang.runtime.composition import Composition, compose, composition +from gaia.engine.lang.runtime.distribution import Distribution +from gaia.engine.lang.runtime.domain import Domain +from gaia.engine.lang.runtime.knowledge import ( + Claim, + ClaimKind, + Context, + Knowledge, + Note, + Question, + Setting, +) +from gaia.engine.lang.runtime.nodes import Operator, Step, Strategy +from gaia.engine.lang.runtime.roles import ( + RoleOccurrence, + register_role_handler, + roles_for_claim, + roles_for_package, +) +from gaia.engine.lang.runtime.variable import Variable + +__all__ = [ + "Action", + "Associate", + "CandidateRelation", + "Claim", + "ClaimKind", + "Compose", + "Composition", + "Compute", + "Context", + "Contradict", + "Decompose", + "DependsOn", + "Derive", + "Directed", + "Distribution", + "Domain", + "Equal", + "Exclusive", + "GaiaGraph", + "Infer", + "Knowledge", + "MaterializationLink", + "Note", + "Observe", + "Operator", + "Question", + "Reasoning", + "Relation", + "RoleOccurrence", + "Scaffold", + "Setting", + "Step", + "Strategy", + "Structural", + "Support", + "Variable", + "attach_reasoning", + "compose", + "composition", + "register_role_handler", + "roles_for_claim", + "roles_for_package", + "validate_no_self_warrant", +] diff --git a/gaia/engine/lang/runtime/action.py b/gaia/engine/lang/runtime/action.py new file mode 100644 index 000000000..a96d50748 --- /dev/null +++ b/gaia/engine/lang/runtime/action.py @@ -0,0 +1,243 @@ +"""Gaia Lang v6 Action class hierarchy.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable + + from gaia.engine.lang.runtime.knowledge import Claim, Knowledge + from gaia.engine.lang.runtime.package import CollectedPackage + + +@dataclass +class GaiaGraph: + """Base Gaia authoring graph record. Parallel to Knowledge, not a Knowledge subclass.""" + + label: str | None = None + rationale: str = "" + background: list[Knowledge] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + _package: CollectedPackage | None = field(default=None, init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + """Register the graph record with the active or inferred package.""" + from gaia.engine.lang.runtime.knowledge import _current_package + + pkg = _current_package.get() + if pkg is None: + from gaia.engine.lang.runtime.package import infer_package_from_callstack + + pkg = infer_package_from_callstack() + if pkg is not None: + self._package = pkg + pkg._register_action(self) + + +@dataclass +class Reasoning(GaiaGraph): + """Reviewable reasoning record that can carry warrant Claims.""" + + warrants: list[Claim] = field(default_factory=list) + + +# Compatibility alias for the pre-Reasoning public name. New code should use +# Reasoning; the alias remains while downstream packages migrate labels/types. +Action = Reasoning + + +def attach_reasoning(claim: Claim, reasoning: Reasoning) -> None: + """Attach a reasoning record to a claim's reverse index exactly once.""" + if all(existing is not reasoning for existing in claim.from_actions): + claim.from_actions.append(reasoning) + + +def validate_no_self_warrant(reasoning: Reasoning, primary: Claim) -> None: + """Reject reasoning records whose primary claim/helper is also their warrant.""" + if any(warrant is primary for warrant in reasoning.warrants): + raise ValueError("reasoning primary claim/helper must not also be its warrant") + + +@dataclass +class Directed(Reasoning): + """Directed reasoning shape: sources or premises point toward a target.""" + + +@dataclass +class Relation(Reasoning): + """Symmetric or non-directed relation among Claims.""" + + +@dataclass +class Support(Directed): + """Directional reasoning: given -> conclusion.""" + + conclusion: Claim | None = None + given: tuple[Claim, ...] = () + + +@dataclass +class Derive(Support): + """Logical derivation.""" + + +@dataclass +class Observe(Support): + """Empirical observation or measurement.""" + + +@dataclass +class Compute(Support): + """Deterministic code execution.""" + + fn: Callable[..., Any] | None = None + code_hash: str | None = None + + +@dataclass +class Scaffold(GaiaGraph): + """Formalization workflow record. Does not enter IR/BP as a warrant.""" + + +@dataclass +class DependsOn(Scaffold): + """Marks unformalized dependencies for a conclusion.""" + + conclusion: Claim | None = None + given: tuple[Claim, ...] = () + + +@dataclass +class CandidateRelation(Scaffold): + """Marks a hypothesized relation that has not been formalized yet.""" + + claims: tuple[Claim, ...] = () + pattern: str | None = None + status: str = "hypothesis" + + @property + def a(self) -> Claim | None: + """Compatibility view for older binary callers.""" + return self.claims[0] if len(self.claims) >= 1 else None + + @property + def b(self) -> Claim | None: + """Compatibility view for older binary callers.""" + return self.claims[1] if len(self.claims) >= 2 else None + + @property + def proposed(self) -> str | None: + """Compatibility view for older proposed-pattern callers.""" + return self.pattern + + +@dataclass +class MaterializationLink: + """Bookkeeping link from scaffold to the formal graph records that handle it.""" + + scaffold: Scaffold + by: tuple[GaiaGraph, ...] + label: str | None = None + rationale: str = "" + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class Structural(Relation): + """Hard structural constraint between claims or claim formulas.""" + + +@dataclass +class Equal(Structural): + """Declares two Claims equivalent.""" + + a: Claim | None = None + b: Claim | None = None + helper: Claim | None = None + + +@dataclass +class Contradict(Structural): + """Declares two Claims contradictory.""" + + a: Claim | None = None + b: Claim | None = None + helper: Claim | None = None + + +@dataclass +class Exclusive(Structural): + """Declares two Claims form a closed binary partition.""" + + a: Claim | None = None + b: Claim | None = None + helper: Claim | None = None + + +@dataclass +class Decompose(Reasoning): + """Declares a whole Claim equivalent to a formula over atomic Claims.""" + + whole: Claim | None = None + parts: tuple[Claim, ...] = () + formula: Any = None + + +@dataclass +class Infer(Directed): + """Bayesian inference: P(E|H) update.""" + + helper: Claim | None = None + hypothesis: Claim | None = None + evidence: Claim | None = None + given: tuple[Claim, ...] = () + p_e_given_h: float | Claim = 0.5 + p_e_given_not_h: float | Claim | None = 0.5 + + +@dataclass +class Associate(Relation): + """Symmetric probabilistic association between two Claims.""" + + helper: Claim | None = None + a: Claim | None = None + b: Claim | None = None + p_a_given_b: float = 0.5 + p_b_given_a: float = 0.5 + pattern: str | None = None + + +@dataclass +class Compose(Action): + """Action-level composition of child actions into a reviewable DAG.""" + + name: str = "" + version: str = "" + inputs: tuple[Knowledge | str, ...] = () + actions: tuple[Action | str, ...] = () + conclusion: Claim | None = None + + def structure_hash( + self, + input_refs: list[str], + action_refs: list[str], + conclusion_ref: str, + warrant_refs: list[str], + background_refs: list[str] | None = None, + ) -> str: + """Hash the canonical compose payload used for the IR compose ID.""" + payload = { + "name": self.name, + "version": self.version, + "inputs": sorted(input_refs), + "background": sorted(background_refs or []), + "actions": list(action_refs), + "conclusion": conclusion_ref, + "warrants": sorted(warrant_refs), + } + canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode()).hexdigest()[:16] diff --git a/gaia/engine/lang/runtime/composition.py b/gaia/engine/lang/runtime/composition.py new file mode 100644 index 000000000..92819f526 --- /dev/null +++ b/gaia/engine/lang/runtime/composition.py @@ -0,0 +1,203 @@ +"""Runtime support for Gaia action composition templates.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from contextvars import ContextVar +from dataclasses import dataclass, field +from functools import wraps +from typing import Any + +from gaia.engine.lang.runtime.action import ( + Action, + Associate, + Compose, + Contradict, + Decompose, + Equal, + Exclusive, + Infer, + Support, + attach_reasoning, + validate_no_self_warrant, +) +from gaia.engine.lang.runtime.knowledge import Claim, Knowledge + + +@dataclass +class _CompositionScope: + name: str + version: str + captured_actions: list[Action] = field(default_factory=list) + seen_actions: set[int] = field(default_factory=set) + + def capture(self, obj: Any) -> None: + if not isinstance(obj, Action): + return + key = id(obj) + if key in self.seen_actions: + return + self.seen_actions.add(key) + self.captured_actions.append(obj) + + +_current_composition_scope: ContextVar[_CompositionScope | None] = ContextVar( + "_current_composition_scope", + default=None, +) + + +def _capture_registered(obj: Any) -> None: + scope = _current_composition_scope.get() + if scope is not None: + scope.capture(obj) + + +def _knowledge_items(value: Any) -> Iterable[Knowledge]: + if isinstance(value, Knowledge): + yield value + elif isinstance(value, dict): + for item in value.values(): + yield from _knowledge_items(item) + elif isinstance(value, Iterable) and not isinstance(value, str | bytes): + for item in value: + yield from _knowledge_items(item) + + +def _unique_knowledge(items: Iterable[Knowledge]) -> tuple[Knowledge, ...]: + seen: set[int] = set() + result: list[Knowledge] = [] + for item in items: + key = id(item) + if key in seen: + continue + seen.add(key) + result.append(item) + return tuple(result) + + +def _binary_relation_inputs( + action: Equal | Contradict | Exclusive | Associate, +) -> Iterable[Knowledge]: + if action.a is not None: + yield action.a + if action.b is not None: + yield action.b + + +def _decompose_inputs(action: Decompose) -> Iterable[Knowledge]: + if action.whole is not None: + yield action.whole + yield from action.parts + + +def _infer_action_inputs(action: Infer) -> Iterable[Knowledge]: + if action.hypothesis is not None: + yield action.hypothesis + if action.evidence is not None: + yield action.evidence + yield from action.given + if isinstance(action.p_e_given_h, Knowledge): + yield action.p_e_given_h + if isinstance(action.p_e_given_not_h, Knowledge): + yield action.p_e_given_not_h + + +def _action_inputs(action: Action) -> Iterable[Knowledge]: + if isinstance(action, Compose): + yield from (item for item in action.inputs if isinstance(item, Knowledge)) + elif isinstance(action, Support): + yield from action.given + elif isinstance(action, Equal | Contradict | Exclusive): + yield from _binary_relation_inputs(action) + elif isinstance(action, Decompose): + yield from _decompose_inputs(action) + elif isinstance(action, Infer): + yield from _infer_action_inputs(action) + elif isinstance(action, Associate): + yield from _binary_relation_inputs(action) + + +def _action_outputs(action: Action) -> Iterable[Knowledge]: + if isinstance(action, Compose | Support): + if action.conclusion is not None: + yield action.conclusion + elif isinstance(action, Equal | Contradict | Exclusive | Infer | Associate) and ( + action.helper is not None + ): + yield action.helper + yield from action.warrants + + +def _infer_inputs( + *, + args: tuple[Any, ...], + kwargs: dict[str, Any], + actions: list[Action], + background: list[Knowledge], +) -> tuple[Knowledge, ...]: + explicit_inputs = list(_knowledge_items(args)) + explicit_inputs.extend(_knowledge_items(kwargs)) + + produced = {id(item) for action in actions for item in _action_outputs(action)} + background_ids = {id(item) for item in background} + action_inputs = [ + item + for action in actions + for item in _action_inputs(action) + if id(item) not in produced and id(item) not in background_ids + ] + return _unique_knowledge([*explicit_inputs, *action_inputs]) + + +def compose( + *, + name: str, + version: str, + background: list[Knowledge] | None = None, + warrants: list[Claim] | None = None, + rationale: str = "", + label: str | None = None, +) -> Callable[[Callable[..., Claim]], Callable[..., Claim]]: + """Decorate a function as a Gaia action composition template.""" + + def decorator(fn: Callable[..., Claim]) -> Callable[..., Claim]: + @wraps(fn) + def wrapper(*args: Any, **kwargs: Any) -> Claim: + scope = _CompositionScope(name, version) + token = _current_composition_scope.set(scope) + try: + result = fn(*args, **kwargs) + finally: + _current_composition_scope.reset(token) + if not isinstance(result, Claim): + raise TypeError("@compose functions must return a Claim object") + + compose_background = list(background or []) + compose_action = Compose( + label=label, + rationale=rationale, + background=compose_background, + warrants=list(warrants or []), + name=name, + version=version, + inputs=_infer_inputs( + args=args, + kwargs=kwargs, + actions=scope.captured_actions, + background=compose_background, + ), + actions=tuple(scope.captured_actions), + conclusion=result, + ) + validate_no_self_warrant(compose_action, result) + attach_reasoning(result, compose_action) + return result + + return wrapper + + return decorator + + +composition = compose +Composition = Compose diff --git a/gaia/engine/lang/runtime/distribution.py b/gaia/engine/lang/runtime/distribution.py new file mode 100644 index 000000000..039704761 --- /dev/null +++ b/gaia/engine/lang/runtime/distribution.py @@ -0,0 +1,704 @@ +"""Distribution — a continuous quantity declared with a probability distribution. + +This is the Lang-side first-class wrapper around the existing computational +distribution objects in ``gaia/engine/bayes/distributions/``. It carries +:class:`Knowledge`-style identity (label, provenance, metadata) so authors can +name a continuous quantity once and reference it elsewhere (predicates, +equations, observe sugar) — the existing pydantic ``_BaseDistribution`` class +hierarchy is preserved as the computational backend (held in ``self._impl``). + +Lang-only — like :class:`Variable` and :class:`Domain`, it overrides +``__post_init__`` to skip IR-bound knowledge map registration. Distributions +do not appear as top-level IR Knowledge nodes; they are referenced from +claim/action metadata that the BP layer consumes. + +Operator overloading on Distribution produces :class:`BoolExpr` (for +comparisons used as claim propositions / equations) and +:class:`DerivedDistribution` (for arithmetic combinations such as +``baseline + slope * x`` inside an equation proposition). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, ClassVar, cast + +from gaia.engine.lang.runtime.knowledge import Knowledge, _current_package + +if TYPE_CHECKING: + from gaia.engine.bayes.distributions.base import _BaseDistribution + from gaia.engine.bayes.distributions.protocol import ( + Distribution as _DistImpl, + ) + from gaia.engine.lang.dsl.bool_expr import BoolExpr, DerivedDistribution + + +# --------------------------------------------------------------------------- +# Unit-aware parameter coercion +# --------------------------------------------------------------------------- +# The pydantic-backed ``_BaseDistribution`` only accepts numeric scalars (or +# deferred references with a ``.symbol`` attribute) as parameter values. +# Authors writing scientific-domain code naturally reach for unit-aware values +# (e.g. ``Normal("T_c", mu=q(200, "K"), sigma=q(50, "K"))``); this helper +# strips the unit, hands the magnitude to the pydantic constructor, and +# returns the per-param unit dict so the factory can stash it on the +# Distribution's metadata for downstream audit and consistency checks. + + +def _coerce_quantity_params(params: dict[str, Any]) -> tuple[dict[str, Any], dict[str, str]]: + """Strip Pint Quantity values from a parameter dict. + + Returns a pair ``(magnitudes, units)`` where ``magnitudes`` is the same + keys with ``.magnitude`` extracted from any Quantity-typed values + (non-Quantity values pass through unchanged), and ``units`` is the + subset of keys whose value carried a unit, mapped to the canonical + Pint unit string (e.g. ``"kelvin"``, ``"meter / second"``). + """ + from gaia.unit import is_quantity, to_literal + + magnitudes: dict[str, Any] = {} + units: dict[str, str] = {} + for name, value in params.items(): + if is_quantity(value): + literal = to_literal(value) + magnitudes[name] = literal.value + units[name] = literal.unit + else: + magnitudes[name] = value + return magnitudes, units + + +def _validate_shared_unit( + units: dict[str, str], group: tuple[str, ...], distribution_name: str +) -> str | None: + """Verify that all parameters in ``group`` share the same unit (or none). + + Returns the shared unit string when the group has a unit, ``None`` when + no parameter in the group carries a unit. Raises ``ValueError`` when + parameters in the group disagree (some unit-typed, some not, or different + unit strings) — this catches mistakes like + ``Normal("T", mu=q(200, "K"), sigma=50)`` early. + """ + in_group = {name: units[name] for name in group if name in units} + if not in_group: + return None + unit_set = set(in_group.values()) + if len(unit_set) > 1: + raise ValueError( + f"{distribution_name} location/scale parameters {sorted(in_group)} " + f"must share a single unit; got {in_group}." + ) + missing = [name for name in group if name not in units] + if missing: + raise ValueError( + f"{distribution_name} location/scale parameters disagree on " + f"unit-aware-ness: {sorted(in_group)} carry units, " + f"{sorted(missing)} do not. Pass either all unitless scalars or " + "all gaia.unit.Quantity values." + ) + return next(iter(unit_set)) + + +def _attach_units( + metadata_kwarg: dict[str, Any] | None, + units: dict[str, str], + shared_unit: str | None, +) -> dict[str, Any]: + """Merge per-param units (and optional shared unit) into metadata. + + Always returns a dict (a fresh copy) — the bare-scalar / unitless call + path also gets a fresh empty dict rather than ``None`` so + :class:`Distribution` retains the Knowledge invariant that ``metadata`` + is dict-typed. Downstream readers can therefore use + ``distribution.metadata.get(...)`` directly without defensive + ``(meta or {})`` patterns. + """ + meta = dict(metadata_kwarg or {}) + if units: + existing_units = dict(meta.get("units") or {}) + existing_units.update(units) + meta["units"] = existing_units + if shared_unit is not None: + meta.setdefault("unit", shared_unit) + return meta + + +def _inverse_unit(unit: str) -> str: + """Return the canonical inverse unit string for a Pint unit literal.""" + from gaia.unit import ureg + + return str((1 / ureg.parse_units(unit)).units) + + +@dataclass(init=False, eq=False) +class Distribution(Knowledge): + """Knowledge-wrapped continuous quantity with a probability distribution. + + Use the family-specific factories (:func:`Normal`, :func:`LogNormal`, + :func:`Beta`, etc.) rather than constructing this directly — they wrap the + matching ``gaia.engine.bayes.distributions._BaseDistribution`` subclass into + a Distribution carrying a content string + identity. + + The wrapped computational object is available as ``.impl`` and exposes + ``logpdf`` / ``logpmf`` / ``cdf`` / ``support`` / ``model_dump`` via thin + delegating properties on this class. + """ + + __gaia_term__: ClassVar[bool] = True + + _impl: Any = field(default=None, init=False, repr=False, compare=False) + + def __init__( + self, + content: str, + *, + impl: _BaseDistribution, + format: str = "markdown", + **kwargs: Any, + ) -> None: + """Create a Knowledge-wrapped distribution. + + Args: + content: Human-readable description of what this quantity is. + impl: A ``_BaseDistribution`` subclass instance (Normal, Beta, …) + that provides the computational backend. + format: Content format (markdown by default). + **kwargs: Standard :class:`Knowledge` keyword arguments + (``title``, ``label``, ``metadata``, ``provenance``, …). + """ + from gaia.engine.bayes.distributions.base import _BaseDistribution + + if not isinstance(impl, _BaseDistribution): + raise TypeError( + "Distribution(impl=...) must be a _BaseDistribution instance " + "from gaia.engine.bayes.distributions; got " + f"{type(impl).__name__}. Use the family factories (Normal, " + "LogNormal, Beta, ...) instead of constructing Distribution " + "directly." + ) + super().__init__(content=content, type="distribution", format=format, **kwargs) + self._impl = impl + + def __post_init__(self) -> None: + """Associate with the package for provenance, but skip IR registration. + + Mirrors the Lang-only treatment in :class:`Variable` and :class:`Domain` + — distributions exist for the author and Lang-side compiler, but the + IR sees them only through claim/action metadata that references them. + Appending to ``pkg.distributions`` lets compile-time diagnostics + detect quantities declared but never referenced. + """ + pkg = _current_package.get() + source_module = None + if pkg is None: + from gaia.engine.lang.runtime.package import infer_package_and_module + + pkg, source_module = infer_package_and_module() + if pkg is not None: + self._source_module = source_module + self._package = pkg + # NO pkg._register_knowledge(self) — Lang-only. + pkg.distributions.append(self) + + # Restore object-identity hash. ``@dataclass(eq=False)`` does NOT + # auto-generate a structural hash, but Python sets ``__hash__ = None`` + # whenever ``__eq__`` is overridden (which we do below to return a + # BoolExpr). Without this explicit definition the class would be + # unhashable and break set/dict membership. Python short-circuits + # identical-object set membership before calling ``__eq__``, so + # ``dist in {dist}`` works correctly even though the eq op itself + # returns a BoolExpr rather than a bool. + def __hash__(self) -> int: + """Return object-identity hash so containers can store Distributions.""" + return id(self) + + # ----- Computational backend delegations -------------------------------- + # The ``_impl`` field is typed Any at the dataclass level so the dataclass + # machinery does not need to resolve the (TYPE_CHECKING-only) backend + # types. Each accessor casts to the appropriate type for delegation: + # the runtime-checkable :class:`Distribution` Protocol from + # ``gaia.engine.bayes.distributions.protocol`` for the standard methods, and + # to the concrete ``_BaseDistribution`` for ``_resolved_params`` (which + # the protocol does not surface). + + @property + def impl(self) -> _BaseDistribution: + """Return the wrapped computational distribution object.""" + return cast("_BaseDistribution", self._impl) + + @property + def kind(self) -> str: + """Distribution family identifier (``"normal"``, ``"beta"``, ...).""" + return cast("_DistImpl", self._impl).kind + + @property + def params(self) -> dict[str, Any]: + """Return the distribution parameter dictionary.""" + return dict(cast("_DistImpl", self._impl).params) + + def logpdf(self, x: float) -> float: + """Evaluate the log probability density at ``x`` (continuous).""" + return cast("_DistImpl", self._impl).logpdf(x) + + def logpmf(self, k: int) -> float: + """Evaluate the log probability mass at ``k`` (discrete).""" + return cast("_DistImpl", self._impl).logpmf(k) + + def support(self) -> tuple[float, float]: + """Return the inclusive support bounds of the distribution.""" + return cast("_DistImpl", self._impl).support() + + def cdf(self, x: float) -> float: + """Cumulative distribution function P(X <= x). + + Used at compile time to compute the prior of a predicate claim + (``P(k > c) = 1 - dist.cdf(c)``). Lazy import of scipy keeps this + out of the cold import path. + """ + from gaia.engine.bayes.adapters.scipy_backend import _to_scipy_dist + + impl = cast("_BaseDistribution", self._impl) + resolved = impl._resolved_params() + return float(_to_scipy_dist(impl.kind, resolved).cdf(x)) + + def model_dump(self) -> dict[str, Any]: + """Return the JSON-serialisable distribution literal payload.""" + return cast("_DistImpl", self._impl).model_dump() + + # ----- Operator overloading -- comparisons return BoolExpr -------------- + # + # The comparison operators below are intentionally not boolean; they + # construct a :class:`BoolExpr` describing the proposition (``k > 1e-3``). + # Authors pass these expressions to ``claim(content, expr)`` to get a + # discrete Claim whose prior is computed from the underlying distribution. + # ``BoolExpr.__bool__`` raises so accidental ``if k > 1e-3:`` use in + # Python control flow surfaces as a clear error rather than always-truthy. + + def __gt__(self, other: Any) -> BoolExpr: + """``k > x`` → :class:`BoolExpr` for use as a claim proposition.""" + from gaia.engine.lang.dsl.bool_expr import BoolExpr + + return BoolExpr(">", self, other) + + def __ge__(self, other: Any) -> BoolExpr: + """``k >= x`` → :class:`BoolExpr` for use as a claim proposition.""" + from gaia.engine.lang.dsl.bool_expr import BoolExpr + + return BoolExpr(">=", self, other) + + def __lt__(self, other: Any) -> BoolExpr: + """``k < x`` → :class:`BoolExpr` for use as a claim proposition.""" + from gaia.engine.lang.dsl.bool_expr import BoolExpr + + return BoolExpr("<", self, other) + + def __le__(self, other: Any) -> BoolExpr: + """``k <= x`` → :class:`BoolExpr` for use as a claim proposition.""" + from gaia.engine.lang.dsl.bool_expr import BoolExpr + + return BoolExpr("<=", self, other) + + def __eq__(self, other: Any) -> Any: + """``k == x`` → :class:`BoolExpr` (used as equation proposition). + + Note: this overrides Python's structural equality to return a BoolExpr + rather than ``bool``. Use ``a is b`` or ``a.label == b.label`` for + identity checks. ``__hash__`` is preserved as identity hash so set/dict + membership still works. + """ + from gaia.engine.lang.dsl.bool_expr import BoolExpr + + return BoolExpr("==", self, other) + + def __ne__(self, other: Any) -> Any: + """``k != x`` → :class:`BoolExpr` (rarely useful but symmetric).""" + from gaia.engine.lang.dsl.bool_expr import BoolExpr + + return BoolExpr("!=", self, other) + + # ----- Operator overloading -- arithmetic returns DerivedDistribution --- + + def __add__(self, other: Any) -> DerivedDistribution: + """``k + x`` → :class:`DerivedDistribution` (for equation RHS).""" + from gaia.engine.lang.dsl.bool_expr import DerivedDistribution + + return DerivedDistribution("+", self, other) + + def __radd__(self, other: Any) -> DerivedDistribution: + """Reflected ``x + k`` → :class:`DerivedDistribution`.""" + from gaia.engine.lang.dsl.bool_expr import DerivedDistribution + + return DerivedDistribution("+", other, self) + + def __sub__(self, other: Any) -> DerivedDistribution: + """``k - x`` → :class:`DerivedDistribution`.""" + from gaia.engine.lang.dsl.bool_expr import DerivedDistribution + + return DerivedDistribution("-", self, other) + + def __rsub__(self, other: Any) -> DerivedDistribution: + """Reflected ``x - k`` → :class:`DerivedDistribution`.""" + from gaia.engine.lang.dsl.bool_expr import DerivedDistribution + + return DerivedDistribution("-", other, self) + + def __mul__(self, other: Any) -> DerivedDistribution: + """``k * x`` → :class:`DerivedDistribution`.""" + from gaia.engine.lang.dsl.bool_expr import DerivedDistribution + + return DerivedDistribution("*", self, other) + + def __rmul__(self, other: Any) -> DerivedDistribution: + """Reflected ``x * k`` → :class:`DerivedDistribution`.""" + from gaia.engine.lang.dsl.bool_expr import DerivedDistribution + + return DerivedDistribution("*", other, self) + + def __truediv__(self, other: Any) -> DerivedDistribution: + """``k / x`` → :class:`DerivedDistribution`.""" + from gaia.engine.lang.dsl.bool_expr import DerivedDistribution + + return DerivedDistribution("/", self, other) + + def __rtruediv__(self, other: Any) -> DerivedDistribution: + """Reflected ``x / k`` → :class:`DerivedDistribution`.""" + from gaia.engine.lang.dsl.bool_expr import DerivedDistribution + + return DerivedDistribution("/", other, self) + + def __neg__(self) -> DerivedDistribution: + """Unary ``-k`` → :class:`DerivedDistribution`.""" + from gaia.engine.lang.dsl.bool_expr import DerivedDistribution + + return DerivedDistribution("-", 0, self) + + +# --------------------------------------------------------------------------- +# Family-specific factories — top-level author API +# +# These are the canonical author-facing entry points. They accept either bare +# numeric scalars or :class:`gaia.unit.Quantity` values for parameters. When +# Quantities are supplied, the unit string is recorded on the resulting +# Distribution's ``metadata['units']`` (per-param) and, for distributions +# with a shared location/scale, ``metadata['unit']`` (the unit of the +# underlying random variable). The pydantic ``_BaseDistribution`` continues +# to receive only numeric magnitudes so its frozen-Pydantic validation pass +# is unchanged. +# +# Per-distribution unit semantics: +# ``Normal``, ``StudentT``, ``Cauchy`` — location/scale share a unit +# ``Gamma`` (alpha, rate) — alpha dimensionless; +# ``rate`` carries inverse +# random-variable unit +# ``Exponential`` (rate) — ``rate`` carries inverse +# random-variable unit +# ``Poisson`` (rate) — dimensionless expected +# count; no unit-typed rate +# ``LogNormal``, ``Beta``, ``ChiSquared``, — all parameters are +# ``Binomial`` conventionally dimensionless; +# raise if a Quantity is +# passed (use the content +# string to convey the unit +# of the underlying RV). +# --------------------------------------------------------------------------- + + +def _build_distribution( + content: str, + *, + impl_cls: type[_BaseDistribution], + raw_params: dict[str, Any], + location_scale_group: tuple[str, ...] = (), + unit_carriers: tuple[str, ...] = (), + inverse_unit_carriers: tuple[str, ...] = (), + dimensionless_params: tuple[str, ...] = (), + distribution_name: str, + kwargs: dict[str, Any], +) -> Distribution: + """Common factory body — coerce Quantities, validate units, construct.""" + magnitudes, units = _coerce_quantity_params(raw_params) + if dimensionless_params: + offending = {p: units[p] for p in dimensionless_params if p in units} + if offending: + raise ValueError( + f"{distribution_name} parameters {sorted(offending)} are " + "dimensionless and must be passed as bare scalars; got " + f"unit-typed values {offending}. Encode the random " + "variable's unit in the content string instead." + ) + shared_unit = ( + _validate_shared_unit(units, location_scale_group, distribution_name) + if location_scale_group + else None + ) + impl = impl_cls(**magnitudes) + new_kwargs = dict(kwargs) + new_kwargs["metadata"] = _attach_units(new_kwargs.get("metadata"), units, shared_unit) + if inverse_unit_carriers: + carrier_units = {p: units[p] for p in inverse_unit_carriers if p in units} + if carrier_units: + new_kwargs["metadata"] = _attach_units( + new_kwargs.get("metadata"), {}, _inverse_unit(next(iter(carrier_units.values()))) + ) + elif unit_carriers: + carrier_units = {p: units[p] for p in unit_carriers if p in units} + if carrier_units: + new_kwargs["metadata"] = _attach_units( + new_kwargs.get("metadata"), {}, next(iter(carrier_units.values())) + ) + return Distribution(content, impl=impl, **new_kwargs) + + +def Normal( + content: str, + *, + mu: Any, + sigma: Any, + **kwargs: Any, +) -> Distribution: + """Create a Normal-distributed continuous quantity with a name. + + ``mu`` and ``sigma`` may both be bare scalars or both be + :class:`gaia.unit.Quantity` values sharing a unit; mixing them raises. + """ + from gaia.engine.bayes.distributions.continuous import Normal as _BaseNormal + + return _build_distribution( + content, + impl_cls=_BaseNormal, + raw_params={"mu": mu, "sigma": sigma}, + location_scale_group=("mu", "sigma"), + distribution_name="Normal", + kwargs=kwargs, + ) + + +def LogNormal( + content: str, + *, + mu: Any, + sigma: Any, + **kwargs: Any, +) -> Distribution: + """Create a LogNormal-distributed continuous quantity with a name. + + The LogNormal parameters live in log-space; ``mu`` and ``sigma`` must be + dimensionless scalars. Encode the unit of the underlying random variable + in the content string (e.g. ``LogNormal("k / s^-1", mu=log(1e-3), sigma=2)``). + """ + from gaia.engine.bayes.distributions.continuous import LogNormal as _BaseLogNormal + + return _build_distribution( + content, + impl_cls=_BaseLogNormal, + raw_params={"mu": mu, "sigma": sigma}, + dimensionless_params=("mu", "sigma"), + distribution_name="LogNormal", + kwargs=kwargs, + ) + + +def Beta( + content: str, + *, + alpha: Any, + beta: Any, + **kwargs: Any, +) -> Distribution: + """Create a Beta-distributed continuous quantity with a name. + + Beta shape parameters ``alpha`` and ``beta`` are dimensionless. + """ + from gaia.engine.bayes.distributions.continuous import Beta as _BaseBeta + + return _build_distribution( + content, + impl_cls=_BaseBeta, + raw_params={"alpha": alpha, "beta": beta}, + dimensionless_params=("alpha", "beta"), + distribution_name="Beta", + kwargs=kwargs, + ) + + +def Exponential( + content: str, + *, + rate: Any, + **kwargs: Any, +) -> Distribution: + """Create an Exponential-distributed continuous quantity with a name. + + ``rate`` may be a bare scalar or a :class:`gaia.unit.Quantity` (typically + ``1 / time``). The corresponding random variable's unit is the inverse of + ``rate``'s unit; for predicate / observe consistency we record that + inverse unit as the distribution's canonical ``metadata["unit"]``. + """ + from gaia.engine.bayes.distributions.continuous import Exponential as _BaseExponential + + return _build_distribution( + content, + impl_cls=_BaseExponential, + raw_params={"rate": rate}, + inverse_unit_carriers=("rate",), + distribution_name="Exponential", + kwargs=kwargs, + ) + + +def Gamma( + content: str, + *, + alpha: Any, + rate: Any, + **kwargs: Any, +) -> Distribution: + """Create a Gamma-distributed continuous quantity with a name. + + ``alpha`` is dimensionless; ``rate`` may carry the inverse unit of the + underlying random variable (typically ``1 / x``). + """ + from gaia.engine.bayes.distributions.continuous import Gamma as _BaseGamma + + return _build_distribution( + content, + impl_cls=_BaseGamma, + raw_params={"alpha": alpha, "rate": rate}, + dimensionless_params=("alpha",), + inverse_unit_carriers=("rate",), + distribution_name="Gamma", + kwargs=kwargs, + ) + + +def StudentT( + content: str, + *, + df: Any, + mu: Any, + sigma: Any, + **kwargs: Any, +) -> Distribution: + """Create a Student-t distributed continuous quantity with a name. + + ``df`` is dimensionless; ``mu`` and ``sigma`` share the location/scale + unit of the underlying random variable. + """ + from gaia.engine.bayes.distributions.continuous import StudentT as _BaseStudentT + + return _build_distribution( + content, + impl_cls=_BaseStudentT, + raw_params={"df": df, "mu": mu, "sigma": sigma}, + location_scale_group=("mu", "sigma"), + dimensionless_params=("df",), + distribution_name="StudentT", + kwargs=kwargs, + ) + + +def Cauchy( + content: str, + *, + mu: Any, + gamma: Any, + **kwargs: Any, +) -> Distribution: + """Create a Cauchy-distributed continuous quantity with a name. + + ``mu`` and ``gamma`` share the location/scale unit of the underlying + random variable. + """ + from gaia.engine.bayes.distributions.continuous import Cauchy as _BaseCauchy + + return _build_distribution( + content, + impl_cls=_BaseCauchy, + raw_params={"mu": mu, "gamma": gamma}, + location_scale_group=("mu", "gamma"), + distribution_name="Cauchy", + kwargs=kwargs, + ) + + +def ChiSquared( + content: str, + *, + df: Any, + **kwargs: Any, +) -> Distribution: + """Create a Chi-squared distributed continuous quantity with a name. + + ``df`` is dimensionless. + """ + from gaia.engine.bayes.distributions.continuous import ChiSquared as _BaseChiSquared + + return _build_distribution( + content, + impl_cls=_BaseChiSquared, + raw_params={"df": df}, + dimensionless_params=("df",), + distribution_name="ChiSquared", + kwargs=kwargs, + ) + + +def Binomial( + content: str, + *, + n: Any, + p: Any, + **kwargs: Any, +) -> Distribution: + """Create a Binomial-distributed discrete quantity with a name. + + ``n`` and ``p`` are dimensionless. + """ + from gaia.engine.bayes.distributions.discrete import Binomial as _BaseBinomial + + return _build_distribution( + content, + impl_cls=_BaseBinomial, + raw_params={"n": n, "p": p}, + dimensionless_params=("n", "p"), + distribution_name="Binomial", + kwargs=kwargs, + ) + + +def Poisson( + content: str, + *, + rate: Any, + **kwargs: Any, +) -> Distribution: + """Create a Poisson-distributed discrete quantity with a name. + + ``rate`` is the dimensionless expected count for the interval encoded by + the quantity name. Pass a bare scalar; unit-typed rates are rejected. + """ + from gaia.engine.bayes.distributions.discrete import Poisson as _BasePoisson + + return _build_distribution( + content, + impl_cls=_BasePoisson, + raw_params={"rate": rate}, + dimensionless_params=("rate",), + distribution_name="Poisson", + kwargs=kwargs, + ) + + +__all__ = [ + "Beta", + "Binomial", + "Cauchy", + "ChiSquared", + "Distribution", + "Exponential", + "Gamma", + "LogNormal", + "Normal", + "Poisson", + "StudentT", +] diff --git a/gaia/engine/lang/runtime/domain.py b/gaia/engine/lang/runtime/domain.py new file mode 100644 index 000000000..3d01151b2 --- /dev/null +++ b/gaia/engine/lang/runtime/domain.py @@ -0,0 +1,54 @@ +"""Domain — a user-declared typed sort backing Variable types and quantification. + +Lang-only: subclasses Knowledge for identity/provenance, but overrides +__post_init__ to skip the IR-bound knowledge map registration. See spec §2.4. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from gaia.engine.lang.runtime.knowledge import Knowledge, _current_package + + +@dataclass(init=False, eq=False) +class Domain(Knowledge): + """A user-declared, finite, enumerable typed sort. + + Subclasses Knowledge so it carries identity, provenance, and metadata. + Lang-only: does NOT enter the package's IR-bound knowledge map. + """ + + members: list[Any] = field(default_factory=list) + + def __init__( + self, + content: str, + *, + members: list[Any], + format: str = "markdown", + **kwargs: Any, + ) -> None: + """Create a finite enumerable authoring domain.""" + if not isinstance(members, list): + raise TypeError("members must be a list") + if len(members) == 0: + raise ValueError("members must be a non-empty list") + super().__init__(content=content, type="domain", format=format, **kwargs) + self.members = list(members) + + def __post_init__(self) -> None: + """Associate the domain with package provenance without IR registration.""" + # Override Knowledge.__post_init__: associate with the package for provenance, + # but DO NOT call pkg._register_knowledge — Domain is Lang-only (spec §2.4). + pkg = _current_package.get() + source_module = None + if pkg is None: + from gaia.engine.lang.runtime.package import infer_package_and_module + + pkg, source_module = infer_package_and_module() + if pkg is not None: + self._source_module = source_module + self._package = pkg + # No pkg._register_knowledge(self) — Lang-only. diff --git a/gaia/engine/lang/runtime/knowledge.py b/gaia/engine/lang/runtime/knowledge.py new file mode 100644 index 000000000..78a111fa3 --- /dev/null +++ b/gaia/engine/lang/runtime/knowledge.py @@ -0,0 +1,310 @@ +"""Gaia Lang v6 Knowledge class hierarchy.""" + +from __future__ import annotations + +from contextvars import ContextVar +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING, Any, ClassVar + +from gaia.engine.lang.runtime.param import UNBOUND + +if TYPE_CHECKING: + from gaia.engine.lang.runtime.action import Action + from gaia.engine.lang.runtime.package import CollectedPackage + +_current_package: ContextVar[CollectedPackage | None] = ContextVar("_current_package", default=None) + + +class _SafeFormatDict(dict[str, object]): + """Return {key} for missing keys instead of raising KeyError.""" + + def __missing__(self, key: str) -> str: + return f"{{{key}}}" + + +@dataclass +class Knowledge: + """Base knowledge node. Plain text plus metadata.""" + + content: str + format: str = "markdown" + type: str = "knowledge" + title: str | None = None + background: list[Knowledge] = field(default_factory=list) + parameters: list[dict[str, Any]] = field(default_factory=list) + provenance: list[dict[str, str]] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + label: str | None = None + strategy: Any | None = None + _package: CollectedPackage | None = field(default=None, init=False, repr=False, compare=False) + _source_module: str | None = field(default=None, init=False, repr=False, compare=False) + _declaration_index: int | None = field(default=None, init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + """Register the knowledge node with the active or inferred package.""" + pkg = _current_package.get() + source_module = None + if pkg is None: + from gaia.engine.lang.runtime.package import infer_package_and_module + + pkg, source_module = infer_package_and_module() + if pkg is not None: + self._source_module = source_module + self._package = pkg + pkg._register_knowledge(self) + + def __hash__(self) -> int: + """Return object-identity hash for mutable runtime nodes.""" + return id(self) + + +@dataclass(init=False, eq=False) +class Note(Knowledge): + """Non-probabilistic contextual material. Does not enter BP.""" + + def __init__(self, content: str, *, format: str = "markdown", **kwargs: Any) -> None: + """Create a non-probabilistic note.""" + if "prior" in kwargs: + raise TypeError("Note cannot have a prior.") + super().__init__(content=content, type="note", format=format, **kwargs) + + +@dataclass(init=False, eq=False) +class Context(Note): + """Deprecated compatibility alias for Note.""" + + def __init__(self, content: str, *, format: str = "markdown", **kwargs: Any) -> None: + """Create a deprecated context note alias.""" + if "prior" in kwargs: + raise TypeError("Context cannot have a prior.") + metadata = dict(kwargs.pop("metadata", {}) or {}) + metadata.setdefault("legacy_kind", "context") + super().__init__(content=content, format=format, metadata=metadata, **kwargs) + + +@dataclass(init=False, eq=False) +class Setting(Note): + """Deprecated compatibility alias for Note.""" + + def __init__(self, content: str, *, format: str = "markdown", **kwargs: Any) -> None: + """Create a deprecated setting note alias.""" + if "prior" in kwargs: + raise TypeError("Setting cannot have a prior.") + metadata = dict(kwargs.pop("metadata", {}) or {}) + metadata.setdefault("legacy_kind", "setting") + super().__init__(content=content, format=format, metadata=metadata, **kwargs) + + +class ClaimKind(Enum): + """Shape discriminator for the structured-content of a Claim (spec §4.2). + + GENERAL — default; formula optional, no structural commitments + PARAMETER — asserts a Variable takes a specific value (Equals(var, const)) + QUANTIFIED — top-level quantifier (Forall/Exists) in formula + + NOT a "role" (hypothesis/prediction/observation-as-evidence) — those live + on action graph nodes. Observation is an Observe action, not a Claim kind. + NOT helper-claim metadata. + """ + + GENERAL = "general" + PARAMETER = "parameter" + QUANTIFIED = "quantified" + + +def _validate_formula_and_kind(formula: Any, kind: ClaimKind) -> None: + if formula is not None: + from gaia.engine.lang.formula.predicate import is_formula + + if not is_formula(formula): + raise TypeError(f"formula must be a Formula or None, got {type(formula).__name__}") + if not isinstance(kind, ClaimKind): + raise TypeError(f"kind must be a ClaimKind member, got {type(kind).__name__}") + + +def _split_param_kwargs( + kwargs: dict[str, Any], + param_fields: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any]]: + param_values: dict[str, Any] = {} + knowledge_kwargs: dict[str, Any] = {} + for key, value in kwargs.items(): + if key in param_fields: + param_values[key] = value + else: + knowledge_kwargs[key] = value + return param_values, knowledge_kwargs + + +def _parameter_entries( + param_fields: dict[str, Any], + param_values: dict[str, Any], +) -> list[dict[str, Any]]: + params = [] + for name, ann in param_fields.items(): + val = param_values.get(name, UNBOUND) + stored_val = val.value if isinstance(val, Enum) else val + params.append( + { + "name": name, + "type": ann.__name__ if isinstance(ann, type) else str(ann), + "value": stored_val, + } + ) + return params + + +def _render_templated_content( + content: str | None, + *, + template: str, + param_fields: dict[str, Any], + param_values: dict[str, Any], + knowledge_kwargs: dict[str, Any], +) -> str | None: + if content is not None or not template or not param_fields: + return content + + metadata = dict(knowledge_kwargs.get("metadata") or {}) + metadata["content_template"] = template + knowledge_kwargs["metadata"] = metadata + rendered_template = template + render_values: dict[str, Any] = {} + for name in param_fields: + val = param_values.get(name, UNBOUND) + if val is UNBOUND: + continue + if isinstance(val, Knowledge): + ref = f"[@{val.label or '?'}]" + render_values[name] = ref + rendered_template = rendered_template.replace(f"[@{name}]", ref) + elif isinstance(val, Enum): + render_values[name] = val.value + else: + render_values[name] = val + return rendered_template.format_map(_SafeFormatDict(render_values)) + + +@dataclass(init=False, eq=False) +class Claim(Knowledge): + """Proposition with prior. Participates in BP.""" + + prior: float | None = None + from_actions: list[Action] = field(default_factory=list) + formula: Any = None + kind: ClaimKind = ClaimKind.GENERAL + _param_fields: ClassVar[dict[str, Any]] = {} + + def __init_subclass__(cls, **kwargs: Any) -> None: + """Collect subclass-specific parameter fields for templated claims.""" + super().__init_subclass__(**kwargs) + base_fields = { + "content", + "format", + "type", + "title", + "background", + "parameters", + "provenance", + "metadata", + "label", + "strategy", + "prior", + "from_actions", + "supported_by", + "supports", + "targets", + "formula", + "kind", + } + cls._param_fields = { + name: ann + for name, ann in getattr(cls, "__annotations__", {}).items() + if name not in base_fields and not name.startswith("_") + } + + def __bool__(self) -> bool: + """Reject accidental use of Claim objects in Python truth tests.""" + raise TypeError( + "Claim objects do not have Python truth values. Use claim(formula=...) " + "with ClaimAtom/land/lor/lnot to build structured formula claims; " + "legacy ~A, A & B, and A | B shortcuts still exist but emit " + "DeprecationWarning." + ) + + def __invert__(self) -> Claim: + """Create the deprecated propositional negation helper.""" + from gaia.engine.lang.dsl.propositional import not_ + + return not_(self) + + def __and__(self, other: Claim) -> Claim: + """Create the deprecated propositional conjunction helper.""" + if not isinstance(other, Claim): + return NotImplemented + from gaia.engine.lang.dsl.propositional import and_ + + return and_(self, other) + + def __or__(self, other: Claim) -> Claim: + """Create the deprecated propositional disjunction helper.""" + if not isinstance(other, Claim): + return NotImplemented + from gaia.engine.lang.dsl.propositional import or_ + + return or_(self, other) + + def __init__( + self, + content: str | None = None, + *, + prior: float | None = None, + from_actions: list[Any] | None = None, + formula: Any = None, + kind: ClaimKind = ClaimKind.GENERAL, + **kwargs: Any, + ) -> None: + """Create a probabilistic claim node.""" + _validate_formula_and_kind(formula, kind) + param_fields = getattr(self.__class__, "_param_fields", {}) + param_values, knowledge_kwargs = _split_param_kwargs(kwargs, param_fields) + params = _parameter_entries(param_fields, param_values) + + template = self.__class__.__doc__ or "" + content = _render_templated_content( + content, + template=template, + param_fields=param_fields, + param_values=param_values, + knowledge_kwargs=knowledge_kwargs, + ) + + for name, val in param_values.items(): + object.__setattr__(self, name, val) + + super().__init__( + content=content or "", + type="claim", + parameters=params or knowledge_kwargs.pop("parameters", []), + **knowledge_kwargs, + ) + self.prior = prior + self.from_actions = list(from_actions or []) + self.formula = formula + self.kind = kind + + +@dataclass(init=False, eq=False) +class Question(Knowledge): + """Open inquiry. Does not enter BP.""" + + targets: list[Claim] = field(default_factory=list) + + def __init__(self, content: str, **kwargs: Any) -> None: + """Create a non-probabilistic question.""" + if "prior" in kwargs: + raise TypeError("Question cannot have a prior.") + targets = kwargs.pop("targets", []) + super().__init__(content=content, type="question", **kwargs) + self.targets = list(targets) diff --git a/gaia/lang/runtime/nodes.py b/gaia/engine/lang/runtime/nodes.py similarity index 52% rename from gaia/lang/runtime/nodes.py rename to gaia/engine/lang/runtime/nodes.py index 716a9c4cc..231e0153b 100644 --- a/gaia/lang/runtime/nodes.py +++ b/gaia/engine/lang/runtime/nodes.py @@ -2,47 +2,10 @@ from __future__ import annotations -from contextvars import ContextVar from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any +from typing import Any -if TYPE_CHECKING: - from gaia.lang.runtime.package import CollectedPackage - -_current_package: ContextVar[CollectedPackage | None] = ContextVar("_current_package", default=None) - - -@dataclass -class Knowledge: - """A knowledge declaration (claim, setting, or question).""" - - content: str - type: str # "claim" | "setting" | "question" - title: str | None = None - background: list[Knowledge] = field(default_factory=list) - parameters: list[dict] = field(default_factory=list) - provenance: list[dict[str, str]] = field(default_factory=list) - metadata: dict[str, Any] = field(default_factory=dict) - label: str | None = None - strategy: Strategy | None = None - _package: CollectedPackage | None = field(default=None, init=False, repr=False, compare=False) - _source_module: str | None = field(default=None, init=False, repr=False, compare=False) - _declaration_index: int | None = field(default=None, init=False, repr=False, compare=False) - - def __post_init__(self): - pkg = _current_package.get() - source_module = None - if pkg is None: - from gaia.lang.runtime.package import infer_package_and_module - - pkg, source_module = infer_package_and_module() - if pkg is not None: - self._source_module = source_module - self._package = pkg - pkg._register_knowledge(self) - - def __hash__(self) -> int: - return id(self) +from gaia.engine.lang.runtime.knowledge import Knowledge, _current_package @dataclass @@ -69,17 +32,21 @@ class Strategy: reason: ReasonInput = "" metadata: dict[str, Any] = field(default_factory=dict) label: str | None = None - formal_expr: list | None = None + formal_expr: list[Any] | None = None sub_strategies: list[Strategy] = field(default_factory=list) composition_warrant: Knowledge | None = None + _source_module: str | None = field(default=None, init=False, repr=False, compare=False) - def __post_init__(self): + def __post_init__(self) -> None: + """Register the strategy with the active or inferred package.""" pkg = _current_package.get() + source_module = None if pkg is None: - from gaia.lang.runtime.package import infer_package_from_callstack + from gaia.engine.lang.runtime.package import infer_package_and_module - pkg = infer_package_from_callstack() + pkg, source_module = infer_package_and_module() if pkg is not None: + self._source_module = source_module pkg._register_strategy(self) @@ -93,10 +60,11 @@ class Operator: reason: str = "" metadata: dict[str, Any] = field(default_factory=dict) - def __post_init__(self): + def __post_init__(self) -> None: + """Register the operator with the active or inferred package.""" pkg = _current_package.get() if pkg is None: - from gaia.lang.runtime.package import infer_package_from_callstack + from gaia.engine.lang.runtime.package import infer_package_from_callstack pkg = infer_package_from_callstack() if pkg is not None: diff --git a/gaia/lang/runtime/package.py b/gaia/engine/lang/runtime/package.py similarity index 66% rename from gaia/lang/runtime/package.py rename to gaia/engine/lang/runtime/package.py index 5e174d0ce..40bb77286 100644 --- a/gaia/lang/runtime/package.py +++ b/gaia/engine/lang/runtime/package.py @@ -4,9 +4,17 @@ import inspect import sys +from contextvars import Token from pathlib import Path +from types import TracebackType +from typing import TYPE_CHECKING, Any -from gaia.lang.runtime.nodes import Knowledge, Operator, Strategy, _current_package +from gaia.engine.lang.runtime.knowledge import Knowledge, _current_package +from gaia.engine.lang.runtime.nodes import Operator, Strategy + +if TYPE_CHECKING: + from gaia.engine.lang.runtime.action import GaiaGraph, MaterializationLink + from gaia.engine.lang.runtime.distribution import Distribution try: import tomllib @@ -17,28 +25,50 @@ class CollectedPackage: """Internal collector for declarations belonging to a knowledge package.""" - def __init__(self, name: str, *, namespace: str = "github", version: str = "0.1.0"): + def __init__(self, name: str, *, namespace: str = "github", version: str = "0.1.0") -> None: + """Create an empty declaration collector for a package.""" self.name = name self.namespace = namespace self.version = version self.knowledge: list[Knowledge] = [] self.strategies: list[Strategy] = [] self.operators: list[Operator] = [] - self._token = None + self.actions: list[GaiaGraph] = [] + self.materializations: list[MaterializationLink] = [] + # Lang-only registry of Distribution objects declared while this + # package was active. Distributions are NOT added to ``knowledge`` + # (they are not IR-bound — see gaia/engine/lang/runtime/distribution.py), + # but the declaration list lets compile-time diagnostics detect + # quantities that are declared but never referenced. + self.distributions: list[Distribution] = [] + self._token: Token[CollectedPackage | None] | None = None self._module_counters: dict[str | None, int] = {} self._module_order: list[str] = [] + self._module_titles: dict[str, str] | None = None self._exported_labels: set[str] = set() + self._resolution_policy: Any | None = None - def __enter__(self): + def __enter__(self) -> CollectedPackage: + """Activate this package collector for module-scope declarations.""" self._token = _current_package.set(self) return self - def __exit__(self, *exc): - _current_package.reset(self._token) + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + """Deactivate this package collector.""" + if self._token is not None: + _current_package.reset(self._token) self._token = None - def _register_knowledge(self, k: Knowledge): + def _register_knowledge(self, k: Knowledge) -> None: self.knowledge.append(k) + from gaia.engine.lang.runtime.composition import _capture_registered + + _capture_registered(k) module = k._source_module if module not in self._module_counters: if module is not None: @@ -47,14 +77,30 @@ def _register_knowledge(self, k: Knowledge): k._declaration_index = self._module_counters[module] self._module_counters[module] += 1 - def _register_strategy(self, s: Strategy): + def _register_strategy(self, s: Strategy) -> None: self.strategies.append(s) + from gaia.engine.lang.runtime.composition import _capture_registered + + _capture_registered(s) - def _register_operator(self, o: Operator): + def _register_operator(self, o: Operator) -> None: self.operators.append(o) + from gaia.engine.lang.runtime.composition import _capture_registered + + _capture_registered(o) + + def _register_action(self, a: GaiaGraph) -> None: + self.actions.append(a) + from gaia.engine.lang.runtime.composition import _capture_registered + + _capture_registered(a) + + def _register_materialization(self, link: MaterializationLink) -> None: + self.materializations.append(link) @property def exported(self) -> list[str]: + """Return exported knowledge labels in declaration order.""" if self._exported_labels: return [k.label for k in self.knowledge if k.label in self._exported_labels] return [k.label for k in self.knowledge if k.label is not None] @@ -77,6 +123,7 @@ def _find_pyproject(start: Path) -> Path | None: def pyproject_for_module(module_name: str) -> Path | None: + """Return the nearest pyproject.toml for a loaded module.""" if module_name in _module_pyproject_cache: return _module_pyproject_cache[module_name] @@ -132,7 +179,7 @@ def _caller_module_name() -> str | None: frame = frame.f_back while frame is not None: module_name = frame.f_globals.get("__name__") - if isinstance(module_name, str) and not module_name.startswith("gaia.lang"): + if isinstance(module_name, str) and not module_name.startswith("gaia."): return module_name frame = frame.f_back finally: @@ -141,6 +188,7 @@ def _caller_module_name() -> str | None: def infer_package_from_callstack() -> CollectedPackage | None: + """Infer the active knowledge package from the first non-Gaia caller.""" pkg, _ = infer_package_and_module() return pkg @@ -169,10 +217,12 @@ def infer_package_and_module() -> tuple[CollectedPackage | None, str | None]: def get_inferred_package(pyproject: Path) -> CollectedPackage | None: + """Return the cached inferred package for a pyproject path.""" return _inferred_packages.get(pyproject.resolve()) def reset_inferred_package(pyproject: Path, *, module_name: str | None = None) -> None: + """Clear inferred-package and module-to-pyproject caches.""" pyproject = pyproject.resolve() _inferred_packages.pop(pyproject, None) stale = [name for name, cached in _module_pyproject_cache.items() if cached == pyproject] diff --git a/gaia/engine/lang/runtime/param.py b/gaia/engine/lang/runtime/param.py new file mode 100644 index 000000000..a470e32f1 --- /dev/null +++ b/gaia/engine/lang/runtime/param.py @@ -0,0 +1,35 @@ +"""Parameterization primitives for Gaia Lang v6.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +class _Unbound: + """Sentinel for unbound parameters. Not None.""" + + _instance: _Unbound | None = None + + def __new__(cls) -> _Unbound: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self) -> str: + return "UNBOUND" + + def __bool__(self) -> bool: + return False + + +UNBOUND = _Unbound() + + +@dataclass +class Param: + """A single parameter in a parameterized Knowledge type.""" + + name: str + type: type + value: Any = field(default_factory=lambda: UNBOUND) diff --git a/gaia/engine/lang/runtime/roles.py b/gaia/engine/lang/runtime/roles.py new file mode 100644 index 000000000..85a371b1b --- /dev/null +++ b/gaia/engine/lang/runtime/roles.py @@ -0,0 +1,316 @@ +"""Role projection over authored Gaia Lang actions.""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +from gaia.engine.lang.runtime.action import ( + Action, + Associate, + CandidateRelation, + Compose, + Compute, + Contradict, + Decompose, + DependsOn, + Derive, + Equal, + Exclusive, + Infer, + Observe, + Support, +) +from gaia.engine.lang.runtime.knowledge import Claim + +if TYPE_CHECKING: + from gaia.engine.lang.runtime.package import CollectedPackage + + +@dataclass(frozen=True) +class RoleOccurrence: + """A claim role at a specific occurrence in an authored action.""" + + claim: Claim + role: str + action: Action + action_type: str + action_label: str | None = None + path: tuple[str, ...] = () + source: str = "explicit_field" + + +type ActionGraph = "CollectedPackage | Sequence[Action]" +RoleAdder = Callable[[Claim | None, str], None] +RoleHandler = Callable[[Action, RoleAdder], None] + +_ROLE_HANDLERS: dict[type[Action], RoleHandler] = {} + + +def register_role_handler(action_type: type[Action], handler: RoleHandler) -> None: + """Register role projection for an optional action subclass.""" + if not isinstance(action_type, type) or not issubclass(action_type, Action): + raise TypeError("action_type must be an Action subclass") + _ROLE_HANDLERS[action_type] = handler + + +def roles_for_claim( + claim: Claim, + graph: ActionGraph, + *, + include_background: bool = True, + include_warrants: bool = True, +) -> tuple[RoleOccurrence, ...]: + """Return all authored action roles for ``claim``.""" + return tuple( + occurrence + for occurrence in _iter_role_occurrences( + graph, + include_background=include_background, + include_warrants=include_warrants, + ) + if occurrence.claim is claim + ) + + +def roles_for_package( + graph: ActionGraph, + *, + include_background: bool = True, + include_warrants: bool = True, +) -> dict[Claim, tuple[RoleOccurrence, ...]]: + """Index authored action roles by claim identity.""" + roles: dict[Claim, list[RoleOccurrence]] = defaultdict(list) + for occurrence in _iter_role_occurrences( + graph, + include_background=include_background, + include_warrants=include_warrants, + ): + roles[occurrence.claim].append(occurrence) + return {claim: tuple(occurrences) for claim, occurrences in roles.items()} + + +def _graph_actions(graph: ActionGraph) -> Sequence[Action]: + return tuple(cast(Sequence[Action], getattr(graph, "actions", graph))) + + +def _iter_role_occurrences( + graph: ActionGraph, + *, + include_background: bool, + include_warrants: bool, +) -> tuple[RoleOccurrence, ...]: + occurrences: list[RoleOccurrence] = [] + for action in _graph_actions(graph): + _collect_action_roles( + action, + occurrences, + path=(), + include_background=include_background, + include_warrants=include_warrants, + ) + return tuple(occurrences) + + +def _collect_observation_action_roles(action: Action, add: RoleAdder) -> bool: + """Collect roles for observation, compute, and derive actions.""" + if isinstance(action, Observe): + add(action.conclusion, "observation") + for given in action.given: + add(given, "observation_context") + elif isinstance(action, Compute): + add(action.conclusion, "computed_result") + for given in action.given: + add(given, "compute_input") + elif isinstance(action, Derive): + add(action.conclusion, "conclusion") + for given in action.given: + add(given, "premise") + else: + return False + return True + + +def _collect_dependency_action_roles(action: Action, add: RoleAdder) -> bool: + """Collect roles for dependency and candidate-relation actions.""" + if isinstance(action, DependsOn): + add(action.conclusion, "dependency_target") + for given in action.given: + add(given, "unformalized_dependency") + elif isinstance(action, CandidateRelation): + pattern = action.pattern or "relation" + for claim in action.claims: + add(claim, f"candidate_{pattern}_target") + else: + return False + return True + + +def _collect_infer_action_roles(action: Action, add: RoleAdder) -> bool: + """Collect roles for probabilistic infer and associate actions.""" + if isinstance(action, Infer): + add(action.hypothesis, "hypothesis") + add(action.evidence, "evidence") + for given in action.given: + add(given, "condition") + add(action.helper, "likelihood_helper") + if isinstance(action.p_e_given_h, Claim): + add(action.p_e_given_h, "likelihood_parameter") + if isinstance(action.p_e_given_not_h, Claim): + add(action.p_e_given_not_h, "likelihood_parameter") + elif isinstance(action, Associate): + add(action.a, "association_target") + add(action.b, "association_target") + add(action.helper, "association_helper") + else: + return False + return True + + +def _collect_relation_action_roles(action: Action, add: RoleAdder) -> bool: + """Collect roles for pairwise relation operator actions.""" + if isinstance(action, Equal): + add(action.a, "equivalent_claim") + add(action.b, "equivalent_claim") + add(action.helper, "equivalence_helper") + elif isinstance(action, Contradict): + add(action.a, "contradiction_target") + add(action.b, "contradiction_target") + add(action.helper, "contradiction_helper") + elif isinstance(action, Exclusive): + add(action.a, "exclusive_alternative") + add(action.b, "exclusive_alternative") + add(action.helper, "exclusivity_helper") + else: + return False + return True + + +def _collect_structural_action_roles( + action: Action, + add: RoleAdder, + occurrences: list[RoleOccurrence], + *, + path: tuple[str, ...], + include_background: bool, + include_warrants: bool, +) -> bool: + """Collect roles for decomposition, composition, and support actions.""" + if isinstance(action, Decompose): + add(action.whole, "decomposition_whole") + for part in action.parts: + add(part, "decomposition_part") + elif isinstance(action, Compose): + for item in action.inputs: + if isinstance(item, Claim): + add(item, "composition_input") + add(action.conclusion, "composition_conclusion") + for index, child in enumerate(action.actions): + if isinstance(child, Action): + child_label = child.label or f"action_{index}" + _collect_action_roles( + child, + occurrences, + path=(*path, child_label), + include_background=include_background, + include_warrants=include_warrants, + ) + elif isinstance(action, Support): + add(action.conclusion, "conclusion") + for given in action.given: + add(given, "premise") + else: + return False + return True + + +def _collect_builtin_action_roles( + action: Action, + add: RoleAdder, + occurrences: list[RoleOccurrence], + *, + path: tuple[str, ...], + include_background: bool, + include_warrants: bool, +) -> None: + """Collect roles for built-in Gaia Lang action classes.""" + if _collect_observation_action_roles(action, add): + return + if _collect_dependency_action_roles(action, add): + return + if _collect_infer_action_roles(action, add): + return + if _collect_relation_action_roles(action, add): + return + _collect_structural_action_roles( + action, + add, + occurrences, + path=path, + include_background=include_background, + include_warrants=include_warrants, + ) + + +def _collect_common_action_roles( + action: Action, + add: Callable[..., None], + *, + include_background: bool, + include_warrants: bool, +) -> None: + """Collect background and warrant roles shared by every action.""" + if include_background: + for background in action.background: + if isinstance(background, Claim): + add(background, "background", source="background") + if include_warrants: + for warrant in getattr(action, "warrants", ()) or (): + add(warrant, "warrant", source="warrant") + + +def _collect_action_roles( + action: Action, + occurrences: list[RoleOccurrence], + *, + path: tuple[str, ...], + include_background: bool, + include_warrants: bool, +) -> None: + def add(claim: Claim | None, role: str, *, source: str = "explicit_field") -> None: + if claim is None: + return + occurrences.append( + RoleOccurrence( + claim=claim, + role=role, + action=action, + action_type=type(action).__name__, + action_label=action.label, + path=path, + source=source, + ) + ) + + for cls in type(action).__mro__: + handler = _ROLE_HANDLERS.get(cls) + if handler is not None: + handler(action, add) + break + + _collect_builtin_action_roles( + action, + add, + occurrences, + path=path, + include_background=include_background, + include_warrants=include_warrants, + ) + _collect_common_action_roles( + action, + add, + include_background=include_background, + include_warrants=include_warrants, + ) diff --git a/gaia/engine/lang/runtime/variable.py b/gaia/engine/lang/runtime/variable.py new file mode 100644 index 000000000..f3588b301 --- /dev/null +++ b/gaia/engine/lang/runtime/variable.py @@ -0,0 +1,91 @@ +"""Variable — typed term Knowledge subclass. + +Lang-only: like Domain, overrides __post_init__ to skip IR-bound knowledge map +registration. Carries the Term protocol marker so it can appear in formulas. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, ClassVar, cast + +from gaia.engine.lang.formula.primitives import PrimitiveType +from gaia.engine.lang.runtime.domain import Domain +from gaia.engine.lang.runtime.knowledge import Knowledge, _current_package + + +@dataclass(init=False, eq=False) +class Variable(Knowledge): + """A typed term referenceable by formulas, models, and actions. + + Subclasses Knowledge for identity, provenance, metadata. Carries a symbol + used in formulas, a domain (PrimitiveType or user-declared Domain), and an + optional bound value. Binding semantics (CONSTANT / FREE / BOUND_BY_CLAIM) + are inferred by Milestone B's compiler; this class stores only authored data. + + Lang-only: does NOT enter the package's IR-bound knowledge map (spec §2.4). + """ + + # Term protocol marker — see gaia.engine.lang.formula.term.is_term (Milestone A Task 5) + __gaia_term__: ClassVar[bool] = True + + symbol: str = field(default="") + domain: PrimitiveType | Domain = field(default=cast(PrimitiveType, None)) + value: Any | None = None + + def __init__( + self, + *, + symbol: str, + domain: PrimitiveType | Domain, + value: Any | None = None, + content: str | None = None, + format: str = "markdown", + **kwargs: Any, + ) -> None: + """Create a typed authoring variable.""" + if not isinstance(symbol, str) or not symbol: + raise TypeError("symbol must be a non-empty string") + if not isinstance(domain, (PrimitiveType, Domain)): + raise TypeError("domain must be a PrimitiveType or a Domain") + + if value is not None: + _validate_value(value, domain) + + if content is None: + content = _default_content(symbol, domain, value) + + super().__init__(content=content, type="variable", format=format, **kwargs) + self.symbol = symbol + self.domain = domain + self.value = value + + def __post_init__(self) -> None: + """Associate the variable with package provenance without IR registration.""" + # Override Knowledge.__post_init__: associate with the package for provenance, + # but DO NOT call pkg._register_knowledge — Variable is Lang-only (spec §2.4). + pkg = _current_package.get() + source_module = None + if pkg is None: + from gaia.engine.lang.runtime.package import infer_package_and_module + + pkg, source_module = infer_package_and_module() + if pkg is not None: + self._source_module = source_module + self._package = pkg + + +def _validate_value(value: Any, domain: PrimitiveType | Domain) -> None: + if isinstance(domain, PrimitiveType): + if not domain.accepts(value): + raise ValueError(f"value {value!r} not accepted by primitive type {domain}") + else: + if value not in domain.members: + raise ValueError(f"value {value!r} not in domain members of {domain.label or 'Domain'}") + + +def _default_content(symbol: str, domain: PrimitiveType | Domain, value: Any | None) -> str: + domain_name = domain.name if isinstance(domain, PrimitiveType) else (domain.label or "Domain") + if value is None: + return f"Variable {symbol}: {domain_name}" + return f"Variable {symbol}: {domain_name} = {value!r}" diff --git a/gaia/engine/packaging.py b/gaia/engine/packaging.py new file mode 100644 index 000000000..56ec8b1b2 --- /dev/null +++ b/gaia/engine/packaging.py @@ -0,0 +1,1433 @@ +"""Engine-side package loading + compilation surface. + +Public facade `gaia.engine.packaging`: loading Gaia user packages from disk, +compiling them into IR artifacts, priors application, and dependency-graph +loading. +""" + +from __future__ import annotations + +import hashlib +import importlib +import json +import subprocess +import sys +from collections import defaultdict, deque +from dataclasses import dataclass +from datetime import UTC, datetime +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version +from pathlib import Path +from types import ModuleType +from typing import TYPE_CHECKING, Any, cast + +from packaging.requirements import InvalidRequirement, Requirement + +from gaia.engine.ir.parameterization import ( + ResolutionPolicy, + default_resolution_policy, +) +from gaia.engine.lang.compiler import CompiledPackage +from gaia.engine.lang.dsl.register_prior import resolve_priors_to_metadata +from gaia.engine.lang.runtime import Knowledge, Strategy +from gaia.engine.lang.runtime.package import ( + CollectedPackage, + get_inferred_package, + pyproject_for_module, + reset_inferred_package, +) + +if TYPE_CHECKING: + from gaia.engine.ir.graphs import LocalCanonicalGraph + from gaia.engine.ir.knowledge import Knowledge as IrKnowledge + +try: + import tomllib +except ImportError: + import tomli as tomllib # type: ignore[no-redef] + + +__all__ = [ + "CompiledPackage", + "GaiaPackagingError", + "LoadedGaiaPackage", + "apply_package_priors", + "collect_foreign_node_priors", + "compile_loaded_package_artifact", + "ensure_package_env", + "load_dependency_compiled_graphs", + "load_gaia_package", +] + + +class GaiaPackagingError(RuntimeError): + """Engine packaging error surface (raised by load / compile / priors paths).""" + + +_MANIFEST_SCHEMA_VERSION = 1 + + +@dataclass +class LoadedGaiaPackage: + """In-memory result of ``load_gaia_package``. + + Bundles pyproject metadata, the imported user module, and the + collected runtime DSL objects (Knowledge / Strategy / Operator). + """ + + pkg_path: Path + config: dict[str, Any] + project_config: dict[str, Any] + gaia_config: dict[str, Any] + project_name: str + import_name: str + source_root: Path + module: ModuleType + package: CollectedPackage + + +@dataclass +class _FillsContext: + loaded: LoadedGaiaPackage + compiled: CompiledPackage + dependency_specs: dict[str, str] + import_to_dist: dict[str, str] + knowledge_by_qid: dict[str, IrKnowledge] + manifest_cache: dict[str, dict[str, Any]] + seen_relation_keys: set[tuple[str, str, str]] + + +def _import_fresh(import_name: str) -> ModuleType: + stale_modules = [ + name for name in sys.modules if name == import_name or name.startswith(f"{import_name}.") + ] + for name in stale_modules: + sys.modules.pop(name, None) + importlib.invalidate_caches() + previous = sys.dont_write_bytecode + sys.dont_write_bytecode = True + try: + return importlib.import_module(import_name) + finally: + sys.dont_write_bytecode = previous + + +def _source_module_for_loaded_module(module_name: str, pkg: CollectedPackage) -> str | None: + if module_name == pkg.name or module_name.endswith(".__init__"): + return None + return module_name.removeprefix(f"{pkg.name}.") + + +def _is_assignable_name(name: str) -> bool: + return not (name.startswith("__") and name.endswith("__")) + + +def _assign_labels(module: ModuleType, pkg: CollectedPackage, source_module: str | None) -> None: + local_knowledge_ids = {id(k) for k in pkg.knowledge} + local_strategy_ids = {id(s) for s in pkg.strategies} + for attr, obj in vars(module).items(): + if not _is_assignable_name(attr): + continue + if ( + isinstance(obj, Knowledge) + and id(obj) in local_knowledge_ids + and obj.label is None + and getattr(obj, "_source_module", None) == source_module + ): + obj.label = attr + if ( + isinstance(obj, Strategy) + and id(obj) in local_strategy_ids + and obj.label is None + and getattr(obj, "_source_module", None) == source_module + ): + obj.label = attr + + +def _assign_labels_for_loaded_modules() -> None: + for module_name, module in list(sys.modules.items()): + if module is None or not isinstance(module_name, str): + continue + pyproject = pyproject_for_module(module_name) + if pyproject is None: + continue + pkg = get_inferred_package(pyproject) + if pkg is None: + continue + source_module = _source_module_for_loaded_module(module_name, pkg) + _assign_labels(module, pkg, source_module) + + +def _is_auxiliary_source_module(parts: tuple[str, ...]) -> bool: + if "reviews" in parts: + return True + return len(parts) == 1 and parts[0] in {"priors", "review"} + + +def _source_module_name(import_name: str, package_dir: Path, path: Path) -> str | None: + relative = path.relative_to(package_dir) + if relative.name == "__init__.py": + if relative.parent == Path("."): + return None + parts = relative.parent.parts + else: + parts = relative.with_suffix("").parts + if _is_auxiliary_source_module(parts): + return None + return f"{import_name}.{'.'.join(parts)}" + + +def _import_package_source_modules(import_name: str, package_dir: Path) -> None: + module_names = [ + module_name + for path in sorted(package_dir.rglob("*.py")) + if (module_name := _source_module_name(import_name, package_dir, path)) is not None + ] + previous = sys.dont_write_bytecode + sys.dont_write_bytecode = True + try: + for module_name in module_names: + try: + importlib.import_module(module_name) + except Exception as exc: + raise GaiaPackagingError( + f"Error importing package module {module_name}: {exc}" + ) from exc + finally: + sys.dont_write_bytecode = previous + + +def _load_pyproject_config(pkg_path: Path) -> dict[str, Any]: + """Load pyproject.toml for a Gaia package path.""" + pyproject = pkg_path / "pyproject.toml" + if not pyproject.exists(): + raise GaiaPackagingError("Error: no pyproject.toml found.") + with open(pyproject, "rb") as f: + return tomllib.load(f) + + +def _package_identity(config: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any], str, str]: + """Validate and return project config, Gaia config, project name, and version.""" + project_config = config.get("project", {}) + gaia_config = config.get("tool", {}).get("gaia", {}) + if gaia_config.get("type") != "knowledge-package": + raise GaiaPackagingError( + "Error: not a Gaia knowledge package ([tool.gaia].type != 'knowledge-package')." + ) + + project_name = project_config.get("name") + version = project_config.get("version") + if not isinstance(project_name, str) or not project_name: + raise GaiaPackagingError("Error: [project].name is required.") + if not isinstance(version, str) or not version: + raise GaiaPackagingError("Error: [project].version is required.") + return project_config, gaia_config, project_name, version + + +def _source_root_for_package(pkg_path: Path, import_name: str, project_name: str) -> Path: + """Return the source root containing the package import module.""" + package_roots = [pkg_path, pkg_path / "src"] + source_root = next((root for root in package_roots if (root / import_name).exists()), None) + if source_root is not None: + return source_root + expected_paths = ", ".join( + f"{candidate.relative_to(pkg_path)}/" + for candidate in (root / import_name for root in package_roots) + ) + raise GaiaPackagingError( + f"Error: package source directory '{import_name}/' not found.\n" + f" Derived from [project] name {project_name!r}.\n" + ' Derivation: strip trailing "-gaia" when present, then convert ' + "hyphens to underscores.\n" + f" Expected at one of: {expected_paths}" + ) + + +def _import_package_module(import_name: str) -> ModuleType: + """Import a Gaia package module with CLI error wrapping.""" + try: + return _import_fresh(import_name) + except Exception as exc: + raise GaiaPackagingError(f"Error importing package: {exc}") from exc + + +def _module_titles(import_name: str, pkg: CollectedPackage) -> dict[str, str]: + """Extract first-line docstrings for loaded package submodules.""" + module_titles: dict[str, str] = {} + for mod_name in pkg._module_order: + sub = sys.modules.get(f"{import_name}.{mod_name}") + if sub is None: + continue + doc = getattr(sub, "__doc__", None) + if isinstance(doc, str) and doc.strip(): + module_titles[mod_name] = doc.strip().split("\n")[0].strip() + return module_titles + + +def ensure_package_env(pkg_path: Path) -> None: + """Run ``uv sync`` in *pkg_path* so dependencies are importable. + + Skipped when the directory has no ``pyproject.toml`` or when ``uv`` + is not on ``$PATH``. Failures are non-fatal (a warning is printed) + because the user may manage dependencies another way. + """ + if not (pkg_path / "pyproject.toml").exists(): + return + import shutil + + if shutil.which("uv") is None: + return + result = subprocess.run( + ["uv", "sync", "--quiet"], + cwd=pkg_path, + capture_output=True, + text=True, + ) + if result.returncode != 0: + import logging + + logging.getLogger(__name__).debug("uv sync in %s: %s", pkg_path, result.stderr.strip()) + + +def load_gaia_package(path: str | Path = ".") -> LoadedGaiaPackage: + """Load a Gaia knowledge package from a local directory.""" + pkg_path = Path(path).resolve() + pyproject = pkg_path / "pyproject.toml" + config = _load_pyproject_config(pkg_path) + project_config, gaia_config, project_name, version = _package_identity(config) + + import_name = project_name.removesuffix("-gaia").replace("-", "_") + reset_inferred_package(pyproject, module_name=import_name) + source_root = _source_root_for_package(pkg_path, import_name, project_name) + + source_root_str = str(source_root) + if source_root_str not in sys.path: + sys.path.insert(0, source_root_str) + + module = _import_package_module(import_name) + _import_package_source_modules(import_name, source_root / import_name) + + pkg = get_inferred_package(pyproject) + if pkg is None: + raise GaiaPackagingError( + "Error: no Gaia declarations found. Declare Knowledge/Strategy/Operator objects " + "directly in the module and export the public surface via __all__ when needed." + ) + + _assign_labels_for_loaded_modules() + + # Record exported labels from __all__ for the compiler + export_names = getattr(module, "__all__", None) + if isinstance(export_names, list) and all(isinstance(n, str) for n in export_names): + pkg._exported_labels = set(export_names) + + module_titles = _module_titles(import_name, pkg) + if module_titles: + pkg._module_titles = module_titles + + pkg.name = import_name + pkg.version = version + if "namespace" in gaia_config: + pkg.namespace = gaia_config["namespace"] + + return LoadedGaiaPackage( + pkg_path=pkg_path, + config=config, + project_config=project_config, + gaia_config=gaia_config, + project_name=project_name, + import_name=import_name, + source_root=source_root, + module=module, + package=pkg, + ) + + +def compile_loaded_package(loaded: LoadedGaiaPackage) -> dict[str, Any]: + """Compile an already loaded Gaia package to IR JSON.""" + from gaia.engine.lang.compiler import compile_package + + return compile_package(loaded.package) + + +def compile_loaded_package_artifact(loaded: LoadedGaiaPackage) -> CompiledPackage: + """Compile an already loaded Gaia package to IR plus runtime mappings.""" + from gaia.engine.lang.compiler import compile_package_artifact + from gaia.engine.lang.refs import ReferenceError, load_references + + try: + references = load_references(loaded.pkg_path / "references.json") + return compile_package_artifact(loaded.package, references=references) + except ReferenceError as e: + raise GaiaPackagingError(str(e)) from e + + +def _knowledge_display_name(knowledge: Knowledge) -> str: + return knowledge.label or knowledge.content or repr(knowledge) + + +def _load_resolution_policy(loaded: LoadedGaiaPackage) -> ResolutionPolicy: + """Auto-import ``priors.py`` (if present) and read the package's ResolutionPolicy. + + Importing the module triggers any ``register_prior()`` calls inside it, + populating ``claim.metadata['prior_records']`` as a side effect. If the + module additionally exports ``RESOLUTION_POLICY``, that policy is used in + place of :func:`default_resolution_policy`. + + Rejects the legacy ``PRIORS = {...}`` dict with a migration error pointing + to ``register_prior``. + """ + priors_module_name = f"{loaded.import_name}.priors" + priors_path = loaded.source_root / loaded.import_name / "priors.py" + if not priors_path.exists(): + return default_resolution_policy() + + existing_knowledge_ids = {id(k) for k in loaded.package.knowledge} + + try: + module = _import_fresh(priors_module_name) + except Exception as exc: + raise GaiaPackagingError(f"Error importing priors.py: {exc}") from exc + + new_knowledge = [k for k in loaded.package.knowledge if id(k) not in existing_knowledge_ids] + if new_knowledge: + names = ", ".join(_knowledge_display_name(k) for k in new_knowledge[:5]) + suffix = " ..." if len(new_knowledge) > 5 else "" + raise GaiaPackagingError( + "Error: priors.py must not declare new Knowledge objects; it may only " + "reference claims already declared by the package. " + f"New declarations: {names}{suffix}." + ) + + if hasattr(module, "PRIORS"): + raise GaiaPackagingError( + "Error: priors.py exports a `PRIORS = {...}` dict, which is no longer " + "supported (removed in v0.5+). Set priors with register_prior() instead:\n\n" + " from gaia.engine.lang import register_prior\n" + " from . import my_claim\n\n" + ' register_prior(my_claim, value=0.7, justification="literature consensus")\n\n' + "register_prior() supports multiple sources per claim (user, reviewer, " + "engine, agent, calibration) with explicit provenance and Cromwell-checked " + "values. See docs/foundations/gaia-ir/06-parameterization.md for the " + "migration guide and the multi-source prior model." + ) + + if hasattr(module, "RESOLUTION_POLICY"): + policy = module.RESOLUTION_POLICY + if not isinstance(policy, ResolutionPolicy): + raise GaiaPackagingError( + "Error: priors.py exports RESOLUTION_POLICY but it is not a " + f"ResolutionPolicy instance ({type(policy).__name__}). " + "Use:\n\n" + " from gaia.engine.ir import ResolutionPolicy\n" + ' RESOLUTION_POLICY = ResolutionPolicy(strategy="explicit_priority", ...)' + ) + return policy + + return default_resolution_policy() + + +def apply_package_priors(loaded: LoadedGaiaPackage) -> None: + """Resolve multi-source priors and inject the winning value into metadata. + + Pipeline: + + 1. Auto-import ``priors.py`` if present. This runs any + ``register_prior(...)`` calls inside the module, populating + ``claim.metadata['prior_records']`` as a side effect. The legacy + ``PRIORS = {...}`` dict is rejected with a migration error. + 2. Read the package's optional ``RESOLUTION_POLICY`` from ``priors.py``, + falling back to :func:`default_resolution_policy` when absent. + 3. Walk every ``Claim`` in the package. For each claim with one or more + records under ``metadata['prior_records']``, run the policy and write + the winning value/justification to ``metadata['prior']`` / + ``metadata['prior_justification']``. + + All records (winner and losers) are preserved in ``prior_records`` for + audit purposes and for the ``prior_dissent`` / ``prior_overridden`` + diagnostics. + + Authors may also call ``register_prior`` directly from ``__init__.py`` or + any other module imported during package load — those calls populate + ``prior_records`` before this function runs, and are resolved identically + to those declared in ``priors.py``. + """ + policy = _load_resolution_policy(loaded) + loaded.package._resolution_policy = policy + try: + resolve_priors_to_metadata(loaded.package.knowledge, policy) + except (TypeError, ValueError) as exc: + raise GaiaPackagingError(f"Error resolving priors: {exc}") from exc + + +def _manifest_package_name(loaded: LoadedGaiaPackage) -> str: + return loaded.project_name.removesuffix("-gaia") + + +def _manifest_base(loaded: LoadedGaiaPackage, *, ir_hash: str) -> dict[str, Any]: + return { + "manifest_schema_version": _MANIFEST_SCHEMA_VERSION, + "package": _manifest_package_name(loaded), + "version": loaded.project_config["version"], + "ir_hash": ir_hash, + } + + +def _canonical_json_hash(payload: dict[str, Any]) -> str: + raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return f"sha256:{hashlib.sha256(raw.encode()).hexdigest()}" + + +def render_manifest_json(payload: dict[str, Any]) -> str: + return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + + +def _interface_hash( + *, + qid: str, + content_hash: str, + role: str, + parameters: list[dict[str, Any]], +) -> str: + return _canonical_json_hash( + { + "manifest_schema_version": _MANIFEST_SCHEMA_VERSION, + "qid": qid, + "content_hash": content_hash, + "role": role, + "parameters": parameters, + } + ) + + +def _parse_gaia_dependencies( + project_config: dict[str, Any], +) -> tuple[dict[str, str], dict[str, str]]: + """Parse [project].dependencies and return (specs, import_to_dist). + + Returns: + specs: dict mapping distribution name → version specifier string + import_to_dist: dict mapping inferred import name → distribution name + """ + dependencies = project_config.get("dependencies", []) + if not isinstance(dependencies, list): + raise GaiaPackagingError("Error: [project].dependencies must be a list if set.") + specs: dict[str, str] = {} + import_to_dist: dict[str, str] = {} + for raw in dependencies: + if not isinstance(raw, str): + raise GaiaPackagingError("Error: [project].dependencies entries must be strings.") + try: + requirement = Requirement(raw) + except InvalidRequirement as exc: + raise GaiaPackagingError( + f"Error: invalid dependency requirement '{raw}': {exc}" + ) from exc + if requirement.name.endswith("-gaia"): + dist_name = requirement.name + specs[dist_name] = str(requirement.specifier) or "*" + import_name = dist_name.removesuffix("-gaia").replace("-", "_") + import_to_dist[import_name] = dist_name + return specs, import_to_dist + + +def _import_module(import_name: str) -> ModuleType: + module = sys.modules.get(import_name) + if module is not None: + return module + return importlib.import_module(import_name) + + +def _load_json_file(path: Path, *, description: str) -> dict[str, Any]: + try: + return cast(dict[str, Any], json.loads(path.read_text())) + except json.JSONDecodeError as exc: + raise GaiaPackagingError(f"Error: {description} is not valid JSON: {exc}") from exc + + +def _locate_dependency_manifest_root(import_name: str) -> Path | None: + pyproject = pyproject_for_module(import_name) + if pyproject is not None: + return pyproject.parent + + module = _import_module(import_name) + module_file = getattr(module, "__file__", None) + if not module_file: + return None + module_path = Path(module_file).resolve() + package_dir = module_path.parent + candidates = [package_dir, package_dir.parent, package_dir.parent.parent] + for candidate in candidates: + if (candidate / ".gaia" / "manifests" / "premises.json").exists(): + return candidate + return None + + +def _validate_dependency_manifest_freshness( + import_name: str, root: Path, stored_ir_hash: str +) -> None: + pyproject = root / "pyproject.toml" + if not pyproject.exists(): + return + loaded = load_gaia_package(root) + compiled = compile_loaded_package_artifact(loaded) + current_ir_hash = compiled.graph.ir_hash or "" + if current_ir_hash != stored_ir_hash: + raise GaiaPackagingError( + f"Error: dependency '{import_name}' has stale .gaia manifests; " + f"run `gaia build compile` in {root}." + ) + + +def _resolve_dependency_premises_manifest(import_name: str) -> tuple[Path, dict[str, Any]]: + root = _locate_dependency_manifest_root(import_name) + if root is None: + raise GaiaPackagingError( + f"Error: could not locate Gaia package root for dependency '{import_name}'." + ) + premises_path = root / ".gaia" / "manifests" / "premises.json" + if not premises_path.exists(): + raise GaiaPackagingError( + f"Error: dependency '{import_name}' is missing .gaia/manifests/premises.json. " + f"This file is generated by `gaia build compile` (gaia-lang >= 0.2.5). " + f"If the dependency was compiled with an older version, upgrade gaia-lang " + f"and recompile: cd {root} && uv add 'gaia-lang>=0.3.0' && gaia build compile" + ) + premises_manifest = _load_json_file( + premises_path, + description=f"{import_name} dependency manifest {premises_path}", + ) + stored_ir_hash = premises_manifest.get("ir_hash") + if not isinstance(stored_ir_hash, str) or not stored_ir_hash: + raise GaiaPackagingError(f"Error: dependency manifest {premises_path} is missing ir_hash.") + _validate_dependency_manifest_freshness(import_name, root, stored_ir_hash) + return root, premises_manifest + + +def _reason_to_text(reason: Any) -> str | None: + if isinstance(reason, str): + return reason or None + if not isinstance(reason, list): + return None + parts: list[str] = [] + for entry in reason: + if isinstance(entry, str): + if entry: + parts.append(entry) + continue + text = getattr(entry, "reason", None) + if isinstance(text, str) and text: + parts.append(text) + return "\n\n".join(parts) or None + + +def _relation_id( + *, + declaring_package: str, + declaring_version: str, + source_qid: str, + source_content_hash: str, + target_qid: str, + target_interface_hash: str, + relation_type: str, +) -> str: + raw = ( + f"{declaring_package}|{declaring_version}|{source_qid}|{source_content_hash}|" + f"{target_qid}|{target_interface_hash}|{relation_type}" + ) + return f"bridge_{hashlib.sha256(raw.encode()).hexdigest()[:16]}" + + +def _fills_relation_metadata(strategy: Strategy) -> dict[str, Any] | None: + """Return fills() relation metadata for a strategy, if present.""" + relation = strategy.metadata.get("gaia", {}).get("relation", {}) + if not isinstance(relation, dict): + return None + if relation.get("type") != "fills": + return None + if len(strategy.premises) != 1 or strategy.conclusion is None: + raise GaiaPackagingError( + "Error: fills() strategies must have exactly one source and one target." + ) + return cast(dict[str, Any], relation) + + +def _validate_fills_owners( + *, + source: Knowledge, + target: Knowledge, + local_package: CollectedPackage, + import_to_dist: dict[str, str], + dependency_specs: dict[str, str], +) -> None: + """Validate fills() source and target package ownership.""" + source_owner = source._package + target_owner = target._package + if target_owner is None or target_owner == local_package: + raise GaiaPackagingError( + "Error: fills() target must be a foreign claim resolved from a dependency package." + ) + if source_owner is not None and source_owner != local_package: + source_dist = import_to_dist.get(source_owner.name) + if source_dist is None or source_dist not in dependency_specs: + raise GaiaPackagingError( + f"Error: fills() source dependency '{source_owner.name}' is not declared in " + "[project].dependencies (no matching *-gaia distribution found)." + ) + target_dist = import_to_dist.get(target_owner.name) + if target_dist is None or target_dist not in dependency_specs: + raise GaiaPackagingError( + f"Error: fills() target dependency '{target_owner.name}' is not declared in " + "[project].dependencies (no matching *-gaia distribution found)." + ) + + +def _dependency_premises_for_owner( + owner: CollectedPackage, + cache: dict[str, dict[str, Any]], +) -> dict[str, Any]: + """Load and cache the dependency premises manifest for a target owner.""" + premises_manifest = cache.get(owner.name) + if premises_manifest is None: + _, premises_manifest = _resolve_dependency_premises_manifest(owner.name) + cache[owner.name] = premises_manifest + premises = premises_manifest.get("premises", []) + if not isinstance(premises, list): + raise GaiaPackagingError( + "Error: dependency premises manifest must contain a premises list." + ) + return premises_manifest + + +def _fills_qids_and_source_hash( + *, + source: Knowledge, + target: Knowledge, + compiled: CompiledPackage, + knowledge_by_qid: dict[str, IrKnowledge], +) -> tuple[str, str, str]: + """Resolve source/target QIDs and source content hash for a fills() relation.""" + source_qid = compiled.knowledge_ids_by_object.get(id(source)) + target_qid = compiled.knowledge_ids_by_object.get(id(target)) + if source_qid is None or target_qid is None: + raise GaiaPackagingError( + "Error: could not resolve fills() source/target QID during compile." + ) + source_knowledge = knowledge_by_qid.get(source_qid) + if source_knowledge is None or source_knowledge.content_hash is None: + raise GaiaPackagingError( + f"Error: could not resolve source content hash for '{source_qid}'." + ) + return source_qid, target_qid, source_knowledge.content_hash + + +def _target_premise_entry( + *, + premises_manifest: dict[str, Any], + target_owner: CollectedPackage, + target_qid: str, +) -> dict[str, Any]: + """Return the dependency local_hole entry for a fills() target QID.""" + premises = premises_manifest.get("premises", []) + entry = next( + ( + premise + for premise in premises + if isinstance(premise, dict) and premise.get("qid") == target_qid + ), + None, + ) + if entry is None: + raise GaiaPackagingError( + f"Error: fills() target '{target_qid}' is not a public premise in dependency " + f"'{target_owner.name}'." + ) + if entry.get("role") != "local_hole": + raise GaiaPackagingError( + f"Error: fills() target '{target_qid}' must resolve to a dependency local_hole, " + f"found role={entry.get('role')!r}." + ) + return entry + + +def _required_manifest_string(manifest: dict[str, Any], key: str, owner_name: str) -> str: + """Return a required string field from a dependency premises manifest.""" + value = manifest.get(key) + if not isinstance(value, str) or not value: + raise GaiaPackagingError( + f"Error: dependency premises manifest for '{owner_name}' is missing {key}." + ) + return value + + +def _target_interface_hash(entry: dict[str, Any], target_qid: str) -> str: + """Return a target premise entry's required interface hash.""" + interface_hash = entry.get("interface_hash") + if not isinstance(interface_hash, str) or not interface_hash: + raise GaiaPackagingError( + f"Error: dependency premise '{target_qid}' is missing interface_hash." + ) + return interface_hash + + +def _mark_unique_fills_relation( + seen_relation_keys: set[tuple[str, str, str]], + *, + source_qid: str, + target_qid: str, + target_interface_hash: str, +) -> None: + """Record a fills() relation key and reject duplicates.""" + relation_key = (source_qid, target_qid, target_interface_hash) + if relation_key in seen_relation_keys: + raise GaiaPackagingError( + f"Error: duplicate fills() relation for source '{source_qid}' and target " + f"'{target_qid}' on interface '{target_interface_hash}'." + ) + seen_relation_keys.add(relation_key) + + +def _build_fills_relation_record( + *, + ctx: _FillsContext, + strategy: Strategy, + relation: dict[str, Any], + source: Knowledge, + target: Knowledge, +) -> dict[str, Any]: + """Build one serialized fills() bridge relation.""" + target_owner = cast(CollectedPackage, target._package) + target_dist = ctx.import_to_dist[target_owner.name] + premises_manifest = _dependency_premises_for_owner(target_owner, ctx.manifest_cache) + source_qid, target_qid, source_content_hash = _fills_qids_and_source_hash( + source=source, + target=target, + compiled=ctx.compiled, + knowledge_by_qid=ctx.knowledge_by_qid, + ) + entry = _target_premise_entry( + premises_manifest=premises_manifest, + target_owner=target_owner, + target_qid=target_qid, + ) + target_interface_hash = _target_interface_hash(entry, target_qid) + _mark_unique_fills_relation( + ctx.seen_relation_keys, + source_qid=source_qid, + target_qid=target_qid, + target_interface_hash=target_interface_hash, + ) + relation_type = str(relation.get("type")) + relation_record = { + "relation_id": _relation_id( + declaring_package=_manifest_package_name(ctx.loaded), + declaring_version=ctx.loaded.project_config["version"], + source_qid=source_qid, + source_content_hash=source_content_hash, + target_qid=target_qid, + target_interface_hash=target_interface_hash, + relation_type=relation_type, + ), + "relation_type": relation_type, + "source_qid": source_qid, + "source_content_hash": source_content_hash, + "target_qid": target_qid, + "target_package": _required_manifest_string( + premises_manifest, "package", target_owner.name + ), + "target_dependency_req": ctx.dependency_specs[target_dist], + "target_resolved_version": _required_manifest_string( + premises_manifest, "version", target_owner.name + ), + "target_role": entry["role"], + "target_interface_hash": target_interface_hash, + "strength": relation.get("strength"), + "mode": relation.get("mode"), + "declared_by_owner_of_source": source._package == ctx.loaded.package, + } + justification = _reason_to_text(strategy.reason) + if justification: + relation_record["justification"] = justification + return relation_record + + +def _resolve_one_fills_relation(ctx: _FillsContext, strategy: Strategy) -> dict[str, Any] | None: + """Resolve one Strategy into a fills() bridge record when applicable.""" + relation = _fills_relation_metadata(strategy) + if relation is None: + return None + source = strategy.premises[0] + target = cast(Knowledge, strategy.conclusion) + _validate_fills_owners( + source=source, + target=target, + local_package=ctx.loaded.package, + import_to_dist=ctx.import_to_dist, + dependency_specs=ctx.dependency_specs, + ) + return _build_fills_relation_record( + ctx=ctx, + strategy=strategy, + relation=relation, + source=source, + target=target, + ) + + +def _resolve_fills_relations( + loaded: LoadedGaiaPackage, compiled: CompiledPackage +) -> list[dict[str, Any]]: + dependency_specs, import_to_dist = _parse_gaia_dependencies(loaded.project_config) + knowledge_by_qid = { + knowledge.id: knowledge for knowledge in compiled.graph.knowledges if knowledge.id + } + ctx = _FillsContext( + loaded=loaded, + compiled=compiled, + dependency_specs=dependency_specs, + import_to_dist=import_to_dist, + knowledge_by_qid=knowledge_by_qid, + manifest_cache={}, + seen_relation_keys=set(), + ) + relations: list[dict[str, Any]] = [] + + for strategy in loaded.package.strategies: + relation_record = _resolve_one_fills_relation(ctx, strategy) + if relation_record is not None: + relations.append(relation_record) + + return sorted(relations, key=lambda item: item["relation_id"]) + + +def _knowledge_manifest_entry(knowledge: IrKnowledge) -> dict[str, Any]: + entry: dict[str, Any] = { + "qid": knowledge.id, + "label": knowledge.label, + "type": str(knowledge.type), + "content": knowledge.content, + "content_hash": knowledge.content_hash, + } + parameters = [parameter.model_dump(mode="json") for parameter in knowledge.parameters] + if parameters: + entry["parameters"] = parameters + return entry + + +def validate_fills_relations(loaded: LoadedGaiaPackage, compiled: CompiledPackage) -> None: + """Validate fills() relations without building full manifests. + + Raises GaiaPackagingError if any fills() strategy has an invalid source, + target, or dependency configuration. Use this for ``gaia build check`` + where manifests are not needed — only validation matters. + """ + _resolve_fills_relations(loaded, compiled) + + +def _manifest_graph_sets( + graph: LocalCanonicalGraph, +) -> tuple[dict[str, IrKnowledge], set[str], set[str]]: + """Return graph knowledge map, exported ids, and exported claim ids.""" + knowledge_by_qid = {knowledge.id: knowledge for knowledge in graph.knowledges if knowledge.id} + exported_qids = { + knowledge.id + for knowledge in graph.knowledges + if knowledge.id is not None and knowledge.exported + } + exported_claim_qids = { + knowledge.id + for knowledge in graph.knowledges + if knowledge.id is not None and knowledge.exported and str(knowledge.type) == "claim" + } + return knowledge_by_qid, exported_qids, exported_claim_qids + + +def _manifest_exports(graph: LocalCanonicalGraph) -> list[dict[str, Any]]: + """Return sorted exported-knowledge manifest entries.""" + return [ + _knowledge_manifest_entry(knowledge) + for knowledge in sorted(graph.knowledges, key=lambda item: item.id or "") + if knowledge.exported and knowledge.id is not None + ] + + +def _local_support_indexes( + loaded: LoadedGaiaPackage, +) -> tuple[set[int], dict[int, list[Strategy]], dict[int, list[Knowledge]]]: + """Index local strategy support edges by object id.""" + local_knowledge_ids = {id(knowledge) for knowledge in loaded.package.knowledge} + local_supports_by_conclusion: dict[int, list[Strategy]] = defaultdict(list) + downstream_conclusions_by_premise: dict[int, list[Knowledge]] = defaultdict(list) + downstream_seen: dict[int, set[int]] = defaultdict(set) + + for strategy in loaded.package.strategies: + conclusion = strategy.conclusion + if ( + conclusion is None + or conclusion.type != "claim" + or id(conclusion) not in local_knowledge_ids + ): + continue + local_supports_by_conclusion[id(conclusion)].append(strategy) + for premise in strategy.premises: + if premise.type != "claim": + continue + premise_id = id(premise) + conclusion_id = id(conclusion) + if conclusion_id in downstream_seen[premise_id]: + continue + downstream_seen[premise_id].add(conclusion_id) + downstream_conclusions_by_premise[premise_id].append(conclusion) + return local_knowledge_ids, local_supports_by_conclusion, downstream_conclusions_by_premise + + +def _public_premise_objects( + *, + loaded: LoadedGaiaPackage, + compiled: CompiledPackage, + exported_claim_qids: set[str], + local_knowledge_ids: set[int], + local_supports_by_conclusion: dict[int, list[Strategy]], +) -> dict[int, Knowledge]: + """Collect leaf claims feeding exported local conclusions.""" + public_premises: dict[int, Knowledge] = {} + visited_supported_claims: set[int] = set() + + def walk_supported_claim(claim_node: Knowledge) -> None: + for strategy in local_supports_by_conclusion.get(id(claim_node), []): + for premise in strategy.premises: + if premise.type != "claim": + continue + premise_id = id(premise) + if premise_id in local_knowledge_ids and local_supports_by_conclusion.get( + premise_id + ): + if premise_id in visited_supported_claims: + continue + visited_supported_claims.add(premise_id) + walk_supported_claim(premise) + continue + public_premises[premise_id] = premise + + exported_claim_roots = [ + knowledge + for knowledge in loaded.package.knowledge + if knowledge.type == "claim" + and compiled.knowledge_ids_by_object.get(id(knowledge)) in exported_claim_qids + ] + for root in exported_claim_roots: + root_id = id(root) + if root_id in visited_supported_claims: + continue + visited_supported_claims.add(root_id) + walk_supported_claim(root) + return public_premises + + +def _required_by_exports( + *, + premise: Knowledge, + compiled: CompiledPackage, + downstream_conclusions_by_premise: dict[int, list[Knowledge]], + exported_claim_qids: set[str], +) -> list[str]: + """Return exported conclusions downstream of a public premise.""" + queue: deque[Knowledge] = deque([premise]) + seen_claims = {id(premise)} + required_by_set: set[str] = set() + while queue: + current = queue.popleft() + for conclusion in downstream_conclusions_by_premise.get(id(current), []): + conclusion_id = id(conclusion) + if conclusion_id in seen_claims: + continue + seen_claims.add(conclusion_id) + conclusion_qid = compiled.knowledge_ids_by_object.get(conclusion_id) + if conclusion_qid is None: + continue + if conclusion_qid in exported_claim_qids: + required_by_set.add(conclusion_qid) + continue + queue.append(conclusion) + return sorted(required_by_set) + + +def _premise_manifest_entry( + *, + premise: Knowledge, + compiled: CompiledPackage, + knowledge_by_qid: dict[str, IrKnowledge], + local_knowledge_ids: set[int], + exported_qids: set[str], + exported_claim_qids: set[str], + downstream_conclusions_by_premise: dict[int, list[Knowledge]], +) -> dict[str, Any] | None: + """Build one premises.json entry for a public premise object.""" + premise_qid = compiled.knowledge_ids_by_object.get(id(premise)) + if premise_qid is None: + return None + knowledge = knowledge_by_qid.get(premise_qid) + if knowledge is None or knowledge.content_hash is None: + return None + role = "local_hole" if id(premise) in local_knowledge_ids else "foreign_dependency" + parameters = [parameter.model_dump(mode="json") for parameter in knowledge.parameters] + entry: dict[str, Any] = { + "qid": premise_qid, + "label": knowledge.label, + "content": knowledge.content, + "content_hash": knowledge.content_hash, + "role": role, + "interface_hash": _interface_hash( + qid=premise_qid, + content_hash=knowledge.content_hash, + role=role, + parameters=parameters, + ), + "exported": premise_qid in exported_qids, + "required_by": _required_by_exports( + premise=premise, + compiled=compiled, + downstream_conclusions_by_premise=downstream_conclusions_by_premise, + exported_claim_qids=exported_claim_qids, + ), + } + if parameters: + entry["parameters"] = parameters + return entry + + +def _manifest_premises( + *, + loaded: LoadedGaiaPackage, + compiled: CompiledPackage, + knowledge_by_qid: dict[str, IrKnowledge], + exported_qids: set[str], + exported_claim_qids: set[str], +) -> list[dict[str, Any]]: + """Build the premises.json entries for exported local conclusions.""" + local_ids, supports_by_conclusion, downstream_by_premise = _local_support_indexes(loaded) + public_premises = _public_premise_objects( + loaded=loaded, + compiled=compiled, + exported_claim_qids=exported_claim_qids, + local_knowledge_ids=local_ids, + local_supports_by_conclusion=supports_by_conclusion, + ) + entries: list[dict[str, Any]] = [] + for premise in sorted( + public_premises.values(), + key=lambda item: compiled.knowledge_ids_by_object.get(id(item), ""), + ): + entry = _premise_manifest_entry( + premise=premise, + compiled=compiled, + knowledge_by_qid=knowledge_by_qid, + local_knowledge_ids=local_ids, + exported_qids=exported_qids, + exported_claim_qids=exported_claim_qids, + downstream_conclusions_by_premise=downstream_by_premise, + ) + if entry is not None: + entries.append(entry) + return entries + + +def build_package_manifests( + loaded: LoadedGaiaPackage, compiled: CompiledPackage +) -> dict[str, dict[str, Any]]: + """Build package-level interface manifests from compiled IR plus runtime package state. + + Emits four sibling manifest files under ``.gaia/manifests/``: + + - ``exports.json`` — every knowledge node in the package flagged ``exported``. + These are the package's public interface claims that downstream packages + may depend on. + - ``premises.json`` — every **leaf** claim (a claim with no supporting + strategy in the local package) that feeds into an exported conclusion. + Each entry carries a ``role`` field: + + * ``local_hole`` — the leaf claim is declared in the **current** package + but has no derivation chain. These are the package's primary evidence + and abduction alternatives — i.e. the propositions the author accepts + as given inputs to the reasoning graph. + * ``foreign_dependency`` — the leaf claim originates in an upstream + ``*-gaia`` dependency and is consumed by a local strategy via the + dependency's ``exports.json``. + + - ``holes.json`` — the subset of ``premises.json`` entries whose role is + ``local_hole``. Despite the name, a "hole" here does **not** mean an + unresolved cross-package reference (foreign dependencies already have + their own resolution path via the dep's exports). A ``local_hole`` is a + local leaf claim that a *downstream* package could optionally "fill" with + more specific evidence via the ``fills`` relation — but the current + package is perfectly valid with its leaves unfilled. + - ``bridges.json`` — ``fills`` relations declared in the local package that + point at hole qids in an upstream dependency's manifest. Empty for + packages with no upstream deps. + + Concrete example from the ``watson-rfdiffusion-2023-gaia`` package: + + - 7 exports (the paper's exported conclusions, e.g. ``binder_success_rate``) + - 32 local holes: 20 primary observations (e.g. ``denoising_process``, + ``binder_specificity``) + 12 abduction alternatives + (``alt_nonspecific_binding_p53_mdm2``, etc.) + - 0 foreign dependencies (watson has no upstream ``*-gaia`` deps) + - 0 bridges (watson doesn't fill any upstream holes) + + All 32 holes are **declared claims** in the local package — they appear in + ``ir.json`` as regular knowledge nodes with ``exported=false`` and no + supporting strategy. They are reported as "holes" because the `holes.json` + manifest is indexing *local leaves that downstream packages could optionally + refine*, not *unresolved references*. + + See ``docs/specs/2026-04-08-gaia-lang-hole-fills-design.md`` §3.2 for the + full rationale on why "hole" is a release-scoped interface role rather than + a source primitive. + """ + fills_relations = _resolve_fills_relations(loaded, compiled) + graph = compiled.graph + knowledge_by_qid, exported_qids, exported_claim_qids = _manifest_graph_sets(graph) + exports = _manifest_exports(graph) + premises = _manifest_premises( + loaded=loaded, + compiled=compiled, + knowledge_by_qid=knowledge_by_qid, + exported_qids=exported_qids, + exported_claim_qids=exported_claim_qids, + ) + + holes = [ + {key: value for key, value in premise.items() if key != "role" and key != "exported"} + for premise in premises + if premise["role"] == "local_hole" + ] + + return { + "exports.json": { + **_manifest_base(loaded, ir_hash=graph.ir_hash or ""), + "exports": exports, + }, + "premises.json": { + **_manifest_base(loaded, ir_hash=graph.ir_hash or ""), + "premises": premises, + }, + "holes.json": { + **_manifest_base(loaded, ir_hash=graph.ir_hash or ""), + "holes": holes, + }, + "bridges.json": { + **_manifest_base(loaded, ir_hash=graph.ir_hash or ""), + "bridges": fills_relations, + }, + } + + +def collect_foreign_node_priors( + graph: LocalCanonicalGraph, + pkg_path: Path, +) -> dict[str, float]: + """Collect upstream beliefs for foreign knowledge nodes. + + Scans ``.gaia/dep_beliefs/*.json`` for belief manifests downloaded by + ``gaia add``. For each foreign knowledge node in *graph* (i.e. a node + whose QID does **not** start with the local ``{namespace}:{package}::`` + prefix), if the upstream manifest contains a matching ``knowledge_id``, + the upstream belief is included in the returned dict. + + The returned dict is suitable for passing as ``node_priors`` to + ``lower_local_graph()``, which gives these values highest explicit- + override priority (above ``metadata["prior"]``). + """ + dep_beliefs_dir = pkg_path / ".gaia" / "dep_beliefs" + if not dep_beliefs_dir.is_dir(): + return {} + + # Build upstream beliefs mapping from all dep_beliefs files + upstream_beliefs: dict[str, float] = {} + for beliefs_file in sorted(dep_beliefs_dir.glob("*.json")): + try: + data = json.loads(beliefs_file.read_text()) + except (OSError, json.JSONDecodeError): + continue + beliefs_list = data.get("beliefs") + if not isinstance(beliefs_list, list): + continue + for entry in beliefs_list: + if not isinstance(entry, dict): + continue + kid = entry.get("knowledge_id") + belief = entry.get("belief") + if isinstance(kid, str) and isinstance(belief, (int, float)): + upstream_beliefs[kid] = float(belief) + + if not upstream_beliefs: + return {} + + # Determine local prefix to identify foreign nodes + local_prefix = f"{graph.namespace}:{graph.package_name}::" + + foreign_priors: dict[str, float] = {} + for knowledge in graph.knowledges: + kid = knowledge.id + if kid is None or kid.startswith(local_prefix): + continue + if kid in upstream_beliefs: + foreign_priors[kid] = upstream_beliefs[kid] + + return foreign_priors + + +@dataclass +class DependencyGraph: + """A dependency's compiled IR loaded from disk.""" + + import_name: str + dist_name: str + root: Path + graph: Any # LocalCanonicalGraph (imported lazily to avoid circular deps) + + +def load_dependency_compiled_graphs( + project_config: dict[str, Any], + *, + depth: int = 1, + _seen: set[str] | None = None, +) -> list[DependencyGraph]: + """Discover direct ``-gaia`` dependencies and load their compiled IR. + + Parameters + ---------- + project_config: + The ``[project]`` section of the local ``pyproject.toml``. + depth: + How many levels of transitive dependencies to load. + 1 = direct deps only, 2+ = recurse, -1 = unlimited. + _seen: + Internal dedup set (QID prefixes already loaded). Callers should + not pass this. + + Returns: + ------- + Flat list of :class:`DependencyGraph` for all discovered dependencies + (deduplicated by ``namespace:package_name``). + """ + from gaia.engine.ir.graphs import LocalCanonicalGraph + + if _seen is None: + _seen = set() + + _specs, import_to_dist = _parse_gaia_dependencies(project_config) + result: list[DependencyGraph] = [] + + for import_name, dist_name in sorted(import_to_dist.items()): + root = _locate_dependency_manifest_root(import_name) + if root is None: + raise GaiaPackagingError( + f"Could not locate Gaia package root for dependency '{import_name}'. " + f"Is '{dist_name}' installed?" + ) + ir_path = root / ".gaia" / "ir.json" + if not ir_path.exists(): + raise GaiaPackagingError( + f"Dependency '{import_name}' is missing .gaia/ir.json. " + f"Run 'gaia build compile' in {root}." + ) + ir_data = _load_json_file(ir_path, description=f"{import_name} .gaia/ir.json") + graph = LocalCanonicalGraph.model_validate(ir_data) + + # Dedup by namespace:package_name + qid_prefix = f"{graph.namespace}:{graph.package_name}" + if qid_prefix in _seen: + continue + _seen.add(qid_prefix) + + result.append( + DependencyGraph( + import_name=import_name, + dist_name=dist_name, + root=root, + graph=graph, + ) + ) + + # Recurse into transitive deps if requested + if depth > 1 or depth == -1: + dep_pyproject = root / "pyproject.toml" + if dep_pyproject.exists(): + try: + dep_config = tomllib.loads(dep_pyproject.read_text()) + except Exception: + continue + dep_project = dep_config.get("project", {}) + next_depth = depth - 1 if depth > 1 else -1 + transitive = load_dependency_compiled_graphs( + dep_project, depth=next_depth, _seen=_seen + ) + result.extend(transitive) + + return result + + +def gaia_lang_version() -> str: + """Return the installed gaia-lang version, or 'unknown' for dev checkouts. + + Used by compile (to stamp `.gaia/compile_metadata.json`) and by tests. We + deliberately return a string sentinel instead of raising so that running + `gaia build compile` inside an un-built editable checkout still produces a valid + metadata file — downstream consumers can detect 'unknown' and decide. + """ + try: + return _pkg_version("gaia-lang") + except PackageNotFoundError: + return "unknown" + + +def _utc_now_iso() -> str: + """UTC timestamp in ISO-8601 with Z suffix and second precision.""" + return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _render_compile_metadata(ir_hash: str) -> str: + """Build the `.gaia/compile_metadata.json` payload. + + This file is the canonical provenance anchor for a compiled IR: it records + which `gaia-lang` version produced the IR, pinned to the IR hash the + metadata file sits next to. `gaia run infer` copies the version into its + output artifacts so beliefs can be correlated back to the compile + environment, and `gaia pkg register` reads this file to populate + `Versions.toml`'s `gaia_lang_version` field without depending on the live + process environment (which may have been upgraded between compile and + register). + """ + payload = { + "gaia_lang_version": gaia_lang_version(), + "compiled_at": _utc_now_iso(), + "ir_hash": ir_hash, + } + return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + + +def write_compiled_artifacts( + pkg_path: Path, + ir: dict[str, Any], + *, + manifests: dict[str, dict[str, Any]] | None = None, + formalization_manifest: dict[str, Any] | None = None, +) -> Path: + """Write .gaia compilation artifacts and return the output directory.""" + gaia_dir = pkg_path / ".gaia" + gaia_dir.mkdir(exist_ok=True) + ir_json = json.dumps(ir, ensure_ascii=False, indent=2, sort_keys=True) + (gaia_dir / "ir.json").write_text(ir_json) + (gaia_dir / "ir_hash").write_text(ir["ir_hash"]) + (gaia_dir / "compile_metadata.json").write_text(_render_compile_metadata(ir["ir_hash"])) + if formalization_manifest is not None: + (gaia_dir / "formalization_manifest.json").write_text( + render_manifest_json(formalization_manifest) + ) + if manifests: + manifests_dir = gaia_dir / "manifests" + manifests_dir.mkdir(exist_ok=True) + for filename, payload in manifests.items(): + (manifests_dir / filename).write_text(render_manifest_json(payload)) + return gaia_dir diff --git a/gaia/trace/__init__.py b/gaia/engine/trace/__init__.py similarity index 74% rename from gaia/trace/__init__.py rename to gaia/engine/trace/__init__.py index bda4634d5..55245941b 100644 --- a/gaia/trace/__init__.py +++ b/gaia/engine/trace/__init__.py @@ -10,16 +10,16 @@ 目标:审计/debug/学习一段 ARM 执行轨迹时,给出可解释、可重算、不易作弊的报告。 """ -from gaia.trace.diagnostics import TraceDiagnosticKind -from gaia.trace.review import TraceReviewReport, run_trace_review -from gaia.trace.schema import ClaimRef, Trace, TraceEvent, TraceManifest +from gaia.engine.trace.diagnostics import TraceDiagnosticKind +from gaia.engine.trace.review import TraceReviewReport, run_trace_review +from gaia.engine.trace.schema import ClaimRef, Trace, TraceEvent, TraceManifest __all__ = [ "ClaimRef", "Trace", + "TraceDiagnosticKind", "TraceEvent", "TraceManifest", - "TraceDiagnosticKind", "TraceReviewReport", "run_trace_review", ] diff --git a/gaia/trace/diagnostics.py b/gaia/engine/trace/diagnostics.py similarity index 90% rename from gaia/trace/diagnostics.py rename to gaia/engine/trace/diagnostics.py index a269f1c68..cb6529cbf 100644 --- a/gaia/trace/diagnostics.py +++ b/gaia/engine/trace/diagnostics.py @@ -1,4 +1,4 @@ -"""Trace 域 11 个确定性 detector。 +"""Trace-domain deterministic diagnostic detectors. 设计纪律: - 每个 detector 是纯函数 ``(Trace, ...) -> list[Diagnostic]``,不抛异常 @@ -15,18 +15,19 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Callable from pathlib import Path -from typing import Any, Callable, Literal +from typing import Any, Literal -from gaia.inquiry.diagnostics import Diagnostic -from gaia.trace.hashing import ( +from gaia.engine.inquiry.diagnostics import Diagnostic +from gaia.engine.trace.hashing import ( GENESIS_PREV_HASH, compute_events_root, compute_manifest_hash, recompute_chain, ) -from gaia.trace.loader import LoadResult, SchemaIssue -from gaia.trace.schema import Trace, TraceEvent +from gaia.engine.trace.loader import LoadResult, SchemaIssue +from gaia.engine.trace.schema import Trace, TraceEvent TraceDiagnosticKind = Literal[ "trace_schema_violation", @@ -64,7 +65,7 @@ def _diag( suggested_edit: str = "", data: dict[str, Any] | None = None, ) -> Diagnostic: - """构造 Diagnostic(kind 走字符串,绕开 inquiry Literal 类型限制)。""" + """Construct a diagnostic while preserving trace-specific kind strings.""" return Diagnostic( severity=severity, # type: ignore[arg-type] kind=kind, # type: ignore[arg-type] @@ -81,7 +82,7 @@ def _diag( def from_schema_issues(issues: list[SchemaIssue]) -> list[Diagnostic]: - """把 loader 收集到的 SchemaIssue 转 Diagnostic。""" + """Convert loader schema issues into trace diagnostics.""" out: list[Diagnostic] = [] for issue in issues: out.append( @@ -102,6 +103,14 @@ def from_schema_issues(issues: list[SchemaIssue]) -> list[Diagnostic]: def detect_hash_chain(trace: Trace) -> list[Diagnostic]: + """Detect broken event hash-chain links. + + Args: + trace: Loaded trace to inspect. + + Returns: + Diagnostics for genesis or previous-hash mismatches. + """ events = trace.events if not events: return [] @@ -154,6 +163,14 @@ def detect_hash_chain(trace: Trace) -> list[Diagnostic]: def detect_manifest_hash(trace: Trace) -> list[Diagnostic]: + """Detect manifest hashes that no longer match trace contents. + + Args: + trace: Loaded trace to inspect. + + Returns: + Diagnostics for `events_root` or `manifest_hash` mismatches. + """ out: list[Diagnostic] = [] expected_root = compute_events_root(trace.events) if trace.manifest.events_root and trace.manifest.events_root != expected_root: @@ -188,7 +205,9 @@ def detect_manifest_hash(trace: Trace) -> list[Diagnostic]: f"declared {trace.manifest.manifest_hash[:12]}..., " f"recomputed {expected_manifest_hash[:12]}..." ), - suggested_edit="Recompute manifest_hash = sha256(canonical_json(manifest \\ {manifest_hash})).", + suggested_edit=( + "Recompute manifest_hash = sha256(canonical_json(manifest \\ {manifest_hash}))." + ), data={ "declared": trace.manifest.manifest_hash, "recomputed": expected_manifest_hash, @@ -202,6 +221,14 @@ def detect_manifest_hash(trace: Trace) -> list[Diagnostic]: def detect_timestamps(trace: Trace) -> list[Diagnostic]: + """Detect event timestamps that move backward. + + Args: + trace: Loaded trace to inspect. + + Returns: + Diagnostics for timestamp ordering violations. + """ out: list[Diagnostic] = [] for i in range(1, len(trace.events)): prev_ts = trace.events[i - 1].ts @@ -233,6 +260,14 @@ def detect_timestamps(trace: Trace) -> list[Diagnostic]: def detect_seq(trace: Trace) -> list[Diagnostic]: + """Detect non-contiguous event sequence numbers. + + Args: + trace: Loaded trace to inspect. + + Returns: + Diagnostics for missing or reordered sequence numbers. + """ out: list[Diagnostic] = [] if not trace.events: return out @@ -276,6 +311,14 @@ def _tokenize(text: str) -> set[str]: def detect_decision_grounds(trace: Trace) -> list[Diagnostic]: + """Detect decision events whose reasons do not reference their inputs. + + Args: + trace: Loaded trace to inspect. + + Returns: + Diagnostics for missing or weakly grounded decision reasons. + """ out: list[Diagnostic] = [] for ev in trace.events: if ev.kind != "decision": @@ -329,7 +372,7 @@ def detect_decision_grounds(trace: Trace) -> list[Diagnostic]: def detect_tool_pairing(trace: Trace) -> list[Diagnostic]: - """tool_call 之后必须出现匹配的 tool_result 或 retry 或带 error 的同 actor 事件。""" + """Require each tool call to be closed by a result, retry, or actor error.""" out: list[Diagnostic] = [] events = trace.events for i, ev in enumerate(events): @@ -382,7 +425,7 @@ def detect_tool_pairing(trace: Trace) -> list[Diagnostic]: def _default_resolver_factory(package_path: str | Path | None) -> ReviewIdResolver: - """默认走 ``/.gaia/inquiry/reviews/.json`` 文件存在性。 + """Resolve review IDs through package-local inquiry review snapshots. package_path 为 None ⇒ 永远 False(detector 会把所有 ref 标 unresolved)。 """ @@ -408,14 +451,14 @@ def detect_claim_refs( resolver: ReviewIdResolver | None = None, package_path: str | Path | None = None, ) -> list[Diagnostic]: - """resolver 优先(测试可注入);否则按 package_path 走文件系统检查。""" + """Detect claim references whose review IDs cannot be resolved.""" res = resolver or _default_resolver_factory(package_path) out: list[Diagnostic] = [] for ev in trace.events: for j, ref in enumerate(ev.refs): try: ok = bool(res(ref.review_id)) - except Exception as exc: # noqa: BLE001 — resolver 不允许 crash detector + except Exception as exc: ok = False msg_extra = f" (resolver raised: {exc!r})" else: @@ -451,6 +494,14 @@ def detect_claim_refs( def detect_parent_links(trace: Trace) -> list[Diagnostic]: + """Detect parent links that point outside the current trace. + + Args: + trace: Loaded trace to inspect. + + Returns: + Diagnostics for dangling `parent_event_id` values. + """ ids = {ev.event_id for ev in trace.events} out: list[Diagnostic] = [] for ev in trace.events: @@ -479,7 +530,7 @@ def detect_parent_links(trace: Trace) -> list[Diagnostic]: def detect_retry(trace: Trace, *, max_chain: int = RETRY_CHAIN_LIMIT_DEFAULT) -> list[Diagnostic]: - """从每个 retry 事件向上追 parent 链,链长 > max_chain 视为 diverged。""" + """Detect retry chains whose length exceeds the configured limit.""" out: list[Diagnostic] = [] by_id = {ev.event_id: ev for ev in trace.events} for ev in trace.events: @@ -524,7 +575,7 @@ def detect_retry(trace: Trace, *, max_chain: int = RETRY_CHAIN_LIMIT_DEFAULT) -> def detect_actor(trace: Trace) -> list[Diagnostic]: - """同一 parent 链内 actor 切换且无 decision 解释 ⇒ info 提示。 + """Detect unexplained actor switches within each parent-event group. 分组依据:``parent_event_id``(None ⇒ 顶层组);同一 group 内事件以 seq 升序 扫描,前后 actor 不同且中间没有 decision 事件 ⇒ 标 actor_switch_unexplained。 @@ -581,7 +632,7 @@ def run_all_detectors( package_path: str | Path | None = None, retry_chain_limit: int = RETRY_CHAIN_LIMIT_DEFAULT, ) -> list[Diagnostic]: - """按固定顺序跑 11 个 detector。""" + """Run all trace detectors in their stable review order.""" diags: list[Diagnostic] = [] diags.extend(from_schema_issues(load_result.issues)) if load_result.trace is None: diff --git a/gaia/trace/hashing.py b/gaia/engine/trace/hashing.py similarity index 83% rename from gaia/trace/hashing.py rename to gaia/engine/trace/hashing.py index 95b16c052..7f7428086 100644 --- a/gaia/trace/hashing.py +++ b/gaia/engine/trace/hashing.py @@ -1,4 +1,4 @@ -"""Canonical-json + sha256 链——trace 抗作弊的最底层算法。 +"""Canonical JSON and SHA-256 helpers for trace tamper checks. 设计纪律(吸取 dz-fusion P0-1 教训:reviewer 不允许走"trace 自己说我没坏" 的捷径,必须独立重算): @@ -23,17 +23,17 @@ import hashlib import json -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any -from gaia.trace.schema import TraceEvent, TraceManifest +from gaia.engine.trace.schema import TraceEvent, TraceManifest # 首事件 prev_hash 的约定值(空串),便于 writer 与 reviewer 共识 GENESIS_PREV_HASH: str = "" def _to_jsonable(value: Any) -> Any: - """递归把 datetime 等非 JSON 原生类型转成确定性表示。 + """Convert values into deterministic JSON-compatible structures. - datetime:先归一化到 UTC,再 isoformat(with 'Z'),byte-equal 不依赖 tz suffix - dict:按 key 递归(key 必须是 str;非 str key 抛 TypeError) @@ -48,10 +48,7 @@ def _to_jsonable(value: Any) -> Any: raise ValueError("non-finite float in canonical_json input") return value if isinstance(value, datetime): - if value.tzinfo is None: - v = value.replace(tzinfo=timezone.utc) - else: - v = value.astimezone(timezone.utc) + v = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) s = v.isoformat() if s.endswith("+00:00"): s = s[:-6] + "Z" @@ -71,7 +68,7 @@ def _to_jsonable(value: Any) -> Any: def canonical_json(value: Any) -> bytes: - """跨平台、跨进程 byte-equal 的 JSON 编码。 + """Encode JSON byte-identically across platforms and processes. 用于 hash chain 与 events_root;任何调整都会让既有 trace 失效,需慎重升 schema。 """ @@ -86,11 +83,12 @@ def canonical_json(value: Any) -> bytes: def sha256_hex(data: bytes) -> str: + """Return the hexadecimal SHA-256 digest for bytes.""" return hashlib.sha256(data).hexdigest() def event_payload(event: TraceEvent) -> dict[str, Any]: - """链 hash 用的 event 投影:包含全部 schema 字段,但 ``prev_hash`` 不参与。 + """Project an event into the payload used for chain hashing. 把 prev_hash 从 hash 输入里剔除是关键:否则改 prev_hash 会让 hash 也变, chain 本身就形成自洽循环——reviewer 将检不出篡改。 @@ -101,11 +99,12 @@ def event_payload(event: TraceEvent) -> dict[str, Any]: def hash_event(event: TraceEvent) -> str: + """Hash one trace event after removing its `prev_hash` link.""" return sha256_hex(canonical_json(event_payload(event))) def recompute_chain(events: list[TraceEvent]) -> list[str]: - """逐条独立算每条 event 的 hash,返回长度等于 events 的列表。 + """Recompute each event hash independently. - 不读 event.prev_hash;reviewer 用返回值与 events[i+1].prev_hash 比对 - 空 events → 空列表 @@ -114,20 +113,20 @@ def recompute_chain(events: list[TraceEvent]) -> list[str]: def compute_events_root(events: list[TraceEvent]) -> str: - """整段 events 的根 hash——独立于 chain,第二条校验路径。""" + """Compute the root hash for the full event sequence.""" payload = [event_payload(ev) for ev in events] return sha256_hex(canonical_json(payload)) def compute_manifest_hash(manifest: TraceManifest) -> str: - """manifest 自身 hash,``manifest_hash`` 字段不参与(避免 self-reference)。""" + """Compute the manifest hash with `manifest_hash` excluded.""" d = manifest.model_dump() d.pop("manifest_hash", None) return sha256_hex(canonical_json(d)) def verify_chain(events: list[TraceEvent]) -> tuple[bool, int | None]: - """链完整性快速检查:返回 (ok, broken_at_seq)。 + """Check hash-chain integrity and return `(ok, broken_at_seq)`. - events[0].prev_hash 必须等于 ``GENESIS_PREV_HASH`` - 第 i 条(i >= 1)的 prev_hash 必须等于 hash_event(events[i-1]) diff --git a/gaia/trace/loader.py b/gaia/engine/trace/loader.py similarity index 89% rename from gaia/trace/loader.py rename to gaia/engine/trace/loader.py index 3bbb2f00b..5437a393e 100644 --- a/gaia/trace/loader.py +++ b/gaia/engine/trace/loader.py @@ -1,4 +1,4 @@ -"""Trace 文件加载——支持单 JSON 与 JSONL 两种布局,schema 失败统一降级。 +"""Trace file loading for JSON and JSONL layouts. reviewer 主流程不允许加载阶段 crash:任何破坏(非法 json、缺字段、类型错) 都被翻译成 ``trace_schema_violation`` 诊断条目,正常进 ranking 流。 @@ -9,26 +9,30 @@ import json from dataclasses import dataclass, field from pathlib import Path +from typing import Any from pydantic import ValidationError -from gaia.trace.schema import Trace, TraceEvent, TraceManifest +from gaia.engine.trace.schema import Trace, TraceEvent, TraceManifest @dataclass class SchemaIssue: - """加载/校验失败的统一表示——后续转成 Diagnostic。""" + """Represent one load or validation issue for later diagnostics.""" message: str location: str = "" # 例如 "events[3].kind" - raw: dict | None = field(default=None) + raw: dict[str, Any] | None = field(default=None) @dataclass class LoadResult: - """加载结果:要么 trace 非空,要么 issues 非空,二者也可同时存在 + """Represent a trace load attempt. + + 要么 trace 非空,要么 issues 非空,二者也可同时存在 (比如 manifest 校验通过但部分 event 损坏——loader 会把损坏 event 跳过、 - 其余装入 trace、在 issues 报告损坏行号)。""" + 其余装入 trace、在 issues 报告损坏行号)。 + """ trace: Trace | None issues: list[SchemaIssue] = field(default_factory=list) @@ -139,8 +143,8 @@ def _load_jsonl(path: Path) -> LoadResult: return LoadResult(trace=trace, issues=issues, raw_path=str(path)) -def _try_partial(data: dict, issues: list[SchemaIssue], path: Path) -> LoadResult: - """单 JSON 整体校验失败时退化:尽量保住 manifest,逐条解析 events。""" +def _try_partial(data: dict[str, Any], issues: list[SchemaIssue], path: Path) -> LoadResult: + """Preserve valid manifest and events after whole-file validation fails.""" manifest_raw = data.get("manifest") events_raw = data.get("events", []) manifest = None @@ -167,7 +171,7 @@ def _try_partial(data: dict, issues: list[SchemaIssue], path: Path) -> LoadResul def _detect_layout(path: Path) -> str: - """通过文件后缀 + 首字符快速判断单 JSON 还是 JSONL。""" + """Infer whether a trace file uses JSON or JSONL layout.""" suffix = path.suffix.lower() if suffix == ".jsonl" or suffix == ".ndjson": return "jsonl" @@ -191,7 +195,7 @@ def _detect_layout(path: Path) -> str: def load_trace(path: str | Path) -> LoadResult: - """加载 trace 文件,自动识别 JSON 与 JSONL 布局。 + """Load a trace file and detect JSON or JSONL layout automatically. 永不抛异常——任何错误都进 ``LoadResult.issues``。 """ diff --git a/gaia/trace/ranking.py b/gaia/engine/trace/ranking.py similarity index 81% rename from gaia/trace/ranking.py rename to gaia/engine/trace/ranking.py index d0f84dfd6..ae0c207da 100644 --- a/gaia/trace/ranking.py +++ b/gaia/engine/trace/ranking.py @@ -1,4 +1,4 @@ -"""ARM Trace v1 — diagnostic / next-edit 排序。 +"""Rank ARM Trace diagnostics and next edits. 独立于 ``gaia.inquiry.ranking``,避免污染 inquiry 模式表(inquiry 测试要求 ``supported_modes`` 集合精确等于 inquiry 那一套)。语义上完全平移 inquiry @@ -11,8 +11,9 @@ from __future__ import annotations -from gaia.inquiry.diagnostics import Diagnostic, NextEdit +from collections.abc import Callable +from gaia.engine.inquiry.diagnostics import Diagnostic, NextEdit # 与 ARM Trace v1 §1.4 优先级对齐:schema-violation 必前;hash chain / # manifest hash 是抗作弊核心,紧随其后;causal / reference / observability @@ -42,14 +43,15 @@ def supported_modes() -> tuple[str, ...]: + """Return the supported trace review ranking modes.""" return tuple(_MODE_RANK.keys()) -def _key(mode: str): +def _key(mode: str) -> Callable[[Diagnostic | NextEdit], tuple[int, int, str]]: table = _MODE_RANK.get(mode, _MODE_RANK["trace"]) publish = mode == "publish" - def _k(d: Diagnostic | NextEdit): + def _k(d: Diagnostic | NextEdit) -> tuple[int, int, str]: kind_rank = table.get(d.kind, _UNKNOWN_KIND_RANK) sev_rank = _SEVERITY_RANK.get(d.severity, 9) # publish:warning 与 error 同档(都是 0),保留 info 在后 @@ -61,8 +63,10 @@ def _k(d: Diagnostic | NextEdit): def rank_diagnostics(diags: list[Diagnostic], mode: str = "trace") -> list[Diagnostic]: + """Rank diagnostics for trace or publish review mode.""" return sorted(diags, key=_key(mode)) def rank_next_edits(edits: list[NextEdit], mode: str = "trace") -> list[NextEdit]: + """Rank suggested next edits for trace or publish review mode.""" return sorted(edits, key=_key(mode)) diff --git a/gaia/trace/render.py b/gaia/engine/trace/render.py similarity index 91% rename from gaia/trace/render.py rename to gaia/engine/trace/render.py index 517ffb8a1..23f93520f 100644 --- a/gaia/trace/render.py +++ b/gaia/engine/trace/render.py @@ -1,4 +1,4 @@ -"""Trace review 三件套渲染:text / markdown / json。 +"""Render trace review reports as text, Markdown, or JSON. 设计原则: - 与 ``gaia.inquiry.render`` 一致的章节顺序(§1 → §8)与命名约定 @@ -13,13 +13,14 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from gaia.trace.review import TraceReviewReport + from gaia.engine.trace.review import TraceReviewReport # ============ Text ============ -def render_text(report: "TraceReviewReport") -> str: +def render_text(report: TraceReviewReport) -> str: + """Render a trace review report as the plain-text CLI view.""" out: list[str] = [] out.append("=" * 72) out.append(f"ARM Trace Review — {report.trace_review_id}") @@ -104,7 +105,8 @@ def render_text(report: "TraceReviewReport") -> str: # ============ Markdown ============ -def render_markdown(report: "TraceReviewReport") -> str: +def render_markdown(report: TraceReviewReport) -> str: + """Render a trace review report as Markdown.""" out: list[str] = [] out.append(f"# ARM Trace Review — `{report.trace_review_id}`") out.append("") @@ -180,12 +182,13 @@ def render_markdown(report: "TraceReviewReport") -> str: # ============ JSON ============ -def to_json_dict(report: "TraceReviewReport") -> dict[str, Any]: +def to_json_dict(report: TraceReviewReport) -> dict[str, Any]: + """Return the JSON-compatible mapping for a trace review report.""" return report.to_json_dict() -def render_json(report: "TraceReviewReport") -> str: - """决定性 JSON 渲染:sort_keys=True 保证跨运行 byte-equal。""" +def render_json(report: TraceReviewReport) -> str: + """Render deterministic JSON with stable key ordering.""" return json.dumps( report.to_json_dict(), ensure_ascii=False, diff --git a/gaia/trace/review.py b/gaia/engine/trace/review.py similarity index 86% rename from gaia/trace/review.py rename to gaia/engine/trace/review.py index 4524e6de4..503be6444 100644 --- a/gaia/trace/review.py +++ b/gaia/engine/trace/review.py @@ -1,4 +1,4 @@ -"""TraceReviewReport:八段 review 容器(与 gaia.inquiry.ReviewReport 设计同质)。 +"""Trace review report assembly for the eight-section ARM review. 字段命名尽量与 inquiry 平行:``trace_review_id`` / ``created_at`` / ``mode`` 等 保持一致,方便用户读两种 review 输出无认知断层。 @@ -7,16 +7,15 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any -from gaia.inquiry.diagnostics import Diagnostic, NextEdit -from gaia.trace.ranking import rank_diagnostics, rank_next_edits -from gaia.inquiry.snapshot import mint_review_id -from gaia.trace.diagnostics import ( - ReviewIdResolver, +from gaia.engine.inquiry.diagnostics import Diagnostic, NextEdit +from gaia.engine.inquiry.snapshot import mint_review_id +from gaia.engine.trace.diagnostics import ( RETRY_CHAIN_LIMIT_DEFAULT, + ReviewIdResolver, detect_actor, detect_claim_refs, detect_decision_grounds, @@ -29,18 +28,19 @@ detect_tool_pairing, from_schema_issues, ) -from gaia.trace.hashing import ( +from gaia.engine.trace.hashing import ( GENESIS_PREV_HASH, compute_events_root, compute_manifest_hash, recompute_chain, ) -from gaia.trace.loader import LoadResult, load_trace -from gaia.trace.schema import Trace +from gaia.engine.trace.loader import LoadResult, load_trace +from gaia.engine.trace.ranking import rank_diagnostics, rank_next_edits +from gaia.engine.trace.schema import Trace def _utcnow_iso() -> str: - return datetime.now(tz=timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + return datetime.now(tz=UTC).isoformat(timespec="seconds").replace("+00:00", "Z") # ============ 八段容器 ============ @@ -48,13 +48,14 @@ def _utcnow_iso() -> str: @dataclass class TraceReviewReport: - """ARM Trace 八段 review。 + """Represent the eight-section ARM trace review report. section mapping(参 PLAN 与 gaia.inquiry.ReviewReport): §1 Header — trace_review_id / created_at / path / mode §2 Manifest — manifest_status / manifest_hash / counts §3 Hash Chain — hash_chain (ok / broken_at_seq / recomputed_root) - §4 Causal Health — causal_health (tool_pairing / decision_grounds / parent_links / actor_continuity) + §4 Causal Health — causal_health + (tool_pairing / decision_grounds / parent_links / actor_continuity) §5 Reference Validity — reference_validity (claim_ref 解析摘要) §6 Tampering Signals — tampering (篡改信号集中视图) §7 Execution Stats — execution_stats (actors / kind 分布 / retry / time_span) @@ -92,6 +93,7 @@ class TraceReviewReport: next_edits_structured: list[NextEdit] = field(default_factory=list) def to_json_dict(self) -> dict[str, Any]: + """Return the JSON-compatible report payload.""" d: dict[str, Any] = { "trace_review_id": self.trace_review_id, "created_at": self.created_at, @@ -126,6 +128,11 @@ def _to_next_edit(d: Diagnostic) -> NextEdit: ) +def _diagnostic_kind(diagnostic: Diagnostic) -> str: + """Return the runtime diagnostic kind, including trace-specific extensions.""" + return str(diagnostic.kind) + + # ============ §2/§3/§4/§5/§6/§7 各段汇总 ============ @@ -186,20 +193,26 @@ def _build_causal_health_section(diags: list[Diagnostic], trace: Trace | None) - return { "tool_pairing": { "calls": calls, - "unresolved": sum(1 for d in diags if d.kind == "tool_call_without_result"), + "unresolved": sum( + 1 for d in diags if _diagnostic_kind(d) == "tool_call_without_result" + ), }, "decision_grounds": { "decisions": decisions, - "ungrounded": sum(1 for d in diags if d.kind == "decision_without_grounds"), + "ungrounded": sum( + 1 for d in diags if _diagnostic_kind(d) == "decision_without_grounds" + ), }, "parent_links": { - "orphans": sum(1 for d in diags if d.kind == "orphan_event"), + "orphans": sum(1 for d in diags if _diagnostic_kind(d) == "orphan_event"), }, "actor_continuity": { - "unexplained_switches": sum(1 for d in diags if d.kind == "actor_switch_unexplained"), + "unexplained_switches": sum( + 1 for d in diags if _diagnostic_kind(d) == "actor_switch_unexplained" + ), }, "retry": { - "diverged_chains": sum(1 for d in diags if d.kind == "retry_diverged"), + "diverged_chains": sum(1 for d in diags if _diagnostic_kind(d) == "retry_diverged"), }, } @@ -210,7 +223,7 @@ def _build_reference_section( if trace is None: return [] # 用 detector 的 resolver 逻辑,但这里要全集(resolved 也列出) - from gaia.trace.diagnostics import _default_resolver_factory # type: ignore + from gaia.engine.trace.diagnostics import _default_resolver_factory res = resolver or _default_resolver_factory(package_path) out: list[dict[str, Any]] = [] @@ -235,7 +248,7 @@ def _build_reference_section( def _build_tampering_section(diags: list[Diagnostic]) -> list[dict[str, Any]]: - """收集所有 error 级、与篡改强相关的 diagnostic 摘要。""" + """Collect error-level diagnostics that strongly indicate tampering.""" keep = { "trace_schema_violation", "trace_hash_chain_broken", @@ -296,7 +309,7 @@ def run_trace_review( retry_chain_limit: int = RETRY_CHAIN_LIMIT_DEFAULT, snapshot_dir: str | Path | None = None, ) -> TraceReviewReport: - """加载 trace 文件 → 跑全部 detector → 汇总八段 → 写 snapshot → 返回 report。 + """Load a trace, run detectors, build sections, and save a snapshot. ``mode`` 与 ranking 的 mode 表对齐;默认 ``"trace"``。 ``mode == "publish"`` 时 ranking 套 publish 表,warning 也会被前置。 @@ -357,7 +370,7 @@ def run_trace_review( # 写 snapshot——失败不应让 review 失败 try: - from gaia.trace.snapshot import save_trace_review_snapshot + from gaia.engine.trace.snapshot import save_trace_review_snapshot save_trace_review_snapshot(report, snapshot_dir=snapshot_dir) except Exception: diff --git a/gaia/trace/schema.py b/gaia/engine/trace/schema.py similarity index 89% rename from gaia/trace/schema.py rename to gaia/engine/trace/schema.py index c36d7c7a9..b20367cfc 100644 --- a/gaia/trace/schema.py +++ b/gaia/engine/trace/schema.py @@ -1,4 +1,4 @@ -"""ARM Trace v1 schema(pydantic)。 +"""Pydantic schema for ARM Trace v1. 字段语义参考 ARM 协议 v1 §7(Trace modality): - 一段 trace 由 manifest(元信息 + hash 锚)和 events(事件流)构成 @@ -40,7 +40,7 @@ class ClaimRef(BaseModel): - """单条 trace 事件指向的 gaia knowledge claim。""" + """Reference one Gaia knowledge claim from a trace event.""" model_config = ConfigDict(extra="forbid") @@ -50,7 +50,7 @@ class ClaimRef(BaseModel): class TraceEvent(BaseModel): - """ARM trace 的最小记账单位。 + """Represent the smallest accounting unit in an ARM trace. - prev_hash:前一事件的 canonical-json sha256(首事件 ``""``) - seq:单调递增整数,从 0 起,reviewer 校验连续 @@ -76,7 +76,9 @@ class TraceEvent(BaseModel): class TraceManifest(BaseModel): - """Trace 元信息。``events_root`` / ``manifest_hash`` 在写入端计算后冻结, + """Describe trace metadata and hash anchors. + + ``events_root`` / ``manifest_hash`` 在写入端计算后冻结, reviewer 端独立重算比对——任何字段被改 ⇒ 哈希不一致。 ``signature`` 字段是 v2 hook,v1 不验签也不强求填。 @@ -95,7 +97,7 @@ class TraceManifest(BaseModel): class Trace(BaseModel): - """完整 trace = manifest + 有序 events。""" + """Represent a complete trace as a manifest plus ordered events.""" model_config = ConfigDict(extra="forbid") diff --git a/gaia/trace/snapshot.py b/gaia/engine/trace/snapshot.py similarity index 75% rename from gaia/trace/snapshot.py rename to gaia/engine/trace/snapshot.py index 262bb37e6..7a0ea6582 100644 --- a/gaia/trace/snapshot.py +++ b/gaia/engine/trace/snapshot.py @@ -1,4 +1,4 @@ -"""TraceReviewReport snapshot:JSON 写到 ``.gaia/trace/reviews/.json``。""" +"""Persist trace review report snapshots under `.gaia/trace/reviews`.""" from __future__ import annotations @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from gaia.trace.review import TraceReviewReport + from gaia.engine.trace.review import TraceReviewReport def _default_dir() -> Path: @@ -15,11 +15,11 @@ def _default_dir() -> Path: def save_trace_review_snapshot( - report: "TraceReviewReport", + report: TraceReviewReport, *, snapshot_dir: str | Path | None = None, ) -> Path: - """把 review 写成 JSON snapshot,返回写入路径。 + """Write a trace review JSON snapshot and return its path. 与 inquiry snapshot 同样的目录布局,便于工具链统一遍历。 """ diff --git a/gaia/inquiry/render.py b/gaia/inquiry/render.py deleted file mode 100644 index d06985228..000000000 --- a/gaia/inquiry/render.py +++ /dev/null @@ -1,400 +0,0 @@ -"""Spec §8 text renderer + §9.1 JSON serializer for ReviewReport.""" - -from __future__ import annotations - -import json -from typing import Any - -from gaia.inquiry.focus import FocusBinding -from gaia.inquiry.proof_state import ProofContext - - -def render_text(report: "ReviewReport") -> str: # noqa: F821 - forward ref from review.py - lines: list[str] = [] - lines.append("Gaia Inquiry Review") - lines.append("─" * 20) - lines.append("") - - lines.append("## Focus") - f = report.focus - if f.resolved_id: - lines.append(f" {f.resolved_label} ({f.kind}, id={f.resolved_id})") - elif f.raw: - lines.append(f" (freeform) {f.raw}") - else: - lines.append(" (no focus set)") - lines.append(f" mode: {report.mode}") - lines.append("") - - lines.append("## Compile") - lines.append(f" status: {report.compile_status}") - if report.ir_hash: - lines.append(f" ir_hash: {report.ir_hash}") - for k, v in report.counts.items(): - lines.append(f" {k}: {v}") - lines.append("") - - lines.append("## Semantic diff") - d = report.semantic_diff - if d.baseline_review_id is None: - lines.append(" (no baseline review — run `gaia inquiry review` again to diff)") - elif d.is_empty: - lines.append(f" baseline: {d.baseline_review_id}") - lines.append(" (no semantic changes)") - else: - lines.append(f" baseline: {d.baseline_review_id}") - # §14.2 — print every non-empty category with consistent +/- prefixes. - for tag, items in ( - ("claims", d.added_claims), - ("questions", d.added_questions), - ("settings", d.added_settings), - ("strategies", d.added_strategies), - ("operators", d.added_operators), - ): - if items: - lines.append(f" + {len(items)} {tag}") - for tag, items in ( - ("claims", d.removed_claims), - ("questions", d.removed_questions), - ("settings", d.removed_settings), - ("strategies", d.removed_strategies), - ("operators", d.removed_operators), - ): - if items: - lines.append(f" - {len(items)} {tag}") - if d.changed_claims: - lines.append(f" ~ {len(d.changed_claims)} changed claims") - if d.changed_strategies: - lines.append(f" ~ {len(d.changed_strategies)} changed strategies") - if d.changed_operators: - lines.append(f" ~ {len(d.changed_operators)} changed operators") - if d.changed_priors: - lines.append(" changed priors:") - for delta in d.changed_priors: - lines.append(f" - {delta.label}: {delta.before} → {delta.after}") - if d.changed_exports: - lines.append(" changed exports:") - for delta in d.changed_exports: - lines.append(f" - {delta.label}: {delta.before} → {delta.after}") - lines.append("") - - lines.append("## Graph health") - gh = report.graph_health - lines.append(f" warnings: {len(gh['warnings'])}") - lines.append(f" errors: {len(gh['errors'])}") - lines.append(f" orphaned claims: {len(gh['orphaned_claims'])}") - lines.append(f" background-only claims: {len(gh['background_only_claims'])}") - lines.append(f" independent claims missing priors: {len(gh['prior_holes'])}") - lines.append(f" possible duplicate claims: {len(gh['possible_duplicates'])}") - for msg in gh["errors"]: - lines.append(f" ! {msg}") - for msg in gh["warnings"]: - lines.append(f" · {msg}") - lines.append("") - - lines.append("## Inquiry tree") - it = report.inquiry_tree - lines.append(f" goals: {it['goals']}") - lines.append(f" accepted warrants: {it['accepted_warrants']}") - lines.append(f" unreviewed warrants: {it['unreviewed_warrants']}") - lines.append(f" blocked paths: {it['blocked_paths']}") - lines.append(f" structural holes: {len(it['structural_holes'])}") - lines.append("") - - lines.append("## Prior holes") - if not report.prior_holes: - lines.append(" (all independent claims have priors set)") - else: - for h in report.prior_holes: - lines.append(f" - {h['label']}") - preview = h.get("content", "") - if preview: - lines.append(f" content: {preview}") - lines.append(f" prior: {h['prior']}") - lines.append("") - - lines.append("## Belief report") - br = report.belief_report - if not br["ran_inference"]: - lines.append(" (inference skipped)") - else: - if br.get("focus"): - foc = br["focus"] - if foc.get("delta") is not None: - lines.append( - f" focus {foc['label']}: {foc['before']} → {foc['after']} " - f"(Δ={foc['delta']:+.3f})" - ) - else: - lines.append(f" focus {foc['label']}: {foc['after']:.3f}") - lines.append(f" total claims with beliefs: {len(br['beliefs'])}") - if br.get("largest_increases"): - lines.append(" largest increases:") - for item in br["largest_increases"]: - lines.append(f" - {item['label']}: {item['before']} → {item['after']}") - if br.get("largest_decreases"): - lines.append(" largest decreases:") - for item in br["largest_decreases"]: - lines.append(f" - {item['label']}: {item['before']} → {item['after']}") - lines.append("") - - if report.proof_context is not None and ( - report.proof_context.obligations - or report.proof_context.hypotheses - or report.proof_context.rejections - ): - pc = report.proof_context - lines.append("## Proof state") - lines.append(f" obligations ({len(pc.obligations)}):") - for ob in pc.obligations: - lines.append(f" - [{ob.diagnostic_kind}] {ob.content}") - if pc.hypotheses: - lines.append(f" hypotheses ({len(pc.hypotheses)}):") - for hp in pc.hypotheses: - lines.append(f" - {hp.content}") - if pc.rejections: - lines.append(f" rejections ({len(pc.rejections)}):") - for rj in pc.rejections: - lines.append(f" - {rj.target_strategy}: {rj.content}") - lines.append("") - - lines.append("## Next edits") - if not report.next_edits: - lines.append(" (no suggested edits)") - else: - for i, edit in enumerate(report.next_edits, 1): - lines.append(f" {i}. {edit}") - - return "\n".join(lines) - - -def to_json_dict(report: "ReviewReport") -> dict[str, Any]: # noqa: F821 - return { - "review_id": report.review_id, - "created_at": report.created_at, - "path": report.path, - "focus": _focus_to_dict(report.focus), - "mode": report.mode, - "compile": { - "status": report.compile_status, - "ir_hash": report.ir_hash, - "counts": dict(report.counts), - }, - "semantic_diff": report.semantic_diff.to_dict(), - "graph_health": report.graph_health, - "inquiry_tree": report.inquiry_tree, - "prior_holes": list(report.prior_holes), - "belief_report": report.belief_report, - "diagnostics": [d.to_dict() for d in report.diagnostics], - "next_edits": list(report.next_edits), - "next_edits_structured": [e.to_dict() for e in report.next_edits_structured], - "proof_context": _proof_context_to_dict(report.proof_context), - } - - -def _focus_to_dict(f: FocusBinding) -> dict[str, Any]: - return { - "raw": f.raw, - "resolved_id": f.resolved_id, - "resolved_label": f.resolved_label, - "kind": f.kind, - } - - -def _proof_context_to_dict(pc: ProofContext | None) -> dict[str, Any]: - if pc is None: - return {"obligations": [], "hypotheses": [], "rejections": []} - return { - "obligations": [vars(o) for o in pc.obligations], - "hypotheses": [vars(h) for h in pc.hypotheses], - "rejections": [vars(r) for r in pc.rejections], - } - - -def render_markdown(report) -> str: - """Spec §17.2 Markdown renderer. - - Mirrors the eight-section text layout but uses Markdown headings, bullet - lists, and fenced code blocks for IDs/source anchors. The section names - match render_text exactly so agents can diff outputs. - """ - md: list[str] = [] - md.append("# Gaia Inquiry Review") - md.append("") - md.append(f"- **review_id**: `{report.review_id}`") - md.append(f"- **created_at**: `{report.created_at}`") - md.append(f"- **path**: `{report.path}`") - md.append("") - - md.append("## Focus") - f = report.focus - if f.resolved_id: - md.append(f"- **target**: `{f.resolved_label}` (`{f.kind}`, id=`{f.resolved_id}`)") - elif f.raw: - md.append(f"- **freeform**: `{f.raw}`") - else: - md.append("- _no focus set_") - md.append(f"- **mode**: `{report.mode}`") - md.append("") - - md.append("## Compile") - md.append(f"- status: `{report.compile_status}`") - if report.ir_hash: - md.append(f"- ir_hash: `{report.ir_hash}`") - for k, v in report.counts.items(): - md.append(f"- {k}: {v}") - md.append("") - - md.append("## Semantic diff") - d = report.semantic_diff - if d.baseline_review_id is None: - md.append("_no baseline review yet_") - elif d.is_empty: - md.append(f"baseline: `{d.baseline_review_id}` — no semantic changes") - else: - md.append(f"baseline: `{d.baseline_review_id}`") - md.append("") - for heading, items in ( - ("Added claims", d.added_claims), - ("Removed claims", d.removed_claims), - ("Added questions", d.added_questions), - ("Removed questions", d.removed_questions), - ("Added settings", d.added_settings), - ("Removed settings", d.removed_settings), - ("Added strategies", d.added_strategies), - ("Removed strategies", d.removed_strategies), - ("Added operators", d.added_operators), - ("Removed operators", d.removed_operators), - ): - if items: - md.append(f"**{heading}** ({len(items)})") - for x in items: - md.append(f"- `{x}`") - md.append("") - for heading, deltas in ( - ("Changed claims", d.changed_claims), - ("Changed strategies", d.changed_strategies), - ("Changed operators", d.changed_operators), - ("Changed priors", d.changed_priors), - ("Changed exports", d.changed_exports), - ): - if deltas: - md.append(f"**{heading}** ({len(deltas)})") - for delta in deltas: - md.append( - f"- `{delta.label}` _{delta.field}_: `{delta.before}` → `{delta.after}`" - ) - md.append("") - md.append("") - - md.append("## Graph health") - gh = report.graph_health - md.append(f"- warnings: {len(gh['warnings'])}") - md.append(f"- errors: {len(gh['errors'])}") - md.append(f"- orphaned claims: {len(gh['orphaned_claims'])}") - md.append(f"- background-only claims: {len(gh['background_only_claims'])}") - md.append(f"- prior holes: {len(gh['prior_holes'])}") - md.append(f"- possible duplicates: {len(gh['possible_duplicates'])}") - if gh["errors"]: - md.append("") - md.append("**Errors**") - for msg in gh["errors"]: - md.append(f"- {msg}") - if gh["warnings"]: - md.append("") - md.append("**Warnings**") - for msg in gh["warnings"]: - md.append(f"- {msg}") - md.append("") - - md.append("## Inquiry tree") - it = report.inquiry_tree - md.append(f"- goals: {it['goals']}") - md.append(f"- accepted warrants: {it['accepted_warrants']}") - md.append(f"- unreviewed warrants: {it['unreviewed_warrants']}") - md.append(f"- blocked paths: {it['blocked_paths']}") - md.append(f"- structural holes: {len(it['structural_holes'])}") - md.append("") - - md.append("## Prior holes") - if not report.prior_holes: - md.append("_all independent claims have priors set_") - else: - for h in report.prior_holes: - md.append(f"- **{h['label']}**") - preview = h.get("content", "") - if preview: - md.append(f" - content: {preview}") - md.append(f" - prior: `{h['prior']}`") - md.append("") - - md.append("## Belief report") - br = report.belief_report - if not br["ran_inference"]: - md.append("_inference skipped_") - else: - if br.get("focus"): - foc = br["focus"] - if foc.get("delta") is not None: - md.append( - f"- focus **{foc['label']}**: {foc['before']} → {foc['after']} " - f"(Δ={foc['delta']:+.3f})" - ) - else: - md.append(f"- focus **{foc['label']}**: {foc['after']:.3f}") - md.append(f"- claims with beliefs: {len(br['beliefs'])}") - if br.get("largest_increases"): - md.append("") - md.append("**Largest increases**") - for item in br["largest_increases"]: - md.append(f"- `{item['label']}`: {item['before']} → {item['after']}") - if br.get("largest_decreases"): - md.append("") - md.append("**Largest decreases**") - for item in br["largest_decreases"]: - md.append(f"- `{item['label']}`: {item['before']} → {item['after']}") - md.append("") - - if report.proof_context is not None and ( - report.proof_context.obligations - or report.proof_context.hypotheses - or report.proof_context.rejections - ): - pc = report.proof_context - md.append("## Proof state") - if pc.obligations: - md.append(f"**Obligations** ({len(pc.obligations)})") - for ob in pc.obligations: - md.append(f"- _[{ob.diagnostic_kind}]_ {ob.content}") - if pc.hypotheses: - md.append("") - md.append(f"**Hypotheses** ({len(pc.hypotheses)})") - for hp in pc.hypotheses: - md.append(f"- {hp.content}") - if pc.rejections: - md.append("") - md.append(f"**Rejections** ({len(pc.rejections)})") - for rj in pc.rejections: - md.append(f"- `{rj.target_strategy}`: {rj.content}") - md.append("") - - md.append("## Next edits") - if not report.next_edits_structured and not report.next_edits: - md.append("_no suggested edits_") - else: - items = report.next_edits_structured or [None for _ in report.next_edits] - for i, edit in enumerate(report.next_edits_structured, 1): - anchor = "" - if edit.source_anchor is not None: - a = edit.source_anchor - anchor = f" — `{a.file}:{a.line}`" - md.append(f"{i}. _[{edit.kind}/{edit.severity}]_ {edit.text}{anchor}") - if not report.next_edits_structured: - for i, edit in enumerate(report.next_edits, 1): - md.append(f"{i}. {edit}") - - return "\n".join(md) - - -def render_json(report) -> str: - return json.dumps(to_json_dict(report), ensure_ascii=False, indent=2) diff --git a/gaia/ir/__init__.py b/gaia/ir/__init__.py deleted file mode 100644 index 1c5854cd5..000000000 --- a/gaia/ir/__init__.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Gaia IR — data models for the Gaia reasoning hypergraph. - -Three entities: Knowledge (propositions), Operator (deterministic constraints), -Strategy (reasoning declarations with three forms). - -Parameterization (probability parameters) acts on LocalCanonicalGraph. - -Spec: docs/foundations/gaia-ir/ -""" - -from gaia.ir.knowledge import ( - Knowledge, - KnowledgeType, - PackageRef, - Parameter, - make_qid, -) -from gaia.ir.operator import Operator, OperatorType -from gaia.ir.strategy import ( - CompositeStrategy, - FormalExpr, - FormalStrategy, - Step, - Strategy, - StrategyType, -) -from gaia.ir.graphs import LocalCanonicalGraph -from gaia.ir.formalize import FormalizationResult, formalize_named_strategy -from gaia.ir.parameterization import ( - CROMWELL_EPS, - ParameterizationSource, - PriorRecord, - ResolutionPolicy, - StrategyParamRecord, -) - -__all__ = [ - # Knowledge - "Knowledge", - "KnowledgeType", - "PackageRef", - "Parameter", - "make_qid", - # Operator - "Operator", - "OperatorType", - # Strategy - "CompositeStrategy", - "FormalExpr", - "FormalStrategy", - "Step", - "Strategy", - "StrategyType", - # Graphs - "LocalCanonicalGraph", - # Formalization - "FormalizationResult", - "formalize_named_strategy", - # Parameterization - "CROMWELL_EPS", - "ParameterizationSource", - "PriorRecord", - "ResolutionPolicy", - "StrategyParamRecord", -] diff --git a/gaia/ir/coarsen.py b/gaia/ir/coarsen.py deleted file mode 100644 index fdd63aabf..000000000 --- a/gaia/ir/coarsen.py +++ /dev/null @@ -1,434 +0,0 @@ -"""Coarsen a Gaia IR to show only leaf premises → exported conclusions. - -All intermediate nodes are folded away. Each multi-hop reasoning chain -becomes a single ``infer`` edge connecting a leaf premise to an exported -conclusion it supports (directly or transitively). -""" - -from __future__ import annotations - - -def coarsen_ir(ir: dict, exported_ids: set[str]) -> dict: - """Produce a coarse-grained IR with leaf premises and exported conclusions. - - Parameters - ---------- - ir: - Full compiled IR dict with knowledges, strategies, operators. - exported_ids: - Set of knowledge IDs that are exported conclusions. - - Returns - ------- - A new IR dict (same schema) containing only leaf premises + exported - conclusions, connected by ``infer`` strategies representing transitive - reasoning chains. - """ - # 1. Identify all nodes concluded by a strategy or operator - strat_conclusions = {s["conclusion"] for s in ir["strategies"] if s.get("conclusion")} - op_conclusions = {o["conclusion"] for o in ir["operators"] if o.get("conclusion")} - all_concluded = strat_conclusions | op_conclusions - - # 2. Identify leaf premises: claims not concluded by any strategy/operator, - # excluding helpers and settings - leaf_ids: set[str] = set() - for k in ir["knowledges"]: - kid = k["id"] - label = k.get("label") or "" - if label.startswith("__") or label.startswith("_anon"): - continue - if kid not in all_concluded and k["type"] == "claim": - leaf_ids.add(kid) - - # Induction intentionally forms a fine-grained confirmation cycle: - # law -> observations via support, and observations -> law via the - # induction composite. The observations are therefore concluded nodes, - # but they are still the coarse evidence interface for the law. Seed them - # as surrogate leaves so coarse BFS exposes obs -> law instead of guessing - # a cycle-breaking leaf later. - helper_labels = {k["id"]: k.get("label") or "" for k in ir["knowledges"]} - for s in ir["strategies"]: - if s.get("type") != "induction": - continue - conc = s.get("conclusion") - if not conc: - continue - for premise in s.get("premises", []): - if premise == conc: - continue - label = helper_labels.get(premise, "") - if label.startswith("__") or label.startswith("_anon"): - continue - leaf_ids.add(premise) - - # 3. Build forward adjacency: for each node, which conclusions does it - # support (as a premise of a strategy or variable of an operator)? - forward: dict[str, set[str]] = {} - for s in ir["strategies"]: - conc = s.get("conclusion") - if not conc: - continue - for p in s.get("premises", []): - forward.setdefault(p, set()).add(conc) - for o in ir["operators"]: - conc = o.get("conclusion") - if not conc: - continue - for v in o.get("variables", []): - forward.setdefault(v, set()).add(conc) - - # 4. For each leaf premise, BFS forward to find which exported conclusions - # it transitively supports. Stop at exported conclusions. - edges: list[tuple[str, str]] = [] - for leaf in leaf_ids: - visited: set[str] = set() - queue = [leaf] - while queue: - node = queue.pop(0) - if node in visited: - continue - visited.add(node) - if node != leaf and node in exported_ids: - edges.append((leaf, node)) - continue - for neighbor in forward.get(node, []): - if neighbor not in visited: - queue.append(neighbor) - - # 4b. Also find exported → exported edges (one exported supports another) - for exp in exported_ids: - visited: set[str] = set() - queue = list(forward.get(exp, [])) - while queue: - node = queue.pop(0) - if node in visited: - continue - visited.add(node) - if node in exported_ids: - edges.append((exp, node)) - continue - for neighbor in forward.get(node, []): - if neighbor not in visited: - queue.append(neighbor) - - # 4c. Handle unreachable exported conclusions. - # Some exported conclusions have no path from leaf premises — e.g. when - # induction patterns create cycles (law → obs and obs₁+obs₂ → law) making - # every node "concluded". For unreachable exports, reverse-BFS to find the - # deepest non-helper claims and promote them to surrogate leaf premises. - connected_exports_so_far = {e[1] for e in edges} - orphaned_exports = exported_ids - connected_exports_so_far - if orphaned_exports: - # Build reverse adjacency: conclusion → premises - reverse_adj: dict[str, set[str]] = {} - for s in ir["strategies"]: - conc = s.get("conclusion") - if not conc: - continue - for p in s.get("premises", []): - reverse_adj.setdefault(conc, set()).add(p) - for o in ir["operators"]: - conc = o.get("conclusion") - if not conc: - continue - for v in o.get("variables", []): - reverse_adj.setdefault(conc, set()).add(v) - - # For each orphaned export, reverse-BFS to find claims with no further - # non-helper predecessors — these are "cycle-breaking" leaves. - kid_labels = {k["id"]: k.get("label") or "" for k in ir["knowledges"]} - kid_types = {k["id"]: k.get("type", "") for k in ir["knowledges"]} - surrogate_leaves: set[str] = set() - - for orphan in orphaned_exports: - visited: set[str] = set() - queue = list(reverse_adj.get(orphan, [])) - while queue: - node = queue.pop(0) - if node in visited: - continue - visited.add(node) - lbl = kid_labels.get(node, "") - if lbl.startswith("__") or lbl.startswith("_anon"): - # Skip helpers, keep searching - for pred in reverse_adj.get(node, []): - if pred not in visited: - queue.append(pred) - continue - if kid_types.get(node) != "claim": - continue - # If this node has no non-helper predecessors, it's a surrogate leaf - preds = reverse_adj.get(node, set()) - non_helper_preds = { - p - for p in preds - if not kid_labels.get(p, "").startswith("__") - and not kid_labels.get(p, "").startswith("_anon") - and kid_types.get(p) == "claim" - } - if not non_helper_preds: - surrogate_leaves.add(node) - else: - # Check if all predecessors are already visited (cycle) - if non_helper_preds <= visited: - surrogate_leaves.add(node) - else: - for pred in preds: - if pred not in visited: - queue.append(pred) - - # Run forward BFS from surrogate leaves - leaf_ids |= surrogate_leaves - for leaf in surrogate_leaves: - visited_fwd: set[str] = set() - queue_fwd = [leaf] - while queue_fwd: - node = queue_fwd.pop(0) - if node in visited_fwd: - continue - visited_fwd.add(node) - if node != leaf and node in exported_ids: - edges.append((leaf, node)) - continue - for neighbor in forward.get(node, []): - if neighbor not in visited_fwd: - queue_fwd.append(neighbor) - - # 5. Deduplicate edges - unique_edges = sorted(set(edges)) - - # 6. Determine which leaf premises are actually connected to exports - connected_leaves = {e[0] for e in unique_edges} - connected_exports = {e[1] for e in unique_edges} - - # 7. Build coarse knowledges (only connected nodes) - keep_ids = connected_leaves | connected_exports - coarse_knowledges = [] - for k in ir["knowledges"]: - if k["id"] in keep_ids: - coarse_knowledges.append(k) - - # 8. Build coarse strategies (one infer per edge) - coarse_strategies = [] - by_conclusion: dict[str, list[str]] = {} - for src, dst in unique_edges: - if src == dst: - continue - by_conclusion.setdefault(dst, []).append(src) - - for conc, premises in by_conclusion.items(): - coarse_strategies.append( - { - "type": "infer", - "premises": sorted(premises), - "conclusion": conc, - "reason": "", - } - ) - - # 9. Preserve operators whose variables/conclusion touch keep_ids. - # Also pull in any operator variables not yet in keep_ids so the - # constraint renders completely. - coarse_operators = [] - for o in ir.get("operators", []): - conc = o.get("conclusion") - variables = o.get("variables", []) - all_nodes = set(variables) - if conc: - all_nodes.add(conc) - # Keep operator if at least one endpoint is in keep_ids - if all_nodes & keep_ids: - coarse_operators.append(o) - # Pull in any missing variables/conclusion - for nid in all_nodes: - if nid not in keep_ids: - keep_ids.add(nid) - k = next((k for k in ir["knowledges"] if k["id"] == nid), None) - if k and not k.get("label", "").startswith("__"): - coarse_knowledges.append(k) - - return { - "package_name": ir.get("package_name", ""), - "namespace": ir.get("namespace", ""), - "knowledges": coarse_knowledges, - "strategies": coarse_strategies, - "operators": coarse_operators, - } - - -def _binary_entropy(p: float) -> float: - """H(Bernoulli(p)) in bits.""" - import math - - if p <= 0 or p >= 1: - return 0.0 - return -(p * math.log2(p) + (1 - p) * math.log2(1 - p)) - - -def mutual_information( - cpt: list[float], - premise_priors: list[float], -) -> float: - """Compute I(premises; conclusion) in bits from a coarse CPT. - - Parameters - ---------- - cpt: - CPT of length 2^k, indexed by binary encoding of premise assignment. - premise_priors: - Prior probability of each premise being true (length k). - - Returns - ------- - Mutual information in bits. - """ - k = len(premise_priors) - assert len(cpt) == (1 << k) - - # P(C=1) marginal and conditional entropy H(C|P) - p_c1 = 0.0 - h_c_given_p = 0.0 - - for assignment in range(1 << k): - # P(assignment) = product of premise marginals - p_assignment = 1.0 - for bit in range(k): - pi = premise_priors[bit] - if (assignment >> bit) & 1: - p_assignment *= pi - else: - p_assignment *= 1 - pi - - p_c1_given_a = cpt[assignment] - p_c1 += p_assignment * p_c1_given_a - h_c_given_p += p_assignment * _binary_entropy(p_c1_given_a) - - h_c = _binary_entropy(p_c1) - return max(0.0, h_c - h_c_given_p) - - -def compute_coarse_cpts( - ir: dict, - coarse: dict, - node_priors: dict[str, float] | None = None, - strategy_params: dict[str, list[float]] | None = None, - strategy_indices: set[int] | None = None, -) -> dict[int, list[float]]: - """Compute effective CPTs for coarse infer strategies via tensor contraction. - - Lowers the canonical graph once, precomputes each IR strategy's effective - CPT via ``strategy_cpt`` (sharing a cache across coarse strategies), and - contracts strategy CPTs + operator tensors + unary priors for each coarse - strategy. Exact — no BP iterations. - - Returns a dict mapping strategy index to CPT (list of 2^k floats). - """ - from gaia.bp.contraction import ( - contract_to_cpt, - cpt_tensor_to_list, - factor_to_tensor, - strategy_cpt, - ) - from gaia.bp.factor_graph import Factor - from gaia.bp.lowering import _OPERATOR_MAP, lower_local_graph - from gaia.ir.graphs import LocalCanonicalGraph - - priors = dict(node_priors or {}) - strat_params = dict(strategy_params or {}) - indices = ( - strategy_indices if strategy_indices is not None else set(range(len(coarse["strategies"]))) - ) - - # Build the canonical graph and lower it once. The lowered fg carries - # every variable's prior (including ones set by _lower_strategy for - # relation-operator conclusions or auto-formalized helper claims). - canon = LocalCanonicalGraph( - **{ - key: ir[key] - for key in ("knowledges", "strategies", "operators", "namespace", "package_name") - } - ) - fg = lower_local_graph( - canon, - node_priors=priors, - strategy_conditional_params=strat_params, - ) - - # Build operator tensors directly from canon.operators. Each operator - # becomes one factor tensor using the same FactorType mapping as - # lower_local_graph's operator pass. - operator_tensors: list[tuple] = [] - for op in canon.operators: - op_factor = Factor( - factor_id=f"op_{op.conclusion}", - factor_type=_OPERATOR_MAP[op.operator], - variables=list(op.variables), - conclusion=op.conclusion, - ) - operator_tensors.append(factor_to_tensor(op_factor)) - - # Precompute every IR strategy's effective CPT once, shared cache. - from gaia.ir.strategy import CompositeStrategy - - strat_by_id = {s.strategy_id: s for s in canon.strategies if s.strategy_id} - cache: dict = {} - strategy_tensors: list[tuple] = [] - for s in canon.strategies: - # CompositeStrategy organizes sub-strategies; its CPT is already a - # contraction of its children's CPTs. Including it as a separate - # tensor would double-count every path through the composite. - # The children themselves are iterated normally below / above. - if isinstance(s, CompositeStrategy): - continue - sub_tensor, sub_axes = strategy_cpt( - s, - strat_by_id=strat_by_id, - strat_params=strat_params, - var_priors=fg.variables, - namespace=canon.namespace, - package_name=canon.package_name, - cache=cache, - ) - strategy_tensors.append((sub_tensor, sub_axes)) - - all_tensors = strategy_tensors + operator_tensors - - # Union of all axis labels touched by any tensor. - all_axes: set[str] = set() - for _, axes in all_tensors: - all_axes.update(axes) - - result: dict[int, list[float]] = {} - - for i, s in enumerate(coarse["strategies"]): - if i not in indices: - continue - coarse_premises = list(s["premises"]) - coarse_conclusion = s["conclusion"] - free = [*coarse_premises, coarse_conclusion] - if len(free) != len(set(free)): - raise ValueError( - f"coarse strategy {i}: conclusion {coarse_conclusion!r} must not also " - "appear in premises" - ) - free_set = set(free) - - # Unary priors for every variable that: - # - appears in at least one collected tensor's axes - # - is not a coarse free variable - # - exists in fg.variables (has a registered prior) - # Helper claims absorbed inside a strategy CPT do NOT appear in - # all_axes and so are correctly skipped here (their priors were - # already applied inside the strategy CPT). - unary_priors = { - v: fg.variables[v] for v in all_axes if v not in free_set and v in fg.variables - } - - cpt_tensor = contract_to_cpt( - all_tensors, - free_vars=free, - unary_priors=unary_priors, - ) - result[i] = cpt_tensor_to_list(cpt_tensor, free, coarse_premises, coarse_conclusion) - - return result diff --git a/gaia/ir/linearize.py b/gaia/ir/linearize.py deleted file mode 100644 index 7b0634999..000000000 --- a/gaia/ir/linearize.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Linearize a coarse reasoning graph into a narrative outline. - -Topological sort → layering → connectivity-based grouping → narrative sections. -Grouping uses high-cohesion/low-coupling: nodes sharing premises or conclusions -are grouped together, independent of the Python module structure. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field - - -@dataclass -class NarrativeEntry: - """One claim in the narrative outline.""" - - kid: str - label: str - title: str - type: str - exported: bool - prior: float | None - belief: float | None - derived_from: list[str] - supports: list[str] - strategy_type: str - mi_bits: float - - -@dataclass -class NarrativeSection: - """A group of entries forming a narrative section.""" - - title: str - layer: int - entries: list[NarrativeEntry] = field(default_factory=list) - - -def _union_find_group( - nodes: list[str], - edges: list[tuple[str, str]], -) -> list[set[str]]: - """Cluster nodes by connectivity using union-find.""" - parent: dict[str, str] = {n: n for n in nodes} - - def find(x: str) -> str: - while parent[x] != x: - parent[x] = parent[parent[x]] - x = parent[x] - return x - - def union(a: str, b: str) -> None: - ra, rb = find(a), find(b) - if ra != rb: - parent[ra] = rb - - for a, b in edges: - if a in parent and b in parent: - union(a, b) - - groups: dict[str, set[str]] = {} - for n in nodes: - root = find(n) - groups.setdefault(root, set()).add(n) - return list(groups.values()) - - -def linearize_narrative( - coarse: dict, - beliefs: dict[str, float] | None = None, - priors: dict[str, float] | None = None, - mi_per_strategy: dict[int, float] | None = None, -) -> list[NarrativeSection]: - """Convert a coarse reasoning DAG into a linear narrative outline. - - Algorithm: - 1. Build adjacency from coarse strategies + operators - 2. Topological sort → assign layer to each node - 3. Within each layer, group nodes by shared connectivity - (high cohesion / low coupling — not based on Python modules) - 4. Name each group by its most prominent claim - 5. Merge consecutive groups that are tightly connected - """ - beliefs = beliefs or {} - priors = priors or {} - mi_map = mi_per_strategy or {} - - kid_to_k = {k["id"]: k for k in coarse["knowledges"]} - exported_ids = {k["id"] for k in coarse["knowledges"] if k.get("exported")} - - # Build adjacency - forward: dict[str, list[str]] = {} - backward: dict[str, list[str]] = {} - strategy_for_conclusion: dict[str, dict] = {} - strategy_idx_for_conclusion: dict[str, int] = {} - - for i, s in enumerate(coarse["strategies"]): - conc = s["conclusion"] - strategy_for_conclusion[conc] = s - strategy_idx_for_conclusion[conc] = i - for p in s["premises"]: - forward.setdefault(p, []).append(conc) - backward.setdefault(conc, []).append(p) - - for o in coarse.get("operators", []): - conc = o.get("conclusion") - for v in o.get("variables", []): - if conc: - forward.setdefault(v, []).append(conc) - backward.setdefault(conc, []).append(v) - - # Topological sort → layer assignment - all_kids = {k["id"] for k in coarse["knowledges"] if not k.get("label", "").startswith("__")} - in_degree: dict[str, int] = {kid: 0 for kid in all_kids} - for conc, plist in backward.items(): - if conc in all_kids: - in_degree[conc] = len([p for p in plist if p in all_kids]) - - layers: dict[str, int] = {} - queue = [kid for kid in all_kids if in_degree.get(kid, 0) == 0] - layer = 0 - while queue: - next_queue: list[str] = [] - for kid in queue: - layers[kid] = layer - for kid in queue: - for neighbor in forward.get(kid, []): - if neighbor in all_kids and neighbor not in layers: - in_degree[neighbor] -= 1 - if in_degree[neighbor] <= 0: - next_queue.append(neighbor) - queue = next_queue - layer += 1 - - for kid in all_kids: - if kid not in layers: - layers[kid] = layer - - max_layer = max(layers.values()) if layers else 0 - - # Build narrative entries - entries_by_kid: dict[str, NarrativeEntry] = {} - for k in coarse["knowledges"]: - kid = k["id"] - label = k.get("label", "") - if label.startswith("__"): - continue - if kid not in all_kids: - continue - - derived_labels = [] - stype = "" - mi = 0.0 - if kid in strategy_for_conclusion: - s = strategy_for_conclusion[kid] - stype = s.get("type", "") - derived_labels = [kid_to_k[p].get("label", "?") for p in s["premises"] if p in kid_to_k] - idx = strategy_idx_for_conclusion.get(kid) - if idx is not None: - mi = mi_map.get(idx, 0.0) - - supports_labels = [ - kid_to_k[c].get("label", "?") for c in forward.get(kid, []) if c in kid_to_k - ] - - entries_by_kid[kid] = NarrativeEntry( - kid=kid, - label=label, - title=k.get("title") or label, - type=k.get("type", "claim"), - exported=kid in exported_ids, - prior=priors.get(kid), - belief=beliefs.get(kid), - derived_from=derived_labels, - supports=supports_labels, - strategy_type=stype, - mi_bits=mi, - ) - - # Group within each layer by shared connectivity - # Two nodes in the same layer are connected if they share a parent or child - sections: list[NarrativeSection] = [] - for lyr in range(max_layer + 1): - layer_kids = [kid for kid in all_kids if layers.get(kid) == lyr and kid in entries_by_kid] - if not layer_kids: - continue - - # Build affinity edges: two nodes are connected if they share - # a common premise, a common conclusion, or a common operator - affinity_edges: list[tuple[str, str]] = [] - # Shared parent: two nodes derived from the same premise - parent_to_children: dict[str, list[str]] = {} - for kid in layer_kids: - for p in backward.get(kid, []): - parent_to_children.setdefault(p, []).append(kid) - for _parent, children in parent_to_children.items(): - for i in range(len(children)): - for j in range(i + 1, len(children)): - affinity_edges.append((children[i], children[j])) - - # Shared child: two nodes that support the same conclusion - child_to_parents: dict[str, list[str]] = {} - for kid in layer_kids: - for c in forward.get(kid, []): - child_to_parents.setdefault(c, []).append(kid) - for _child, parents in child_to_parents.items(): - for i in range(len(parents)): - for j in range(i + 1, len(parents)): - affinity_edges.append((parents[i], parents[j])) - - groups = _union_find_group(layer_kids, affinity_edges) - - for group in sorted( - groups, - key=lambda g: min(entries_by_kid[k].belief or 0 for k in g if k in entries_by_kid), - ): - # Name the group by its most prominent entry - group_entries = [entries_by_kid[kid] for kid in group if kid in entries_by_kid] - group_entries.sort(key=lambda e: (e.exported, e.belief or 0)) - - # Pick a descriptive name: the highest-belief exported claim, or the first entry - name_entry = group_entries[-1] if group_entries else None - group_title = name_entry.title if name_entry else f"Layer {lyr}" - - sections.append( - NarrativeSection( - title=group_title, - layer=lyr, - entries=group_entries, - ) - ) - - return sections - - -def render_narrative_outline(sections: list[NarrativeSection]) -> str: - """Render narrative sections as markdown for agent consumption.""" - lines: list[str] = [] - lines.append("# Narrative Outline") - lines.append("") - lines.append( - "Auto-generated from the coarse reasoning graph. " - "Sections are grouped by connectivity (high cohesion, low coupling) " - "and ordered by topological layer. Use this as the backbone for " - "writing narrative summaries." - ) - lines.append("") - - entry_num = 0 - for section in sections: - lines.append(f"## {section.title}") - lines.append("") - for entry in section.entries: - entry_num += 1 - star = " ★" if entry.exported else "" - prior_str = f"{entry.prior:.2f}" if entry.prior is not None else "0.50" - belief_str = f"{entry.belief:.2f}" if entry.belief is not None else "—" - - lines.append( - f"{entry_num}. **{entry.title}{star}** (prior: {prior_str} → belief: {belief_str})" - ) - - if entry.derived_from: - mi_str = f" [{entry.mi_bits:.2f} bits]" if entry.mi_bits > 0 else "" - lines.append( - f" - ← {entry.strategy_type}({', '.join(entry.derived_from)}){mi_str}" - ) - - if entry.supports: - lines.append(f" - → supports: {', '.join(entry.supports)}") - - lines.append("") - - return "\n".join(lines) diff --git a/gaia/ir/operator.py b/gaia/ir/operator.py deleted file mode 100644 index 7d47fae57..000000000 --- a/gaia/ir/operator.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Operator — deterministic logical constraints between Knowledge. - -Implements docs/foundations/gaia-ir/gaia-ir.md §2. -""" - -from __future__ import annotations - -from enum import StrEnum -from typing import Any - -from pydantic import BaseModel, model_validator - - -class OperatorType(StrEnum): - """Operator types (§2.2). All are deterministic (ψ ∈ {0,1}, no free parameters).""" - - IMPLICATION = "implication" # A=1 → B must =1 - EQUIVALENCE = "equivalence" # A=B - CONTRADICTION = "contradiction" # ¬(A=1 ∧ B=1) - COMPLEMENT = "complement" # A≠B (XOR) - DISJUNCTION = "disjunction" # ¬(all Aᵢ=0) - CONJUNCTION = "conjunction" # M = A₁ ∧ ... ∧ Aₖ - - -class Operator(BaseModel): - """Deterministic logical constraint between Knowledge nodes. - - Operators have no probability parameters — they encode logical structure. - They can appear standalone (top-level operators array) or embedded in FormalExpr. - """ - - operator_id: str | None = None # lco_ prefix - scope: str | None = None # "local" (None when embedded in FormalExpr) - - operator: OperatorType - variables: list[str] # ordered input Knowledge IDs (conclusion never appears here) - conclusion: str # output Knowledge ID (separate from variables for all types) - - metadata: dict[str, Any] | None = None - - @model_validator(mode="after") - def _validate_invariants(self) -> Operator: - if self.scope not in (None, "local"): - raise ValueError("scope must be one of: None, 'local'") - - if ( - self.scope == "local" - and self.operator_id is not None - and not self.operator_id.startswith("lco_") - ): - raise ValueError("local operators must use an operator_id with lco_ prefix") - - # §2.4: conclusion must NEVER appear in variables (inputs-only separation) - if self.conclusion in self.variables: - raise ValueError( - f"conclusion '{self.conclusion}' must not appear in variables " - f"(variables are inputs only)" - ) - - # §2.4: arity constraints per operator type - if self.operator == OperatorType.IMPLICATION: - if len(self.variables) != 2: - raise ValueError("operator=implication requires exactly 2 variables (inputs)") - - elif self.operator == OperatorType.CONJUNCTION: - if len(self.variables) < 2: - raise ValueError("operator=conjunction requires at least 2 variables (inputs)") - - elif self.operator in ( - OperatorType.EQUIVALENCE, - OperatorType.CONTRADICTION, - OperatorType.COMPLEMENT, - ): - if len(self.variables) != 2: - raise ValueError(f"operator={self.operator} requires exactly 2 variables") - - elif self.operator == OperatorType.DISJUNCTION: - if len(self.variables) < 2: - raise ValueError("operator=disjunction requires at least 2 variables") - - return self diff --git a/gaia/ir/parameterization.py b/gaia/ir/parameterization.py deleted file mode 100644 index 048adcc08..000000000 --- a/gaia/ir/parameterization.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Parameterization — probability parameters for Gaia IR graphs. - -Implements docs/foundations/gaia-ir/parameterization.md. -""" - -from __future__ import annotations - -from datetime import datetime, timezone -from typing import Any - -from pydantic import BaseModel, model_validator - -CROMWELL_EPS: float = 1e-3 -"""Cromwell's rule epsilon — all probabilities clamped to [EPS, 1-EPS].""" - - -def _clamp(value: float) -> float: - return max(CROMWELL_EPS, min(1 - CROMWELL_EPS, value)) - - -class PriorRecord(BaseModel): - """Prior probability for a claim Knowledge. - - Only type=claim Knowledge has PriorRecord. Values are Cromwell-clamped. - Multiple records for the same knowledge_id may exist from different sources. - """ - - knowledge_id: str - value: float - source_id: str - justification: str = "" - created_at: datetime = None # type: ignore[assignment] - - def model_post_init(self, __context: Any) -> None: - if self.created_at is None: - object.__setattr__(self, "created_at", datetime.now(timezone.utc)) - object.__setattr__(self, "value", _clamp(self.value)) - - -class StrategyParamRecord(BaseModel): - """Conditional probability parameters for a Strategy. - - Only parameterized strategies need StrategyParamRecord: - - infer: 2^k values (full CPT, one per premise truth-value combination) - - noisy_and: 1 value (P(conclusion=true | all premises=true)) - - FormalStrategy types (deduction, abduction, etc.) derive behavior from - FormalExpr + interface-claim priors — no independent StrategyParamRecord. - - All values are Cromwell-clamped. - """ - - strategy_id: str # lcs_ prefix - conditional_probabilities: list[float] - source_id: str - justification: str = "" - created_at: datetime = None # type: ignore[assignment] - - def model_post_init(self, __context: Any) -> None: - if self.created_at is None: - object.__setattr__(self, "created_at", datetime.now(timezone.utc)) - clamped = [_clamp(p) for p in self.conditional_probabilities] - object.__setattr__(self, "conditional_probabilities", clamped) - - -class ParameterizationSource(BaseModel): - """Metadata about the model/policy that produced a batch of records.""" - - source_id: str - model: str - policy: str | None = None - config: dict[str, Any] | None = None - created_at: datetime - - -class ResolutionPolicy(BaseModel): - """Policy for resolving multiple parameterization records before BP runs. - - Strategies: - - "latest": pick the most recent record per Knowledge/Strategy. - - "source": use only records from a specific ParameterizationSource. - - prior_cutoff filters records to those created before the given timestamp, - enabling reproducible BP runs. - """ - - strategy: str # "latest" | "source" - source_id: str | None = None - prior_cutoff: datetime | None = None - - @model_validator(mode="after") - def _validate_source_requires_source_id(self) -> ResolutionPolicy: - if self.strategy == "source" and self.source_id is None: - raise ValueError("strategy='source' requires source_id to be set") - return self diff --git a/gaia/ir/validator.py b/gaia/ir/validator.py deleted file mode 100644 index 84f841513..000000000 --- a/gaia/ir/validator.py +++ /dev/null @@ -1,722 +0,0 @@ -"""Gaia IR validator — structural validation on every IR update. - -Implements issue #233. Validates Knowledge, Operator, Strategy, and graph-level -invariants as defined in docs/foundations/gaia-ir/gaia-ir.md. -""" - -from __future__ import annotations - -import math -from dataclasses import dataclass, field - -from gaia.ir.knowledge import Knowledge, KnowledgeType, is_qid -from gaia.ir.operator import Operator, OperatorType -from gaia.ir.strategy import Strategy, CompositeStrategy, FormalStrategy, StrategyType -from gaia.ir.graphs import LocalCanonicalGraph, _canonical_json -from gaia.ir.parameterization import ( - CROMWELL_EPS, - PriorRecord, - StrategyParamRecord, -) - - -def _parse_qid(qid: str) -> tuple[str, str, str] | None: - """Parse QID into (namespace, package_name, label). Returns None if not valid QID.""" - parts = qid.split("::", 1) - if len(parts) != 2: - return None - prefix_parts = parts[0].split(":", 1) - if len(prefix_parts) != 2: - return None - return (prefix_parts[0], prefix_parts[1], parts[1]) - - -_PARAMETERIZED_TYPES = {StrategyType.INFER, StrategyType.NOISY_AND} -_STRUCTURAL_HELPER_OPERATOR_TYPES = { - OperatorType.CONJUNCTION, - OperatorType.DISJUNCTION, - OperatorType.EQUIVALENCE, - OperatorType.CONTRADICTION, - OperatorType.COMPLEMENT, - OperatorType.IMPLICATION, -} - - -@dataclass -class ValidationResult: - valid: bool = True - errors: list[str] = field(default_factory=list) - warnings: list[str] = field(default_factory=list) - - def error(self, msg: str) -> None: - self.errors.append(msg) - self.valid = False - - def warn(self, msg: str) -> None: - self.warnings.append(msg) - - def merge(self, other: ValidationResult) -> None: - self.errors.extend(other.errors) - self.warnings.extend(other.warnings) - if not other.valid: - self.valid = False - - -# --------------------------------------------------------------------------- -# 1. Knowledge validation -# --------------------------------------------------------------------------- - - -def _validate_knowledges( - knowledges: list[Knowledge], - scope: str, - result: ValidationResult, - *, - graph_namespace: str | None = None, - graph_package_name: str | None = None, -) -> dict[str, Knowledge]: - """Validate Knowledge nodes and return id→Knowledge lookup.""" - lookup: dict[str, Knowledge] = {} - - for k in knowledges: - # ID format check - if scope == "local": - if k.id and not is_qid(k.id): - result.error( - f"Knowledge '{k.id}': expected QID format " - f"(namespace:package_name::label) in local graph" - ) - - # uniqueness - if k.id in lookup: - result.error(f"Knowledge '{k.id}': duplicate ID") - if k.id: - lookup[k.id] = k - - # type - if k.type not in set(KnowledgeType): - result.error(f"Knowledge '{k.id}': invalid type '{k.type}'") - - metadata = k.metadata or {} - if "prior" in metadata: - prior = metadata["prior"] - if isinstance(prior, bool) or not isinstance(prior, (int, float)): - result.error( - f"Knowledge '{k.id}': metadata prior must be a number, " - f"got {type(prior).__name__}" - ) - else: - prior_value = float(prior) - if not math.isfinite(prior_value): - result.error(f"Knowledge '{k.id}': metadata prior must be finite") - elif prior_value < CROMWELL_EPS or prior_value > 1 - CROMWELL_EPS: - result.error( - f"Knowledge '{k.id}': metadata prior {prior_value} outside Cromwell bounds " - f"[{CROMWELL_EPS}, {1 - CROMWELL_EPS}]" - ) - - # local-layer shape rules - if scope == "local": - if k.content is None: - result.error(f"Knowledge '{k.id}': local layer requires content") - - # label uniqueness check for local scope - if scope == "local": - labels = [k.label for k in knowledges if k.label] - if len(labels) != len(set(labels)): - seen: set[str] = set() - for label in labels: - if label in seen: - result.error(f"Knowledge label '{label}': duplicate in local graph") - seen.add(label) - - # graph namespace is a free-form string (e.g. "github", "paper", "dp") - # — no validation constraint on allowed values. - - return lookup - - -# --------------------------------------------------------------------------- -# 2. Operator validation -# --------------------------------------------------------------------------- - - -def _validate_operators( - operators: list[Operator], - knowledge_lookup: dict[str, Knowledge], - scope: str, - result: ValidationResult, - *, - top_level: bool, -) -> None: - """Validate top-level Operators against the knowledge set.""" - for op in operators: - if top_level and (op.operator_id is None or op.scope is None): - result.error( - "Top-level Operator must set both operator_id and scope " - "(embedded FormalExpr operators may omit them)" - ) - - if top_level and op.operator_id is not None: - if not op.operator_id.startswith("lco_"): - result.error(f"Operator '{op.operator_id}': expected lco_ prefix in {scope} graph") - - # operator scope must be compatible with graph scope - if op.scope is not None and op.scope != scope: - result.error( - f"Operator '{op.operator_id}': scope '{op.scope}' incompatible with {scope} graph" - ) - - # reference completeness — variables (inputs only) - for var_id in op.variables: - if var_id not in knowledge_lookup: - result.error(f"Operator '{op.operator_id}': variable '{var_id}' not found in graph") - elif knowledge_lookup[var_id].type != KnowledgeType.CLAIM: - result.error( - f"Operator '{op.operator_id}': variable '{var_id}' is " - f"'{knowledge_lookup[var_id].type}', must be claim" - ) - - # conclusion reference completeness (required str, always present) - if op.conclusion not in knowledge_lookup: - result.error( - f"Operator '{op.operator_id}': conclusion '{op.conclusion}' not found in graph" - ) - elif knowledge_lookup[op.conclusion].type != KnowledgeType.CLAIM: - result.error( - f"Operator '{op.operator_id}': conclusion '{op.conclusion}' is " - f"'{knowledge_lookup[op.conclusion].type}', must be claim" - ) - - # conclusion must NOT be in variables (belt-and-suspenders, Pydantic also checks) - if op.conclusion in op.variables: - result.error( - f"Operator '{op.operator_id}': conclusion '{op.conclusion}' must not be in variables" - ) - - -# --------------------------------------------------------------------------- -# 3. Strategy validation -# --------------------------------------------------------------------------- - - -def _validate_strategy( - strategy: Strategy, - knowledge_lookup: dict[str, Knowledge], - scope: str, - result: ValidationResult, - strategy_lookup: dict[str, Strategy] | None = None, -) -> None: - """Validate a single Strategy (any form) against the knowledge set.""" - sid = strategy.strategy_id or "" - - # premise reference + type - for pid in strategy.premises: - if pid not in knowledge_lookup: - result.error(f"Strategy '{sid}': premise '{pid}' not found in graph") - elif knowledge_lookup[pid].type != KnowledgeType.CLAIM: - result.error( - f"Strategy '{sid}': premise '{pid}' is '{knowledge_lookup[pid].type}', must be claim" - ) - - # conclusion reference + type - if strategy.conclusion is not None: - if strategy.conclusion not in knowledge_lookup: - result.error(f"Strategy '{sid}': conclusion '{strategy.conclusion}' not found in graph") - elif knowledge_lookup[strategy.conclusion].type != KnowledgeType.CLAIM: - result.error( - f"Strategy '{sid}': conclusion '{strategy.conclusion}' is " - f"'{knowledge_lookup[strategy.conclusion].type}', must be claim" - ) - - # no self-loop - if strategy.conclusion is not None and strategy.conclusion in strategy.premises: - result.error(f"Strategy '{sid}': conclusion in premises (self-loop)") - - # background reference (any type OK, just must exist) - if strategy.background: - for bid in strategy.background: - if bid not in knowledge_lookup: - result.warn(f"Strategy '{sid}': background '{bid}' not found in graph") - - # scope/prefix checks - if strategy.scope != scope: - result.error(f"Strategy '{sid}': scope '{strategy.scope}' incompatible with {scope} graph") - if strategy.strategy_id and not strategy.strategy_id.startswith("lcs_"): - result.error(f"Strategy '{sid}': expected lcs_ prefix in {scope} graph") - - # form-specific validation - if isinstance(strategy, CompositeStrategy): - _validate_composite_sub_strategies(strategy, strategy_lookup, result) - - if isinstance(strategy, FormalStrategy): - _validate_operators( - strategy.formal_expr.operators, - knowledge_lookup, - scope, - result, - top_level=False, - ) - _validate_formal_expr_closure(strategy, knowledge_lookup, result) - - -def _validate_composite_sub_strategies( - strategy: CompositeStrategy, - strategy_lookup: dict[str, Strategy] | None, - result: ValidationResult, -) -> None: - """Validate CompositeStrategy sub_strategy references exist.""" - sid = strategy.strategy_id or "" - if strategy_lookup is None: - return - for sub_id in strategy.sub_strategies: - if sub_id not in strategy_lookup: - result.error( - f"CompositeStrategy '{sid}': sub_strategy '{sub_id}' not found as top-level strategy" - ) - - -def _validate_composite_dag( - strategies: list[Strategy], - result: ValidationResult, -) -> None: - """Check that CompositeStrategy sub_strategy references form a DAG (no cycles).""" - # Build adjacency: composite strategy_id -> list of sub_strategy_ids - adj: dict[str, list[str]] = {} - composite_ids: set[str] = set() - for s in strategies: - if isinstance(s, CompositeStrategy) and s.strategy_id: - adj[s.strategy_id] = list(s.sub_strategies) - composite_ids.add(s.strategy_id) - - # DFS cycle detection - WHITE, GRAY, BLACK = 0, 1, 2 - color: dict[str, int] = {sid: WHITE for sid in adj} - - def dfs(node: str) -> bool: - """Returns True if cycle found.""" - color[node] = GRAY - for nb in adj.get(node, []): - if nb not in color: - continue # non-composite, leaf — no cycle through it - if color[nb] == GRAY: - result.error(f"CompositeStrategy cycle detected involving '{node}' -> '{nb}'") - return True - if color[nb] == WHITE: - if dfs(nb): - return True - color[node] = BLACK - return False - - for sid in adj: - if color[sid] == WHITE: - dfs(sid) - - -def _validate_formal_expr_closure( - strategy: FormalStrategy, - knowledge_lookup: dict[str, Knowledge], - result: ValidationResult, -) -> None: - """Validate FormalExpr reference closure and DAG (§5 of 08-validation.md). - - Each Operator's variables/conclusion must reference one of: - - The FormalStrategy's premises (interface input) - - The FormalStrategy's conclusion (interface output) - - Another Operator's conclusion in the same FormalExpr (internal intermediate) - - Operator conclusion dependencies must form a DAG (no cycles). - """ - sid = strategy.strategy_id or "" - allowed: set[str] = set(strategy.premises) - if strategy.conclusion is not None: - allowed.add(strategy.conclusion) - - # Collect all operator conclusions in this FormalExpr as internal intermediates - operator_conclusions: set[str] = set() - for op in strategy.formal_expr.operators: - operator_conclusions.add(op.conclusion) - - full_allowed = allowed | operator_conclusions - - for op in strategy.formal_expr.operators: - for var_id in op.variables: - if var_id not in full_allowed: - result.error( - f"FormalStrategy '{sid}': operator variable '{var_id}' not in " - f"strategy premises/conclusion or operator conclusions (reference closure)" - ) - if op.conclusion not in full_allowed: - result.error( - f"FormalStrategy '{sid}': operator conclusion '{op.conclusion}' not in " - f"strategy premises/conclusion or operator conclusions (reference closure)" - ) - - # DAG check: operator conclusion dependencies must not cycle (§5.3) - # Build adjacency: conclusion -> set of conclusions it depends on (via variables) - conclusion_to_deps: dict[str, set[str]] = {} - for op in strategy.formal_expr.operators: - deps = {v for v in op.variables if v in operator_conclusions} - conclusion_to_deps[op.conclusion] = deps - - WHITE, GRAY, BLACK = 0, 1, 2 - color: dict[str, int] = {c: WHITE for c in conclusion_to_deps} - - def dfs(node: str) -> bool: - color[node] = GRAY - for dep in conclusion_to_deps.get(node, set()): - if dep not in color: - continue - if color[dep] == GRAY: - result.error( - f"FormalStrategy '{sid}': FormalExpr cycle detected " - f"involving '{node}' -> '{dep}'" - ) - return True - if color[dep] == WHITE and dfs(dep): - return True - color[node] = BLACK - return False - - for c in conclusion_to_deps: - if color[c] == WHITE: - dfs(c) - - -def _validate_private_node_isolation( - strategies: list[Strategy], - operators: list[Operator], - result: ValidationResult, -) -> None: - """Validate that internal FormalExpr nodes are not referenced externally. - - A 'private' node is an operator conclusion in a FormalExpr that is NOT in - the owning FormalStrategy's own premises/conclusion interface. Such nodes - must not be referenced by any other top-level strategy or top-level operator. - """ - # Collect private nodes per FormalStrategy: operator conclusions that are NOT - # in the owning strategy's premises or conclusion - private_nodes: dict[str, str] = {} # node_id -> owning strategy_id - for s in strategies: - if isinstance(s, FormalStrategy): - sid = s.strategy_id or "" - own_interface: set[str] = set(s.premises) - if s.conclusion is not None: - own_interface.add(s.conclusion) - for op in s.formal_expr.operators: - if op.conclusion not in own_interface: - private_nodes[op.conclusion] = sid - - # Check: no other strategy references a private node - for s in strategies: - sid = s.strategy_id or "" - for pid in s.premises: - if pid in private_nodes and private_nodes[pid] != sid: - result.error( - f"Strategy '{sid}': premise '{pid}' is a private internal node " - f"of FormalStrategy '{private_nodes[pid]}'" - ) - if s.conclusion is not None and s.conclusion in private_nodes: - owner = private_nodes[s.conclusion] - if owner != sid: - result.error( - f"Strategy '{sid}': conclusion '{s.conclusion}' is a private internal node " - f"of FormalStrategy '{owner}'" - ) - - # Check: no top-level operator references a private node - for op in operators: - oid = op.operator_id or "" - for var_id in op.variables: - if var_id in private_nodes: - result.error( - f"Operator '{oid}': variable '{var_id}' is a private internal node " - f"of FormalStrategy '{private_nodes[var_id]}'" - ) - if op.conclusion in private_nodes: - result.error( - f"Operator '{oid}': conclusion '{op.conclusion}' is a private internal node " - f"of FormalStrategy '{private_nodes[op.conclusion]}'" - ) - - -def _validate_strategies( - strategies: list[Strategy], - operators: list[Operator], - knowledge_lookup: dict[str, Knowledge], - scope: str, - result: ValidationResult, -) -> None: - """Validate all top-level Strategies.""" - seen_ids: set[str] = set() - strategy_lookup: dict[str, Strategy] = {} - - for s in strategies: - if s.strategy_id: - strategy_lookup[s.strategy_id] = s - - for s in strategies: - # uniqueness (top-level only) - if s.strategy_id and s.strategy_id in seen_ids: - result.error(f"Strategy '{s.strategy_id}': duplicate ID") - if s.strategy_id: - seen_ids.add(s.strategy_id) - - _validate_strategy(s, knowledge_lookup, scope, result, strategy_lookup) - - # DAG check for CompositeStrategy references - _validate_composite_dag(strategies, result) - - # Private node isolation check (includes top-level operators) - _validate_private_node_isolation(strategies, operators, result) - - -# --------------------------------------------------------------------------- -# 4. Graph-level validation -# --------------------------------------------------------------------------- - - -def _validate_scope_consistency( - knowledge_lookup: dict[str, Knowledge], - operators: list[Operator], - strategies: list[Strategy], - scope: str, - result: ValidationResult, -) -> None: - """Ensure all references use the correct ID format for the scope.""" - - def _check_id_format(id_: str, context: str) -> None: - if id_ and not is_qid(id_): - result.error( - f"{context} has wrong format for {scope} graph " - f"(expected QID namespace:package::label)" - ) - - for s in strategies: - for pid in s.premises: - _check_id_format(pid, f"Strategy '{s.strategy_id}': premise '{pid}'") - if s.conclusion: - _check_id_format( - s.conclusion, f"Strategy '{s.strategy_id}': conclusion '{s.conclusion}'" - ) - - def _check_operator_ids(op: Operator, context: str) -> None: - for var_id in op.variables: - _check_id_format(var_id, f"{context} '{op.operator_id}': variable '{var_id}'") - if op.conclusion: - _check_id_format( - op.conclusion, f"{context} '{op.operator_id}': conclusion '{op.conclusion}'" - ) - - for op in operators: - _check_operator_ids(op, "Operator") - - # Also check FormalExpr-embedded operators - for s in strategies: - if isinstance(s, FormalStrategy): - for op in s.formal_expr.operators: - _check_operator_ids(op, f"FormalStrategy '{s.strategy_id}' operator") - - -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - - -def validate_local_graph(graph: LocalCanonicalGraph) -> ValidationResult: - """Validate a LocalCanonicalGraph.""" - result = ValidationResult() - - knowledge_lookup = _validate_knowledges( - graph.knowledges, - "local", - result, - graph_namespace=graph.namespace, - graph_package_name=graph.package_name, - ) - _validate_operators(graph.operators, knowledge_lookup, "local", result, top_level=True) - _validate_strategies(graph.strategies, graph.operators, knowledge_lookup, "local", result) - _validate_scope_consistency( - knowledge_lookup, graph.operators, graph.strategies, "local", result - ) - - # hash consistency - if graph.ir_hash is not None: - recomputed = _canonical_json(graph.knowledges, graph.operators, graph.strategies) - import hashlib - - expected = f"sha256:{hashlib.sha256(recomputed.encode()).hexdigest()}" - if graph.ir_hash != expected: - result.error( - f"LocalCanonicalGraph ir_hash mismatch: stored={graph.ir_hash}, computed={expected}" - ) - - return result - - -# --------------------------------------------------------------------------- -# 5. Parameterization completeness (pre-BP) -# --------------------------------------------------------------------------- - - -def validate_parameterization( - graph: LocalCanonicalGraph, - priors: list[PriorRecord], - strategy_params: list[StrategyParamRecord], -) -> ValidationResult: - """Validate parameterization completeness before BP run. - - Checks that every independent claim Knowledge has at least one PriorRecord - and every parameterized Strategy (infer/noisy_and) has a StrategyParamRecord. - FormalStrategy types derive behavior from FormalExpr — no params needed. - - Three categories of claims are excluded from PriorRecord requirements: - - 1. **Strategy conclusions** — claims that appear as the conclusion of any - Strategy. Their belief is derived from premises via BP; they do not need - independent priors (but may optionally have them). - 2. **Top-level structural helper claims** — conclusions of top-level Operators - with structural types (conjunction/disjunction/equivalence/contradiction/ - complement). Their truth value is fully determined by the Operator. - These are PROHIBITED from having independent PriorRecords. - 3. **FormalExpr private nodes** — ANY operator conclusion inside a FormalExpr - that is NOT in the owning FormalStrategy's premises/conclusion interface. - Per spec §4 of 04-helper-claims.md, private nodes must not carry - independent PriorRecord regardless of the operator type. - These are PROHIBITED from having independent PriorRecords. - - Generated public interface claims (e.g. abduction's AlternativeExplanationForObs) - are part of the strategy interface, so they remain ordinary claim inputs and - still require PriorRecord. - """ - result = ValidationResult() - - # collect claim knowledge_ids - claim_ids = {k.id for k in graph.knowledges if k.type == KnowledgeType.CLAIM and k.id} - - # --- Identify claims exempt from PriorRecord requirements --- - - # (a) Claims that MUST NOT have PriorRecords (prohibited) - no_prior_allowed: set[str] = set() - - # Top-level structural helper claims — conclusions of structural operators - for op in graph.operators: - if op.operator in _STRUCTURAL_HELPER_OPERATOR_TYPES: - no_prior_allowed.add(op.conclusion) - - # FormalExpr private nodes — operator conclusions inside FormalExpr - # that are NOT in the owning strategy's premises/conclusion interface - for s in graph.strategies: - if isinstance(s, FormalStrategy): - own_interface: set[str] = set(s.premises) - if s.conclusion is not None: - own_interface.add(s.conclusion) - for op in s.formal_expr.operators: - if op.conclusion not in own_interface: - no_prior_allowed.add(op.conclusion) - - # (b) Claims that don't NEED PriorRecords but may optionally have them - # Strategy conclusions — their belief derives from premises via BP - strategy_conclusions: set[str] = set() - for s in graph.strategies: - if s.conclusion is not None: - strategy_conclusions.add(s.conclusion) - - # Combined: all claims exempt from the "must have prior" check - prior_exempt = no_prior_allowed | strategy_conclusions - - # collect strategy ids, split by parameterized vs not - parameterized_ids: set[str] = set() - all_strategy_ids: set[str] = set() - for s in graph.strategies: - if s.strategy_id: - all_strategy_ids.add(s.strategy_id) - if isinstance(s, CompositeStrategy): - continue # composite delegates to sub-strategies, no own params - if s.type in _PARAMETERIZED_TYPES: - parameterized_ids.add(s.strategy_id) - - # check prior coverage (exclude exempt claims) - prior_knowledge_ids = {r.knowledge_id for r in priors} - for cid in claim_ids: - if cid in prior_exempt: - continue # derived or structural — no prior needed - if cid not in prior_knowledge_ids: - result.error(f"Claim '{cid}': missing PriorRecord") - - # prohibited claims must NOT have PriorRecords (spec §4 of 04-helper-claims.md) - for r_prior in priors: - if r_prior.knowledge_id in no_prior_allowed: - result.error( - f"PriorRecord '{r_prior.knowledge_id}': private or structural helper claim " - f"must not have independent PriorRecord" - ) - - # check strategy param coverage — only for parameterized types - param_strategy_ids = {r.strategy_id for r in strategy_params} - for sid in parameterized_ids: - if sid not in param_strategy_ids: - result.error(f"Strategy '{sid}': missing StrategyParamRecord") - - # warn if StrategyParamRecord exists for non-parameterized type - non_parameterized_ids = all_strategy_ids - parameterized_ids - for r in strategy_params: - if r.strategy_id in non_parameterized_ids: - result.warn( - f"StrategyParamRecord '{r.strategy_id}': strategy type is not parameterized " - f"(only infer/noisy_and need params)" - ) - - # check conditional_probabilities arity — only for infer/noisy_and - strategy_lookup = {s.strategy_id: s for s in graph.strategies if s.strategy_id} - for r in strategy_params: - s = strategy_lookup.get(r.strategy_id) - if s is None: - continue # dangling ref handled below - if s.type not in _PARAMETERIZED_TYPES: - continue # non-parameterized, already warned - actual = len(r.conditional_probabilities) - if s.type == StrategyType.INFER: - expected = 2 ** len(s.premises) - if actual != expected: - result.error( - f"StrategyParamRecord '{r.strategy_id}': infer strategy with " - f"{len(s.premises)} premises requires 2^{len(s.premises)}={expected} " - f"conditional_probabilities, got {actual}" - ) - elif s.type == StrategyType.NOISY_AND: - if actual != 1: - result.error( - f"StrategyParamRecord '{r.strategy_id}': noisy_and strategy " - f"requires 1 conditional_probability, got {actual}" - ) - - # Cromwell bounds on priors - for r in priors: - if r.value < CROMWELL_EPS or r.value > 1 - CROMWELL_EPS: - result.error( - f"PriorRecord '{r.knowledge_id}': value {r.value} outside Cromwell bounds " - f"[{CROMWELL_EPS}, {1 - CROMWELL_EPS}]" - ) - - # Cromwell bounds on strategy params - for r in strategy_params: - for i, p in enumerate(r.conditional_probabilities): - if p < CROMWELL_EPS or p > 1 - CROMWELL_EPS: - result.error( - f"StrategyParamRecord '{r.strategy_id}': " - f"conditional_probabilities[{i}]={p} outside Cromwell bounds" - ) - - # dangling references: priors for non-existent claims - all_knowledge_ids = {k.id for k in graph.knowledges if k.id} - for r in priors: - if r.knowledge_id not in all_knowledge_ids: - result.warn(f"PriorRecord '{r.knowledge_id}': references non-existent Knowledge") - - # dangling references: params for non-existent strategies - for r in strategy_params: - if r.strategy_id not in all_strategy_ids: - result.warn(f"StrategyParamRecord '{r.strategy_id}': references non-existent Strategy") - - return result diff --git a/gaia/lang/__init__.py b/gaia/lang/__init__.py deleted file mode 100644 index fff919264..000000000 --- a/gaia/lang/__init__.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Gaia Lang v5 — Python DSL for knowledge authoring.""" - -from gaia.lang.dsl import ( - abduction, - analogy, - case_analysis, - claim, - compare, - composite, - complement, - contradiction, - deduction, - disjunction, - elimination, - equivalence, - extrapolation, - fills, - induction, - infer, - mathematical_induction, - noisy_and, - question, - setting, - support, -) -from gaia.lang.runtime import Knowledge, Operator, Step, Strategy - -__all__ = [ - "Knowledge", - "Operator", - "Step", - "Strategy", - "abduction", - "analogy", - "case_analysis", - "claim", - "compare", - "composite", - "complement", - "contradiction", - "deduction", - "disjunction", - "elimination", - "equivalence", - "extrapolation", - "fills", - "induction", - "infer", - "mathematical_induction", - "noisy_and", - "question", - "setting", - "support", -] diff --git a/gaia/lang/compiler/__init__.py b/gaia/lang/compiler/__init__.py deleted file mode 100644 index 80505d27e..000000000 --- a/gaia/lang/compiler/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from gaia.lang.compiler.compile import CompiledPackage, compile_package, compile_package_artifact - -__all__ = ["CompiledPackage", "compile_package", "compile_package_artifact"] diff --git a/gaia/lang/compiler/compile.py b/gaia/lang/compiler/compile.py deleted file mode 100644 index 2957a8efa..000000000 --- a/gaia/lang/compiler/compile.py +++ /dev/null @@ -1,540 +0,0 @@ -"""Gaia Lang v5 — compile collected module declarations to Gaia IR v2 JSON.""" - -from __future__ import annotations - -import hashlib -import json -import re -from dataclasses import dataclass -from typing import Any - -from gaia.ir import ( - CompositeStrategy as IrCompositeStrategy, - FormalExpr as IrFormalExpr, - FormalStrategy as IrFormalStrategy, - Knowledge as IrKnowledge, - LocalCanonicalGraph, - Operator as IrOperator, - Parameter as IrParameter, - PackageRef as IrPackageRef, - Step as IrStep, - Strategy as IrStrategy, - formalize_named_strategy, - make_qid, -) -from gaia.lang.refs import ( - ReferenceError, - check_collisions, - extract, - resolve, - validate_groups, -) -from gaia.lang.runtime import Knowledge, Operator -from gaia.lang.runtime.package import CollectedPackage - -_COMPILE_TIME_FORMAL_STRATEGIES = frozenset( - { - "deduction", - "elimination", - "mathematical_induction", - "case_analysis", - "abduction", - "analogy", - "extrapolation", - "support", - "compare", - } -) - - -@dataclass -class CompiledPackage: - """Compiled Gaia package plus runtime-object to IR-ID mappings.""" - - graph: LocalCanonicalGraph - knowledge_ids_by_object: dict[int, str] - strategies_by_object: dict[int, IrStrategy] - - def to_json(self) -> dict[str, Any]: - return self.graph.model_dump(mode="json", exclude_none=True, serialize_as_any=True) - - -def _content_hash(k: Knowledge) -> str: - """SHA-256(type + content + sorted(parameters)).""" - params_str = json.dumps(sorted(k.parameters, key=lambda p: p.get("name", "")), sort_keys=True) - raw = f"{k.type}|{k.content}|{params_str}" - return hashlib.sha256(raw.encode()).hexdigest() - - -_LABEL_RE = re.compile(r"[^a-z0-9_]") - - -def _normalize_label(label: str) -> str: - normalized = _LABEL_RE.sub("_", label.strip().lower()) - if not normalized: - return "_anon" - if not (normalized[0].isalpha() or normalized[0] == "_"): - normalized = f"_{normalized}" - return normalized - - -def _anonymous_label(k: Knowledge, *, prefix: str = "_anon") -> str: - return f"{prefix}_{_content_hash(k)[:8]}" - - -def _make_qid(namespace: str, package_name: str, label: str) -> str: - return make_qid(namespace, package_name, label) - - -def _is_local(k: Knowledge, pkg: CollectedPackage) -> bool: - """Check if a Knowledge node belongs to this package (vs imported from another).""" - return k in pkg.knowledge - - -def _is_composition_warrant(k: Knowledge) -> bool: - """Composition warrants are strategy metadata, not IR knowledge nodes.""" - return k.metadata.get("helper_kind") == "composition_validity" - - -def _knowledge_id( - k: Knowledge, - pkg: CollectedPackage, - *, - local_anon_counter: int, -) -> tuple[str, int]: - if _is_local(k, pkg): - label = k.label or f"_anon_{local_anon_counter:03d}" - next_counter = local_anon_counter + int(k.label is None) - return _make_qid(pkg.namespace, pkg.name, label), next_counter - - metadata_qid = k.metadata.get("qid") - if isinstance(metadata_qid, str): - return metadata_qid, local_anon_counter - - owner = k._package - if owner is not None: - foreign_label = k.label or _anonymous_label(k) - return _make_qid(owner.namespace, owner.name, foreign_label), local_anon_counter - - fallback_label = _normalize_label(k.label or _anonymous_label(k)) - return _make_qid("external", "anonymous", fallback_label), local_anon_counter - - -def _knowledge_metadata(k: Knowledge) -> dict[str, Any] | None: - metadata = dict(k.metadata) - return metadata or None - - -def _knowledge_provenance(k: Knowledge) -> list[IrPackageRef] | None: - if not k.provenance: - return None - return [IrPackageRef(**item) for item in k.provenance] - - -def _metadata_with_reason( - metadata: dict[str, Any], reason: str | list | None -) -> dict[str, Any] | None: - merged = dict(metadata) - if isinstance(reason, str) and reason: - merged["reason"] = reason - return merged or None - - -def _operator_to_ir( - o: Operator, - knowledge_map: dict[int, str], - *, - top_level: bool, -) -> IrOperator: - payload: dict[str, Any] = { - "operator": o.operator, - "variables": [knowledge_map[id(v)] for v in o.variables], - "conclusion": knowledge_map[id(o.conclusion)], - "metadata": _metadata_with_reason(o.metadata, o.reason), - } - if top_level: - payload["operator_id"] = _operator_id(o, knowledge_map) - payload["scope"] = "local" - return IrOperator(**payload) - - -_SYMMETRIC_OPS = frozenset( - {"equivalence", "contradiction", "complement", "disjunction", "conjunction"} -) - - -def _operator_id(o: Operator, knowledge_map: dict[int, str]) -> str: - var_ids = [knowledge_map[id(v)] for v in o.variables] - if o.operator in _SYMMETRIC_OPS: - var_ids = sorted(var_ids) - conclusion_id = knowledge_map[id(o.conclusion)] - raw = f"{o.operator}|{'|'.join(var_ids)}|{conclusion_id}" - return f"lco_{hashlib.sha256(raw.encode()).hexdigest()[:16]}" - - -def _step_ref( - value: Knowledge | str | None, - knowledge_map: dict[int, str], -) -> str | None: - if value is None: - return None - if isinstance(value, Knowledge): - return knowledge_map[id(value)] - if isinstance(value, str): - return value - raise ValueError(f"Unsupported step reference type: {type(value)!r}") - - -def _step_refs( - values: list[Knowledge | str] | None, - knowledge_map: dict[int, str], -) -> list[str] | None: - if not values: - return None - refs = [_step_ref(value, knowledge_map) for value in values] - return [ref for ref in refs if ref is not None] - - -def _compile_reason( - reason: str | list, - knowledge_map: dict[int, str], -) -> list[IrStep] | None: - """Compile a reason (str or list[str | Step]) into IR Steps.""" - if isinstance(reason, str): - return None # simple string goes to metadata.reason, not steps - if not reason: - return None - from gaia.lang.runtime.nodes import Step as DslStep - - ir_steps: list[IrStep] = [] - for entry in reason: - if isinstance(entry, str): - ir_steps.append(IrStep(reasoning=entry)) - elif isinstance(entry, DslStep): - ir_steps.append( - IrStep( - reasoning=entry.reason, - premises=_step_refs(entry.premises, knowledge_map) if entry.premises else None, - ) - ) - else: - raise ValueError(f"Unsupported reason entry type: {type(entry)!r}") - return ir_steps or None - - -def _collect_refs_from_text( - text: str | None, - label_table: dict[str, str], - references: dict[str, Any], -) -> tuple[list[str], list[str]]: - """Scan a piece of text and return (knowledge_refs, citation_refs). - - Enforces: - - homogeneous-group rule (raises ReferenceError on mixed groups) - - strict-form errors on unknown keys (raises ReferenceError) - Ignores opportunistic (bare) misses silently. - """ - if not text: - return [], [] - result = extract(text) - - # §3.2: mixed-group check - validate_groups(result.groups, result.markers, label_table, references) - - knowledge_refs: list[str] = [] - citation_refs: list[str] = [] - for marker in result.markers: - kind = resolve(marker.key, label_table, references) - if kind == "knowledge": - knowledge_refs.append(marker.key) - elif kind == "citation": - citation_refs.append(marker.key) - else: # unknown - if marker.strict: - raise ReferenceError( - f"unknown reference key '@{marker.key}' in strict form " - f"(in brackets): it is neither a knowledge label nor a " - f"citation key. add it to the package or references.json, " - f"or use the bare form `@{marker.key}` for opportunistic " - f"handling." - ) - # opportunistic miss → silent literal - - # Dedupe while preserving order - return ( - list(dict.fromkeys(knowledge_refs)), - list(dict.fromkeys(citation_refs)), - ) - - -def compile_package_artifact( - pkg: CollectedPackage, - *, - references: dict[str, Any] | None = None, -) -> CompiledPackage: - """Compile collected declarations into Gaia IR plus runtime mappings.""" - if references is None: - references = {} - # Build knowledge closure: local declarations + referenced foreign nodes. - knowledge_nodes: list[Knowledge] = [] - seen_knowledge: set[int] = set() - formal_operators: set[int] = set() - - def register_knowledge(k: Knowledge) -> None: - key = id(k) - if key in seen_knowledge: - return - knowledge_nodes.append(k) - seen_knowledge.add(key) - - def register_strategy_knowledge(strategy: Any) -> None: - for premise in strategy.premises: - register_knowledge(premise) - for background in strategy.background: - register_knowledge(background) - if strategy.conclusion is not None: - register_knowledge(strategy.conclusion) - # composition_warrant is metadata-only, not a BP variable. - # Do NOT register it as knowledge — it has no prior, no lowering, - # no factor graph participation. Render tools will read it - # directly from the Strategy object. - if strategy.formal_expr: - for op in strategy.formal_expr: - formal_operators.add(id(op)) - for variable in op.variables: - register_knowledge(variable) - if op.conclusion is not None: - register_knowledge(op.conclusion) - for sub_strategy in strategy.sub_strategies: - register_strategy_knowledge(sub_strategy) - - for k in pkg.knowledge: - if _is_composition_warrant(k): - continue - register_knowledge(k) - for s in pkg.strategies: - register_strategy_knowledge(s) - for o in pkg.operators: - for variable in o.variables: - register_knowledge(variable) - if o.conclusion is not None: - register_knowledge(o.conclusion) - - # Assign stable IDs to all knowledge nodes, preserving foreign package identity when known. - knowledge_map: dict[int, str] = {} - local_anon_counter = 0 - for k in knowledge_nodes: - knowledge_id, local_anon_counter = _knowledge_id( - k, pkg, local_anon_counter=local_anon_counter - ) - knowledge_map[id(k)] = knowledge_id - - exported_labels = getattr(pkg, "_exported_labels", set()) - ir_knowledges = [ - IrKnowledge( - id=knowledge_map[id(k)], - label=k.label, - title=getattr(k, "title", None), - type=k.type, - content=k.content, - parameters=[IrParameter(**p) for p in k.parameters], - provenance=_knowledge_provenance(k), - metadata=_knowledge_metadata(k), - module=getattr(k, "_source_module", None), - declaration_index=getattr(k, "_declaration_index", None), - exported=k.label in exported_labels if k.label else False, - ) - for k in knowledge_nodes - ] - - ir_operators = [ - _operator_to_ir(o, knowledge_map, top_level=True) - for o in pkg.operators - if id(o) not in formal_operators - ] - - ir_strategies: list[IrStrategy] = [] - generated_knowledges: list[IrKnowledge] = [] - compiled_strategies: dict[int, IrStrategy] = {} - - def compile_strategy(s) -> IrStrategy: - strategy_key = id(s) - if strategy_key in compiled_strategies: - return compiled_strategies[strategy_key] - - steps = _compile_reason(s.reason, knowledge_map) - payload: dict[str, Any] = { - "scope": "local", - "type": s.type, - "premises": [knowledge_map[id(p)] for p in s.premises], - "conclusion": knowledge_map[id(s.conclusion)] if s.conclusion else None, - "background": [knowledge_map[id(b)] for b in s.background] or None, - "steps": steps, - "metadata": _metadata_with_reason(s.metadata, s.reason), - } - if s.sub_strategies: - payload["sub_strategies"] = [ - compile_strategy(sub_strategy).strategy_id for sub_strategy in s.sub_strategies - ] - ir_strategy = IrCompositeStrategy(**payload) - elif s.formal_expr: - payload["formal_expr"] = IrFormalExpr( - operators=[ - _operator_to_ir(op, knowledge_map, top_level=False) for op in s.formal_expr - ] - ) - ir_strategy = IrFormalStrategy(**payload) - elif s.type in _COMPILE_TIME_FORMAL_STRATEGIES: - result = formalize_named_strategy( - scope="local", - type_=s.type, - premises=payload["premises"], - conclusion=payload["conclusion"], - namespace=pkg.namespace, - package_name=pkg.name, - background=payload["background"], - steps=steps, - metadata=payload["metadata"], - ) - generated_knowledges.extend(result.knowledges) - ir_strategy = result.strategy - else: - ir_strategy = IrStrategy(**payload) - - compiled_strategies[strategy_key] = ir_strategy - return ir_strategy - - emitted_strategies: set[int] = set() - for s in pkg.strategies: - strategy_key = id(s) - if strategy_key in emitted_strategies: - continue - ir_strategies.append(compile_strategy(s)) - emitted_strategies.add(strategy_key) - - # Build label-to-QID table from the full knowledge closure (local + imported foreign nodes). - label_to_id: dict[str, str] = {} - for k in knowledge_nodes: - if k.label: - label_to_id[k.label] = knowledge_map[id(k)] - - # Spec §3.5: fail-fast on label / citation-key collision. - check_collisions(label_to_id, references) - - # Spec §3.2 + §3.3: scan all text for references and accumulate per-node. - # Strategy reasons can be str, list[str | Step], or None. - from gaia.lang.runtime.nodes import Step as DslStep - - # Accumulate (knowledge_refs, citation_refs) per Knowledge node by id(). - refs_by_knowledge: dict[int, tuple[set[str], set[str]]] = {} - - def _accumulate(k: Knowledge, text: str | None) -> None: - if not text: - return - k_refs, c_refs = _collect_refs_from_text(text, label_to_id, references) - if k_refs or c_refs: - current = refs_by_knowledge.setdefault(id(k), (set(), set())) - current[0].update(k_refs) - current[1].update(c_refs) - - def _scan_strategy_refs(s) -> None: - """Recursively scan strategy and its sub_strategies for references. - - Refs from the strategy's reason are attributed to ``s.conclusion`` - (the Knowledge node whose metadata carries the provenance). If the - conclusion is foreign (e.g. a ``fills()`` bridge whose target is an - imported dep node) or ``None``, refs are still VALIDATED — mixed - groups and strict-form unknowns still fail compile — but they are - NOT accumulated into provenance. Bridge provenance belongs to the - local source or the bridge manifest, not the dep-owned target. - """ - target = s.conclusion - target_is_local = target is not None and _is_local(target, pkg) - - def _handle(text: str | None) -> None: - if not text: - return - if target_is_local: - _accumulate(target, text) - else: - # Still run validation (mixed groups, strict misses) but - # drop provenance — we have no local node to attach it to. - _collect_refs_from_text(text, label_to_id, references) - - if isinstance(s.reason, str): - _handle(s.reason) - elif isinstance(s.reason, list): - for entry in s.reason: - if isinstance(entry, str): - _handle(entry) - elif isinstance(entry, DslStep): - _handle(entry.reason) - for sub in s.sub_strategies: - _scan_strategy_refs(sub) - - for s in pkg.strategies: - _scan_strategy_refs(s) - - # Only scan content of LOCAL knowledge nodes. Foreign (imported) nodes - # were already validated when the dependency was compiled; re-validating - # them against the consumer's symbol table would break cross-package - # imports the moment a dep adopts the new reference syntax. - for k in knowledge_nodes: - if _is_local(k, pkg): - _accumulate(k, k.content) - - # Write provenance metadata onto IR knowledge nodes. - # Belt-and-suspenders: never mutate foreign nodes' metadata even if - # refs_by_knowledge somehow picks them up — provenance belongs to the - # package that owns the node. - for k in knowledge_nodes: - if not _is_local(k, pkg): - continue - refs = refs_by_knowledge.get(id(k)) - if not refs: - continue - k_refs, c_refs = refs - if not k_refs and not c_refs: - continue - qid = knowledge_map[id(k)] - for i, ir_k in enumerate(ir_knowledges): - if ir_k.id != qid: - continue - metadata = dict(ir_k.metadata) if ir_k.metadata else {} - gaia_meta = dict(metadata.get("gaia", {})) - provenance: dict[str, Any] = dict(gaia_meta.get("provenance", {})) - if c_refs: - provenance["cited_refs"] = sorted(c_refs) - if k_refs: - provenance["referenced_claims"] = sorted(k_refs) - gaia_meta["provenance"] = provenance - metadata["gaia"] = gaia_meta - ir_knowledges[i] = ir_k.model_copy(update={"metadata": metadata}) - break - - module_order = pkg._module_order if pkg._module_order else None - module_titles = getattr(pkg, "_module_titles", None) or None - graph = LocalCanonicalGraph( - namespace=pkg.namespace, - package_name=pkg.name, - knowledges=[*ir_knowledges, *generated_knowledges], - operators=ir_operators, - strategies=ir_strategies, - module_order=module_order, - module_titles=module_titles if module_titles else None, - ) - - return CompiledPackage( - graph=graph, - knowledge_ids_by_object=dict(knowledge_map), - strategies_by_object=dict(compiled_strategies), - ) - - -def compile_package( - pkg: CollectedPackage, - *, - references: dict[str, Any] | None = None, -) -> dict[str, Any]: - """Compile collected declarations into LocalCanonicalGraph JSON.""" - return compile_package_artifact(pkg, references=references).to_json() diff --git a/gaia/lang/dsl/__init__.py b/gaia/lang/dsl/__init__.py deleted file mode 100644 index e53b5dc1a..000000000 --- a/gaia/lang/dsl/__init__.py +++ /dev/null @@ -1,42 +0,0 @@ -from gaia.lang.dsl.knowledge import claim, question, setting -from gaia.lang.dsl.operators import complement, contradiction, disjunction, equivalence -from gaia.lang.dsl.strategies import ( - abduction, - analogy, - case_analysis, - compare, - composite, - deduction, - elimination, - extrapolation, - fills, - induction, - infer, - mathematical_induction, - noisy_and, - support, -) - -__all__ = [ - "abduction", - "analogy", - "case_analysis", - "claim", - "compare", - "composite", - "complement", - "contradiction", - "deduction", - "disjunction", - "elimination", - "equivalence", - "extrapolation", - "fills", - "induction", - "infer", - "mathematical_induction", - "noisy_and", - "question", - "setting", - "support", -] diff --git a/gaia/lang/dsl/knowledge.py b/gaia/lang/dsl/knowledge.py deleted file mode 100644 index db312c310..000000000 --- a/gaia/lang/dsl/knowledge.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Gaia Lang v5 — Knowledge DSL functions (claim, setting, question).""" - -from gaia.lang.runtime import Knowledge - - -def setting(content: str, *, title: str | None = None, **metadata) -> Knowledge: - """Declare a background assumption. No probability, no BP participation.""" - provenance = metadata.pop("provenance", None) - return Knowledge( - content=content.strip(), - type="setting", - title=title, - provenance=provenance or [], - metadata=_flatten_metadata(metadata), - ) - - -def question(content: str, *, title: str | None = None, **metadata) -> Knowledge: - """Declare a research question. No probability, no BP participation.""" - provenance = metadata.pop("provenance", None) - return Knowledge( - content=content.strip(), - type="question", - title=title, - provenance=provenance or [], - metadata=_flatten_metadata(metadata), - ) - - -def _flatten_metadata(metadata: dict) -> dict: - """Unwrap nested metadata={"metadata": {...}} into a flat dict.""" - if "metadata" in metadata and isinstance(metadata["metadata"], dict) and len(metadata) == 1: - return metadata["metadata"] - return metadata - - -def claim( - content: str, - *, - title: str | None = None, - background: list[Knowledge] | None = None, - parameters: list[dict] | None = None, - provenance: list[dict[str, str]] | None = None, - **metadata, -) -> Knowledge: - """Declare a scientific assertion. The only type carrying probability.""" - return Knowledge( - content=content.strip(), - type="claim", - title=title, - background=background or [], - parameters=parameters or [], - provenance=provenance or [], - metadata=_flatten_metadata(metadata), - ) diff --git a/gaia/lang/runtime/__init__.py b/gaia/lang/runtime/__init__.py deleted file mode 100644 index eb89edd86..000000000 --- a/gaia/lang/runtime/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from gaia.lang.runtime.nodes import Knowledge, Operator, Step, Strategy - -__all__ = ["Knowledge", "Operator", "Step", "Strategy"] diff --git a/gaia/review/__init__.py b/gaia/review/__init__.py deleted file mode 100644 index b6bec1502..000000000 --- a/gaia/review/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Gaia review sidecar DSL (DEPRECATED). - -.. deprecated:: 0.4.2 - Review sidecars are superseded by ``priors.py`` and inline ``reason+prior`` - pairing in the DSL. Use ``priors.py`` (exports ``PRIORS: dict``) for leaf - claim priors, and the ``prior=`` keyword on strategies for warrant priors. - This module is retained for backward compatibility and will be removed in a - future major release. -""" - -from gaia.review.models import ( - ClaimReview, - GeneratedClaimReview, - ReviewBundle, - StrategyReview, - review_claim, - review_generated_claim, - review_strategy, -) - -__all__ = [ - "ClaimReview", - "GeneratedClaimReview", - "ReviewBundle", - "StrategyReview", - "review_claim", - "review_generated_claim", - "review_strategy", -] diff --git a/gaia/review/models.py b/gaia/review/models.py deleted file mode 100644 index bab44f64d..000000000 --- a/gaia/review/models.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Author-facing review sidecar models (DEPRECATED). - -.. deprecated:: 0.4.2 - Use ``priors.py`` and inline ``reason+prior`` pairing instead. -""" - -from __future__ import annotations - -import warnings -from dataclasses import dataclass, field -from typing import Any - -from gaia.lang.runtime import Knowledge, Strategy - -_DEPRECATION_MSG = ( - "Review sidecars are deprecated since gaia-lang 0.4.2. " - "Use priors.py and inline reason+prior pairing instead. " - "See the gaia-cli skill for the recommended workflow." -) - - -@dataclass -class ClaimReview: - """Review for an explicit claim Knowledge node.""" - - subject: Knowledge - prior: float | None = None - judgment: str | None = None - justification: str = "" - metadata: dict[str, Any] = field(default_factory=dict) - - -@dataclass -class GeneratedClaimReview: - """Review for a generated strategy-interface claim. - - These claims are introduced during IR formalization and therefore cannot be - referenced as author-facing ``Knowledge`` objects in the main package module. - They are instead addressed by the owning strategy plus an interface role. - """ - - subject: Strategy - role: str - prior: float | None = None - occurrence: int = 0 - judgment: str | None = None - justification: str = "" - metadata: dict[str, Any] = field(default_factory=dict) - - -@dataclass -class StrategyReview: - """Review for a reasoning Strategy. - - Only parameterized strategies (``infer`` / ``noisy_and``) consume numeric - parameters during BP. Formal strategies may still carry judgments and - justifications for human review. - """ - - subject: Strategy - conditional_probability: float | None = None - conditional_probabilities: list[float] | None = None - judgment: str | None = None - justification: str = "" - metadata: dict[str, Any] = field(default_factory=dict) - - def __post_init__(self) -> None: - if self.conditional_probability is not None and self.conditional_probabilities is not None: - raise ValueError( - "review_strategy() accepts either conditional_probability or " - "conditional_probabilities, not both." - ) - - -@dataclass -class ReviewBundle: - """Top-level review artifact exported from ``review.py``.""" - - objects: list[ClaimReview | GeneratedClaimReview | StrategyReview] - source_id: str = "self_review" - model: str | None = None - policy: str | None = None - config: dict[str, Any] | None = None - - def __post_init__(self) -> None: - warnings.warn(_DEPRECATION_MSG, DeprecationWarning, stacklevel=2) - if not self.source_id: - raise ValueError("ReviewBundle.source_id must be non-empty.") - - -def review_claim( - subject: Knowledge, - *, - prior: float | None = None, - judgment: str | None = None, - justification: str = "", - metadata: dict[str, Any] | None = None, -) -> ClaimReview: - warnings.warn(_DEPRECATION_MSG, DeprecationWarning, stacklevel=2) - return ClaimReview( - subject=subject, - prior=prior, - judgment=judgment, - justification=justification, - metadata=dict(metadata or {}), - ) - - -def review_generated_claim( - subject: Strategy, - role: str, - *, - prior: float | None = None, - occurrence: int = 0, - judgment: str | None = None, - justification: str = "", - metadata: dict[str, Any] | None = None, -) -> GeneratedClaimReview: - warnings.warn(_DEPRECATION_MSG, DeprecationWarning, stacklevel=2) - if occurrence < 0: - raise ValueError("occurrence must be >= 0") - return GeneratedClaimReview( - subject=subject, - role=role, - prior=prior, - occurrence=occurrence, - judgment=judgment, - justification=justification, - metadata=dict(metadata or {}), - ) - - -def review_strategy( - subject: Strategy, - *, - conditional_probability: float | None = None, - conditional_probabilities: list[float] | None = None, - judgment: str | None = None, - justification: str = "", - metadata: dict[str, Any] | None = None, -) -> StrategyReview: - warnings.warn(_DEPRECATION_MSG, DeprecationWarning, stacklevel=2) - return StrategyReview( - subject=subject, - conditional_probability=conditional_probability, - conditional_probabilities=list(conditional_probabilities) - if conditional_probabilities is not None - else None, - judgment=judgment, - justification=justification, - metadata=dict(metadata or {}), - ) diff --git a/gaia/stats.py b/gaia/stats.py new file mode 100644 index 000000000..f66dbc017 --- /dev/null +++ b/gaia/stats.py @@ -0,0 +1,185 @@ +"""Distribution literal factory functions for Gaia authors. + +This module owns metadata-only distribution declarations. It intentionally does +not import scipy. The capitalized built-in distribution names are functions, +not classes, following scientific-computing authoring conventions. +""" + +from __future__ import annotations + +import hashlib +import inspect +from collections.abc import Callable +from typing import Any, Literal + +from gaia.engine.ir.schemas import ( + CallableRef, + DistributionKind, + DistributionLiteral, + DistributionParam, +) + + +def _param_to_ir(value: Any) -> DistributionParam: + if isinstance(value, bool): + raise TypeError("Distribution parameters must be numeric scalars, not bool") + if isinstance(value, int | float): + return value + from gaia.unit import is_quantity, to_literal + + if is_quantity(value): + return to_literal(value) + raise TypeError(f"Unsupported distribution parameter type: {type(value).__name__}") + + +def _spec(kind: DistributionKind, **params: Any) -> DistributionLiteral: + return DistributionLiteral( + kind=kind, + params={name: _param_to_ir(value) for name, value in params.items()}, + ) + + +def Normal(*, sigma: Any, mu: Any = 0.0) -> DistributionLiteral: + """Create a metadata literal for a normal distribution. + + Args: + sigma: Distribution scale parameter, either numeric or a Gaia quantity. + mu: Distribution location parameter, either numeric or a Gaia quantity. + + Returns: + A distribution literal with kind ``normal``. + """ + return _spec("normal", mu=mu, sigma=sigma) + + +def LogNormal(*, sigma: Any, mu: Any = 0.0) -> DistributionLiteral: + """Create a metadata literal for a log-normal distribution. + + Args: + sigma: Distribution scale parameter, either numeric or a Gaia quantity. + mu: Distribution log-location parameter, either numeric or a Gaia quantity. + + Returns: + A distribution literal with kind ``lognormal``. + """ + return _spec("lognormal", mu=mu, sigma=sigma) + + +def StudentT(*, df: float, sigma: Any, mu: Any = 0.0) -> DistributionLiteral: + """Create a metadata literal for a Student's t distribution. + + Args: + df: Degrees of freedom. + sigma: Distribution scale parameter, either numeric or a Gaia quantity. + mu: Distribution location parameter, either numeric or a Gaia quantity. + + Returns: + A distribution literal with kind ``student_t``. + """ + return _spec("student_t", df=df, mu=mu, sigma=sigma) + + +def Cauchy(*, gamma: Any, mu: Any = 0.0) -> DistributionLiteral: + """Create a metadata literal for a Cauchy distribution. + + Args: + gamma: Distribution scale parameter, either numeric or a Gaia quantity. + mu: Distribution location parameter, either numeric or a Gaia quantity. + + Returns: + A distribution literal with kind ``cauchy``. + """ + return _spec("cauchy", mu=mu, gamma=gamma) + + +def Binomial(*, n: int, p: float) -> DistributionLiteral: + """Create a metadata literal for a binomial distribution. + + Args: + n: Number of Bernoulli trials. + p: Success probability for each trial. + + Returns: + A distribution literal with kind ``binomial``. + """ + return _spec("binomial", n=n, p=p) + + +def Poisson(*, rate: Any) -> DistributionLiteral: + """Create a metadata literal for a Poisson distribution. + + Args: + rate: Expected event rate, either numeric or a Gaia quantity. + + Returns: + A distribution literal with kind ``poisson``. + """ + return _spec("poisson", rate=rate) + + +def Exponential(*, rate: Any) -> DistributionLiteral: + """Create a metadata literal for an exponential distribution. + + Args: + rate: Event rate parameter, either numeric or a Gaia quantity. + + Returns: + A distribution literal with kind ``exponential``. + """ + return _spec("exponential", rate=rate) + + +def Beta(*, alpha: float, beta: float) -> DistributionLiteral: + """Create a metadata literal for a beta distribution. + + Args: + alpha: First positive shape parameter. + beta: Second positive shape parameter. + + Returns: + A distribution literal with kind ``beta``. + """ + return _spec("beta", alpha=alpha, beta=beta) + + +def _callable_source_hash(fn: Callable[..., Any]) -> str: + """Return a best-effort provenance hash, not a stable identity key.""" + try: + source = inspect.getsource(fn) + except (OSError, TypeError): + source = repr(fn) + return f"sha256:{hashlib.sha256(source.encode()).hexdigest()}" + + +def custom_distribution( + fn: Callable[..., Any], + *, + name: str, + version: str | None = None, + params: dict[str, Any] | None = None, + purity: Literal["pure", "impure", "unknown"] = "unknown", +) -> DistributionLiteral: + """Create a metadata literal for an author-provided distribution function. + + Args: + fn: Callable that implements or identifies the distribution. + name: Stable distribution name for the callable reference. + version: Optional version string for the callable reference. + params: Optional literal parameters to store with the distribution. + purity: Purity declaration for downstream execution policy. + + Returns: + A custom distribution literal carrying a callable reference. + """ + callable_ref = CallableRef( + name=name, + version=version, + signature=str(inspect.signature(fn)), + source_hash=_callable_source_hash(fn), + purity=purity, + ) + return DistributionLiteral( + kind="custom", + params={key: _param_to_ir(value) for key, value in (params or {}).items()}, + callable_ref=callable_ref, + ) diff --git a/gaia/unit.py b/gaia/unit.py new file mode 100644 index 000000000..47be06d1d --- /dev/null +++ b/gaia/unit.py @@ -0,0 +1,36 @@ +"""Gaia unit facade built on Pint.""" + +from __future__ import annotations + +from typing import Any, TypeGuard + +from pint import Quantity as PintQuantity +from pint import UnitRegistry + +from gaia.engine.ir.schemas import QuantityLiteral + +ureg: UnitRegistry[Any] = UnitRegistry() +Quantity: type[PintQuantity[Any]] = ureg.Quantity +type QuantityT = PintQuantity[Any] + + +def is_quantity(value: object) -> TypeGuard[QuantityT]: + """Return True when value is a Quantity from Gaia's shared registry.""" + return isinstance(value, Quantity) and getattr(value, "_REGISTRY", None) is ureg + + +def q(value: float, unit: str) -> QuantityT: + """Create a Pint quantity using Gaia's shared unit registry.""" + return ureg.Quantity(value, unit) + + +def to_literal(quantity: QuantityT) -> QuantityLiteral: + """Convert a Gaia runtime quantity to the IR literal carrier.""" + if not is_quantity(quantity): + raise TypeError("Expected a gaia.unit.Quantity from the shared registry") + return QuantityLiteral(value=float(quantity.magnitude), unit=str(quantity.units)) + + +def from_literal(literal: QuantityLiteral) -> QuantityT: + """Rehydrate an IR quantity literal into a runtime quantity.""" + return ureg.Quantity(literal.value, literal.unit) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 000000000..e98a408a7 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,138 @@ +site_name: Gaia +site_description: Python DSL for knowledge authoring, compilation, and inference +site_url: https://siliconeinstein.github.io/Gaia/ +repo_url: https://github.com/SiliconEinstein/Gaia +docs_dir: docs +site_dir: site +exclude_docs: | + archive/** + design/** + ideas/** + plans/** + specs/** + superpowers/** + +validation: + nav: + omitted_files: ignore + +theme: + name: material + features: + - navigation.sections + - navigation.indexes + - toc.follow + +plugins: + - search + - mkdocstrings: + handlers: + python: + paths: + - . + options: + docstring_style: google + filters: + - "!^_" + heading_level: 2 + inherited_members: false + members_order: source + merge_init_into_class: true + separate_signature: true + show_root_heading: true + show_signature_annotations: true + show_source: true + +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.superfences + +nav: + - Home: README.md + - Start Here: + - What Is Gaia?: for-visitors/what-is-gaia.md + - Quick Start: for-users/quick-start.md + - Hole And Bridge Tutorial: for-users/hole-bridge-tutorial.md + - User Reference: + - Language Reference: for-users/language-reference.md + - CLI Commands: for-users/cli-commands.md + - Foundational Docs: + - Overview: foundations/README.md + - Theory: + - Plausible Reasoning: foundations/theory/01-plausible-reasoning.md + - MaxEnt Grounding: foundations/theory/02-maxent-grounding.md + - Propositional Operators: foundations/theory/03-propositional-operators.md + - Reasoning Strategies: foundations/theory/04-reasoning-strategies.md + - Formalization Methodology: foundations/theory/05-formalization-methodology.md + - Factor Graphs: foundations/theory/06-factor-graphs.md + - Belief Propagation: foundations/theory/07-belief-propagation.md + - Causality And Jaynes: foundations/theory/08-causality-and-jaynes.md + - Ecosystem: + - Product Scope: foundations/ecosystem/01-product-scope.md + - Decentralized Architecture: foundations/ecosystem/02-decentralized-architecture.md + - Authoring And Publishing: foundations/ecosystem/03-authoring-and-publishing.md + - Registry Operations: foundations/ecosystem/04-registry-operations.md + - Review And Curation: foundations/ecosystem/05-review-and-curation.md + - Belief Flow And Quality: foundations/ecosystem/06-belief-flow-and-quality.md + - Related Systems: foundations/ecosystem/07-related-systems.md + - Gaia Lang Design: + - Knowledge And Reasoning: foundations/gaia-lang/knowledge-and-reasoning.md + - Predicate Logic: foundations/gaia-lang/predicate-logic.md + - Bayes Semantics: foundations/gaia-lang/bayes.md + - Package Model: foundations/gaia-lang/package.md + - Gaia IR Design: + - Overview: foundations/gaia-ir/01-overview.md + - Structure Contract: foundations/gaia-ir/02-gaia-ir.md + - Identity And Hashing: foundations/gaia-ir/03-identity-and-hashing.md + - Helper Claims: foundations/gaia-ir/04-helper-claims.md + - Canonicalization: foundations/gaia-ir/05-canonicalization.md + - Parameterization: foundations/gaia-ir/06-parameterization.md + - Lowering: foundations/gaia-ir/07-lowering.md + - Validation: foundations/gaia-ir/08-validation.md + - Belief Propagation: + - Belief State: foundations/bp/belief-state.md + - Formal Strategy Lowering: foundations/bp/formal-strategy-lowering.md + - Inference: foundations/bp/inference.md + - Choosing An Algorithm: foundations/bp/choosing-algorithm.md + - Local Vs Global: foundations/bp/local-vs-global.md + - Potentials: foundations/bp/potentials.md + - Review: + - Review Pipeline: foundations/review/review-pipeline.md + - Review Report Contract: foundations/contracts/review-report.md + - Rebuttal Report Contract: foundations/contracts/rebuttal-report.md + - CLI: + - Workflow: foundations/cli/workflow.md + - Compilation: foundations/cli/compilation.md + - Inference: foundations/cli/inference.md + - Registration: foundations/cli/registration.md + - API Reference: + - Engine: + - Overview: reference/engine/index.md + - bayes: reference/engine/bayes.md + - bp: reference/engine/bp.md + - ir: reference/engine/ir.md + - lang: reference/engine/lang.md + - inquiry: reference/engine/inquiry.md + - trace: reference/engine/trace.md + - packaging: reference/engine/packaging.md + - IR Submodules: + - Logic Utilities: reference/engine/ir/logic.md + - Lang Submodules: + - Authoring DSL: reference/engine/lang/dsl.md + - Runtime Models: reference/engine/lang/runtime.md + - Formula AST: reference/engine/lang/formula.md + - Compiler: reference/engine/lang/compiler.md + - References: reference/engine/lang/refs.md + - CLI: + - Overview: reference/cli/index.md + - build: reference/cli/build.md + - run: reference/cli/run.md + - inspect: reference/cli/inspect.md + - review: reference/cli/review.md + - inquiry: reference/cli/inquiry.md + - pkg: reference/cli/pkg.md + - trace: reference/cli/trace.md + - Internals: reference/cli/internals.md + - Migration to alpha 0: migration.md + - Documentation Policy: documentation-policy.md diff --git a/pyproject.toml b/pyproject.toml index 82473c546..c84cce066 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gaia-lang" -version = "0.4.3" +version = "0.5.0" description = "Gaia Lang — Python DSL for knowledge authoring, compilation, and inference" readme = "README.md" license = {text = "MIT"} @@ -20,7 +20,10 @@ dependencies = [ "pydantic>=2.0", "typer[all]>=0.12", "numpy>=1.26,<2.4", # numpy 2.4 breaks pytest-cov (double-import C extension) + "scipy>=1.13", "opt-einsum>=3.3", + "sympy>=1.13,<2", + "pint>=0.23", "httpx>=0.27", "faiss-cpu>=1.7", ] @@ -31,11 +34,25 @@ Repository = "https://github.com/SiliconEinstein/Gaia" Issues = "https://github.com/SiliconEinstein/Gaia/issues" [project.optional-dependencies] +stats = [ + "scipy>=1.12", +] dev = [ + "commitizen>=4.0,<5", + "mypy>=2.0.0", + "pre-commit>=4.6.0", "pytest>=8.0", "pytest-asyncio>=0.23", "pytest-cov>=5.0", + "pytest-xdist>=3.5", "ruff>=0.3", + "scipy-stubs>=1.17.1.4", + "syrupy>=5.1", +] +docs = [ + "mkdocs>=1.6,<2", + "mkdocs-material>=9.6", + "mkdocstrings[python]>=0.29", ] [build-system] @@ -43,16 +60,33 @@ requires = ["setuptools>=69.0"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] -include = ["gaia", "gaia.ir*", "gaia.lang*", "gaia.bp*", "gaia.cli*", "gaia.review*"] +include = [ + "gaia", + "gaia.bp*", + "gaia.cli*", + "gaia.engine*", + "gaia.inquiry*", + "gaia.ir*", + "gaia.lang*", + "gaia.logic*", + "gaia.trace*", +] [tool.setuptools.package-data] -"gaia.cli.templates.pages" = ["**/*"] +"gaia.cli.starmap_assets" = ["**/*"] +"gaia.cli.starmap_replay_assets" = ["**/*"] [tool.pytest.ini_options] +addopts = [ + "--strict-markers", +] asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" testpaths = ["tests"] markers = [ - "slow: slow tests (e.g. npm build)", + "legacy_dsl: tests that intentionally exercise deprecated v5 DSL compatibility", + "slow: slower regression tests kept out of the default fast feedback slice", + "pr_gate: tests gated at PR CI time (fast feedback; full suite runs in nightly via make test-all)", ] [project.scripts] @@ -64,6 +98,122 @@ exclude_lines = [ "if TYPE_CHECKING:", ] +[tool.mypy] +python_version = "3.12" +files = ["gaia", "tests"] +strict = true +warn_unused_configs = true +warn_unused_ignores = true +show_error_codes = true +pretty = true +explicit_package_bases = true +exclude = [ + "^tmp/", + "^\\.venv/", + "^viz/node_modules/", +] + +[[tool.mypy.overrides]] +module = [ + "opt_einsum.*", + "sympy.*", + "tomli", + "scipy.*", +] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = [ + "tests.*", +] +disallow_untyped_defs = false +disallow_incomplete_defs = false +disallow_untyped_decorators = false +check_untyped_defs = false +disable_error_code = [ + # Test fixtures intentionally use loose JSON-like dicts, invalid runtime inputs, + # and deprecated-path ignores that should not shape production annotations. + "arg-type", + "attr-defined", + "comparison-overlap", + "index", + "no-any-return", + "no-untyped-call", + "return-value", + "type-arg", + "union-attr", + "unused-ignore", +] + [tool.ruff] line-length = 100 target-version = "py312" +extend-exclude = [ + "tmp", +] + +[tool.ruff.lint] +select = [ + "ARG", + "B", + "C4", + "C90", + "D", + "DTZ", + "E", + "ERA", + "F", + "I", + "PGH", + "RET", + "RUF", + "SIM", + "UP", + "W", +] +ignore = [ + # Mutually exclusive pydocstyle rules; keep the Google-compatible pair. + "D203", + "D213", +] + +[tool.ruff.lint.per-file-ignores] +"**/__init__.py" = ["F401"] +"tests/**/*.py" = [ + "D100", + "D101", + "D102", + "D103", + "D104", + "D105", + "D106", + "D107", + "RUF001", + "RUF002", + "RUF003", +] +"gaia/engine/trace/**/*.py" = ["RUF001", "RUF002", "RUF003"] +"gaia/engine/bp/*.py" = ["RUF001", "RUF002", "RUF003"] +"gaia/engine/inquiry/**/*.py" = ["RUF001", "RUF002", "RUF003"] +"gaia/cli/commands/_dot.py" = ["RUF001", "RUF002", "RUF003"] +"gaia/cli/commands/_stellaris_svg.py" = ["RUF001", "RUF002", "RUF003"] +"gaia/cli/commands/trace.py" = ["RUF001", "RUF002", "RUF003"] +"examples/mendel-v0-5-gaia/src/mendel_v0_5/*.py" = ["RUF001", "RUF002", "RUF003"] + +[tool.ruff.lint.mccabe] +max-complexity = 12 + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.commitizen] +name = "cz_conventional_commits" +tag_format = "v$version" +version_scheme = "pep440" +version_provider = "pep621" +update_changelog_on_bump = false + +[dependency-groups] +dev = [ + "scipy-stubs>=1.17.1.4", +] diff --git a/scripts/check_ir_schema_bump.py b/scripts/check_ir_schema_bump.py new file mode 100644 index 000000000..9944a0106 --- /dev/null +++ b/scripts/check_ir_schema_bump.py @@ -0,0 +1,46 @@ +"""Pre-push hook: ensure IR_SCHEMA_SNAPSHOT_HASH matches current IR hash. + +Fires when IR Pydantic models have changed (computed hash differs from +snapshot) without a corresponding bump of IR_SCHEMA_VERSION and update +to IR_SCHEMA_SNAPSHOT_HASH. + +Spec ref: PR #620 §6 + 协作单 四·Q5 R2 dispatch (double-write). +""" + +from __future__ import annotations + +import sys + +from gaia._meta import ( + IR_SCHEMA_SNAPSHOT_HASH, + IR_SCHEMA_VERSION, + compute_current_ir_hash, +) + + +def main() -> int: + """Exit non-zero if the current IR hash drifts from the committed snapshot.""" + current = compute_current_ir_hash() + if current == IR_SCHEMA_SNAPSHOT_HASH: + return 0 + print( + f"[FAIL] IR schema hash changed.\n" + f" snapshot: {IR_SCHEMA_SNAPSHOT_HASH}\n" + f" current: {current}\n" + f" current IR_SCHEMA_VERSION: {IR_SCHEMA_VERSION}\n" + f"\n" + f"Action — if this is a field add/remove/rename in gaia/engine/ir/:\n" + f" 1. Bump IR_SCHEMA_VERSION to the next ir-vN in gaia/_meta.py\n" + f" 2. Update IR_SCHEMA_SNAPSHOT_HASH to {current!r}\n" + f" 3. Add the new version to ALLOWED_IR_VERSIONS\n" + f"\n" + f"Action — if a refactor unexpectedly changed schema serialization:\n" + f" audit the diff; if schema is intentionally stable, update only\n" + f" IR_SCHEMA_SNAPSHOT_HASH to {current!r} (no version bump).", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_suppression_budget.py b/scripts/check_suppression_budget.py new file mode 100644 index 000000000..566f15757 --- /dev/null +++ b/scripts/check_suppression_budget.py @@ -0,0 +1,134 @@ +"""No-net-growth gate for type-check / lint suppressions in `gaia/`. + +Counts lines under tracked `gaia/**/*.py` that carry a `# noqa[: …]` or +`# type: ignore[: …]` comment and compares the total against the budget +declared in `scripts/suppression_budget.txt`. + +Pass criterion: ``current <= budget``. Going down is always fine; going +up requires bumping the budget file in the same commit, with a justifying +paragraph in the commit body. + +The script is intentionally stdlib-only so it can run as a `local` hook +under pre-commit without dragging extra dev deps into the gate path. + +Exit codes +---------- +0 + Current count is at or below the budget. Prints both numbers. +1 + Current count exceeds the budget. Prints the budget, the current + count, and every offending suppression line so the diff is obvious. +2 + Configuration error (budget file unreadable / not an integer, git + invocation failed, etc.). + +Usage +----- +:: + + uv run python scripts/check_suppression_budget.py +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +BUDGET_FILE = REPO_ROOT / "scripts" / "suppression_budget.txt" +SCOPE_PATTERN = "gaia/**/*.py" + +# Match either the ruff suppression marker (with or without a `: CODE` +# suffix) or the mypy `type: ignore` marker (with or without `[CODE]`). +# Bare forms are already kept out of the tree by ruff PGH003 / PGH004, +# so this regex just needs to locate the markers themselves. +SUPPRESSION_RE = re.compile(r"#\s*(?:type:\s*ignore|noqa)\b") + + +def _read_budget(path: Path) -> int: + """Return the integer budget from `path`, skipping `#` comment lines.""" + if not path.is_file(): + raise SystemExit(f"budget file not found: {path}") + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + try: + return int(line) + except ValueError as exc: + raise SystemExit(f"budget file {path} has non-integer content: {line!r}") from exc + raise SystemExit(f"budget file {path} contains no integer line") + + +def _tracked_files(pattern: str) -> list[str]: + """Return tracked files matching `pattern` via `git ls-files`.""" + try: + proc = subprocess.run( + ["git", "ls-files", pattern], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ) + except FileNotFoundError as exc: + raise SystemExit("git executable not found on PATH") from exc + except subprocess.CalledProcessError as exc: + raise SystemExit(f"`git ls-files {pattern}` failed: {exc.stderr.strip()}") from exc + return [line for line in proc.stdout.splitlines() if line] + + +def _collect_suppressions(files: list[str]) -> list[tuple[str, int, str]]: + """Return `(path, lineno, line)` triples for every suppression hit.""" + hits: list[tuple[str, int, str]] = [] + for rel in files: + full = REPO_ROOT / rel + try: + text = full.read_text(encoding="utf-8") + except OSError: + # File listed by git but missing on disk (e.g. mid-rename); skip. + continue + for lineno, line in enumerate(text.splitlines(), start=1): + if SUPPRESSION_RE.search(line): + hits.append((rel, lineno, line.rstrip())) + return hits + + +def main() -> int: + """Compare current suppression count against the budget and report.""" + budget = _read_budget(BUDGET_FILE) + files = _tracked_files(SCOPE_PATTERN) + hits = _collect_suppressions(files) + current = len(hits) + + if current <= budget: + print( + f"suppression budget OK: {current} <= {budget} " + f"(scope: {SCOPE_PATTERN}, files scanned: {len(files)})" + ) + if current < budget: + print( + "note: current count is below the budget; consider lowering " + f"{BUDGET_FILE.relative_to(REPO_ROOT)} to {current} to lock the win." + ) + return 0 + + print( + f"suppression budget exceeded: {current} > {budget} " + f"(scope: {SCOPE_PATTERN}, files scanned: {len(files)})", + file=sys.stderr, + ) + print("All current suppression sites:", file=sys.stderr) + for path, lineno, line in hits: + print(f" {path}:{lineno}: {line}", file=sys.stderr) + print( + "\nIf the new suppression is necessary, raise the integer in " + f"{BUDGET_FILE.relative_to(REPO_ROOT)} and justify it in the commit body.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/run_package_corpus.py b/scripts/run_package_corpus.py new file mode 100644 index 000000000..27f15c1c5 --- /dev/null +++ b/scripts/run_package_corpus.py @@ -0,0 +1,258 @@ +"""Run the gaia toolchain end-to-end against the locked alpha package corpus. + +This script implements the package-corpus runner described in the gaia +release-channel strategy spec +(`docs/specs/2026-05-16-gaia-release-channel-strategy.md`, +§5 Package Corpus E2E and §8 Minimal Implementation Plan step 3). + +Locked alpha corpus (hardcoded; do not parameterize): + - examples/galileo-v0-5-gaia + - examples/mendel-v0-5-gaia + +Per-package toolchain steps (in order; first failure stops the package +and the whole run): + + gaia build compile + gaia build check + gaia build check --gate + gaia run infer + gaia run render --target docs + gaia run render --target github + gaia run render --target obsidian + +After all toolchain steps succeed for a package, the GitHub-render +publication-bundle assertions from spec §5 are verified against +``/.github-output/``: + + MUST exist and be non-empty: + README.md + wiki/Home.md + docs/public/data/graph.json + docs/public/data/meta.json + docs/public/data/beliefs.json + MUST NOT exist (Vite/React leak prevention): + docs/package.json + docs/src + +Exit codes +---------- +0 + All corpus packages green. +N (N >= 1) + The N-th package in the locked list failed. galileo failure exits 1, + mendel failure exits 2. + +Errors are echoed to stderr with the format:: + + [FAIL] /: + +Success prints to stdout:: + + [OK] corpus all green: galileo mendel + +Invocation +---------- +This script expects an activated virtualenv with ``gaia`` on ``PATH`` +(it invokes ``gaia`` as a bare command, not ``uv run gaia``). The +nightly workflow that wires this in (spec §8 step 2) is responsible for +activating the venv before calling the script. + +For local verification from the repo root:: + + uv run python scripts/run_package_corpus.py + +``uv run`` injects the project venv so ``gaia`` resolves correctly. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Locked alpha corpus per spec §5 + dispatch lock. Order matters: it +# determines the per-package failure exit code (1-indexed). +CORPUS_PACKAGES: tuple[tuple[str, Path], ...] = ( + ("galileo", REPO_ROOT / "examples" / "galileo-v0-5-gaia"), + ("mendel", REPO_ROOT / "examples" / "mendel-v0-5-gaia"), +) + +# Toolchain step labels and argv tails. The leading ``gaia`` and the +# trailing ```` are added by ``_run_step``. +TOOLCHAIN_STEPS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("build-compile", ("build", "compile")), + ("build-check", ("build", "check")), + ("build-check-gate", ("build", "check", "--gate")), + ("run-infer", ("run", "infer")), + ("render-docs", ("run", "render", "--target", "docs")), + ("render-github", ("run", "render", "--target", "github")), + ("render-obsidian", ("run", "render", "--target", "obsidian")), +) + +# Spec §5 GitHub-render assertions. Paths are relative to +# ``/.github-output/``. +REQUIRED_GITHUB_OUTPUTS: tuple[str, ...] = ( + "README.md", + "wiki/Home.md", + "docs/public/data/graph.json", + "docs/public/data/meta.json", + "docs/public/data/beliefs.json", +) +FORBIDDEN_GITHUB_OUTPUTS: tuple[str, ...] = ( + "docs/package.json", + "docs/src", +) +# Paths that must additionally parse as JSON (subset of REQUIRED). +JSON_GITHUB_OUTPUTS: tuple[str, ...] = ( + "docs/public/data/graph.json", + "docs/public/data/meta.json", + "docs/public/data/beliefs.json", +) + + +@dataclass(frozen=True) +class StepFailure: + """A single failing step inside one package's pipeline.""" + + package: str + step: str + reason: str + + +def compute_exit_code(failing_index: int | None) -> int: + """Return the process exit code for a corpus run. + + ``failing_index`` is the 0-based index in :data:`CORPUS_PACKAGES` of + the first package that failed, or ``None`` if every package passed. + Per spec §8.3 the exit code is ``failing_index + 1`` so galileo + failure exits 1 and mendel failure exits 2. + """ + if failing_index is None: + return 0 + return failing_index + 1 + + +def _format_output_tail(stderr: str, stdout: str, *, max_lines: int = 5) -> str: + """Return the last ``max_lines`` non-empty output lines, single-line. + + Prefers ``stderr`` when populated; falls back to ``stdout`` because the + gaia CLI emits some failure detail (e.g. ``gaia build check --gate`` + quality-gate report) on stdout, and a single-line diagnostic shouldn't + appear empty when the CI logs clearly show the failure. + """ + stream = stderr if stderr.strip() else stdout + label = "stderr" if stderr.strip() else "stdout" + lines = [line.rstrip() for line in stream.splitlines() if line.strip()] + tail = lines[-max_lines:] + return f"({label}) " + " | ".join(tail) if tail else "" + + +def _run_step( + package_name: str, + step_label: str, + argv_tail: tuple[str, ...], + pkg_path: Path, +) -> StepFailure | None: + """Invoke one toolchain step; return a :class:`StepFailure` or ``None``.""" + argv = ["gaia", *argv_tail, str(pkg_path)] + try: + proc = subprocess.run( + argv, + check=False, + capture_output=True, + text=True, + ) + except FileNotFoundError: + return StepFailure( + package=package_name, + step=step_label, + reason="`gaia` executable not found on PATH (activate the project venv)", + ) + if proc.returncode != 0: + return StepFailure( + package=package_name, + step=step_label, + reason=( + f"{' '.join(argv[:-1])} exited {proc.returncode}: " + f"{_format_output_tail(proc.stderr, proc.stdout)}" + ), + ) + return None + + +def assert_github_render_outputs(pkg_path: Path) -> str | None: + """Verify spec §5 GitHub-render publication-bundle invariants. + + Returns ``None`` on success, or a one-line failure reason. The + runner wraps the reason in the canonical ``[FAIL] /:`` form. + """ + out_root = pkg_path / ".github-output" + if not out_root.is_dir(): + return f"missing .github-output/ directory under {pkg_path}" + + for rel in REQUIRED_GITHUB_OUTPUTS: + target = out_root / rel + if not target.is_file(): + return f"missing required output: {rel}" + if target.stat().st_size == 0: + return f"required output is empty: {rel}" + + for rel in JSON_GITHUB_OUTPUTS: + target = out_root / rel + try: + json.loads(target.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return f"required output is not valid JSON ({rel}): {exc}" + + for rel in FORBIDDEN_GITHUB_OUTPUTS: + target = out_root / rel + if target.exists(): + return f"forbidden output present (Vite/React leak): {rel}" + + return None + + +def _run_package(package_name: str, pkg_path: Path) -> StepFailure | None: + """Run the full toolchain and assertions for one package.""" + if not pkg_path.is_dir(): + return StepFailure( + package=package_name, + step="setup", + reason=f"corpus package directory not found: {pkg_path}", + ) + for step_label, argv_tail in TOOLCHAIN_STEPS: + failure = _run_step(package_name, step_label, argv_tail, pkg_path) + if failure is not None: + return failure + assertion_reason = assert_github_render_outputs(pkg_path) + if assertion_reason is not None: + return StepFailure( + package=package_name, + step="render-assert", + reason=assertion_reason, + ) + return None + + +def main() -> int: + """Drive the corpus run; return the process exit code.""" + for index, (name, path) in enumerate(CORPUS_PACKAGES): + failure = _run_package(name, path) + if failure is not None: + print( + f"[FAIL] {failure.package}/{failure.step}: {failure.reason}", + file=sys.stderr, + flush=True, + ) + return compute_exit_code(index) + names = " ".join(name for name, _ in CORPUS_PACKAGES) + print(f"[OK] corpus all green: {names}", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/suppression_budget.txt b/scripts/suppression_budget.txt new file mode 100644 index 000000000..72c25d5f6 --- /dev/null +++ b/scripts/suppression_budget.txt @@ -0,0 +1,12 @@ +# No-net-growth budget for `# noqa` / `# type: ignore` lines under +# tracked `gaia/**/*.py`. Enforced by +# `scripts/check_suppression_budget.py`, wired into pre-commit. +# +# The integer below is the maximum allowed count. The gate fails only on +# net growth (current > budget); going down is always fine. +# +# Bumping the budget is permitted but not casual: any commit that +# increases this number must justify the new suppression in the commit +# body (which suppression, which code, why no alternative). The point of +# the gate is to make adding a suppression a deliberate, reviewable act. +27 diff --git a/skills/formalization/SKILL.md b/skills/formalization/SKILL.md deleted file mode 100644 index 2c09a108f..000000000 --- a/skills/formalization/SKILL.md +++ /dev/null @@ -1,890 +0,0 @@ ---- -name: formalization -description: Use when formalizing a knowledge source (scientific paper, textbook chapter, technical report, etc.) into a Gaia knowledge package — six-pass process extracting propositions, connecting reasoning, checking completeness, refining strategy types, verifying structural integrity, and polishing for standalone readability. ---- - -# Knowledge Formalization - -Extract the knowledge structure from a source (scientific paper, textbook, technical report, etc.) into a Gaia knowledge package with claims, reasoning strategies, and priors (`priors.py`). - -**REQUIRED:** Use **gaia-cli** skill for CLI commands (compile, check, infer, register) and **gaia-lang** skill for DSL syntax (claim, setting, strategies, operators). - -## Overview - -Formalization is a **six-pass** process. Each pass builds on the previous one. Do NOT skip passes or combine them. - -**Key principle: Formalization is incremental.** After completing each pass, write code, compile, and check. Do not wait until all passes are done before writing code. Feedback from `gaia compile` and `gaia check` is critical input for the next pass. - -```dot -digraph formalization { - rankdir=TB; - node [shape=box]; - - p1 [label="Pass 1: Extract\n→ write DSL code"]; - r1 [label="gaia compile + gaia check"]; - p2 [label="Pass 2: Connect\n→ add strategies + operators"]; - r2 [label="gaia compile + gaia check"]; - p3 [label="Pass 3: Check Completeness\n(@labels, missing reasoning, isolated nodes)"]; - r3 [label="gaia compile + gaia check"]; - p4 [label="Pass 4: Refine Strategy Types\n(infer → specific types)"]; - r4 [label="gaia compile + gaia check"]; - p5 [label="Pass 5: Verify Structural Integrity\n(evidence independence, operator semantics)"]; - r5 [label="gaia compile + gaia check"]; - p6 [label="Pass 6: Polish for Standalone Readability\n(self-containedness, figures, formatting)"]; - r6 [label="gaia compile + gaia check"]; - priors [label="gaia check --hole\n→ Write priors.py"]; - infer [label="gaia infer .\n→ .gaia/beliefs.json"]; - interpret [label="Interpret BP results"]; - analysis [label="Write ANALYSIS.md"]; - readme [label="gaia render . --target github\n+ /gaia:publish"]; - - p1 -> r1 -> p2 -> r2 -> p3 -> r3 -> p4 -> r4 -> p5 -> r5 -> p6 -> r6; - r6 -> priors -> infer -> interpret; - interpret -> p1 [label="structural issues" style=dashed]; - interpret -> priors [label="prior issues" style=dashed]; - interpret -> analysis -> readme; -} -``` - -| Pass | Focus | Core question | -|------|-------|---------------| -| 1 | Content extraction | Are claims/settings extracted? Atomic? | -| 2 | Reasoning connections | Are strategies, operators, and contradictions modeled? | -| 3 | Content completeness | Any missing premises, orphans, or @label errors? | -| 4 | Strategy precision | Are strategy types correct (support/deduction/abduction/induction/...)? | -| 5 | Structural integrity | Is evidence independent? Are operator semantics correct? | -| 6 | Standalone readability | Can a reviewer understand everything without the original source? | - -## Scope - -Formalize the **complete** source — not just the main result. A partial formalization leaves reasoning gaps: premises without support, alternatives without comparison, intermediate steps without justification. If the source is too large (e.g., a full textbook), formalize one chapter at a time, each as a separate Gaia package. - -## Pass 0: Prepare Artifacts - -Copy the original source materials into the package's `artifacts/` directory, and create a `references.json` for bibliographic citations: - -``` -my-package-gaia/ -├── artifacts/ # Original source materials -│ ├── paper.pdf # PDF original, or -│ ├── paper.md # markdown version, or -│ └── chapter3.md # textbook chapter, etc. -├── references.json # Bibliography in CSL-JSON format (package-level, shared) -├── src/ -│ └── my_package/ -│ ├── __init__.py -│ ├── motivation.py -│ └── ... -└── pyproject.toml -``` - -Note: `gaia init` does not create the `artifacts/` directory or `references.json`. Create them manually. - -### references.json - -Create `references.json` at the package root. This file holds bibliographic citations in CSL-JSON format (dict-by-key), shared across the entire package. Start with a minimal skeleton — you will fill it incrementally as citations are needed during Passes 1-4: - -```json -{ - "Dias2020": { - "type": "article-journal", - "title": "Room-temperature superconductivity in a carbonaceous sulfur hydride" - } -} -``` - -Keys must follow Pandoc citation key grammar (letters, digits, `_`, `-`, `.`, `:`, `/`). Each entry requires `type` (CSL 1.0.2) and `title` at minimum. Add new entries as you encounter citations during formalization — do not try to enumerate all references upfront. Complete metadata (authors, DOI, volume, pages) is filled in during Pass 6 (Polish). - -This file is optional — if absent, `[@...]` citations are not available. - -Both PDF and markdown formats are supported for artifacts. Throughout the formalization process, always refer back to the originals in `artifacts/` to ensure that numbers, formulas, and reasoning steps are consistent with the source material. - -## Pass 1: Extract Knowledge Nodes - -Read the source **section by section**. For each section, identify: - -| Type | Criterion | Examples | -|------|-----------|---------| -| **setting** | Background facts that cannot be questioned | Mathematical definitions, formal setups, fundamental principles | -| **claim** | Propositions that can be questioned or falsified | Computation results, theoretical derivations, predictions, experimental observations | -| **question** | Questions to be answered | Research questions | - -### Organizing by Module - -Each section corresponds to a Gaia module (Python file): - -- Introduction → `motivation.py` -- Section II → `s2_xxx.py` -- ... - -The module's docstring serves as the section heading. Each knowledge node should have a `title` parameter. - -### Place Knowledge in the Earliest Module - -Each knowledge node belongs in the module corresponding to the section where it **first appears** in the source. Content from the Introduction goes into `motivation.py`. - -Claims in motivation can be freely referenced as premises or background by later modules — they are not restricted by module membership. Settings and questions are typically referenced via `background=`. - -### Setting vs Claim Classification Guide - -**Principle: When in doubt between setting and claim, mark it as claim.** - -| Category | Type | Examples | -|----------|------|---------| -| Mathematical definitions / formal setups | **setting** | Coordinate system choice, variable decomposition definitions, mathematical form of potentials | -| Established fundamental principles | **setting** | Conservation laws, exclusion principle, laws of thermodynamics | -| Standard approximation/method definitions (without applicability assertions) | **setting** | Mathematical expression of an approximation (definition only, not asserting applicability) | -| Whether applicability conditions hold | **claim** | Whether a certain approximation is applicable to a specific system | -| Theoretical frameworks dependent on conditions | **claim** | Theorem B holds when A is satisfied | -| Theoretical derivation results | **claim** | Renormalization relations, scaling laws, asymptotic behavior | -| Numerical computation results | **claim** | Values obtained from computational methods | -| Experimental observations | **claim** | Experimental measurements | - -**Key criterion:** Can this proposition be questioned? If yes → claim. Only mathematical definitions and formal setups qualify as settings. - -**Distinguish definitions from assertions:** The mathematical definition of an approximation is a setting, but "this approximation is unreliable under certain conditions" is a claim. "Decompose the variable into high- and low-frequency parts" is a setting (mathematical operation), but "the contribution of the high-frequency part is negligible" is a claim (physical assertion). - -**Dependency chains:** If A is a setting and B depends on A being true while containing a physical assertion — B is typically a claim. - -Content that the source itself derives — even if the derivation is rigorous — should be a claim, because the derivation process itself may contain errors. - -### Content Format - -Claim content supports **markdown**. Use it for structure: -- Tables: markdown tables for structured data -- Math: `$...$` for inline, `$$...$$` for display equations -- Lists: bullet points for enumerating conditions or items -- Bold/italic: for emphasis on key values or terms - -### Atomicity Principle - -Each claim must be an **atomic proposition** — one claim expresses one thing. - -**Core rule: Theoretical predictions must be separated from experimental results.** - -```python -# BAD: Mixing theory and experiment -result = claim("The model predicts X, the experimental value is Y, deviation Z%.") - -# GOOD: Separated into independent claims -prediction = claim("Based on method XX, the model predicts a certain quantity as X.", title="Model prediction") -experiment = claim("The experimental measurement of a certain quantity is Y.", title="Experimental value") -``` - -Similarly, **method descriptions** and **method application results** should be separated: - -```python -# BAD: Method and result mixed together -result = claim("Using method XX to compute YY yields ZZ.") - -# GOOD: Separated -method = claim("Method XX employs ... strategy ...", title="Method description") -result = claim("The numerical result for YY is ZZ +/- delta.", title="Numerical result") -``` - -### Theory-Experiment Comparison → Abduction - -**Note:** This pattern is applied during Pass 2 (Connect), not Pass 1. It is documented here because the observation/hypothesis/alternative structure influences how you extract knowledge nodes in Pass 1. - -When a theoretical prediction is compared with experimental data, use the **abduction** pattern. Abduction now takes three Strategy objects: two `support` strategies and one `compare` strategy. - -```python -# Build the three component strategies first -s_h = support([new_theory_prediction], experimental_value, - reason="New theory explains observation", prior=0.9) -s_alt = support([old_theory_prediction], experimental_value, - reason="Old theory explains observation", prior=0.5) -pred_new = claim("New theory predicts X") -pred_old = claim("Old theory predicts Y") -comp = compare(pred_new, pred_old, experimental_value, - reason="New theory's deviation is only X%, far better than old theory's Y%", prior=0.9) - -# Then compose them into abduction -abd = abduction(s_h, s_alt, comp, - reason="Both theories attempt to explain the same observation") -``` - -**Note:** `abduction()` returns a Strategy (not a Knowledge). You must assign the return value to a named public variable (e.g., `abd_xxx`) so it gets a label and appears in `gaia check --brief` output. - -**`induction` is a binary composite of support strategies.** Use `induction(s1, s2, law=law)` with two support strategies. Each support must be in the **generative direction**: `support([law], prediction)` — the law predicts an observable consequence. It is chainable: `induction(prev_induction, new_support, law=law)`. - -```python -law = claim("MgB2 universally superconducts below 39K") -pred1 = claim("Sample A: Tc = 39K") # prediction: law implies this -pred2 = claim("Sample B: Tc = 39K") -pred3 = claim("Sample C: Tc = 39K") - -s1 = support([law], pred1, reason="law predicts sample A result", prior=0.9) -s2 = support([law], pred2, reason="law predicts sample B result", prior=0.9) -s3 = support([law], pred3, reason="law predicts sample C result", prior=0.9) - -ind_12 = induction(s1, s2, law=law, reason="Samples A and B independent") -ind_123 = induction(ind_12, s3, law=law, reason="Sample C independent") -``` - -**Direction matters:** `support([law], prediction)` creates a factor where confirmation of the prediction (high prior) flows backward to boost the law. The reversed direction `support([prediction], law)` does NOT work — it will be rejected by the compiler. - -**Semantics of pi(Alt) -- critical:** In abduction, the prior pi(Alt) of the `alternative` represents: **"the probability that Alt alone can explain Obs without H"** -- not whether Alt's calculation is correct. - -For example: If Obs = "experimental Tc = 1.2K" and Alt = "phenomenological theory predicts Tc = 1.9K", then although Alt's calculation itself is not wrong (the calculation indeed gives 1.9K), 1.9K cannot explain the observation of 1.2K. Therefore pi(Alt) should be **low** (e.g., 0.3), rather than high just because "the calculation is correct." - -**Rule of thumb:** If pi(Alt) >= pi(H), it means the alternative theory's explanatory power is no weaker than the hypothesis -- this either means the abduction provides weak support for H, or pi(Alt) has been overestimated. The reviewer should examine carefully. - -### Figures and Tables - -When the source contains figures or tables with important data: - -**Tables:** Use markdown table format in the claim content. The claim must be self-contained — a reviewer should not need to open the original. - -```python -tc_data = claim( - "Measured superconducting transition temperatures:\n\n" - "| Material | $T_c$ (K) | Pressure (GPa) |\n" - "|----------|-----------|----------------|\n" - "| LaH10 | 250 | 200 |\n" - "| H3S | 203 | 150 |\n" - "| YH6 | 224 | 166 |", - title="Tc measurements", - metadata={"source_table": "artifacts/paper.pdf, Table 2"}, -) -``` - -**Figures:** Describe the key quantitative information (values, trends, comparisons) in the claim content. Reference the original figure in metadata for traceability. - -```python -phase_diagram = claim( - "The Tc vs pressure curve shows a dome shape with maximum Tc = 250K at 200 GPa, " - "decreasing to 200K at 250 GPa and 180K at 150 GPa.", - title="Tc-pressure phase diagram", - metadata={ - "figure": "artifacts/images/fig3.png", - "caption": "Fig. 3 | Tc-pressure phase diagram showing dome-shaped dependence.", - }, -) -``` - -**Key principle:** The claim content carries all information needed for judgment. The metadata figure/table reference is for traceability, not for conveying information. - -### Content Must Be Self-Contained - -Each node's content must be a complete, independently understandable proposition. A reviewer reading it should not need additional context to make a judgment. - -```python -# BAD: Requires context to understand -result = claim("The computed result significantly exceeds conventional estimates.") - -# GOOD: Self-contained proposition -result = claim( - "Using method XX to compute YY under condition ZZ yields A +/- delta, " - "compared to the estimate B from conventional method WW, a deviation of approximately C-fold.", - title="Result description", -) -``` - -### Pass 1 Reflection - -After extracting all modules, ask yourself: - -- **Theory vs experiment separated?** For every result where the source compares theory to experiment, do I have separate claims for the theoretical prediction and the experimental measurement? If they're mixed in one claim, I can't use abduction in Pass 2. -- **Figures and tables transcribed?** Are all key numerical values from figures and tables written into claim content (not just referenced)? -- **Each claim independently judgeable?** Can a reviewer assess each claim without reading any other claim? -- **Contradictory claims identified?** When the source argues "A succeeds where B fails," or compares competing methods/hypotheses, have I extracted both sides as separate claims? These pairs will become `contradiction()` operators in Pass 2, providing strong BP constraints. - -### Marking Exported Conclusions - -The source's **core contributions** (new theoretical results, new numerical computation results, new experimental findings, key arguments) should be marked as exported conclusions in `__all__`. These are this knowledge package's external interface -- other packages can reference them. - -Criterion: If this result were removed from the source, the source would lose its core value. - -### Pass 1 Deliverable - -One claim/setting/question list per module. - -Pass 1 only extracts atomic, self-contained knowledge nodes. **Do not prejudge which are "derived conclusions"** -- whether a claim is an independent premise or a derived one depends on how reasoning connections are established in Pass 2, not on the claim itself. - -## Pass 2: Connect -- Write Infer Strategies - -`infer` is the **most general** strategy type in Gaia -- it does not presume any specific reasoning pattern (such as deduction, abduction), and merely expresses "from premises, derive conclusion." Pass 2 uses `infer` as the draft form for all reasoning connections; specific strategy types are refined in Pass 4. - -In Pass 4, most `infer` calls should be refined to specific strategy types (`support`, `deduction`, `abduction`, etc.). If no specific type fits, `infer` can remain as the final type -- but note that `infer` strategies without an explicit CPT default to uniform 0.5, and specifying a full CPT (2^N conditional probabilities) is more work than `support` (author-specified prior). Prefer `support` with `prior=` when all premises jointly support the conclusion. - -For each claim "supported by other claims," write an `infer` strategy (which claims need a strategy is determined case-by-case in Pass 2 -- if the source provides an argument for it, it needs one): - -1. **Write a detailed reason**: Summarize the derivation process from the source -- not a one-sentence summary, but a complete reasoning chain. The reason should enable a domain reader to understand "why these premises lead to this conclusion." - -2. **Identify premises and background**: - - **Claims** used in the derivation → `premises` - - **Settings/questions** used in the derivation → `background` - -### Use @label and [@citation] References in Reasons - -In the reason text, use `@label` to reference knowledge nodes and `[@key]` to cite bibliography entries from `references.json`: - -```python -reason=( - "Based on the XX framework (@framework_claim), under condition YY (@condition_claim), " - "conclusion ZZ can be derived. The derivation uses the property of WW (@property_setting). " - "This follows the approach in [@Dias2020]." -) -``` - -**Knowledge refs** (`@label`): must appear in the strategy's `premises` or `background` list. Verified in Pass 3. - -**Citations** (`[@key]`): must match a key in `references.json`. The strict `[@...]` form raises a compile error if the key is not found. Supports Pandoc group syntax: `[@Bell1964; @CHSH1969]`, `[see @Bell1964, pp. 33-35]`. - -**Rule**: A single `[...]` group must be homogeneous — all knowledge refs or all citations, never mixed. `[@lemma_a; @Bell1964]` is a compile error. - -Citations can also appear in **claim content** to provide traceability: - -```python -tc_measurement = claim( - "The measured superconducting transition temperature is 287.7 K at 267 GPa [@Dias2020].", - title="CSH Tc measurement", -) -``` - -### Key Point for Pass 2: Do Not Miss Implicit Premises - -Sources often have implicit premises. When writing the reason, if you discover the derivation depends on a knowledge node already extracted in Pass 1, be sure to add it to premises or background and reference it with `@label` in the reason. - -### Model Contradictions and Complements - -After writing strategies, model logical constraints between claims using operators. These claim pairs were identified in Pass 1 Reflection; now formalize them. - -**Key distinction — get this right, it matters for BP:** - -- `contradiction(a, b)` = NOT (A AND B): both cannot be true, but both CAN be false -- `complement(a, b)` = A XOR B: exactly one must be true (exhaustive + mutually exclusive) - -**When to use `contradiction()`:** The source argues two claims are incompatible — they cannot both hold. Example: two competing hypotheses about a mechanism, where accepting one rules out the other, but a third option might exist. - -```python -# Correct: these are genuinely mutually exclusive -not_both = contradiction( - claim("The pairing mechanism is phonon-mediated"), - claim("The pairing mechanism is magnon-mediated"), - reason="Phonon and magnon mechanisms produce incompatible signatures; the data matches only one.", -) -``` - -**When to use `complement()`:** Exactly two exhaustive, mutually exclusive options. One MUST be true. - -```python -# Correct: exhaustive binary -one_of = complement( - claim("RFdiffusion outperforms Hallucination on this benchmark"), - claim("Hallucination outperforms or matches RFdiffusion on this benchmark"), - reason="On the same benchmark with the same metric, one must be better or equal.", -) -``` - -**When NOT to use either:** Two claims that are "in tension" but can both be true. Example: "comprehensive improvement across all areas" and "enzyme scaffolding lacks experimental validation" — both can be true (comprehensive improvement does not require every area to have wet-lab validation). Do NOT model these as `contradiction()`. Flag them in the Critical Analysis as unmodeled tensions instead. - -Contradictions and complements are especially valuable in BP because they create strong coupling between nodes — when one side's belief goes up, the other must go down. But a **wrong** contradiction silently distorts all downstream beliefs, so always verify semantics in Pass 5. - -### Pass 2 Reflection - -Before moving to Pass 3, verify: - -- **Theory-experiment pairs use abduction?** Every place the source compares a theoretical prediction against an experimental observation should be connected via abduction (build `support` + `support` + `compare` strategies, then pass to `abduction()`), not `support` or `infer` alone. The relationship is explanatory ("which theory better explains the data?"), not inferential ("premises imply conclusion"). -- **Multiple observations → one law use induction?** If several independent observations all support the same general rule, use `induction(s1, s2, law=law)` with support sub-strategies, not a flat `support` with all observations as premises. -- **No missing alternatives?** Every abduction should have a meaningful alternative — what would explain the observation if the hypothesis were wrong? -- **Contradictions modeled?** Every contradictory claim pair identified in Pass 1 should now have a `contradiction()` operator. Also check: did any new contradictions emerge while writing strategies? - -## Pass 3: Check Completeness - -**Prerequisite:** Code from Pass 1-2 has been written and passes `gaia compile` and `gaia check`. Pass 3 combines `gaia check` feedback with manual review. - -### 3a. Check @label and [@citation] Reference Consistency - -Review each strategy's reason one by one: - -1. **Re-read the reason**: Carefully read every sentence in the reason -2. **Check @label coverage**: Every `@label` in the reason must appear in premises or background -3. **Reverse check**: Every node in premises/background should be referenced by `@label` in the reason (otherwise, why is it a premise?) -4. **Check if additional knowledge is needed**: If the reason mentions an important fact without a corresponding `@label`, go back to Pass 1 to add it -5. **Check [@citation] coverage**: Key claims and reasoning steps from the source paper should cite the original via `[@key]`. Ensure `references.json` contains all referenced keys. - -### 3b. Check for Claims Missing Reasoning - -Use the output of `gaia check` to see if any claim should have reasoning support but lacks a strategy: - -- `gaia check` reports claims that are not the conclusion of any strategy (i.e., leaf nodes) -- Review each leaf node: Is it truly an independent premise? Or should it have an infer strategy? -- Criterion: If the source provides an argument for this claim (not just a statement), it should have a strategy - -### 3c. Check for Isolated Nodes - -- Are there claims that are neither a premise/background of any strategy nor a conclusion of any strategy? -- Isolated nodes indicate they do not participate in the reasoning graph -- either they should not exist, or a strategy referencing them was missed - -The most common mistake at this step is **assuming certain knowledge does not need explicit references**. In Gaia, if the reasoning process depends on a fact, that fact must be a node in the knowledge graph. - -## Pass 4: Refine Strategy Types - -Passes 2-3 produce generic `infer` strategies. Pass 4 refines each `infer` into a specific strategy type. - -### Complete Strategy Reference - -| Strategy | Semantics | When to use | Review needs | -|----------|-----------|-------------|--------------| -| `support` | Soft deduction: premises jointly support conclusion via directed implication with author-specified prior | Default for "premises imply conclusion" with uncertainty | Prior on implication warrant (specified in DSL) | -| `deduction` | Rigid deduction: if all premises true, conclusion necessarily true. Same skeleton as support but deterministic | Strict math proofs, logical syllogisms, definitions | None (deterministic) | -| `compare` | Two predictions compared against an observation (2 equivalences + 1 implication) | Comparing competing predictions | Prior on comparison warrant (specified in DSL) | -| `abduction` | Inference to best explanation. Composite of two `support` strategies + one `compare` strategy | Theory-experiment comparison, inference to best explanation | Priors on component sub-strategies | -| `induction` | Binary composite of support strategies sharing a conclusion (law). Chainable | Repeated experimental confirmations across conditions | Priors on support sub-strategies | -| `analogy` | Source + structural similarity → target | Cross-system reasoning ("works for A, similar to B, so works for B") | None (auto-formalized) | -| `extrapolation` | Source + continuity → target | Predicting beyond measured range | None (auto-formalized) | -| `elimination` | Exhaustive options + excluded candidates → survivor | Process of elimination | None (auto-formalized) | -| `case_analysis` | Exhaustive cases, each implies conclusion → conclusion | Proof by cases | None (auto-formalized) | -| `mathematical_induction` | Base case + inductive step → for-all law | Inductive proofs in mathematics | None (auto-formalized) | -| `composite` | Hierarchical: sub-strategies compose into one argument | Complex reasoning with meaningful intermediate steps | Review leaf sub-strategies only | -| `infer` | General CPT with 2^N entries | Last resort when no specific type fits | `conditional_probabilities` (2^N floats) | - -Also available as **operators** (modeled in Pass 2, not strategies): - -| Operator | Semantics | When to use | -|----------|-----------|-------------| -| `contradiction(a, b)` | NOT (A AND B) — cannot both be true | Incompatible hypotheses | -| `complement(a, b)` | A XOR B — exactly one true | Exhaustive binary choice | -| `equivalence(a, b)` | A = B — same truth value | Logically equivalent formulations | -| `disjunction(*claims)` | At least one true | Exhaustive possibilities | - -### Decision Tree - -```dot -digraph refine { - node [shape=diamond]; - q1 [label="How many\npremises?"]; - q2 [label="Nature of\nreasoning?"]; - q3 [label="Is it a\ncase_analysis?"]; - q4 [label="Can meaningful\nintermediate propositions\nbe found?"]; - - node [shape=box]; - formal [label="formal strategy\n(deduction/abduction/...)"]; - support_box [label="support"]; - case [label="case_analysis"]; - composite [label="composite strategy\ndecompose into sub-steps"]; - recurse [label="Recursively apply\nthis process to\neach sub-step"]; - keep [label="Keep infer (generic)\nor support"]; - - q1 -> q2 [label="1-2"]; - q1 -> q3 [label="3+"]; - q2 -> formal [label="mathematical deduction/\nabduction/analogy/\nextrapolation"]; - q2 -> support_box [label="numerical computation/\napplication"]; - q3 -> case [label="yes"]; - q3 -> q4 [label="no"]; - q4 -> composite [label="yes"]; - q4 -> keep [label="no (3 premises is acceptable)"]; - composite -> recurse; -} -``` - -### Case 1: 1-2 Premises - -First determine the nature of reasoning, then choose the strategy type: - -| Nature of Reasoning | Strategy | Parameters | -|---------------------|----------|------------| -| Strict mathematical derivation (conclusion necessarily follows from premises) | `deduction` | Deterministic (no parameters needed) | -| Numerical computation / application (computational error or empirical uncertainty) | `support` | Prior on implication warrant | -| Observation → hypothesis | `abduction` | Priors on support + compare sub-strategies | -| Source → target analogy | `analogy` | Determined by strategy semantics | -| Extrapolation | `extrapolation` | Determined by strategy semantics | -| Induction (multiple observations → general rule) | `induction` | Priors on support sub-strategies | -| Process of elimination (exhaustiveness + excluded candidates → survivor) | `elimination` | Determined by strategy semantics | -| Inductive proof (base case + inductive step → law) | `mathematical_induction` | Determined by strategy semantics | - -**Key distinction: deduction vs support** - -`deduction` represents **purely deterministic mathematical derivation** -- the derivation steps themselves are error-free, and uncertainty comes only from whether the premises hold. Both `deduction` and `support` share the same skeleton (conjunction + directed implication), but `support` carries an author-specified prior on the implication warrant. - -Criterion: "If all premises are true, does this derivation **necessarily** hold mathematically?" - -- **Yes** → `deduction`. Examples: mathematical proofs, logical syllogisms, reading directly from a definition -- **No** → `support`. Examples: numerical computations with approximation errors, empirical judgments, omitted premises, "usually holds but has exceptions" - -Common misjudgments: -- A derivation in the source looks "rigorous" but omits conditions → use `support` (omitted conditions = implicit uncertainty) -- Conclusion read directly from a definition (e.g., "A is defined as B, therefore A=B") → use `deduction` -- Numerical DFT/MD computation yields a result → use `support` (the computational method itself has uncertainty) - -**Strategy variable naming:** Every strategy **must** be assigned to a named public variable (no `_` prefix). This is required so that strategies appear in `gaia check --brief` output and can be referenced by `priors.py`. Use descriptive names like `strat_tc_al = support(...)`, `composite_workflow = composite(...)`, `abduction_al = abduction(...)`. - -**Claim variable naming:** Every claim **must** be assigned to a named variable (no `_` prefix for claims that need to be visible). Anonymous `claim()` calls or `_` prefixed claims will not get labels and become invisible in CLI output. The only exception: `__` double-underscore prefix is reserved for compiler-generated helper claims. - -### Case 2: 3+ Premises - -**First check**: Is this a `case_analysis` pattern? - -**If not case_analysis**: Try decomposing into a `composite` strategy. Intermediate claims introduced during decomposition should be meaningful propositions, not created purely for the sake of splitting. The composite's coarse graph (top-level premises → conclusion) preserves the original `infer`'s perspective, while the fine graph (sub-strategies) provides step-by-step derivation. - -**If no meaningful intermediate propositions can be found** (i.e., decomposition would be forced): -- **3 premises**: Acceptable to keep as `infer` or `support` -- **4+ premises**: Must decompose, otherwise the BP multiplicative effect will severely suppress belief - -### Pass 4 Reflection - -After refining all strategies, verify: - -- **Every abduction has a meaningful alternative?** The alternative should be a real competing explanation, not a placeholder. If there's no natural alternative, consider whether abduction is the right pattern. -- **Abduction alternatives will be reviewed — are they set up correctly?** Each abduction's alternative will need a prior (set via `prior=` on the `support()` call or in `priors.py`). Remember: π(Alt) = "Can Alt alone explain Obs?" (explanatory power), NOT "Is Alt correct?". Flag any abduction where this distinction might be tricky for the reviewer. -- **Each induction's support sub-strategies independent?** For `induction(s1, s2, law=law)`, each observation should provide independent evidence. If the observations are dependent, consider whether a single support with stronger evidence is more appropriate. -- **Induction support direction correct?** Every support inside `induction()` **must** use the generative direction: `support([law], prediction)` — law predicts an observable consequence. Never `support([prediction], law)`. When the prediction is confirmed (high prior from experimental data), the backward message through `support([law], prediction)` boosts law's belief. The reversed direction does not create this backward flow correctly. - -### Post-Refinement Check - -After refining all strategies, check the **strategy type distribution**: - -- If `support` accounts for more than 70% of strategies, review whether some should be `abduction` (observation → best explanation) or `induction` (multiple independent observations → general law) -- Papers with extensive experimental validation typically have many abductions -- Discussion/conclusion sections that synthesize multiple results often use induction - -Also check **reasoning chain depth** (hops from leaf to exported conclusion): - -- Maximum recommended depth: **3 hops** -- If a derived conclusion has belief < 0.4, the chain is likely too deep -- Fix by flattening: make intermediate claims into leaf premises, or restructure into wider (more premises per strategy) rather than deeper (more strategies in series) - -### Operator Usage - -For operator semantics and syntax, see the **gaia-lang** skill. - -## Pass 5: Verify Structural Integrity - -**Prerequisite:** Pass 4 is complete — all strategy types are finalized. This pass checks that the factor graph correctly represents the source's reasoning structure. It must happen after Pass 4 because strategy type refinement (especially induction) changes the graph topology. - -**Background:** Gaia uses Junction Tree (exact inference). There is no algorithmic double-counting — given any factor graph, JT computes correct posteriors. All issues in this pass are about whether the **model** correctly represents reality: each factor (strategy/operator) should represent a genuinely independent constraint, and each operator's logical semantics should match the actual relationship. - -### 5a. Verify Operator Semantics - -Check operators first — if the graph's hard constraints are wrong, everything downstream is wrong too. - -Review every `contradiction()`, `complement()`, `equivalence()`, and `disjunction()` operator: - -**`contradiction(a, b)` = NOT (A AND B)**: Both cannot be true, but both CAN be false. - -```python -# WRONG: these can both be true — no contradiction! -contradiction( - claim("RFdiffusion succeeds at designing large proteins"), - claim("Hallucination fails at designing large proteins"), -) - -# CORRECT: these cannot both be true -contradiction( - claim("RFdiffusion is inferior to Hallucination on this task"), - claim("RFdiffusion outperforms Hallucination on this task"), -) -``` - -**`complement(a, b)` = A XOR B**: Exactly one must be true. Stronger than contradiction. - -**Three-question checklist for each operator:** -1. Can both claims be true simultaneously? If yes → not a `contradiction`, remove it -2. Can both claims be false simultaneously? If no → should be `complement` (XOR), not `contradiction` (NAND) -3. Is this just "in tension" rather than logically exclusive? Informal tension should NOT be modeled as `contradiction` — flag in Critical Analysis instead - -### 5b. Eliminate Double Counting - -Each factor in the factor graph represents an **independent constraint**. If the same argument appears as two factors, the model claims two independent constraints exist when there is only one. This inflates beliefs — not because JT miscalculates, but because the model is wrong. - -**The unified principle:** every factor must bring genuinely new information that no other factor already provides. When implicit dependencies exist, make them explicit as variables in the graph so JT can correctly reason about them. - -**Pattern 1 — Redundant strategies (same reasoning expressed twice):** - -```python -# 1a. Exact duplicate: standalone support + induction's internal sub-support -support([law], obs, reason="law predicts obs", prior=0.9) # reasoning: law → obs -induction(s1, s2, law=law, reason="...") # internally also creates: law → obs via s1 -# FIX: remove the standalone support, or use it as s1 in induction - -# 1b. Transitive shortcut: A→B→C chain + A→C that is just the chain compressed -support([A], B, reason="A implies B", prior=0.85) -support([B], C, reason="B implies C", prior=0.85) -support([A], C, reason="A implies B implies C", prior=0.85) # redundant with the chain -# FIX: remove the shortcut, OR confirm it represents a genuinely different argument - -# 1c. Derived premise redundancy: A→B, then support([A, B], C) where A supports C only through B -support([A], B, reason="A implies B", prior=0.85) -support([A, B], C, reason="A leads to B which leads to C", prior=0.85) -# FIX: remove A from C's premises → support([B], C, ...) -``` - -**Pattern 2 — Hidden evidence in reason text:** - -Two strategies with identical premises but different `reason` text. The different reasoning contains evidence not captured as premises — extract it. - -```python -# BEFORE: same premises, different reasoning angles -support([sample, obs_R], law, reason="Zero resistance = hallmark of SC", prior=0.85) -support([sample, obs_R], law, reason="Transition width < 0.5K = bulk SC", prior=0.85) -# The "transition width < 0.5K" is evidence hidden in the reason text - -# AFTER: extract hidden evidence as a claim -transition_sharpness = claim("Resistivity transition width < 0.5K") -support([sample, obs_R], law, reason="Zero resistance = hallmark of SC", prior=0.85) -support([sample, transition_sharpness], law, reason="Sharp transition = bulk SC", prior=0.85) -``` - -**Pattern 3 — Unmodeled shared dependencies:** - -Two observations share a common cause (same sample, same instrument) but the cause isn't in the graph. The model treats them as unconditionally independent, losing their correlation. - -```python -# BEFORE: shared sample quality is implicit — correlation lost -obs_R = claim("Sample A: Tc = 39K by resistivity") -obs_chi = claim("Sample A: Tc = 39K by susceptibility") -s1 = support([law], obs_R, reason="law predicts obs_R", prior=0.9) -s2 = support([law], obs_chi, reason="law predicts obs_chi", prior=0.9) -induction(s1, s2, law=law, reason="...") - -# AFTER: extract shared dependency — correlation preserved -sample_quality = claim("Sample A is high-quality single crystal, confirmed by XRD") -support([sample_quality], obs_R, reason="Resistivity depends on @sample_quality", prior=0.9) -support([sample_quality], obs_chi, reason="Susceptibility depends on @sample_quality", prior=0.9) -s1 = support([law], obs_R, reason="law predicts obs_R", prior=0.9) -s2 = support([law], obs_chi, reason="law predicts obs_chi", prior=0.9) -induction(s1, s2, law=law, reason="...") # conditionally independent given sample_quality -``` - -You cannot create new experiments — you formalize what the paper provides. The table below guides the modeling choice: - -| Observation relationship | Modeling approach | -|--------------------------|-------------------| -| Truly independent (different samples, different labs) | `induction` directly | -| Partially independent (shared dependency + independent components) | Extract shared dependency as explicit claim | -| Completely redundant (same data rephrased) | Merge into a single claim | - -**Pattern 4 — Equivalence + separate strategies:** - -`equivalence(a, b)` couples two claims. If both sides have strategies to the same target, check whether each strategy brings information beyond what equivalence already propagates. - -```python -equivalence(claim_A, claim_B) -support([claim_A], law, reason="argument from A's perspective", prior=0.85) -support([claim_B], law, reason="argument from B's perspective", prior=0.85) - -# Ask: does the B→law strategy add information that A→law + equivalence doesn't already provide? -# If NO: remove B→law -# If YES: extract the additional information as a new premise -``` - -**How to check (procedure):** -1. List every claim with 2+ incoming strategies -2. For each pair of strategies: "does each bring genuinely independent new information?" -3. For each `induction`: "do the observations share unmodeled dependencies?" -4. For each `induction`: "are all sub-strategy supports in the generative direction (`support([law], prediction)`)?" If any use `support([prediction], law)`, fix the direction — the compiler will reject the wrong direction. -5. For each `equivalence`: "do both sides need their own strategies to the same target?" -6. For all strategies: "does the reason text contain evidence not captured as premises?" - -### 5c. Re-compile and Verify - -After any structural changes in Pass 5, run `gaia compile` + `gaia check` + `gaia infer` and compare beliefs to before. A significant belief drop after removing a strategy suggests the previous value was inflated by double counting. - -## Pass 6: Polish for Standalone Readability - -**Prerequisite:** The knowledge graph is structurally correct (Pass 5 complete). Pass 6 ensures that every claim, reason, and metadata entry is independently understandable without access to the original source. - -### 6a. Claim Self-Containedness - -Review every claim for standalone readability: - -**Symbols must be self-explanatory:** -- Every mathematical symbol must have a brief explanation on its first appearance in that claim -- Example: Do not write "$\alpha \ll 1$"; write "the parameter $\alpha$ (ratio of XX to YY) is much less than 1" -- The physical meaning of subscripts/superscripts must be explicit - -**Abbreviations must be expanded:** -- Every abbreviation must be expanded on its first appearance in that claim -- Example: Do not write "XXX computes $\lambda$"; write "the such-and-such method (XXX) computes the coupling constant $\lambda$" -- Even if an abbreviation has been expanded in another claim, each claim is independent and must expand it again - -**No comparative assertions without reference:** -- Do not write "significantly larger than X" -- the reader does not know what is being compared -- Do not write "nearly exact agreement" -- the reader does not know what it agrees with -- Numerical comparisons must provide both values - -**Sufficient detail:** -- Can a reader understand what this claim says by reading only this one claim? -- Are conditions and applicable ranges clear? -- Do numerical values include units and error bars? - -### 6b. Data Formatting - -- Tabular data should use markdown tables in claim content -- Key numerical values from figures must be transcribed into the claim text (not just referenced) -- Trends described in prose should include specific data points - -### 6c. Reason Standalone Readability - -Review every strategy's `reason` text: - -- The reason should be a complete reasoning chain, not "see Section 3 of the paper" -- Specific numbers, method names, and conditions should be stated, not implied -- Every `@label` reference should have enough surrounding context that a reader unfamiliar with the label can follow the argument - -### 6d. Figure and Table References - -Add `metadata={"figure": "...", "caption": "..."}` to every claim whose content comes from a specific figure or table: - -1. **Coverage**: Check each module against the source for missing references -2. **Path validity**: Verify each file path exists in `artifacts/` -3. **Caption accuracy**: Copy the figure caption from the source (abbreviated OK, but figure number and key content must be correct) -4. **Strategy metadata**: Strategies whose `reason` references figure data should also carry `metadata` - -### 6e. Complete Citation Metadata - -During Passes 1-4, `references.json` entries were kept minimal (key + type + title). Now fill in complete metadata for all cited references: - -- **author**: full author list (`[{"family": "...", "given": "..."}]`) -- **issued**: publication date (`{"date-parts": [[2020]]}`) -- **container-title**: journal/conference name -- **volume**, **page**, **DOI**: where applicable - -Also verify: every `[@key]` used in claims and reasons has a corresponding entry in `references.json`. Run `gaia compile .` to catch any missing keys (strict `[@key]` form raises a compile error if the key is not found). - -### 6f. Format Consistency - -- Metadata format should be consistent across all claims (same key names, same path conventions) -- Titles should follow a consistent naming style -- Cross-module import patterns should be uniform - -## Write DSL Code - -After completing each pass, write code, compile, and check. For DSL syntax, see the **gaia-lang** skill. - -### Verify with `--brief`, `--show`, and `--hole` - -After compiling, use `gaia check` to verify structure and prior coverage: - -```bash -gaia check . # Summary with prior annotations on independent claims -gaia check --hole . # Detailed hole report: which claims still need priors -gaia check --brief . # Overview: all modules with strategy summaries -gaia check --show s6_xxx . # Expanded view of a specific module -gaia check --show label . # Detail view of a specific claim's warrant tree -``` - -**What to check in default output:** -- Each independent premise shows `prior=X` if set, or `⚠ no prior` if missing -- The summary shows "Holes (no prior set): N" when any holes remain - -**What to check in `--hole` output:** -- Every hole claim has its content and QID listed — use this to write `priors.py` entries -- Every covered claim shows its prior value and justification — verify these are reasonable - -**What to check in `--brief` output:** -- Every strategy should show named labels (not `_anon_xxx`). If a strategy conclusion shows `_anon_xxx`, the strategy's result variable was not assigned to a named Python variable. -- Claims should show their role (independent/derived/structural/background/orphaned) and prior if set. -- Composite strategies should show their sub-strategy tree. -- Use `--show ` to inspect full claim content and warrant trees for review readiness. - -## Write priors.py - -`priors.py` assigns priors to leaf claims. Strategy/operator warrant priors are set via `prior=` in the DSL. - -**Before writing `priors.py`, run `gaia check --hole .`** to see exactly which independent claims need priors, along with their content and current status. Use this as your checklist — address each hole, then re-run `gaia check --hole .` to confirm "All independent claims have priors assigned." - -For how to write `priors.py`, assign priors, and evaluate strategy parameters, see the **review** skill. - -**Do NOT set priors for derived claims.** The inference engine automatically assigns uninformative priors (0.5) to derived claims. Their beliefs are determined entirely by BP propagation from leaf premises. Setting an explicit prior on a derived claim double-counts evidence: the reviewer's judgment and the reasoning chain both reflect the same underlying data. Only set priors for independent (leaf) claims that are not the conclusion of any strategy. - -**Abduction review deserves special attention.** The most common and consequential mistake in review is setting π(Alt) based on whether the alternative's calculation is correct, rather than whether it explains the observation. Before finalizing `priors.py`, go through every abduction and ask: "Does this alternative's prediction actually match the observation?" If not, π(Alt) should be low regardless of the alternative's theoretical validity. - -## Generate GitHub Presentation - -Run `gaia infer .` (or `gaia infer --depth 1 .` for joint cross-package inference) then: -- `gaia render . --target github` + `/gaia:publish` to generate the README with narrative and reasoning graph -- `gaia render . --target docs` to generate per-module detailed reasoning graphs in `docs/detailed-reasoning.md` - -See the **gaia-cli** and **publish** skills for details. - -## Interpret BP Results - -After compiling and running inference, check: - -| Check | Normal | Abnormal | -|-------|--------|----------| -| Independent premises | belief approx prior (small change) | belief significantly pulled down → downstream constraint conflict | -| Derived conclusions | belief > 0.5 (pulled up) | belief < 0.5 → see below | -| Contradiction | One side high, one side low ("picks a side") | Both sides low → prior assignment issue | -| Abduction hypotheses | Clear separation (H > 0.5, Alt < 0.3) | Near equipoise (H ≈ Alt ≈ 0.33) → support direction reversed or observation missing prior | - -If results are clearly wrong (e.g., a well-supported conclusion has belief < 0.3, or a contradiction doesn't pick a side), go back and check: - -1. **Structural issue?** (→ revisit Pass 1-5) Missing premises, wrong strategy type, missing abduction alternative, evidence double-counting -2. **Parameter issue?** (→ revisit `priors.py`) Priors too low/high, conditional_probability miscalibrated, π(Alt) reflecting correctness instead of explanatory power - -For detailed BP troubleshooting, see the **review** skill. - -## Critical Analysis - -After BP results stabilize, produce a **critical analysis** of the source. This is the analytical payoff of formalization — by building the knowledge graph, you now understand the argument's structure well enough to identify its strengths and weaknesses. - -### Weak Points - -Identify claims and reasoning steps that are structurally vulnerable: - -| Signal | What it means | -|--------|---------------| -| Derived conclusion with low belief (< 0.5) | Weak premise support or fragile reasoning chain | -| Long reasoning chain (4+ hops from leaf to conclusion) | Multiplicative effect — small uncertainties compound | -| Abduction where π(Alt) ≈ π(H) | Alternative is equally plausible — evidence doesn't distinguish | -| Leaf claim with low prior and many downstream dependents | A single weak foundation supporting many conclusions | -| `support` with very low prior (< 0.3) | Reviewer flagged this reasoning step as unreliable | -| Claim marked as setting that could be questioned | Hidden assumption not subject to BP updating | - -### Evidence Gaps - -Identify where additional evidence would most strengthen the argument: - -- **Unsupported leaf claims**: Claims with no reasoning support that the source takes as given — what evidence could back them up? -- **Weak abductions**: Where the alternative nearly matches the hypothesis in explanatory power — what new observation could break the tie? -- **Missing comparisons**: Theoretical predictions without experimental validation — what experiment could test them? -- **Single-observation inductions**: Laws supported by only one observation — what additional observations would strengthen the induction? - -### Output - -Write the critical analysis as `ANALYSIS.md` in the package root. This is a **required deliverable** — do not skip it. Include: - -1. **Package statistics**: Knowledge graph counts, strategy type distribution, claim classification, figure reference coverage, BP result summary -2. **Summary**: One paragraph on the argument's overall structure and strength -3. **Weak points**: Table with columns: claim, belief, issue. Include all derived claims with belief < 0.8 and any alternative explanations with belief > 0.25 -4. **Evidence gaps**: Tables covering (a) missing experimental validations, (b) untested conditions, (c) competing explanations not fully resolved -5. **Contradictions**: (a) explicit contradictions modeled with `contradiction()` and how BP resolved them (which side won), (b) internal tensions in the source that were not modeled as formal contradictions but are worth flagging -6. **Confidence assessment**: Tier the exported claims into confidence levels (very high / high / moderate / tentative) with belief ranges - -The critical analysis is the analytical payoff of formalization — it transforms a qualitative reading of the paper into a quantitative structural assessment. Every knowledge package should ship with one. - -## Common Mistakes - -| Mistake | Consequence | Fix | -|---------|-------------|-----| -| Theoretical prediction and experimental result mixed in one claim | Cannot model the verification relationship with abduction | Separate into two claims + abduction | -| Abduction without providing an alternative | Missing comparison with alternative theory | Provide existing theory as alternative | -| Abduction alternative's prior reflects "computational correctness" instead of "explanatory power" | pi(Alt) too high, weakens abduction's support for H | pi(Alt) should answer "Can Alt independently explain Obs?", not "Is Alt's calculation correct?" | -| Reason written too briefly (one sentence) | Reasoning process is untraceable | Summarize derivation steps in detail, reference with @label | -| 4+ premise flat support | Severe BP multiplicative effect | Use composite to decompose into sub-steps with 3 or fewer premises | -| Content not self-contained (symbols/abbreviations unexplained) | Reviewer cannot judge independently | Each claim must independently explain all symbols and abbreviations | -| Marking a questionable proposition as setting | That proposition cannot be updated via BP | When in doubt, mark as claim; only mathematical definitions are settings | -| Marking a condition-dependent theoretical framework as setting | Framework does not participate in BP | Condition-dependent conclusions should be claims | -| Using support for mathematical deduction | Deterministic derivation should not have probability parameters | Use deduction (purely deterministic, same skeleton but rigid) | -| Using deduction for numerical computation/approximate reasoning | Computation has uncertainty, but deduction is purely deterministic | Use support (soft deduction with author-specified prior) | -| Using deduction for "seemingly rigorous" derivation | Source omits premises or conditions | Omitted premises = implicit uncertainty → use support | -| Anonymous strategy call | Strategy invisible in `gaia check --brief`, cannot be reviewed | Assign to named public variable: `strat_xxx = support(...)` | -| `_` prefixed claim or strategy | Node invisible in CLI output, gets no label | Use public names (no `_` prefix); only `__` is reserved for compiler | -| Missing prior for orphaned claim | `gaia infer` errors | All claims (including orphaned) need priors | -| Missing implicit premises in reasoning | Knowledge graph is incomplete | Use `gaia check` + manual review in Pass 3 | -| Not verifying numerical values | Data errors | Cross-check every value against the source | -| Same claim in multiple paths to same conclusion | Evidence double-counted, inflated belief | Ensure each leaf enters a conclusion through exactly one path (Pass 5) | -| Induction with non-independent observations | Overcounted evidence | Extract shared dependencies as explicit claims (Pass 5) | -| Induction support direction reversed (`support([obs], law)` instead of `support([law], prediction)`) | Backward message from confirmed prediction cannot boost law; hypotheses stuck near prior | Flip to generative direction: `support([law], prediction)` — law predicts prediction, confirmation flows back | -| Observation claim missing prior (classified as "derived" because it has incoming supports) | Observation's empirical grounding lost; belief depends entirely on theory supports instead of being anchored by data | Add observation to `priors.py` with high prior (0.9+), or model as setting if it is a directly measured fact | -| Wrong contradiction (claims can both be true) | BP forced to suppress one side incorrectly | Verify operator semantics in Pass 5 | -| Setting prior on derived claim | Double-counts evidence | Do not set priors for derived claims; inference engine defaults to 0.5 | - -## Reference - -- **gaia-lang** skill -- DSL syntax, knowledge types, operators, and API reference -- **gaia-cli** skill -- CLI commands (compile, check, infer, register) and `priors.py` API diff --git a/skills/gaia-cli/SKILL.md b/skills/gaia-cli/SKILL.md deleted file mode 100644 index c68fff354..000000000 --- a/skills/gaia-cli/SKILL.md +++ /dev/null @@ -1,290 +0,0 @@ ---- -name: gaia-cli -description: "Gaia CLI toolchain reference — init, compile, check, add, infer, render, register Gaia knowledge packages." ---- - -# Gaia CLI Toolchain Reference - -Complete reference for the `gaia` command-line tool. Covers the full lifecycle: scaffold a package, author knowledge, compile to IR, validate, review, run inference, and publish. - -## 1. Install - -```bash -pip install gaia-lang # or: uv pip install gaia-lang -gaia --help # verify installation -``` - -Requires Python 3.12+. - -## 2. gaia init - -```bash -gaia init -``` - -The name **must** end with `-gaia` (e.g., `galileo-falling-bodies-gaia`). - -### Naming convention - -| Surface | Convention | Example | -|---------|-----------|---------| -| Git repo | any name (convention: `kebab-case-gaia`) | `galileo-falling-bodies-gaia` | -| PyPI / package name | `kebab-case-gaia` | `galileo-falling-bodies-gaia` | -| Python import | `snake_case` (no `-gaia` suffix) | `galileo_falling_bodies` | - -### What it creates - -- `pyproject.toml` with `[tool.gaia]` section (auto-generated `type` and `uuid`) -- `src//__init__.py` with a starter template -- `.gitignore` -- Auto-runs `uv add gaia-lang` to pin the dependency - -## 3. Package structure - -``` -my-package-gaia/ -├── pyproject.toml # [tool.gaia] type + uuid -├── references.json # Optional: bibliography in CSL-JSON format (for [@key] citations) -├── src/ -│ └── my_package/ -│ ├── __init__.py # DSL declarations, re-exports -│ ├── motivation.py # Optional: organize by chapter/section -│ └── priors.py # Optional: prior assignments via reason+prior pairing -├── artifacts/ # Source material (PDF, markdown) -├── .gaia/ # Created by gaia compile (git tracked) -│ ├── ir.json -│ ├── ir_hash -│ └── beliefs.json # Created by gaia infer -└── .gitignore -``` - -### pyproject.toml required fields - -```toml -[project] -name = "my-package-gaia" -version = "1.0.0" -requires-python = ">=3.12" - -[tool.gaia] -type = "knowledge-package" -uuid = "..." # Auto-generated by gaia init - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" -``` - -## 4. gaia compile - -```bash -gaia compile . -``` - -Imports the top-level package, collects all DSL declarations, and writes: -- `.gaia/ir.json` — the compiled Gaia IR -- `.gaia/ir_hash` — content hash for integrity checks - -### Generate per-module reasoning graphs - -```bash -gaia render . --target docs -``` - -Generates `docs/detailed-reasoning.md` with per-module Mermaid reasoning graphs and full claim details. - -Note: `--target docs` works without beliefs (enriches with posteriors when available). `--target github` requires beliefs on disk — run `gaia infer` first. - -### Generate GitHub presentation skeleton - -```bash -gaia render . --target github -``` - -Generates `.github-output/` with wiki pages, README skeleton, React Pages template, `graph.json`, and manifest. -After generation, use `/gaia:publish` to fill narrative content. - -## 5. gaia check - -```bash -gaia check . -gaia check --brief . -gaia check --show . -gaia check --brief --show . -gaia check --hole . -``` - -Validates package structure and IR consistency. Used by registry CI during publishing. - -Options: -- `--brief` / `-b` — Show per-module warrant structure overview (claims with roles, strategies with priors, operators) -- `--show` / `-s` — Expand a specific module name or claim/strategy label with full warrant trees -- `--hole` — Show detailed prior review report for all independent claims (holes without priors + covered with priors) - -Common errors: -- Missing `[tool.gaia].type` in `pyproject.toml` -- Duplicate labels across declarations -- Stale compiled artifacts (`.gaia/ir_hash` mismatch) -- IR structural validation errors (missing references, duplicate IDs) - -### Diagnostic output - -`gaia check` also reports informational diagnostics (not errors) useful for completeness checking during formalization: - -- **Independent premises** — claims not concluded by any strategy that need priors. Each shows `prior=X` if set, or `⚠ no prior (defaults to 0.5)` if missing. -- **Holes (no prior set)** — count of independent claims still missing priors, shown in the summary when > 0 -- **Derived conclusions** — claims whose belief comes from BP propagation (do not set priors) -- **Orphaned claims** — claims not referenced by any strategy -- **Background-only claims** — claims only used in strategy `background=`, not as premises - -### `--hole` output - -Detailed report for prior review. Lists all independent claims split into two groups: - -- **Holes**: claims without priors — shows each claim's QID, content preview, and `NOT SET (defaults to 0.5)` status -- **Covered**: claims with priors — shows each claim's `prior=X` and justification reason - -Use `--hole` during prior review to: -1. Identify which claims still need priors in `priors.py` -2. Verify that existing prior values and justifications are reasonable -3. Confirm "All independent claims have priors assigned" before running inference - -### `--brief` output - -Per-module overview showing: -- Settings with label + truncated content -- Claims with role (independent/derived/structural/background/orphaned) and prior -- Strategies with type, premise labels → conclusion, prior, and reason -- Operators with type, variables, and reason - -### `--show` output - -When given a **module name** (e.g., `--show motivation`): expands all claims with full content and strategies with recursive warrant trees, including composite sub-strategy expansion. - -When given a **claim label** (e.g., `--show hypothesis`): shows the claim's full content and all strategies that conclude to it, with all premises listed. - -## 6. Priors - -Priors are set directly in DSL source via `reason+prior` pairing and `priors.py`, then baked into claim metadata at compile time. No separate review sidecar is needed. - -### Inline reason+prior pairing - -Strategies accept a `prior=` keyword that pairs with their `reason=`: - -```python -H = claim("Hypothesis X", label="hypothesis") -E = claim("Evidence Y", label="evidence") - -deduction(E, concludes=H, prior=0.85, reason="Strong experimental support") -``` - -### priors.py (batch priors) - -For leaf claims (independent premises) without a concluding strategy, assign priors in `src//priors.py`: - -```python -from . import some_claim, another_claim - -PRIORS = { - some_claim: (0.9, "Justification for high prior."), - another_claim: (0.3, "Justification for low prior."), -} -``` - -These are applied at load time before compilation. `gaia check --brief` shows which claims are independent (need priors) vs derived (get beliefs from BP). - -For complete prior assignment guide and BP interpretation, see the **review** skill. - -## 7. gaia infer - -```bash -gaia infer [PATH] [--depth N] -``` - -Compiles IR into a factor graph, runs belief propagation, and writes posterior beliefs to `.gaia/beliefs.json`. - -- Auto-runs `uv sync --quiet` before loading the package to ensure dependencies are installed. -- Priors come from claim metadata (`priors.py` + DSL `reason+prior` pairing), baked in at compile time. No review sidecar is needed. -- Auto-selects algorithm based on factor graph treewidth: - - **Junction tree** (exact inference, treewidth ≤ 15) - - **Generalized BP** (region decomposition, treewidth 16-30) - - **Loopy BP** (approximation, treewidth > 30) -- Output: `.gaia/beliefs.json` - -### Dependency depth - -| Flag | Behavior | -|------|----------| -| `--depth 0` (default) | Uses flat priors for foreign nodes; optionally reads `dep_beliefs/` for upstream beliefs | -| `--depth 1` | Merges direct dependency factor graphs for joint cross-package inference | -| `--depth -1` | Merges all transitive dependency factor graphs | - -```bash -# Local inference only (default) -gaia infer . - -# Joint inference with direct dependencies -gaia infer . --depth 1 - -# Joint inference with all transitive dependencies -gaia infer . --depth -1 -``` - -With `--depth 0`, foreign nodes (references to claims in other packages) get flat priors unless a `dep_beliefs/` directory provides upstream belief files. With `--depth 1` or `-1`, the dependency packages' full factor graphs are merged into a single graph for joint inference, replacing flat prior injection with structural reasoning. - -## 8. gaia register - -Publishes a package to the Gaia registry. Requires a git tag pushed to GitHub. - -```bash -gaia register . --registry-dir ../gaia-registry --create-pr -``` - -Registry CI validates: -- `gaia compile` succeeds and hash matches `.gaia/ir_hash` -- `gaia check` passes -- Namespace is valid -- UUID is unique across the registry - -**Note:** `gaia register --create-pr` creates the registry branch locally but does not automatically push it to your fork. After running the command, you must manually push and create the PR: - -```bash -cd -git push origin register/- -gh pr create --repo SiliconEinstein/gaia-registry --base main \ - --head :register/- --title "register: " --body "..." -``` - -## 9. gaia add - -Install a registered package from the official registry. - -```bash -gaia add -gaia add --version 1.0.0 # pin version -``` - -Resolves packages from https://github.com/SiliconEinstein/gaia-registry. - -## 10. Workflow summary - -``` -gaia init - → write DSL declarations + priors - → gaia compile . - → gaia check . - → gaia infer . - → gaia render . --target github - → /gaia:publish - → gaia render . --target docs - → gaia register . --registry-dir ../gaia-registry --create-pr -``` - -1. **Scaffold** — `gaia init my-package-gaia` -2. **Author** — Write knowledge declarations in `src//`, set priors via `reason+prior` pairing and `priors.py` -3. **Compile** — `gaia compile .` to produce IR -4. **Validate** — `gaia check .` to catch structural errors early -5. **Infer** — `gaia infer .` to compute posterior beliefs (optionally `--depth 1` for cross-package) -6. **Present** — `gaia render . --target github` to generate GitHub presentation skeleton, then `/gaia:publish` to fill narrative content -7. **Detail** — `gaia render . --target docs` to generate per-module reasoning graphs -8. **Publish** — Tag, push, and `gaia register` to submit to the registry diff --git a/skills/gaia-lang/SKILL.md b/skills/gaia-lang/SKILL.md deleted file mode 100644 index c89933067..000000000 --- a/skills/gaia-lang/SKILL.md +++ /dev/null @@ -1,443 +0,0 @@ ---- -name: gaia-lang -description: "Gaia Lang DSL reference — knowledge declarations, logical operators, reasoning strategies, module organization, and export conventions." ---- - -# Gaia Lang DSL Reference - -Complete reference for authoring Gaia knowledge packages using the Python DSL. - -## 1. Imports - -```python -from gaia.lang import ( - claim, setting, question, # Knowledge - contradiction, equivalence, complement, disjunction, # Operators - support, compare, deduction, abduction, induction, # Strategies - analogy, extrapolation, elimination, case_analysis, - mathematical_induction, composite, infer, fills, - # noisy_and, # deprecated -- use support() -) -``` - -## 2. Knowledge Types - -### `claim(content, *, title=None, background=None, parameters=None, provenance=None, **metadata)` - -The only type that carries probability in BP. Use explicit strategies (`support`, `deduction`, etc.) to connect claims via reasoning. - -```python -# Simple claim -tc = claim("Tc of MgB2 is 39K") - -# Claim with background context (settings/questions, not logical premises) -result = claim( - "The ball reaches the ground in 1.4s", - background=[experimental_setup, newtonian_gravity], -) - -# Parameterized universal claim -universal = claim( - "Material X is a superconductor below Tc(X)", - parameters=[{"name": "X", "type": "material"}], -) - -# Claim with provenance (cross-package attribution) -imported = claim( - "Electron-phonon coupling drives conventional SC", - provenance=[{"package_id": "bcs-theory", "version": "1.0.0"}], -) - -# Claim with title -titled = claim("H = p^2/2m + V(x)", title="Hamiltonian of the system") -``` - -### `setting(content, *, title=None, **metadata)` - -Background context. No probability, no BP participation. -Use for: math definitions, experimental conditions, established principles. -Referenced via `background=` on claims or strategies. - -```python -setup = setting("A ball is dropped from 10m height in vacuum") -definition = setting("Let G = 6.674e-11 N m^2 kg^-2") -``` - -### `question(content, *, title=None, **metadata)` - -Open inquiry. No probability, no BP participation. - -```python -q = question("What is the critical temperature of this material?") -``` - -## 3. Operators (Deterministic Constraints) - -All operators take Knowledge inputs and optional `reason: str` + `prior: float`. `reason` and `prior` must be paired: both or neither. Each returns a helper claim that can be used as a premise in strategies. Prior values must be within Cromwell bounds `[1e-3, 0.999]`. - -| Function | Semantics | Helper claim meaning | -|----------|-----------|---------------------| -| `contradiction(a, b)` | not(A and B) | `not_both_true(A, B)` | -| `equivalence(a, b)` | A = B | `same_truth(A, B)` | -| `complement(a, b)` | A XOR B | `opposite_truth(A, B)` | -| `disjunction(*claims)` | at least one true | `any_true(C0, C1, ...)` | - -```python -# Two hypotheses cannot both be true -not_both = contradiction(hypothesis_a, hypothesis_b, - reason="Mutually exclusive mechanisms", prior=0.99) - -# Two formulations are logically equivalent -same = equivalence(formulation_1, formulation_2, - reason="Algebraic rearrangement", prior=0.95) - -# Exactly one of two alternatives holds -one_of = complement(conventional_sc, unconventional_sc, - reason="Exhaustive classification", prior=0.95) - -# At least one explanation must be true -at_least_one = disjunction( - mechanism_a, mechanism_b, mechanism_c, - reason="These exhaust known possibilities", prior=0.9, -) -``` - -## 4. Strategies - -All strategies auto-register. All accept optional `reason: str | list = ""` and `background: list[Knowledge] | None = None`. - -### Leaf Strategies - -#### `support(premises, conclusion, *, reason="", prior=None, background=None)` - -**The most common strategy type.** Soft deduction based on the directed `implication` operator (A=1 → B must =1): premises jointly support conclusion via forward implication. Same structure as `deduction` (conjunction + directed implication) but with an author-specified prior on the implication warrant. `reason` and `prior` must be paired: both or neither. - -```python -conclusion = claim("MgB2 has two superconducting gaps") -support( - [band_structure_evidence, tunneling_data, specific_heat_anomaly], - conclusion, - reason="Three independent lines of evidence converge", - prior=0.85, -) -``` - -#### `deduction(premises, conclusion, *, reason="", prior=None, background=None)` - -Strict logical entailment based on the directed `implication` operator. Same skeleton as `support` (conjunction + directed implication) but semantically rigid (deterministic). Requires >= 1 premise. `reason` and `prior` must be paired: both or neither. - -Key test: "If premises are all true, is this conclusion NECESSARILY true?" -- Yes -> deduction -- No (approximations, empirical judgment, omitted premises) -> support - -```python -theorem = claim("The series converges") -deduction( - [bounded_above, monotonically_increasing], - theorem, - reason="Monotone convergence theorem", - prior=0.99, - background=[real_analysis_definition], -) -``` - -#### `compare(pred_h, pred_alt, observation, *, reason="", prior=None, background=None)` - -Compare two predictions against an observation. Compiles to 2 equivalence operators (matching each prediction to observation) + 1 implication (inferential ordering). Auto-generates a `comparison_claim` as the conclusion. `reason` and `prior` must be paired: both or neither. - -```python -pred_h = claim("H predicts 3:1 ratio.") -pred_alt = claim("Alt predicts continuous distribution.") -obs = claim("Observed 2.96:1 ratio.") -comp = compare(pred_h, pred_alt, obs, - reason="H matches observation much better", prior=0.9) -# comp.conclusion is the auto-generated comparison claim -``` - -#### `infer(premises, conclusion, *, reason="", background=None)` - -General CPT with 2^k entries. Rarely used directly. - -Review requires: `conditional_probabilities` (list of 2^N floats). - -```python -result = claim("System is in phase X") -infer( - [temperature_condition, pressure_condition], - result, - reason="Phase diagram lookup", -) -``` - -#### `fills(source, target, *, mode=None, strength="exact", background=None, reason="")` - -Cross-package interface bridging. `strength` is `"exact"` | `"partial"` | `"conditional"`. `mode` is `"deduction"` | `"infer"` | `None` (auto-resolved). - -```python -local_evidence = claim("Our measurement confirms the prediction.") -fills(local_evidence, imported_interface_claim, strength="exact") -``` - -#### `noisy_and()` (deprecated) - -**Deprecated -- use `support()` instead.** Emits `DeprecationWarning`. Compiles to `support` internally. - -### Named Strategies (auto-formalized at compile time) - -#### `abduction(support_h, support_alt, comparison, *, background=None, reason="")` - -Inference to best explanation. Takes three Strategy objects: two `support` strategies (for the hypothesis and alternative) and one `compare` strategy. Auto-generates a `composition_warrant` claim. Conclusion comes from the comparison strategy's conclusion. - -```python -H = claim("Discrete heritable factors.") -alt = claim("Blending inheritance.") -obs = claim("F2 ratio is 2.96:1.") -pred_h = claim("H predicts 3:1.") -pred_alt = claim("Blending predicts continuous.") - -s_h = support([H], obs, reason="H explains ratio", prior=0.9) -s_alt = support([alt], obs, reason="Blending explains ratio", prior=0.5) -comp = compare(pred_h, pred_alt, obs, reason="H matches better", prior=0.9) -abd = abduction(s_h, s_alt, comp, reason="Both explain same observation") -# abd.conclusion is comp.conclusion (the comparison claim) -``` - -#### `analogy(source, target, bridge, *, reason="", background=None)` - -`bridge` asserts structural similarity. Premises: [source, bridge] -> target. - -```python -source = claim("BCS theory explains superconductivity in Al") -target = claim("BCS theory explains superconductivity in MgB2") -bridge = claim("MgB2 shares phonon-mediated pairing with Al") -analogy(source, target, bridge, reason="Same mechanism, different material") -``` - -#### `extrapolation(source, target, continuity, *, reason="", background=None)` - -`continuity` asserts conditions remain similar. Premises: [source, continuity] -> target. - -```python -source = claim("Model predicts Tc=39K at ambient pressure") -target = claim("Model predicts Tc=45K at 10GPa") -continuity = claim("Phonon spectrum varies smoothly with pressure") -extrapolation(source, target, continuity, reason="Smooth pressure dependence") -``` - -#### `elimination(exhaustiveness, excluded, survivor, *, reason="", background=None)` - -Process of elimination. `excluded` is a list of `(candidate, evidence_against)` tuples. - -```python -exhaustive = claim("The pairing mechanism is phonon, magnon, or plasmon mediated") -phonon = claim("Phonon-mediated pairing") -magnon = claim("Magnon-mediated pairing") -plasmon = claim("Plasmon-mediated pairing") -no_magnon = claim("Neutron scattering rules out magnon exchange") -no_plasmon = claim("Optical data rules out plasmon exchange") - -elimination( - exhaustive, - excluded=[(magnon, no_magnon), (plasmon, no_plasmon)], - survivor=phonon, - reason="Only phonon mechanism remains", -) -``` - -#### `case_analysis(exhaustiveness, cases, conclusion, *, reason="", background=None)` - -`cases` is a list of `(case_condition, case_implies_conclusion)` tuples. - -```python -exhaustive = claim("Temperature is either above or below Tc") -above_tc = claim("T > Tc") -below_tc = claim("T < Tc") -above_implies = claim("If T > Tc then resistance is finite") -below_implies = claim("If T < Tc then resistance is finite for non-SC") -conclusion = claim("Normal metals have finite resistance at all T") - -case_analysis( - exhaustive, - cases=[(above_tc, above_implies), (below_tc, below_implies)], - conclusion=conclusion, - reason="Covers all temperature regimes", -) -``` - -#### `mathematical_induction(base, step, conclusion, *, reason="", background=None)` - -Premises: [base, step] -> conclusion. - -```python -base = claim("P(1) holds: sum of first 1 natural number equals 1(1+1)/2") -step = claim("If P(k) holds then P(k+1) holds") -conclusion = claim("For all n >= 1, sum of first n natural numbers equals n(n+1)/2") -mathematical_induction(base, step, conclusion, reason="Standard induction on n") -``` - -### Composite Strategies - -#### `induction(support_1, support_2, law, *, background=None, reason="")` - -Binary composite strategy: two support strategies jointly confirm a law. Chainable: `induction(prev_induction, new_support, law)`. Auto-generates a `composition_warrant` claim. - -```python -law = claim("MgB2 universally superconducts below 39K") -obs1 = claim("Sample A shows zero resistance below 39K") -obs2 = claim("Sample B shows zero resistance below 39K") -obs3 = claim("Sample C shows zero resistance below 39K") - -s1 = support([law], obs1, reason="law predicts observation", prior=0.9) -s2 = support([law], obs2, reason="law predicts observation", prior=0.9) -s3 = support([law], obs3, reason="law predicts observation", prior=0.9) - -ind_12 = induction(s1, s2, law=law, reason="Samples A and B are independent") -ind_123 = induction(ind_12, s3, law=law, reason="Sample C independent of A and B") -``` - -#### `composite(premises, conclusion, *, sub_strategies, reason="", background=None, type="infer")` - -Hierarchical composition. Only leaf sub-strategies need prior parameters. - -```python -intermediate = claim("Intermediate result") -final = claim("Final conclusion") - -s1 = deduction([axiom_a, axiom_b], intermediate, reason="From axioms", prior=0.99) -s2 = support([intermediate, empirical_data], final, reason="Combined evidence", prior=0.85) - -composite( - [axiom_a, axiom_b, empirical_data], - final, - sub_strategies=[s1, s2], - reason="Two-stage argument", -) -``` - -## 5. Module Organization - -- One module per chapter/section of source material -- Introduction -> `motivation.py`, Section II -> `s2_xxx.py`, etc. -- Module docstring becomes section title -- Each knowledge node goes in the module where it first appears -- Later modules import from earlier ones: `from .motivation import some_claim` -- `__init__.py` re-exports everything - -``` -src/my_package/ - __init__.py # re-exports all public symbols - motivation.py # "Introduction and Motivation" - s2_background.py # "Section 2: Background" - s3_results.py # "Section 3: Results" - s4_discussion.py # "Section 4: Discussion" -``` - -Example `__init__.py`: - -```python -from .motivation import * -from .s2_background import * -from .s3_results import * -from .s4_discussion import * -``` - -**WARNING: Do NOT define `__all__` in submodules.** If a submodule defines `__all__: list[str] = []`, then `from .module import *` imports nothing, and all claims in that module get anonymous labels (`_anon_xxx`). Only define `__all__` in `__init__.py` to control the package's cross-package exports. - -## 6. Exports and Labels - -`__all__` controls visibility: -- Listed in `__all__` -> **exported** (cross-package interface, other packages can import) -- No `_` prefix -> **public** (visible in package scope) -- `_` prefix -> **private** (package-internal helper) - -```python -__all__ = ["main_theorem", "key_observation"] # exported - -main_theorem = claim("...") # exported (in __all__) -supporting_lemma = claim("...") # public (no underscore, not in __all__) -_helper = claim("...") # private (underscore prefix) -``` - -**Abduction alternative claims must be public.** Claims used as alternatives in abduction need proper labels for `priors.py` to reference them. Use `alt_` prefix (not `_alt_`): - -```python -# CORRECT: public, gets label "alt_nonspecific_binding" -alt_nonspecific_binding = claim("Non-specific binding could explain...") - -# WRONG: private, gets anonymous label, cannot be reviewed -_alt_nonspecific_binding = claim("Non-specific binding could explain...") -``` - -Labels are auto-assigned from Python variable names by `gaia compile`. NEVER set `.label` manually. - -**Strategy naming:** Strategies should also be assigned to named public variables so they appear in `gaia check --brief` output and can be referenced by name. Use descriptive names: `strat_tc_al = support(...)`, `composite_workflow = composite(...)`, `abduction_al = abduction(...)`. Bare strategy calls (e.g., `deduction(...)` without assignment) produce anonymous strategies invisible in CLI output. - -```python -# CORRECT: label "tc_prediction" assigned automatically -tc_prediction = claim("Tc of MgB2 is 39K") - -# WRONG: never do this -tc_prediction.label = "tc_prediction" # anti-pattern -``` - -## 7. References and Citations - -Claim content and strategy reasons support two kinds of references: - -### Knowledge references (`@label`) - -Reference other knowledge nodes by their Python variable name. Opportunistic — if the label is not found, treated as literal text (no error). - -```python -reason="Based on @framework_claim, the result follows from @property_setting." -``` - -### Bibliographic citations (`[@key]`) - -Cite entries from `references.json` (CSL-JSON, at the package root). Strict — missing key is a compile error. - -```python -# In claim content -tc = claim("Tc = 287.7 K at 267 GPa [@Dias2020].", title="CSH Tc") - -# In strategy reason -support([evidence], conclusion, - reason="Following the analysis in [@Hirsch2021], the data is inconsistent.", - prior=0.85) -``` - -Supports Pandoc citation syntax: `[@key1; @key2]` (group), `[see @key, pp. 33-35]` (locator), `[-@key]` (suppress author). - -### references.json format - -```json -{ - "Dias2020": { - "type": "article-journal", - "title": "Room-temperature superconductivity in a carbonaceous sulfur hydride" - } -} -``` - -Each entry requires `type` (CSL 1.0.2) and `title`. Keys follow Pandoc grammar (letters, digits, `_`, `-`, `.`, `:`, `/`). File is optional. - -### Rules - -- **Escape**: `\@key` forces literal -- **No collision**: a key cannot exist in both the label table and `references.json` (compile error) -- **Homogeneous groups**: a single `[...]` group must be all knowledge refs or all citations, never mixed (compile error) - -## 8. Anti-patterns (HARD GATE -- these produce invalid packages) - -| Anti-pattern | Why it fails | Correct approach | -|-------------|-------------|-----------------| -| `Package(...)` context manager | Removed in v5 | Use module structure + `pyproject.toml` | -| Manually setting `.label = "name"` | Labels auto-assigned from variable names | Just assign to a variable | -| `setting` or `question` as strategy premises | Settings/questions have no probability | Use `background=` parameter instead | -| Using `noisy_and()` | Deprecated | Use `support()` instead | -| Old `abduction(observation, hypothesis)` signature | Redesigned | Use `abduction(support_h, support_alt, comparison)` with 3 Strategy objects | -| Providing `reason` without `prior` (or vice versa) | Must be paired | Provide both or neither | -| Building `FormalExpr` by hand | Compiler handles formalization | Use named strategies (deduction, support, etc.) | -| `from gaia.gaia_ir import ...` | Module renamed | Use `from gaia.ir import ...` | -| `dependencies = ["gaia-lang"]` in pyproject.toml | CLI provided externally, not a package dep | Omit gaia-lang from dependencies | -| Omitting `[build-system]` in pyproject.toml | Required for `uv sync` in CI | Always include build-system section | diff --git a/skills/gaia/SKILL.md b/skills/gaia/SKILL.md deleted file mode 100644 index b82127584..000000000 --- a/skills/gaia/SKILL.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -name: gaia -description: "Gaia knowledge formalization toolkit — entry point that routes to the right skill based on what you need." ---- - -# Gaia - -Gaia Lang is a Python DSL for authoring machine-readable scientific knowledge. It compiles propositions, logical constraints, and reasoning strategies into a factor graph for inference via belief propagation. - -## Quick Start - -If gaia-lang is not installed yet: -```bash -pip install gaia-lang -``` - -## What do you need? - -**"I want to formalize a paper/textbook/report"** -→ Use the **formalization** skill (`/gaia:formalization`). It guides you through a six-pass process: Extract, Connect, Check Completeness, Refine Strategy Types, Verify Structural Integrity, Polish. - -**"How do I write claims/strategies/operators?"** -→ Use the **gaia-lang** skill (`/gaia:gaia-lang`). DSL syntax reference for all knowledge types, operators, and strategies. - -**"How do I compile/infer/publish?"** -→ Use the **gaia-cli** skill (`/gaia:gaia-cli`). CLI commands, package structure, `priors.py`, and the full workflow. - -**"How do I assign priors?"** -→ Use the **review** skill (`/gaia:review`). Covers `priors.py` API, prior assignment guide, and BP result interpretation. - -**"I need to fill narrative content for GitHub presentation"** -→ Use the **publish** skill (`/gaia:publish`). Fills narrative sections in `.github-output/` wiki pages, README, and React Pages generated by `gaia render . --target github`. - -## Typical Workflow - -1. `gaia init my-paper-gaia` — scaffold a package -2. Put source material in `artifacts/` -3. Write DSL code (see **gaia-lang** skill) -4. `gaia compile .` + `gaia check .` — compile and validate -5. Write `priors.py` with leaf priors (see **gaia-cli** skill) -6. `gaia infer .` — run belief propagation (add `--depth 1` for joint cross-package inference) -7. `gaia render . --target github` + `/gaia:publish` — generate and fill README -8. `gaia render . --target docs` — generate per-module reasoning graphs -9. `gaia register .` — publish to the official registry - -For guided formalization of a knowledge source, use `/gaia:formalization`. diff --git a/skills/publish/SKILL.md b/skills/publish/SKILL.md deleted file mode 100644 index 3f9c90b73..000000000 --- a/skills/publish/SKILL.md +++ /dev/null @@ -1,271 +0,0 @@ ---- -name: publish -description: "Generate and publish README for a Gaia knowledge package — compile skeleton, fill narrative, push to GitHub." ---- - -# Publish - -Generate a complete README for a Gaia knowledge package and push it to the GitHub repo. - -## Full Pipeline - -``` -gaia render . --target github # Step 1: generate skeleton + narrative outline -/gaia:publish # Step 2: this skill fills narrative + pushes -``` - -## Step 1: Generate Skeleton - -Run in the package directory (requires `gaia compile` and `gaia infer` to have been run first): - -```bash -gaia render . --target github -``` - -This produces `.github-output/` containing: -- `README.md` — skeleton with Mermaid reasoning graph, MI annotation, conclusions table, and placeholders -- `narrative-outline.md` — auto-generated writing backbone (sections grouped by graph connectivity) -- `manifest.json` — checklist of exported conclusions and placeholders - -**Important:** Only copy the skeleton to `README.md` the FIRST time. On subsequent runs, read the new `.github-output/` data (beliefs, outline) but do NOT overwrite the existing README — update it in place. - -## Step 2: Read Inputs - -Primary inputs (drive the narrative): -```bash -cat .github-output/narrative-outline.md # Writing backbone from graph structure -cat .github-output/manifest.json # Exported conclusions list -cat .gaia/beliefs.json # BP results -cat .github-output/docs/public/data/graph.json # Figure metadata + graph data -ls src//*.py # DSL source code (claims, strategies, reasons) -``` - -Optional — read `artifacts/` (original paper, figures) for factual grounding (equations, experimental numbers, figure context). But be careful: the README is an **analysis driven by the reasoning graph**, not a paper summary. The graph may assign low belief to claims the paper presents confidently, or reveal structural weaknesses the paper glosses over. Trust the graph's assessment over the paper's rhetoric. - -## Step 3: Write README - -### Bibliographic Header - -The README must start with a proper citation of the original source material. Read `pyproject.toml` for the description, and the DSL source's module docstring or `artifacts/` for full bibliographic details. - -```markdown -# Package Title - -> **Original work:** [Author1, Author2, et al.] "[Paper Title]." *Journal Name* Volume, Pages (Year). [DOI/arXiv link] - -[badges] - -> [!NOTE] -> This README is an AI-generated analysis based on a [Gaia](https://github.com/SiliconEinstein/Gaia) reasoning graph formalization of the original work. Belief values reflect the graph's probabilistic assessment of each claim's support, not the original authors' confidence. See [ANALYSIS.md](ANALYSIS.md) for detailed verification results. -``` - -The agent should find authors, title, journal from the package's `pyproject.toml` description, module docstrings, or `artifacts/paper.md`. This citation is used for figure attributions later. - -### Badges - -Replace `` with links to Pages and Wiki if they exist. - -### Summary (YOU WRITE) - -One paragraph (3-5 sentences) readable by any scientist: -- What the source material investigates and why it matters -- Core innovation or methodology -- Key results with concrete numbers from the paper (e.g. "predicts Tc(Al) = 0.96 K vs experimental 1.2 K") -- Belief values may be cited parenthetically for the most important conclusions, but the summary should make sense without them - -### MI callout + Mermaid graph (auto-generated, keep as-is) - -The skeleton includes a `[!TIP]` callout with the total mutual information and a Mermaid reasoning graph. Keep both as generated. - -### Reasoning Structure (YOU WRITE) - -Add `## Reasoning Structure` after the Mermaid graph. This is the heart of the README — a **per-conclusion evidence assessment**. For each exported conclusion, analyze how well the evidence supports it. - -**Audience:** A researcher in the paper's field who has NOT read the original paper. After reading this section, they should understand what each conclusion claims, how it was derived, how strong the evidence is, and what risks remain. - -**Ordering:** Follow `narrative-outline.md` — this orders conclusions by the paper's logical arc (from foundational results to final predictions), NOT by belief value. The narrative flow should mirror the paper's argument: theory → computation → validation → predictions. - -**For each conclusion, write:** - -1. **Heading**: Rewrite the claim title into a descriptive sentence that a non-specialist can understand, plus belief value. Don't use the raw label — write a meaningful title. - - BAD: `### Downfolded BSE (belief: 0.33)` - - GOOD: `### The full Bethe-Salpeter equation reduces to a solvable frequency-only form (belief: 0.33)` - -2. **What it says** (1 paragraph): Explain the scientific result in enough detail that a reader unfamiliar with the paper can understand it. Include: - - The key quantitative result (numbers, equations) - - What problem this solves and why it matters - - How it was obtained (method, key approximations) - - Comparison with prior approaches (if applicable) - - Read `artifacts/` for specific details — don't write generic descriptions - -3. **Evidence chains** (2-4 bullet points): Each evidence chain supporting this conclusion: - - Name the chain descriptively - - Trace the key nodes and give the weakest link's belief - - Explain WHY the weakest link is weak (not just the number) - -4. **Figures**: Embed relevant figures from `artifacts/images/` with descriptive captions - -5. **Verdict** (1-2 sentences): Is this conclusion well-supported? What's the main risk? - -**Example:** - -```markdown -### The full Bethe-Salpeter equation reduces to a solvable frequency-only form (belief: 0.33) - -The central theoretical achievement of this work is a rigorous -"downfolding" of the complete momentum-frequency Bethe-Salpeter -equation into a one-dimensional integral equation depending only -on Matsubara frequency: $K(\omega,\omega') = \lambda(\omega,\omega') -- \mu_{\omega_c}(\omega,\omega')$. This is accomplished by -decomposing the pair propagator into coherent and incoherent parts -(an exact mathematical identity), then showing that cross-channel -mixing between Coulomb and phonon sectors is suppressed at -$O(\omega_c^2/\omega_p^2) \leq 1\%$. The resulting equation gives -$\mu^\ast$ and $\lambda$ precise microscopic definitions for the -first time — replacing the phenomenological parameters used since -the 1960s. Numerical validation against the full BSE on a toy model -with aluminum-like parameters shows 0.2% agreement in predicted $T_c$. - -**Evidence support:** -- **Cross-term suppression** (weakest link, belief 0.50): The entire - downfolding rests on cross-channel terms being ~1%. The estimate - uses a plasmon-pole model that may overstate the suppression for - low-density metals or 2D systems. -- **Toy model validation** (belief 0.76): Full vs downfolded BSE - agree at 0.2%, but this uses RPA for the electron vertex — not - the exact vertex function. - -![Fig. 3 | Diagrammatic structure of the BSE](artifacts/images/4_2.jpg) -*The BSE with decomposed pair propagator. Adapted from Cai et al.* - -> This is the theoretical foundation for everything downstream. -> The low belief (0.33) reflects uncertainty propagation from the -> cross-term suppression assumption — if cross terms are larger -> than 1%, the entire framework needs revision. -``` - -The good version explains the science in detail, gives context (why this matters, what existed before), includes the specific mathematical result, and makes the verdict meaningful. - -**What NOT to do:** -- Do not write a narrative essay — write per-conclusion assessments -- Do not use Gaia jargon (noisy_and, abduction, factor, BP, NAND) -- Do not describe graph structure — describe evidence strength -- Do not lead with belief values — lead with the science - -### Key Findings table (auto-generated, keep as-is) - -### Weak Points (YOU WRITE) - -**Focus: internal nodes with low belief — NOT the conclusions themselves** (those are covered in Reasoning Structure). Discuss intermediate claims and premises where the argument is structurally weak. - -
-Weak Points Analysis - -Write 3-5 weak points, each as a full paragraph: - -1. **Executive summary** (1 sentence): The single weakest internal link. - -2. **For each weak point** — an intermediate or hole claim with low belief: - - What the claim says and WHERE it sits in the reasoning chain - - WHY the belief is low — trace backwards to the root cause - - What downstream conclusions are affected (trace forward) - - What assumption is most vulnerable - - What specific evidence or experiment would resolve it - -3. **Structural patterns**: Are there bottleneck nodes that many conclusions depend on? Does uncertainty amplify through the chain? - -Cite belief values parenthetically. Frame as scientific critique, not graph analysis. - -
- -### Evidence Gaps (YOU WRITE) - -
-Evidence Gaps & Future Work - -Group by theme: - -**Experimental gaps:** -- What measurements are missing or imprecise? -- What experiments would most reduce uncertainty? - -**Computational gaps:** -- What calculations are approximate that could be exact? -- What parameters have the largest error bars? - -**Theoretical gaps:** -- What derivations rely on uncontrolled approximations? -- Where does the theory break down? - -For each gap, name which conclusions would improve if it were filled. Prioritize by impact. - -
- -### Link to ANALYSIS.md - -If the package has an `ANALYSIS.md` (generated during formalization Pass 5/6), add a final section linking to it: - -```markdown -## Detailed Analysis - -For structural integrity verification (Pass 5), standalone readability checks (Pass 6), -and complete package statistics, see [ANALYSIS.md](ANALYSIS.md). -``` - -## Step 4: Preview Before Pushing - -Before pushing, verify the README renders correctly: - -```bash -# Quick check: search for unfilled placeholders -grep -n "` placeholder comments remain -- [ ] All exported conclusions from manifest mentioned in Summary or Reasoning Structure -- [ ] Reasoning Structure reads as a scientific narrative — a domain expert can understand it without knowing what Gaia is -- [ ] No Gaia jargon in prose (no "noisy_and", "abduction", "factor graph", "BP", "NAND constraint") -- [ ] Belief values appear only parenthetically, never as the subject of a sentence -- [ ] Figures embedded with captions and attribution -- [ ] Weak Points are scientific critiques, not graph-structure descriptions -- [ ] Bibliographic header present - -## Step 5: Generate Per-Module Graphs - -```bash -gaia render . --target docs -``` - -This writes `docs/detailed-reasoning.md` with per-module Mermaid reasoning graphs and full claim details. Add a `[!NOTE]` callout in the README after the overview Mermaid graph: - -```markdown -> [!NOTE] -> **[Per-module reasoning graphs with full claim details →](docs/detailed-reasoning.md)** -> -> 6 Mermaid diagrams (one per section) with every claim, strategy, and belief value. -``` - -## Step 6: Push to GitHub - -```bash -git add README.md ANALYSIS.md docs/detailed-reasoning.md -git commit -m "docs: update README via /gaia:publish" -git push origin main -``` - -Optionally also push wiki and Pages template: - -```bash -cp -r .github-output/wiki . -cp -r .github-output/docs . -git add wiki/ docs/ -git commit -m "docs: add wiki pages and GitHub Pages template" -git push origin main -``` diff --git a/skills/render-obsidian/SKILL.md b/skills/render-obsidian/SKILL.md deleted file mode 100644 index f3f4b6f92..000000000 --- a/skills/render-obsidian/SKILL.md +++ /dev/null @@ -1,289 +0,0 @@ ---- -name: render-obsidian -description: "Use when user wants a browsable Obsidian wiki from a Gaia knowledge package — generates skeleton, rewrites all pages as rich knowledge documents, audits cross-references." ---- - -# Render Obsidian Wiki - -Generate a rich Obsidian vault (`gaia-wiki/`) from a Gaia knowledge package. - -## Vault Architecture - -``` -gaia-wiki/ -├── claims/ -│ ├── holes/ Leaf premises — reasoning chain endpoints -│ ├── intermediate/ Derived but not exported -│ ├── conclusions/ Exported claims ★ + questions -│ └── context/ Settings, background, structural -├── sections/ Narrative chapters (DSL module order) -│ ├── 01 - Introduction.md -│ ├── ... -│ ├── 07 - Weak Points.md -│ └── 08 - Open Questions.md -├── meta/ beliefs table, holes list -├── _index.md Claim Index + Sections + Reading Path -├── overview.md Simplified Mermaid -└── .obsidian/ -``` - -- **Claims** = atomic content units, numbered by topological order. Each carries full derivation + prior justification. -- **Sections** = narrative chapters following the paper's arc. Agent rewrites titles. Last two sections are Weak Points and Open Questions. -- **Wikilinks** use labels, filenames use titles, `aliases` bridges them. - -## Pipeline - -``` -Step 1: gaia compile + gaia infer -Step 2: gaia render --target obsidian → skeleton -Step 3: Read inputs (IR, beliefs, DSL, artifacts/) -Step 4: Rewrite every page -Step 5: Cross-reference audit -``` - -## Step 3: Read Inputs - -```bash -cat .gaia/ir.json -cat .gaia/beliefs.json -cat src//*.py -ls artifacts/ -``` - -Read `artifacts/` cover-to-cover before writing any page. - -## Step 4: Rewrite Every Page - -**Core principle:** Faithful reproduction. Each page replaces reading the paper for its topic. - -**Language:** Follow user's preference. Frontmatter/wikilinks/Mermaid stay English. - ---- - -### Claim pages (`claims/{holes,intermediate,conclusions,context}/*.md`) - -Each claim is a self-contained article. `#XX` number = position in reasoning chain. - -**Section ordering:** - -1. **Title** — Descriptive in user's language. Keep `#XX` prefix. -2. **Content** — Full explanation, all numbers/equations/conditions. -3. **Background** — Scientific context from `artifacts/`. What problem? Prior work? Gap? Embed figures with `![[file]]` + italic caption. -4. **Derivation** — Reproduce the paper's FULL argument: - - All equations with step-by-step explanation - - Physical reasoning behind each step - - Why each approximation is justified - - Numerical validations from the paper - - Appendix material - - Use `[[label|#XX label]]` for cross-references -5. **Review** — From `beliefs.json` and `priors.py`: - - `**Prior**: 0.95` - - `**Justification**: omega_D/E_F ~ 0.005; Migdal theorem validated.` - - `**Belief**: 0.71` -6. **Supports** — Downstream claims. -7. **Significance** — Why it matters. What breaks if wrong? -8. **Caveats** — Limitations, alternative explanations, uncertainties. - -**Depth by claim type:** - -| Type | Depth | -|------|-------| -| **Conclusions** (★) | Most detailed — full derivation chain, multiple paragraphs per section | -| **Holes** | Focus on source provenance — where does this evidence come from? Method, precision, limitations | -| **Intermediate** | Full derivation of this step in the chain | -| **Context** | Brief — what it establishes and why it's assumed | - ---- - -### Section pages (`sections/*.md`) - -Sections are **narrative chapters** that tell the paper's story. Claims within each section are sorted by topological order (evidence → derivation → conclusion). - -**Goal:** A reader who reads sections 01 through 06 in order should understand the paper's complete argument without ever opening the original paper. Each section is a self-contained chapter of a "textbook rewrite" of the paper. - -**Page structure (from top to bottom):** - -1. **Title** — Descriptive narrative title in user's language. Keep number prefix. - -2. **Overview** (10%) — 2-3 paragraphs setting up the section's question, approach, and key result. - -3. **Per-section Mermaid** — Keep as-is. - -4. **Claims narrative** (70% of the page — THIS IS THE MAIN BODY) — For EVERY claim in topo order, write a `###` heading + 1-3 paragraphs. This is NOT optional. Every claim listed in the skeleton MUST appear with its narrative. - - **CRITICAL: This section is the bulk of the page. Do NOT skip it.** The skeleton has `### [[label|#XX title]]` entries — the agent must expand EACH ONE into a full narrative paragraph. - - For each claim: - - `### [[label|#XX title]]` heading (keep the wikilink) - - What this claim says in plain language, with key numbers and equations - - Why this result matters for the section's argument - - How it connects to the previous and next claims (logical flow) - - If exported (★): **highlight as a key conclusion** with a callout block - - Belief analysis: what does the prior→belief change reveal? - - **Exported conclusions should be highlighted:** - ``` - ### [[downfolded_bse|#43 下折叠 BSE]] ★ - - > [!IMPORTANT] 核心结论 - > 完整的动量-频率 BSE 可以严格化简为仅依赖频率的一维积分方程, - > 误差仅 0.2%。 - - 这是本章最重要的结果... - ``` - -5. **Chapter summary** (10%) — 本章建立了什么,为下一章准备了什么。 - -**Full section page example (showing the required structure):** - -```markdown -# 03 - 从微观推导下折叠 Bethe-Salpeter 方程 - -## 概述 - -(2-3 paragraphs: question, approach, key result) - -(Mermaid graph) - -## 推理链 - -### [[pair_propagator_decomposition|#18 配对传播子分解]] - -配对传播子 $GG$ 可以精确分解为低能相干部分 $\Pi_{\mathrm{BCS}}$ -和高能非相干余项 $\phi$。这不是一个近似——而是一个数学恒等式。 -相干部分携带 Cooper 对数 $\ln(\omega_c/T)$,定义了低能配对通道。 - -论文选择在双电子通道(而非传统的粒子-空穴通道)引入能量尺度 -分离,这是一个关键创新——传统方案会导致低能区域库仑相互作用 -失去屏蔽。这一选择为下面的交叉项压制论证奠定了基础。 - -### [[cross_term_suppressed|#19 交叉项压制]] - -有了配对传播子分解,关键问题是:库仑和声子通道的交叉项是否 -会破坏可分离性?论文利用等离子体极子模型给出了严格的上界估计: -交叉项被压制在 $O(\omega_c^2/\omega_p^2) \leq 1\%$。 - -这是整条推理链中最脆弱的一环——belief 仅 0.50,反映了 1% 这个 -边界条件的不确定性。如果交叉项实际上更大,整个下折叠理论的 -精度保证就会失效。 - -### [[downfolded_bse|#43 下折叠 BSE]] ★ - -> [!IMPORTANT] 核心结论 -> 频率-only 下折叠 BSE:$\Lambda_\omega = \eta_\omega + \pi T -> \sum (\lambda - \mu_{\omega_c}) z^{ph}_{\omega'}/|\omega'| \Lambda_{\omega'}$ - -结合配对传播子分解和交叉项压制,完整 BSE 化简为仅含频率的 -一维积分方程。$\mu^*$ 和 $\lambda$ 获得了精确的微观定义... - -(... more claims ...) - -## 本章小结 - -本章从微观出发严格推导了下折叠 BSE,为 $\mu^*$ 和 $\lambda$ -提供了精确定义。这为第四章通过 vDiagMC 计算 $\mu^*$ 和第五章 -验证 DFPT $\lambda$ 的可靠性奠定了理论基础。 -``` - -**DO NOT** write a section page with only the overview and Mermaid — the claims narrative is the main content that readers come here to read. - -#### Weak Points section - -**Goal:** A reader should understand WHERE the argument is weakest, WHY it's weak, and WHAT could fix it. This is a critical assessment, not a data dump. - -The skeleton provides a table of the 10 lowest-belief claims. Agent should rewrite into a structured analysis: - -1. **Executive summary** (1 paragraph) — The single most important takeaway. What is the weakest link in the entire reasoning chain? If you had to bet on which claim will fail, which one and why? - -2. **Structural analysis** — Group weak points by their position in the reasoning graph: - - **Foundation weaknesses** — Are any leaf premises (holes) controversial? If a widely-accepted fact turns out to be wrong, what collapses? - - **Bottleneck weaknesses** — Are there single claims that many conclusions depend on? A low-belief bottleneck is more dangerous than a low-belief leaf. - - **Propagation effects** — Does the reasoning graph amplify uncertainty? (e.g., "the downfolded BSE has belief 0.33 not because it's intrinsically unreliable, but because it depends on cross-term suppression which has belief 0.50, and the uncertainty propagates through 3 derivation steps") - -3. **For each major weak point** (top 3-5), write a full paragraph: - - What the claim says and where it sits in the reasoning chain - - WHY the belief is low — trace the reasoning graph backwards to find the root cause - - What the reviewer's justification says about the uncertainty - - What competing explanation or alternative approach exists - - What specific evidence or experiment would resolve the uncertainty - - What downstream conclusions would be affected if this claim fails - -4. **Comparison with the paper's own assessment** — Does the paper acknowledge these weaknesses? Does the reasoning graph reveal weaknesses the paper doesn't discuss? - -#### Open Questions section - -**Goal:** A reader should know exactly what work remains to be done, prioritized by impact. This is a research roadmap derived from the reasoning graph. - -The skeleton lists holes and questions. Agent should rewrite into: - -1. **Overview** (1-2 paragraphs) — The big picture: what would make this knowledge package "complete"? What's the most impactful single improvement? - -2. **Open questions from the paper** — If the IR has `type: question` nodes, explain each: - - What the question asks - - Why it matters for the overall argument - - What the paper suggests (if anything) as an approach - - What the reasoning graph says about its impact (which conclusions depend on it?) - -3. **Evidence gaps** (grouped by theme): - - **Experimental gaps:** - - What measurements are missing or imprecise? - - Which claims rely on the weakest experimental evidence? - - What experiments would most reduce uncertainty? - - **Computational gaps:** - - What calculations are approximate that could be made exact? - - What parameters have the largest error bars? - - What computational advances would help? - - **Theoretical gaps:** - - What derivations rely on uncontrolled approximations? - - Where does the theory break down (validity limits)? - - What extensions would broaden applicability? - -4. **Impact analysis** — For each gap, trace forward through the reasoning graph: - - If this hole were filled with higher confidence, which conclusions would improve? - - Rank the holes by "information value": how much would filling this hole reduce overall uncertainty? - -5. **Suggested next steps** — Prioritized list of 3-5 actionable research directions, each with: - - What to do - - Why it's high-impact (which conclusions it would strengthen) - - Estimated difficulty/feasibility - ---- - -### Overview, _index, meta - -- **Overview** — Citation + abstract + simplified Mermaid graph. -- **_index** — Package description + statistics + Claim Index table (with numbers) + Sections table + Reading Path. -- **Meta** — `beliefs.md`: intro + full belief table. `holes.md`: intro + leaf premises table. - ---- - -### Quality standard - -**Faithful reproduction, not summarization.** If the paper devotes 3 pages to a derivation, reproduce them in readable form. Include appendix material. - -**Every page must include:** -- All relevant numerical values (units, error bars) -- Key equations with step-by-step explanation -- Derivation steps from the paper (including appendix) -- Figure embeds with italic captions -- Cross-references with claim numbers `[[label|#XX label]]` -- Review justification where available - -**Figure embeds** — every `![[file]]` must have italic caption: -``` -![[8_0.jpg]] -*图 4:vDiagMC 计算的 μ_EF(r_s)。改编自 Cai et al.* -``` - -### DO NOT - -- Leave skeleton English content -- Write thin summaries -- Use Gaia jargon (noisy_and, abduction, factor graph, BP) -- Modify frontmatter or wikilink targets -- Embed images without captions -- Duplicate full derivations in section pages -- List weak points without explaining WHY they're weak diff --git a/skills/review/SKILL.md b/skills/review/SKILL.md deleted file mode 100644 index 59e498a0f..000000000 --- a/skills/review/SKILL.md +++ /dev/null @@ -1,196 +0,0 @@ ---- -name: review -description: "Assign priors to Gaia knowledge packages via priors.py — prior assignment guide, BP result interpretation, and iteration workflow." ---- - -## 1. Overview - -Priors are set via two mechanisms: - -1. **`priors.py`** — assigns priors to leaf claims (independent premises). Exports a `PRIORS: dict` mapping Knowledge objects to `(prior, justification)` tuples. -2. **Inline `reason+prior` pairing** — strategies accept `prior=` directly in the DSL (e.g., `support(..., prior=0.85, reason="...")`). - -Both are baked into claim metadata at compile time. `gaia infer` reads metadata directly — no separate sidecar file needed. - -### Pre-Review: Inspect the Package - -Before writing `priors.py`, use `gaia check` to understand the package structure and prior coverage: - -```bash -gaia check . # Summary: independent claims annotated with prior status -gaia check --hole . # All independent claims: holes (no prior) + covered (with prior) -gaia check --brief . # Per-module overview: claims, strategies, operators -gaia check --show . # Expanded module: full claim content + warrant trees -gaia check --show . # Specific claim's warrant tree with premises -``` - -**`gaia check .`** annotates each independent premise with `prior=X` or `⚠ no prior (defaults to 0.5)`. Shows a "Holes (no prior set): N" count in the summary. - -**`gaia check --hole .`** splits all independent claims into **Holes** (QID, content, status) and **Covered** (prior value, justification). See §4 for the review workflow built around this output. - -**`--brief` output shows:** -- Per-module breakdown of settings, claims (with role: independent/derived/structural), and strategies -- Strategy summaries with premise labels, conclusion, prior, and reason -- Operator constraints (contradiction, equivalence) with their targets - -**`--show ` expands:** -- Full claim content (not truncated) with role and prior -- Complete warrant trees for each strategy, including composite sub-strategy expansion -- All operator details - -**`--show