diff --git a/docs/reference/cli/author.md b/docs/reference/cli/author.md index 8ed5fc44c..b470b1676 100644 --- a/docs/reference/cli/author.md +++ b/docs/reference/cli/author.md @@ -95,7 +95,7 @@ cutting flags. Per-verb flags layer on top of these. |---|---|---|---| | `--target ` | string | `.` | Path to the target Gaia package root (the directory containing `pyproject.toml`). | | `--file ` | string | `__init__.py` | Relative path under `src//` to append the statement to. Default routes to the package entrypoint. Sibling files (e.g. `priors.py`) must exist first; use `gaia pkg add-module --name ` to scaffold them. | -| `--label ` | string | required (most verbs) | Python identifier the produced binding takes. Must not collide with module or DSL names. | +| `--dsl-binding-name ` | string | none (bare expr) | Python identifier the produced binding takes (` = verb(...)`); the compiled IR label defaults to it. Omit to emit a bare expression statement with no LHS. Must not collide with module or DSL names. | | `--rationale ` | string | none | Natural-language justification carried through to the DSL kwarg. | | `--metadata ` | JSON object | none | Optional metadata dict; rendered as the DSL `metadata=` kwarg. | | `--references ` | csv idents | none | Comma-separated background reference identifiers (only the verbs that accept background context). | @@ -105,13 +105,29 @@ cutting flags. Per-verb flags layer on top of these. | `--interactive` | bool flag | `False` | Surface pre-write warnings as a numbered prompt (human mode only — JSON mode auto-suppresses). | | `--json / --no-json` | bool | `--json` on | Courtesy alias; redundant with the default. `--human` is the actual switch. | -`--label` is **required** for every statement-emitting verb except -`register-prior` (which writes a bare `register_prior(...)` expression with -no LHS binding) and `note` / `claim` / `question` (where the positional -content arg is also required). Relation verbs (`equal` / `contradict` / -`exclusive` / `associate`) return helper Claims; `--export` makes that -returned helper part of the curated public interface. `decompose` returns -its `whole` Claim rather than a separate public helper. +`--dsl-binding-name` names the Python binding the statement produces +(` = verb(...)`); the engine's package loader then defaults the +compiled IR label to that binding name. It is optional at the CLI level — +omit it and the verb emits a bare expression statement with no LHS — but +most statements that get referenced downstream supply it. `register-prior` +writes a bare `register_prior(...)` expression and has no binding flag; +`artifact` / `figure` require `--dsl-binding-name`. + +`--label` is a **separate, verb-specific** flag, not a cross-cutting +binding flag. On the relation and derivation verbs (`equal` / `contradict` +/ `exclusive` / `associate` / `decompose` / `derive` / `observe` / +`compute` / `infer` / `parameter` / `depends-on` / `candidate-relation` / +`materialize`) it renders the engine `label=` kwarg inside the call, +distinct from the Python binding. On `claim` it is optional and emits a +follow-up `.label = ""` assignment (because `claim()` has no +`label=` kwarg), so it requires `--dsl-binding-name`. `note` / `question` / +`variable` / `artifact` / `figure` / `register-prior` have no `--label` at +all. + +Relation verbs (`equal` / `contradict` / `exclusive` / `associate`) return +helper Claims; `--export` makes that returned helper part of the curated +public interface. `decompose` returns its `whole` Claim rather than a +separate public helper. ## Per-verb flag surface (statement-emitting verbs) @@ -122,7 +138,7 @@ gets rendered into the package. ### `note` ``` -gaia author note --label [--target ] +gaia author note --dsl-binding-name [--target ] [--title ] [--metadata ] [--check/--no-check] [--human] [--interactive] ``` @@ -166,9 +182,9 @@ Sugar for `gaia author artifact --kind figure`. A source-bound figure requires ### `claim` ``` -gaia author claim --label [--target ] +gaia author claim --dsl-binding-name [--target ] [--title ] [--prior ] [--predicate ""] - [--references ] [--metadata ] + [--references ] [--label ] [--metadata ] [--check/--no-check] [--human] [--interactive] ``` @@ -179,11 +195,12 @@ gaia author claim --label [--target ] | `--prior ` | no | Optional inline prior in (0, 1); routed via `register_prior` with source `claim_inline`. | | `--predicate ""` | no | Predicate-claim mode — sandbox-validated formula expression rendered as the `formula=` kwarg. See [Restricted-globals sandbox](#restricted-globals-sandbox). | | `--references ` | no | Comma-separated background claims (rendered as `background=` kwarg). | +| `--label ` | no | Optional engine label. `claim()` takes no `label=` kwarg, so the cli emits a follow-up `.label = ""` line after the rendered call; requires `--dsl-binding-name`. | ### `question` ``` -gaia author question --label [--target ] +gaia author question --dsl-binding-name [--target ] [--title ] [--targets ] [--metadata ] ... ``` @@ -196,8 +213,8 @@ gaia author question --label [--target ] ``` gaia author --a --b \ - --label [--target ] - [--rationale ] [--metadata ] [--export] ... + --dsl-binding-name [--target ] + [--label ] [--rationale ] [--metadata ] [--export] ... ``` | Flag | Required | Description | @@ -214,10 +231,10 @@ interface. ### `decompose` ``` -gaia author decompose --whole --parts --label \ +gaia author decompose --whole --parts --dsl-binding-name \ [--target ] [--formula-template ] [--formula-expr ""] - [--rationale ] [--metadata ] ... + [--label ] [--rationale ] [--metadata ] ... ``` | Flag | Required | Description | @@ -231,8 +248,8 @@ gaia author decompose --whole --parts --label \ ``` gaia author derive (--conclusion | --conclusion-content "" | --conclusion-prose "") \ - --given --label [--target ] - [--conclusion-label ] [--rationale ] + --given --dsl-binding-name [--target ] + [--conclusion-label ] [--label ] [--rationale ] [--background ] [--metadata ] ... ``` @@ -253,9 +270,9 @@ into an anonymous Claim at runtime). ### `observe` ``` -gaia author observe (--conclusion | --observation-content "") \ - --label [--target ] - [--observation-label ] [--value ] [--error ] +gaia author observe (--conclusion | --observation-content "" | --observation-prose "") \ + --dsl-binding-name [--target ] + [--observation-label ] [--label ] [--value ] [--error ] [--given ] [--source-refs ] [--rationale ] [--metadata ] ... ``` @@ -272,8 +289,8 @@ gaia author observe (--conclusion | --observation-content "") \ ### `compute` ``` -gaia author compute --conclusion-type --label [--target ] - [--fn ] [--given ] [--rationale ] [--metadata ] ... +gaia author compute --conclusion-type --dsl-binding-name [--target ] + [--fn ] [--label ] [--given ] [--rationale ] [--metadata ] ... ``` | Flag | Required | Description | @@ -290,8 +307,8 @@ The decorator form `@compute` stays at Python-source level (same logic as ``` gaia author infer --evidence \ (--hypothesis | --hypothesis-content "") \ - --p-e-given-h --label [--target ] - [--hypothesis-label ] [--p-e-given-not-h ] + --p-e-given-h --dsl-binding-name [--target ] + [--hypothesis-label ] [--label ] [--p-e-given-not-h ] [--given ] [--rationale ] [--metadata ] ... ``` @@ -309,8 +326,8 @@ gaia author infer --evidence \ ``` gaia author associate --a --b \ - --p-a-given-b --p-b-given-a --label [--target ] - [--pattern ] [--rationale ] [--metadata ] [--export] ... + --p-a-given-b --p-b-given-a --dsl-binding-name [--target ] + [--pattern ] [--label ] [--rationale ] [--metadata ] [--export] ... ``` | Flag | Required | Description | @@ -332,9 +349,9 @@ warning recommending `register_prior(...)` on at least one endpoint. ### `parameter` ``` -gaia author parameter --variable --value --label \ +gaia author parameter --variable --value --dsl-binding-name \ [--target ] [--content ] [--title ] [--prior ] - [--rationale ] [--metadata ] ... + [--label ] [--rationale ] [--metadata ] ... ``` | Flag | Required | Description | @@ -369,8 +386,8 @@ LHS binding, since `register_prior()` returns `None`. ### `depends-on` ``` -gaia author depends-on --conclusion --given --label \ - [--target ] [--rationale ] [--background ] [--metadata ] ... +gaia author depends-on --conclusion --given --dsl-binding-name \ + [--target ] [--label ] [--rationale ] [--background ] [--metadata ] ... ``` | Flag | Required | Description | @@ -382,8 +399,8 @@ gaia author depends-on --conclusion --given --label \ ### `candidate-relation` ``` -gaia author candidate-relation --claims --pattern --label \ - [--target ] [--rationale ] [--background ] [--metadata ] ... +gaia author candidate-relation --claims --pattern --dsl-binding-name \ + [--target ] [--label ] [--rationale ] [--background ] [--metadata ] ... ``` | Flag | Required | Description | @@ -394,8 +411,8 @@ gaia author candidate-relation --claims --pattern --label \ ### `materialize` ``` -gaia author materialize --scaffold --by --label \ - [--target ] [--rationale ] [--metadata ] ... +gaia author materialize --scaffold --by --dsl-binding-name \ + [--target ] [--label ] [--rationale ] [--metadata ] ... ``` | Flag | Required | Description | @@ -533,7 +550,7 @@ single JSON object to stdout matching this schema: |---|---|---| | `target` | always | Resolved absolute path of the target package. | | `written_to` | statement-emitting success | Path of the file the cli appended to (`src//__init__.py`). | -| `label` | statement-emitting success | The `--label` value (None for `register-prior`). | +| `label` | statement-emitting success | The `--dsl-binding-name` value (None for `register-prior`). | | `verb` | always | Echo of the verb name (alongside the top-level `verb`). | | `snippet` | statement-emitting success | The exact Python source string appended to the file. | | `auto_generated` | prose-mode success | List of `{label, snippet}` for each auto-minted Claim. | @@ -575,9 +592,9 @@ matters because the *first* failure determines `kind` and exit code. for `--predicate` / `--formula-expr` distinguish via `prewrite.expr_unsafe` (also exit 2). 3. **(d) Structural self-loop check** — the proposed op's `references` - set must not contain the proposed `--label`. Failure kind: + set must not contain the proposed `--dsl-binding-name`. Failure kind: `prewrite.self_loop` (exit 1). -4. **(c) Collision and reference resolution** — the proposed `--label` +4. **(c) Collision and reference resolution** — the proposed `--dsl-binding-name` must not collide with an existing module binding or DSL surface name; all `references` must resolve to module bindings (or one of the prepended-statement labels in the same invocation). Failure kinds: @@ -592,7 +609,7 @@ unresolved-ref machinery (exit 3). Documented in | Kind | Fires when | Behavior | |---|---|---| -| `prewrite.label_shadow` | The proposed `--label` collides with a Python builtin or DSL surface name (defensive — most shadow cases are intercepted by the (c) hard error). | Run proceeds; warning flows to envelope. | +| `prewrite.label_shadow` | The proposed `--dsl-binding-name` collides with a Python builtin or DSL surface name (defensive — most shadow cases are intercepted by the (c) hard error). | Run proceeds; warning flows to envelope. | | `prewrite.deprecated_ref` | A call site in the generated code or one of `references` names a DSL symbol carrying a `DeprecationWarning` in the engine (sourced via AST scan of `gaia/engine/lang/dsl/**.py` at cli import; merged with a small hand-curated fallback for safety). Scan is narrowed to call positions, so a binding name that happens to match a deprecated factory does not trip the warning. | Run proceeds; `replacement` hint in `where`. | Both flow through `--interactive`: in human mode + `--interactive` + at @@ -778,7 +795,7 @@ re-runs the cli sequence on every PR-gate run and asserts equivalence through the multi-level tolerance helper at `tests/cli/_equivalence_levels.py` — BYTE_TEXT on the user-authored content axes + structural counts, CONTENT_SET on the intrinsic -single-`--label` discipline axis. Mendel is therefore the empirical +label-bag axis. Mendel is therefore the empirical demonstration that the cli surface covers the v0.5 engine end-to-end. ## See also diff --git a/examples/galileo-v0-5-gaia/CLI-AUTHORED.md b/examples/galileo-v0-5-gaia/CLI-AUTHORED.md index 7178ba6c8..d75399eaf 100644 --- a/examples/galileo-v0-5-gaia/CLI-AUTHORED.md +++ b/examples/galileo-v0-5-gaia/CLI-AUTHORED.md @@ -2,7 +2,7 @@ > **Companion to** [`docs/reference/cli/author.md`](../../docs/reference/cli/author.md) and the hand-authored package at `src/galileo_v0_5/__init__.py`. This document shows how the same Galileo falling-body thought experiment can be authored end-to-end through `gaia author ` and `gaia pkg scaffold`, without hand-editing the Python source. -> **Reproduction semantics**: this walkthrough reproduces the IR (knowledge/strategy content + counts + types) of the hand-authored package, not the byte-text source. See the equivalence test under `tests/cli/galileo_demo/` for the asserted axes; a small set of source-text divergences (chiefly the single-`--label` discipline) is documented at end-of-doc and is intrinsic-by-design. +> **Reproduction semantics**: this walkthrough reproduces the IR (knowledge/strategy content + counts + types) of the hand-authored package, not the byte-text source. See the equivalence test under `tests/cli/galileo_demo/` for the asserted axes; a small set of source-text divergences (chiefly that each statement's binding name is passed with `--dsl-binding-name` and carries the IR label) is documented at end-of-doc and is intrinsic-by-design. ## What you get @@ -71,21 +71,21 @@ establishing the package framing and two thought-experiment setups. ```bash gaia author note \ "This package models Galileo's falling-body thought experiment as a comparison between two explanatory models. It does not treat vacuum falling as an observed fact inside the package." \ - --label context \ + --dsl-binding-name context \ --target ./galileo-cli-mirror-gaia gaia author note \ "In the tied-body thought experiment, a heavy body and a light body are bound together and considered as one composite system." \ - --label thought_experiment_setup \ + --dsl-binding-name thought_experiment_setup \ --target ./galileo-cli-mirror-gaia gaia author note \ "The vacuum case is a counterfactual setup in which the resisting medium is absent." \ - --label vacuum_setup \ + --dsl-binding-name vacuum_setup \ --target ./galileo-cli-mirror-gaia ``` -The first label matches the hand-authored binding name verbatim. The +The first binding name matches the hand-authored binding name verbatim. The `prewrite.deprecated_ref` scan is narrowed to call positions, so `context = note(...)` does not trip the warning — the deprecation is about *calling* `context()` as a DSL factory, not about a binding name @@ -101,17 +101,17 @@ resistance). ```bash gaia author claim \ "In air, heavy bodies are often observed to fall faster than light bodies." \ - --label daily_observation \ + --dsl-binding-name daily_observation \ --target ./galileo-cli-mirror-gaia gaia author claim \ "Model A: weight itself causes heavier bodies to have greater natural falling speed." \ - --label aristotle_model \ + --dsl-binding-name aristotle_model \ --target ./galileo-cli-mirror-gaia gaia author claim \ "Model B: differences in falling speed in air are caused by resistance from the medium." \ - --label medium_model \ + --dsl-binding-name medium_model \ --target ./galileo-cli-mirror-gaia ``` @@ -131,26 +131,26 @@ gaia author derive \ --conclusion-prose "Under Model A, heavy bodies should fall faster than light bodies in air." \ --given aristotle_model \ --rationale "If weight directly increases natural falling speed, then heavier bodies falling faster in air is expected." \ - --label aristotle_daily_observation_path \ + --dsl-binding-name aristotle_daily_observation_path \ --target ./galileo-cli-mirror-gaia gaia author equal \ --a aristotle_daily_observation_path --b daily_observation \ --rationale "The daily falling-body observation matches the prediction generated by the weight-speed model." \ - --label aristotle_daily_match \ + --dsl-binding-name aristotle_daily_match \ --target ./galileo-cli-mirror-gaia gaia author derive \ --conclusion-prose "Under Model B, heavy bodies can fall faster than light bodies in air." \ --given medium_model \ --rationale "If air resistance creates the observed speed differences, then heavier compact bodies can fall faster in air without weight itself setting the natural speed." \ - --label medium_daily_observation_path \ + --dsl-binding-name medium_daily_observation_path \ --target ./galileo-cli-mirror-gaia gaia author equal \ --a medium_daily_observation_path --b daily_observation \ --rationale "The daily falling-body observation matches the prediction generated by the medium-resistance model." \ - --label medium_daily_match \ + --dsl-binding-name medium_daily_match \ --target ./galileo-cli-mirror-gaia ``` @@ -164,20 +164,20 @@ gaia author derive \ --conclusion-prose "The tied composite should fall faster than the heavy body alone." \ --given aristotle_model --background thought_experiment_setup \ --rationale "Under the weight-speed model, greater total weight implies greater natural falling speed. In the tied-body setup, the composite contains the heavy body plus an additional light body, so it is heavier than the heavy body alone." \ - --label aristotle_composite_faster \ + --dsl-binding-name aristotle_composite_faster \ --target ./galileo-cli-mirror-gaia gaia author derive \ --conclusion-prose "The tied composite should fall slower than the heavy body alone." \ --given aristotle_model --background thought_experiment_setup \ --rationale "Under the same weight-speed model, the slower light body should retard the faster heavy body when the two are tied together." \ - --label aristotle_composite_slower \ + --dsl-binding-name aristotle_composite_slower \ --target ./galileo-cli-mirror-gaia gaia author contradict \ --a aristotle_composite_faster --b aristotle_composite_slower \ --rationale "For the same tied composite, the weight-speed model yields incompatible predictions." \ - --label aristotle_paradox \ + --dsl-binding-name aristotle_paradox \ --target ./galileo-cli-mirror-gaia ``` @@ -188,7 +188,7 @@ gaia author derive \ --conclusion-prose "In vacuum, bodies of different weights fall at the same rate." \ --given medium_model --background vacuum_setup \ --rationale "If observed speed differences come from medium resistance, then in the vacuum setup, where the resisting medium is absent by definition, the source of those differences is absent." \ - --label medium_vacuum_equal_fall_prediction \ + --dsl-binding-name medium_vacuum_equal_fall_prediction \ --target ./galileo-cli-mirror-gaia ``` @@ -237,12 +237,12 @@ The counts match the hand-authored package compile (`24 / 5 / 3`). ## Documented divergences The cli-authored mirror is **closer-to-byte-text-equivalent** with the -hand-authored package on every axis other than the cli's -single-`--label` flag discipline, which is intrinsic-by-design. +hand-authored package on every axis other than how each statement's IR +label is spelled at the source level, which is intrinsic-by-design. -### LHS binding equals `label=` kwarg (intrinsic) +### Binding name carries the IR label (intrinsic) -The hand-authored file frequently uses different identifiers for the +The hand-authored file sometimes uses different identifiers for the Python binding (LHS of `=`) and the DSL `label=` kwarg: ```python @@ -253,18 +253,26 @@ aristotle_daily_prediction = derive( # ← Python binding ) ``` -The cli enforces `label = derive(..., label="label", ...)` — the LHS -binding and the DSL `label=` kwarg are forced equal because the cli's -single `--label` flag drives both. This is intrinsic to the cli's -single-`--label` discipline; it keeps every author call's binding name -match its referenceable identifier. Subsequent +The walkthrough passes only +`--dsl-binding-name aristotle_daily_observation_path` (and no +`--label`), so the cli renders a bare +`aristotle_daily_observation_path = derive(...)` with no `label=` +kwarg; the engine's package loader then defaults the compiled IR label +to the binding name. The hand-authored form reaches the same IR label +through an explicit `label=` kwarg on a distinct binding. Subsequent `gaia author equal --a aristotle_daily_observation_path` calls reference the Python binding directly. The two source-text forms compile to the same IR. -The galileo equivalence test asserts the **distinct label count** axis -on this dimension at BYTE_TEXT, rather than label identity — both sides -produce the same number of label slots. +`derive` / `equal` / `contradict` also expose a separate optional +`--label ` that renders an explicit engine `label=` +kwarg when a binding and its IR label must differ; the walkthrough +never needs it, because naming each binding after its intended label +is the simplest shape that reproduces the hand-authored IR. + +The galileo equivalence test asserts the **label-bag** axis on this +dimension at CONTENT_SET, rather than label identity — both sides +produce the same set of referenceable labels. ## Equivalence guarantees @@ -285,8 +293,9 @@ the full cli sequence above against a fresh temp directory and asserts: render zero `source_id=` mentions on both sides — the cli omits the kwarg when `--source-id` is not explicitly passed. -The only remaining source-text divergence is the cli's single-`--label` -discipline, which is non-semantic at the IR level. +The only remaining source-text divergence is that each cli statement +carries its IR label via the `--dsl-binding-name` binding name, which +is non-semantic at the IR level. ## See also diff --git a/examples/mendel-v0-5-gaia/CLI-AUTHORED.md b/examples/mendel-v0-5-gaia/CLI-AUTHORED.md index 19b19915c..eeb12726c 100644 --- a/examples/mendel-v0-5-gaia/CLI-AUTHORED.md +++ b/examples/mendel-v0-5-gaia/CLI-AUTHORED.md @@ -2,7 +2,7 @@ > **Companion to** [`docs/reference/cli/author.md`](../../docs/reference/cli/author.md) and the hand-authored package at `src/mendel_v0_5/__init__.py`. This document shows how the Mendel single-factor cross example can be authored end-to-end through `gaia author `, `gaia bayes `, and `gaia pkg `, without hand-editing the Python source. It mirrors the galileo walkthrough at `examples/galileo-v0-5-gaia/CLI-AUTHORED.md` and exercises the harder of the two v0.5 example packages: `bayes` group + `Variable` + Variable-targeted `observe(..., value=...)` + multi-file (`priors.py`) + `--background` on every relation verb. -> **Reproduction semantics**: this walkthrough reproduces the IR (knowledge/strategy content + counts + types) of the hand-authored package, not the byte-text source. See the equivalence test under `tests/cli/mendel_demo/` for the asserted axes; a small set of source-text divergences (chiefly the single-`--label` discipline) is documented at end-of-doc and is intrinsic-by-design. +> **Reproduction semantics**: this walkthrough reproduces the IR (knowledge/strategy content + counts + types) of the hand-authored package, not the byte-text source. See the equivalence test under `tests/cli/mendel_demo/` for the asserted axes; a small set of source-text divergences (chiefly that each statement's binding name carries the IR label) is documented at end-of-doc and is intrinsic-by-design. ## What you get @@ -29,7 +29,7 @@ Mendel touches every cli capability that galileo did not: | `author observe --value` | 1 Variable-targeted quantitative observation (`f2_count_observation`) | | `--background` on relations | `exclusive`, every `observe`, every `derive`, every `equal`, every `contradict`, every `bayes.model`, `bayes.compare` | | Inline-prose `derive --conclusion-prose` | every mendel `derive(...)` uses the inline-prose shape | -| Single-`--label` discipline (intrinsic) | every cli statement renders `label=` inside the call | +| Binding name carries the IR label (intrinsic) | every cli statement's IR label defaults to its binding name (`--dsl-binding-name`, or `--label` for `bayes` verbs) | | Narrowed deprecation scan | hand-authored binding names like `competing_models` reused verbatim | Mendel is therefore the empirical demonstration that the cli surface covers the full v0.5 engine. If anything that mendel reaches for is not directly cli-authorable, the capability claim has a hole; the equivalence test fails fast under that condition. @@ -66,11 +66,11 @@ The Mendel package uses Variables for the F2 counts that feed the `Binomial(name ```bash gaia author variable \ - --label f2_total_count --symbol n_f2 --domain Nat --value 395 \ + --dsl-binding-name f2_total_count --symbol n_f2 --domain Nat --value 395 \ --target ./mendel-cli-mirror-gaia gaia author variable \ - --label f2_dominant_count --symbol k_dominant --domain Nat --value 295 \ + --dsl-binding-name f2_dominant_count --symbol k_dominant --domain Nat --value 295 \ --target ./mendel-cli-mirror-gaia ``` @@ -89,17 +89,17 @@ resolution. Numerically identical at the IR level either way. ```bash gaia author note \ "单因子杂交实验从两个稳定亲本品系开始:一个亲本稳定表现显性表型,另一个亲本稳定表现隐性表型;二者杂交得到 F1,再让 F1 自交得到 F2。" \ - --label monohybrid_cross_setup \ + --dsl-binding-name monohybrid_cross_setup \ --target ./mendel-cli-mirror-gaia gaia author note \ "在该性状上,显性遗传因子会在表型上遮蔽隐性遗传因子。" \ - --label dominance_background \ + --dsl-binding-name dominance_background \ --target ./mendel-cli-mirror-gaia gaia author note \ "F2 的显性/隐性计数是有限样本,因此用点似然(二项 PMF 在观测计数处的取值)衡量模型与数据的贴合度;对手理论取 p ~ Uniform[0,1] 的 diffuse 先验作为参考尺度,不引入任何具体的替代二项参数。" \ - --label finite_sample_background \ + --dsl-binding-name finite_sample_background \ --target ./mendel-cli-mirror-gaia ``` @@ -112,19 +112,19 @@ verbatim. ```bash gaia author claim \ "孟德尔分离模型:遗传因子是离散的;每个个体对某一性状携带一对因子;形成配子时成对因子分离,受精时重新配对;显性因子会遮蔽隐性因子。" \ - --label mendelian_segregation_model \ + --dsl-binding-name mendelian_segregation_model \ --target ./mendel-cli-mirror-gaia gaia author claim \ "混合遗传模型:亲本性状在后代中连续平均;一旦平均,离散的显性/隐性类别就不应在 F2 中作为可计数的类型存在。" \ - --label blending_inheritance_model \ + --dsl-binding-name blending_inheritance_model \ --target ./mendel-cli-mirror-gaia gaia author exclusive \ --a mendelian_segregation_model --b blending_inheritance_model \ --background monohybrid_cross_setup \ --rationale "在同一个单因子性状解释上,离散分离模型和连续混合模型是竞争解释。" \ - --label competing_models \ + --dsl-binding-name competing_models \ --target ./mendel-cli-mirror-gaia ``` @@ -139,21 +139,21 @@ gaia author observe \ --observation-prose "纯种显性亲本与纯种隐性亲本杂交后,F1 后代统一表现显性表型。" \ --background monohybrid_cross_setup \ --rationale "这是单因子杂交实验中 F1 代的定性观察。" \ - --label f1_uniform_dominant_observation \ + --dsl-binding-name f1_uniform_dominant_observation \ --target ./mendel-cli-mirror-gaia gaia author observe \ --observation-prose "F2 个体可以被清晰地划分为显性和隐性两个离散表型类别,不存在连续中间态。" \ --background monohybrid_cross_setup \ --rationale "这是单因子杂交实验中 F2 代的定性观察:表型呈两类,不是连续分布。" \ - --label f2_has_discrete_classes_observation \ + --dsl-binding-name f2_has_discrete_classes_observation \ --target ./mendel-cli-mirror-gaia gaia author observe \ --observation-prose "F1 自交得到的 F2 后代中,原隐性表型作为离散类别重新出现。" \ --background monohybrid_cross_setup \ --rationale "这是单因子杂交实验中 F2 代的定性观察。" \ - --label f2_recessive_reappears_observation \ + --dsl-binding-name f2_recessive_reappears_observation \ --target ./mendel-cli-mirror-gaia gaia author observe \ @@ -161,7 +161,7 @@ gaia author observe \ --value 295 \ --background monohybrid_cross_setup,f2_has_discrete_classes_observation \ --rationale "这是用于贝叶斯点似然比较的 F2 显性/隐性计数数据。" \ - --label f2_count_observation \ + --dsl-binding-name f2_count_observation \ --target ./mendel-cli-mirror-gaia ``` @@ -179,14 +179,14 @@ gaia author derive \ --given mendelian_segregation_model \ --background monohybrid_cross_setup,dominance_background \ --rationale "显性因子在杂合 F1 个体中遮蔽隐性因子。" \ - --label mendel_predicts_f1_dominance \ + --dsl-binding-name mendel_predicts_f1_dominance \ --target ./mendel-cli-mirror-gaia gaia author equal \ --a mendel_predicts_f1_dominance --b f1_uniform_dominant_observation \ --background monohybrid_cross_setup \ --rationale "孟德尔模型对 F1 统一显性的预测与观察相符。" \ - --label f1_mendel_match \ + --dsl-binding-name f1_mendel_match \ --target ./mendel-cli-mirror-gaia gaia author derive \ @@ -194,14 +194,14 @@ gaia author derive \ --given mendelian_segregation_model \ --background monohybrid_cross_setup,dominance_background \ --rationale "离散因子 + 遮蔽 → 两个离散表型类别。" \ - --label mendel_predicts_discrete_classes \ + --dsl-binding-name mendel_predicts_discrete_classes \ --target ./mendel-cli-mirror-gaia gaia author equal \ --a mendel_predicts_discrete_classes --b f2_has_discrete_classes_observation \ --background monohybrid_cross_setup \ --rationale "孟德尔模型预言的两类离散表型与观察到的 F2 两类表型一致。" \ - --label f2_discrete_classes_mendel_match \ + --dsl-binding-name f2_discrete_classes_mendel_match \ --target ./mendel-cli-mirror-gaia gaia author derive \ @@ -209,14 +209,14 @@ gaia author derive \ --given mendelian_segregation_model \ --background monohybrid_cross_setup,dominance_background \ --rationale "分离模型保留了隐性因子,并允许它在 F2 中重新组合为纯合隐性。" \ - --label mendel_predicts_recessive_reappearance \ + --dsl-binding-name mendel_predicts_recessive_reappearance \ --target ./mendel-cli-mirror-gaia gaia author equal \ --a mendel_predicts_recessive_reappearance --b f2_recessive_reappears_observation \ --background monohybrid_cross_setup \ --rationale "孟德尔模型对 F2 隐性重现的预测与观察相符。" \ - --label f2_reappearance_mendel_match \ + --dsl-binding-name f2_reappearance_mendel_match \ --target ./mendel-cli-mirror-gaia gaia author derive \ @@ -224,7 +224,7 @@ gaia author derive \ --given mendelian_segregation_model \ --background monohybrid_cross_setup,dominance_background,finite_sample_background \ --rationale "F1 配子等概率结合,给出 1:2:1 的基因型分布,即每个 F2 个体独立以概率 3/4 表现为显性。" \ - --label mendel_predicts_three_to_one_ratio \ + --dsl-binding-name mendel_predicts_three_to_one_ratio \ --target ./mendel-cli-mirror-gaia ``` @@ -276,14 +276,14 @@ gaia author derive \ --given blending_inheritance_model \ --background monohybrid_cross_setup \ --rationale "连续平均模型把亲本性状视为在后代中均化。" \ - --label blending_predicts_intermediate_f1 \ + --dsl-binding-name blending_predicts_intermediate_f1 \ --target ./mendel-cli-mirror-gaia gaia author contradict \ --a blending_predicts_intermediate_f1 --b f1_uniform_dominant_observation \ --background monohybrid_cross_setup \ --rationale "F1 统一显性与混合模型的中间表型预测相冲突。" \ - --label f1_blending_conflict \ + --dsl-binding-name f1_blending_conflict \ --target ./mendel-cli-mirror-gaia gaia author derive \ @@ -291,14 +291,14 @@ gaia author derive \ --given blending_inheritance_model \ --background monohybrid_cross_setup \ --rationale "连续平均不保留可重新组合的离散遗传单位,因此不给出离散的表型分类。" \ - --label blending_predicts_f2_continuous \ + --dsl-binding-name blending_predicts_f2_continuous \ --target ./mendel-cli-mirror-gaia gaia author contradict \ --a blending_predicts_f2_continuous --b f2_has_discrete_classes_observation \ --background monohybrid_cross_setup \ --rationale "F2 明确划分为两类离散表型,与混合模型的连续分布预测相冲突——这是 framework 级别的冲突:blending 否认的是 F2 可被分类这件事本身。" \ - --label f2_discrete_classes_blending_conflict \ + --dsl-binding-name f2_discrete_classes_blending_conflict \ --target ./mendel-cli-mirror-gaia gaia author derive \ @@ -306,14 +306,14 @@ gaia author derive \ --given blending_inheritance_model \ --background monohybrid_cross_setup \ --rationale "混合模型没有保留可重新组合的离散隐性因子。" \ - --label blending_predicts_no_recessive_reappearance \ + --dsl-binding-name blending_predicts_no_recessive_reappearance \ --target ./mendel-cli-mirror-gaia gaia author contradict \ --a blending_predicts_no_recessive_reappearance --b f2_recessive_reappears_observation \ --background monohybrid_cross_setup \ --rationale "F2 隐性表型作为离散类别重新出现,与混合模型的预测相冲突。" \ - --label f2_reappearance_blending_conflict \ + --dsl-binding-name f2_reappearance_blending_conflict \ --target ./mendel-cli-mirror-gaia ``` @@ -382,18 +382,23 @@ The counts match the hand-authored package compile (`44 / 9 / 7`). ## Documented divergences -All remaining divergences are either ratified intrinsic to the -single-`--label` discipline, or a non-semantic source-text difference -between literal-value and imported-constant authoring (both compile to -the same IR; the equivalence tests pass byte-text on every axis other -than the single-`--label` discipline). +All remaining divergences are either intrinsic to how each statement's +IR label is spelled at the source level, or a non-semantic source-text +difference between literal-value and imported-constant authoring (both +compile to the same IR; the equivalence tests pass byte-text on every +axis other than the label-bag axis). -### 1. LHS binding equals `label=` kwarg (intrinsic) +### 1. Binding name carries the IR label (intrinsic) -Same as the galileo intrinsic divergence. The cli enforces -`label_name = verb(..., label="label_name")` — the LHS Python binding -and the DSL `label=` kwarg are forced equal because the cli's single -`--label` flag drives both. +Same as the galileo intrinsic divergence. Each `gaia author` statement +takes its Python binding from `--dsl-binding-name`; when no engine +`--label` kwarg is passed, the engine's package loader defaults the +compiled IR label to that binding name. The `gaia bayes` verbs spell +the binding with `--label` (which also renders an explicit `label=` +kwarg). Either way the IR label equals the binding name, so the +walkthrough names every binding after its intended label; some +hand-authored statements reach the same label through a distinct +binding plus an explicit `label=` kwarg. The current Mendel package no longer needs a post-binding label mutation for the F2-count data. The CLI-authored source emits the same Variable-targeted @@ -444,14 +449,14 @@ The pytest fixture at `tests/cli/mendel_demo/test_equivalence.py` runs the full | `operator-count` | BYTE_TEXT | 7 operators on both sides. | | `total-knowledge-count` | BYTE_TEXT | 44 knowledge nodes on both sides. | | `knowledge-type-multiset` | BYTE_TEXT | `{note: 3, claim: 41}` on both sides. | -| `label-bag` | CONTENT_SET | Single-`--label` discipline forces every cli statement to render `label=`; some hand-authored statements omit it when binding name == label. Set is identical; multiset differs by the `label=` rendering choice. | +| `label-bag` | CONTENT_SET | Every cli statement's IR label defaults to its binding name; some hand-authored statements reach the same label through a distinct binding plus an explicit `label=` kwarg. The set of distinct labels is identical; the source-text spelling differs. | | `bayes-model-count` | BYTE_TEXT | 2 `bayes.model` calls + 1 `bayes.compare` call on both sides. | | `register-prior-count` | BYTE_TEXT | 6 `register_prior` calls in `priors.py` on both sides. | | `source-id-count` | BYTE_TEXT | `register_prior` calls render zero `source_id=` mentions on both sides — the cli omits the kwarg when `--source-id` is not explicitly passed. | | `variable-observation` | structural assertion | The F2-count data line uses `observe(f2_dominant_count, value=295, ...)`, producing the observation metadata required by Bayes compare. | | `bayes-inline-distribution` | structural assertion | `bayes.model(...)` calls inline `Binomial(...)` / `BetaBinomial(...)` directly — no pre-bound `mendel_count_distribution` / `diffuse_count_distribution` helper bindings. | -The multi-level helper at `tests/cli/_equivalence_levels.py` underwrites both this mendel demo and the galileo demo's equivalence (galileo applies BYTE_TEXT on the resolvable axes, CONTENT_SET on the intrinsic single-`--label` axis; mendel adds the bayes / Variable-observation / multi-file axes on top). +The multi-level helper at `tests/cli/_equivalence_levels.py` underwrites both this mendel demo and the galileo demo's equivalence (galileo applies BYTE_TEXT on the resolvable axes, CONTENT_SET on the intrinsic label-bag axis; mendel adds the bayes / Variable-observation / multi-file axes on top). ## See also diff --git a/gaia/cli/commands/_dot.py b/gaia/cli/commands/_dot.py index e9a77eb3f..d1ea33723 100644 --- a/gaia/cli/commands/_dot.py +++ b/gaia/cli/commands/_dot.py @@ -463,6 +463,38 @@ def _emit_dot_cluster( out.append("") +def _effect_color(effect: float | None) -> str | None: + """Map a signed strategy effect in [-1, 1] to a red-grey-green hex color. + + Mirrors the ``beliefColor`` ramp in ``viz/src/starmap.ts`` (red = 0, + grey = 0.5, green = 1) via ``t = (effect + 1) / 2``, so the static SVG + and the interactive HTML read the same way: red = lowering, grey = + neutral, green = support. Returns None when *effect* is None (unscored + strategy form — support/deduction/... carry no CPT), so the caller keeps + the theme's plain role color instead of a fabricated neutral one. + """ + if effect is None: + return None + t = max(0.0, min(1.0, (effect + 1.0) / 2.0)) + if t < 0.5: + u = t * 2 + r, g, b = 231 + (136 - 231) * u, 76 + (136 - 76) * u, 60 + (136 - 60) * u + else: + u = (t - 0.5) * 2 + r, g, b = 136 + (46 - 136) * u, 136 + (204 - 136) * u, 136 + (113 - 136) * u + return f"#{round(r):02x}{round(g):02x}{round(b):02x}" + + +def _effect_penwidth(effect: float, base: float) -> float: + """Scale a base edge penwidth by the magnitude of a signed *effect*. + + Weak effects (``|effect|`` near 0) stay close to *base*; substantial ones + (``|effect|`` near 1) get up to ~2.3x *base*, so "Substantial lowering" + and "Weak lowering" read as different line weights, not just colors. + """ + return round(base + abs(effect) * base * 1.1, 2) + + def _emit_dot_edges( out: list[str], *, @@ -471,7 +503,16 @@ def _emit_dot_edges( contra_op_ids: set[str], theme: _Theme, ) -> None: - """Append DOT edges with role-specific styling.""" + """Append DOT edges with role-specific styling. + + Conclusion-role edges additionally pick up a sign color/penwidth from + their own ``effect`` field (populated by + :func:`gaia.cli.commands._graph_json._strategy_effect`) — see + :func:`_effect_color` — so a reader can see support (green) vs lowering + (red) directly on the figure, without opening the side panel. Edges + without a computed effect (unscored strategy forms) keep the theme's + plain role styling. + """ known_ids = {n["id"] for n in nodes} out.append(" // edges") for edge in edges: @@ -486,6 +527,16 @@ def _emit_dot_edges( else: role = edge.get("role") attrs = getattr(theme.edge, role, theme.edge.default) if role else theme.edge.default + if role == "conclusion": + effect = cast("float | None", edge.get("effect")) + color = _effect_color(effect) + if color is not None: + # Effect-scored edges replace the theme's role styling + # entirely (not append) — the theme strings already carry + # penwidth/color and duplicated attributes would rely on + # Graphviz's silent last-wins semantics. + penwidth = _effect_penwidth(cast(float, effect), base=1.2) + attrs = f'penwidth={penwidth}, color="{color}"' out.append(f" {_quote_id(src)} -> {_quote_id(tgt)} [{attrs}];") diff --git a/gaia/cli/commands/_graph_json.py b/gaia/cli/commands/_graph_json.py index dec964610..c0b011416 100644 --- a/gaia/cli/commands/_graph_json.py +++ b/gaia/cli/commands/_graph_json.py @@ -11,7 +11,7 @@ from collections import Counter from collections.abc import Iterable, Iterator from pathlib import Path -from typing import Any +from typing import Any, cast from gaia.engine.ir.coarsen import HELPER_LABEL_PREFIXES @@ -151,6 +151,67 @@ def _graph_context( return beliefs, priors, exported, kid_module, modules, cross_module_edges +def _strategy_reason(strategy: dict[str, Any]) -> str: + """Return the local-layer reasoning prose for *strategy*. + + A DSL author's ``reason=`` argument compiles to one of two places + depending on its shape (``ReasonInput``, see + ``gaia.engine.lang.compiler.compile``): + + - list-of-steps form -> ``steps[i].reasoning`` (``gaia.engine.ir.strategy + .Step``), joined here the same way :func:`gaia.cli.commands._inquiry + ._strategy_rationale` does, since a strategy can record more than one + step and truncating to ``steps[0]`` would silently drop the rest; + - plain-string form -> merged into ``metadata["reason"]`` by + ``_metadata_with_reason`` instead of becoming a step. + + ``steps`` is local-scope only (``None`` at global scope, which has + nothing to show). The top-level ``reason`` key never exists on compiled + IR — no such field is defined on ``Strategy`` — but is kept as a last + fallback for hand-built dict fixtures that set it directly, mirroring + :func:`gaia.cli.commands._obsidian`'s ``(metadata.reason) or (reason)`` + chain. + """ + parts: list[str] = [] + for step in strategy.get("steps") or []: + if not isinstance(step, dict): + continue + reasoning = step.get("reasoning") + if isinstance(reasoning, str) and reasoning.strip(): + parts.append(reasoning.strip()) + if parts: + return "\n\n".join(parts) + metadata = strategy.get("metadata") or {} + if metadata.get("reason"): + return cast(str, metadata["reason"]) + return cast(str, strategy.get("reason", "")) + + +def _strategy_effect(strategy: dict[str, Any]) -> float | None: + """Return the signed support/lowering effect of *strategy* on its conclusion. + + ``infer``/``noisy_and`` strategies carry an inline + ``conditional_probabilities`` CPT. For the common single-premise case + (and for the ``infer(evidence, hypothesis=..., p_e_given_h=..., + p_e_given_not_h=...)`` DSL sugar with extra ``given`` context, which only + ever fills the last two CPT slots — see + ``gaia.engine.lang.compiler.compile._infer_conditional_probabilities``), + the last two entries are exactly + ``[P(conclusion | ..., premise=False), P(conclusion | ..., premise=True)]``. + Their difference is positive when the premise raises belief in the + conclusion and negative when it lowers it, in [-1, 1]. + + Other strategy forms (``support``/``deduction``/... FormalStrategy + skeletons, ``associate``) carry no strategy-level CPT and are left + unscored (``None``) rather than guessed — they have no data that + supports the same raise/lower reading. + """ + cp = strategy.get("conditional_probabilities") + if not cp or len(cp) < 2: + return None + return cast(float, cp[-1] - cp[-2]) + + def _iter_strategy_nodes( ir: dict[str, Any], kid_module: dict[str, str] ) -> Iterator[dict[str, Any]]: @@ -164,7 +225,9 @@ def _iter_strategy_nodes( "type": "strategy", "strategy_type": strategy.get("type", ""), "module": kid_module.get(conc, ""), - "reason": strategy.get("reason", ""), + "reason": _strategy_reason(strategy), + "conditional_probabilities": strategy.get("conditional_probabilities"), + "effect": _strategy_effect(strategy), } @@ -206,7 +269,12 @@ def _iter_edges(ir: dict[str, Any]) -> Iterator[dict[str, Any]]: yield {"source": premise, "target": strat_id, "role": "premise"} for background in strategy.get("background", []): yield {"source": background, "target": strat_id, "role": "background"} - yield {"source": strat_id, "target": conc, "role": "conclusion"} + yield { + "source": strat_id, + "target": conc, + "role": "conclusion", + "effect": _strategy_effect(strategy), + } for i, operator in enumerate(ir.get("operators", [])): conc = operator.get("conclusion") diff --git a/gaia/cli/commands/_stellaris_svg.py b/gaia/cli/commands/_stellaris_svg.py index 3e223e0e6..ca8c6a09f 100644 --- a/gaia/cli/commands/_stellaris_svg.py +++ b/gaia/cli/commands/_stellaris_svg.py @@ -182,7 +182,114 @@ def _hex_points(cx: float, cy: float, r: float) -> str: return " ".join(pts) -def _build_legend_svg(include_frontier: bool = False) -> str: +_STRATEGY_GATE_TYPES = frozenset({"deduction", "support"}) + +# (kind, fill, line, label, gate) — kind drives the icon shape; label +# includes leading symbol for ellipse / diamond / hex-* / root rows; gate is +# the (strategy_type | operator_type value) this row documents, or None for +# rows that aren't tied to a specific type (always shown). +_LEGEND_ROW_SPECS: tuple[tuple[str, str, str, str, str | None], ...] = ( + ( + "box-premise", + _LEGEND_PREMISE_FILL, + _LEGEND_PREMISE_LINE, + "premise · no upstream strategy/operator", + None, + ), + ( + "box-derived", + _LEGEND_DERIVED_FILL, + _LEGEND_DERIVED_LINE, + "derived · ≥1 upstream strategy/operator", + None, + ), + ("box-root", _LEGEND_ROOT_FILL, _LEGEND_ROOT_LINE, "★ root claim · belief-prop seed", None), + ("ellipse", _LEGEND_STRAT_FILL, _LEGEND_STRAT_LINE, "∴ deduction", "deduction"), + ( + "diamond", + _LEGEND_SUPPORT_FILL, + _LEGEND_SUPPORT_LINE, + "⊕ support (independent evidence)", + "support", + ), + ("hex-contra", _LEGEND_CONTRA_FILL, _LEGEND_CONTRA_LINE, "⊗ contradiction", "contradiction"), + ("hex-neutral", _LEGEND_NEUTRAL_FILL, _LEGEND_NEUTRAL_LINE, "⊙ equivalence", "equivalence"), + ("hex-neutral", _LEGEND_NEUTRAL_FILL, _LEGEND_NEUTRAL_LINE, "⊃ implication", "implication"), + ("hex-neutral", _LEGEND_NEUTRAL_FILL, _LEGEND_NEUTRAL_LINE, "¬ complement", "complement"), + ("hex-neutral", _LEGEND_NEUTRAL_FILL, _LEGEND_NEUTRAL_LINE, "∨ disjunction", "disjunction"), + ("hex-neutral", _LEGEND_NEUTRAL_FILL, _LEGEND_NEUTRAL_LINE, "∧ conjunction", "conjunction"), +) + + +def _legend_row_present( + gate: str | None, + *, + strategy_types: frozenset[str] | None, + operator_types: frozenset[str] | None, +) -> bool: + """Return whether a legend row should render, given its type ``gate``. + + ``gate is None`` rows (the knowledge-box rows) always render. A + strategy/operator-gated row renders when the caller didn't supply a + type set at all (``None`` = "show everything", for callers without + graph data) or when its type is actually present in the graph. + """ + if gate is None: + return True + types = strategy_types if gate in _STRATEGY_GATE_TYPES else operator_types + return types is None or gate in types + + +# Support/lowering edge colors at the ramp endpoints — same swatch colors the +# interactive HTML legend uses, so both decoding surfaces read identically. +_LEGEND_EFFECT_SUPPORT = "#2ecc71" +_LEGEND_EFFECT_LOWER = "#e74c3c" + + +def _legend_rows( + *, + include_frontier: bool, + include_effect_edges: bool, + strategy_types: frozenset[str] | None, + operator_types: frozenset[str] | None, +) -> list[tuple[str, str, str, str, str | None]]: + """Return the legend rows to render, filtered to glyphs the graph uses. + + See :func:`_build_legend_svg` for what ``strategy_types``/ + ``operator_types``/``include_frontier``/``include_effect_edges`` mean. + """ + rows = [ + row + for row in _LEGEND_ROW_SPECS + if _legend_row_present(row[4], strategy_types=strategy_types, operator_types=operator_types) + ] + if include_effect_edges: + rows.append( + ( + "line", + _LEGEND_EFFECT_SUPPORT, + _LEGEND_EFFECT_SUPPORT, + "edge: supports conclusion", + None, + ) + ) + rows.append( + ("line", _LEGEND_EFFECT_LOWER, _LEGEND_EFFECT_LOWER, "edge: lowers conclusion", None) + ) + if include_frontier: + rows.append( + ("box-fog", _LEGEND_FOG_FILL, _LEGEND_FOG_LINE, "frontier · unexplored (fog)", None) + ) + return rows + + +def _build_legend_svg( + *, + include_frontier: bool = False, + include_effect_edges: bool = False, + strategy_types: frozenset[str] | None = None, + operator_types: frozenset[str] | None = None, +) -> str: """Build a self-contained legend ```` block, pinned top-left. Inserted as a sibling of Graphviz's main render group right before @@ -194,39 +301,35 @@ def _build_legend_svg(include_frontier: bool = False) -> str: diamond with gold-glow) + 6 operators (contradiction with red glow, plus the 5 neutral hex types differentiated by unicode symbol). + ``strategy_types`` / ``operator_types`` are the ``strategy_type`` / + ``operator_type`` values actually present in the graph being rendered + (from graph.json). When given (not None), rows for a glyph whose type + isn't present are dropped — e.g. a graph built entirely from ``infer`` + strategies and zero operators would otherwise ship a legend listing + deduction/support/contradiction/equivalence/implication/complement/ + disjunction/conjunction, none of which the figure actually uses. ``None`` + (the default) keeps every row, for callers that don't have graph data at + hand. + + ``include_effect_edges`` adds the two support/lowering edge-color rows. + The dot emitter colors effect-scored conclusion edges green/red in every + theme, and the standalone SVG has no hover panel — the legend is its only + decoding surface, so the rows appear whenever the caller knows the graph + carries scored edges (the ``gaia inspect starmap`` path passes this from + the actual edge data; default False keeps legacy callers unchanged). + When ``include_frontier`` is True, one extra row documenting the dashed "fog" box is appended — the explorer render overlays open-frontier (unpulled) papers as dashed question-boxes, and only that render draws them. The shared ``gaia inspect starmap`` path has no fog nodes, so it leaves this False and the row is omitted. """ - # (kind, fill, line, label) — kind drives the icon shape; label includes - # leading symbol for ellipse / diamond / hex-* / root rows. - rows: list[tuple[str, str, str, str]] = [ - ( - "box-premise", - _LEGEND_PREMISE_FILL, - _LEGEND_PREMISE_LINE, - "premise · no upstream strategy/operator", - ), - ( - "box-derived", - _LEGEND_DERIVED_FILL, - _LEGEND_DERIVED_LINE, - "derived · ≥1 upstream strategy/operator", - ), - ("box-root", _LEGEND_ROOT_FILL, _LEGEND_ROOT_LINE, "★ root claim · belief-prop seed"), - ("ellipse", _LEGEND_STRAT_FILL, _LEGEND_STRAT_LINE, "∴ deduction"), - ("diamond", _LEGEND_SUPPORT_FILL, _LEGEND_SUPPORT_LINE, "⊕ support (independent evidence)"), - ("hex-contra", _LEGEND_CONTRA_FILL, _LEGEND_CONTRA_LINE, "⊗ contradiction"), - ("hex-neutral", _LEGEND_NEUTRAL_FILL, _LEGEND_NEUTRAL_LINE, "⊙ equivalence"), - ("hex-neutral", _LEGEND_NEUTRAL_FILL, _LEGEND_NEUTRAL_LINE, "⊃ implication"), - ("hex-neutral", _LEGEND_NEUTRAL_FILL, _LEGEND_NEUTRAL_LINE, "¬ complement"), - ("hex-neutral", _LEGEND_NEUTRAL_FILL, _LEGEND_NEUTRAL_LINE, "∨ disjunction"), - ("hex-neutral", _LEGEND_NEUTRAL_FILL, _LEGEND_NEUTRAL_LINE, "∧ conjunction"), - ] - if include_frontier: - rows.append(("box-fog", _LEGEND_FOG_FILL, _LEGEND_FOG_LINE, "frontier · unexplored (fog)")) + rows = _legend_rows( + include_frontier=include_frontier, + include_effect_edges=include_effect_edges, + strategy_types=strategy_types, + operator_types=operator_types, + ) pad_x, pad_y = 16, 14 row_h = 26 icon_w = 32 @@ -248,7 +351,7 @@ def _build_legend_svg(include_frontier: bool = False) -> str: ) y = pad_y + 14 + 14 - for kind, fill, line, label in rows: + for kind, fill, line, label, _gate in rows: cx = pad_x + icon_w / 2 cy = y + row_h / 2 if kind in ("box-premise", "box-derived"): @@ -261,6 +364,10 @@ def _build_legend_svg(include_frontier: bool = False) -> str: f'' ) + elif kind == "line": + parts.append( + f'' + ) elif kind == "box-root": parts.append( f' str: return "\n" + "".join(parts) + "\n" -def inject_legend(svg_text: str, *, include_frontier: bool = False) -> str: +def inject_legend( + svg_text: str, + *, + include_frontier: bool = False, + include_effect_edges: bool = False, + strategy_types: frozenset[str] | None = None, + operator_types: frozenset[str] | None = None, +) -> str: """Inject the stellaris legend ```` before the closing ````. When ``include_frontier`` is True the legend gains a dashed "fog" row for the explorer's open-frontier overlay; default False keeps the plain starmap - legend unchanged. + legend unchanged. ``include_effect_edges`` adds the support/lowering + edge-color rows for graphs whose conclusion edges carry a computed effect. + ``strategy_types``/``operator_types`` are forwarded to + :func:`_build_legend_svg` to drop glyph rows the graph doesn't use. Idempotent: if a ```` is already present, returns the input unchanged. """ if 'id="legend"' in svg_text: return svg_text - legend = _build_legend_svg(include_frontier=include_frontier) + legend = _build_legend_svg( + include_frontier=include_frontier, + include_effect_edges=include_effect_edges, + strategy_types=strategy_types, + operator_types=operator_types, + ) return re.sub(r"()", legend + r"\1", svg_text, count=1) @@ -422,7 +544,13 @@ def _rewrite(m: re.Match[str]) -> str: def post_process_stellaris_svg( - svg_text: str, dot_source: str | None = None, *, include_frontier: bool = False + svg_text: str, + dot_source: str | None = None, + *, + include_frontier: bool = False, + include_effect_edges: bool = False, + strategy_types: frozenset[str] | None = None, + operator_types: frozenset[str] | None = None, ) -> str: """Apply all stellaris SVG transforms: defs + bg recolour + legend. @@ -435,9 +563,19 @@ def post_process_stellaris_svg( ``include_frontier`` (keyword-only, default False) adds the dashed "fog" legend row for the explorer render's open-frontier overlay. The shared ``gaia inspect starmap`` caller leaves it False so its legend is unchanged. + + ``include_effect_edges``/``strategy_types``/``operator_types`` are + forwarded to :func:`inject_legend` so the legend documents the + support/lowering edge colors when the graph carries scored edges and only + lists glyphs the graph actually uses (defaults keep legacy callers + unchanged). """ processed = inject_legend( - recolor_background(inject_defs(svg_text)), include_frontier=include_frontier + recolor_background(inject_defs(svg_text)), + include_frontier=include_frontier, + include_effect_edges=include_effect_edges, + strategy_types=strategy_types, + operator_types=operator_types, ) if dot_source is not None: processed = ensure_contradiction_classes( diff --git a/gaia/cli/commands/author/equal.py b/gaia/cli/commands/author/equal.py index 0866801be..52027bda1 100644 --- a/gaia/cli/commands/author/equal.py +++ b/gaia/cli/commands/author/equal.py @@ -6,10 +6,11 @@ equal(a, b, *, background=None, rationale="", label=None) -The CLI requires ``--label`` so the helper Claim returned by ``equal`` -is referenceable in subsequent author commands (the underlying DSL -function makes ``label`` optional, but the agent-facing CLI promotes it -to required for binding hygiene). +``--dsl-binding-name`` supplies the Python LHS so the helper Claim +returned by ``equal`` is referenceable in subsequent author commands; +``--label`` is the separate, optional engine ``label=`` kwarg passed +through into the rendered call. Both are optional — omit both to emit a +bare expression statement. """ from __future__ import annotations diff --git a/gaia/cli/commands/starmap.py b/gaia/cli/commands/starmap.py index cabcf9e03..579c49cc3 100644 --- a/gaia/cli/commands/starmap.py +++ b/gaia/cli/commands/starmap.py @@ -63,11 +63,48 @@ def _render_html(template: str, graph_json: str) -> str: return template.replace(GRAPH_DATA_PLACEHOLDER, injection, 1) -def _render_svg(dot_source: str, *, theme: str) -> str: +def _type_sets_from_nodes(nodes: list[dict[str, Any]]) -> tuple[frozenset[str], frozenset[str]]: + """Return the distinct (strategy_type, operator_type) values present in *nodes*. + + Feeds the stellaris legend so it only lists glyphs the graph actually + uses instead of every strategy/operator type the theme knows how to + draw (see :func:`gaia.cli.commands._stellaris_svg._build_legend_svg`). + """ + strategy_types = { + n["strategy_type"] for n in nodes if n.get("type") == "strategy" and n.get("strategy_type") + } + operator_types = { + n["operator_type"] for n in nodes if n.get("type") == "operator" and n.get("operator_type") + } + return frozenset(strategy_types), frozenset(operator_types) + + +def _has_effect_edges(edges: list[dict[str, Any]]) -> bool: + """True when any conclusion edge carries a computed support/lowering effect. + + Decides whether the stellaris legend documents the green/red edge colors: + the dot emitter colors effect-scored conclusion edges in every theme, and + the standalone SVG's legend is its only decoding surface. + """ + return any( + e.get("role") == "conclusion" and isinstance(e.get("effect"), (int, float)) for e in edges + ) + + +def _render_svg( + dot_source: str, + *, + theme: str, + strategy_types: frozenset[str] = frozenset(), + operator_types: frozenset[str] = frozenset(), + include_effect_edges: bool = False, +) -> 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 + the ```` glow filter block, recolour the canvas background, and + build a legend scoped to *strategy_types*/*operator_types* (plus the + support/lowering edge rows when *include_effect_edges*) — see :mod:`gaia.cli.commands._stellaris_svg`. Raises: @@ -101,7 +138,13 @@ def _render_svg(dot_source: str, *, theme: str) -> str: ) svg = proc.stdout if theme in ("stellaris", "dark"): - svg = post_process_stellaris_svg(svg, dot_source=dot_source) + svg = post_process_stellaris_svg( + svg, + dot_source=dot_source, + include_effect_edges=include_effect_edges, + strategy_types=strategy_types, + operator_types=operator_types, + ) return svg @@ -185,7 +228,9 @@ def _load_starmap_beliefs(loaded: Any, compiled: Any) -> dict[str, Any] | None: return beliefs_data -def _render_starmap_content(graph_json: str, *, fmt: str, theme: str) -> str: +def _render_starmap_content( + graph_json: str, graph_payload: dict[str, Any], *, fmt: str, theme: str +) -> str: """Render graph JSON into the requested starmap output format.""" if fmt == "html": try: @@ -195,8 +240,15 @@ def _render_starmap_content(graph_json: str, *, fmt: str, theme: str) -> str: raise typer.Exit(1) from exc if fmt == "svg": dot_source = to_dot(graph_json, theme=theme) + strategy_types, operator_types = _type_sets_from_nodes(graph_payload.get("nodes", [])) try: - return _render_svg(dot_source, theme=theme) + return _render_svg( + dot_source, + theme=theme, + strategy_types=strategy_types, + operator_types=operator_types, + include_effect_edges=_has_effect_edges(graph_payload.get("edges", [])), + ) except GaiaPackagingError as exc: typer.echo(str(exc), err=True) raise typer.Exit(1) from exc @@ -294,7 +346,7 @@ def starmap_command( exported_ids=exported_ids, ) graph_payload = json.loads(graph_json) - content = _render_starmap_content(graph_json, fmt=fmt, theme=theme) + content = _render_starmap_content(graph_json, graph_payload, fmt=fmt, theme=theme) out_path = Path(out) if out is not None else Path(_DEFAULT_OUT[fmt]) if not out_path.is_absolute(): diff --git a/gaia/cli/starmap_assets/template.html b/gaia/cli/starmap_assets/template.html index 03bd4fb62..4b33bdcf4 100644 --- a/gaia/cli/starmap_assets/template.html +++ b/gaia/cli/starmap_assets/template.html @@ -5,11 +5,11 @@ Gaia Starmap - - + `,r.appendChild(s);const u=s.querySelector(".filter-toggle"),h=s.querySelector(".filter-body"),l=s.querySelector(".caret");u.addEventListener("click",()=>{h.classList.toggle("collapsed"),l.textContent=h.classList.contains("collapsed")?"▸":"▾"});function d(){n.forEachNode((c,v)=>{const E=v.kind||"unknown",T=a.get(E)!==!1;n.setNodeAttribute(c,"hidden",!T)}),n.forEachEdge((c,v,E,T,A,D)=>{const m=A.hidden===!0,y=D.hidden===!0;n.setEdgeAttribute(c,"hidden",m||y)}),e.refresh()}return h.querySelectorAll('input[type="checkbox"]').forEach(c=>{c.addEventListener("change",()=>{a.set(c.dataset.type,c.checked),d()})}),{refresh:d}}function ks(r,n,e){const t=r.clientWidth||180,i=r.clientHeight||130,o=document.createElement("canvas");o.width=t*window.devicePixelRatio,o.height=i*window.devicePixelRatio,o.style.width=`${t}px`,o.style.height=`${i}px`,r.appendChild(o);const a=document.createElement("div");a.className="viewport",r.appendChild(a);const s=o.getContext("2d");s.scale(window.devicePixelRatio,window.devicePixelRatio);let u={minX:-100,maxX:100,minY:-100,maxY:100};function h(){let D=1/0,m=-1/0,y=1/0,_=-1/0;if(n.forEachNode((w,k)=>{const g=k.x,z=k.y;gm&&(m=g),z_&&(_=z)}),!Number.isFinite(D))return;const G=(m-D)*.05||10,F=(_-y)*.05||10;u={minX:D-G,maxX:m+G,minY:y-F,maxY:_+F}}function l(D,m){const y=(D-u.minX)/(u.maxX-u.minX||1),_=(m-u.minY)/(u.maxY-u.minY||1);return[y*t,_*i]}function d(){s.clearRect(0,0,t,i),s.fillStyle="rgba(255,255,255,0.05)",s.fillRect(0,0,t,i),n.forEachNode((D,m)=>{if(m.hidden)return;const[y,_]=l(m.x,m.y);s.fillStyle=m.color||"#888",s.fillRect(y-1,_-1,2,2)})}function c(){const D=e.getContainer().getBoundingClientRect(),m=e.viewportToGraph({x:0,y:0}),y=e.viewportToGraph({x:D.width,y:D.height}),[_,G]=l(m.x,m.y),[F,w]=l(y.x,y.y),k=Math.max(0,Math.min(_,F)),g=Math.max(0,Math.min(G,w)),z=Math.min(t,Math.abs(F-_)),j=Math.min(i,Math.abs(w-G));a.style.left=`${k}px`,a.style.top=`${g}px`,a.style.width=`${z}px`,a.style.height=`${j}px`}function v(){h(),d(),c()}o.addEventListener("click",D=>{const m=o.getBoundingClientRect(),y=(D.clientX-m.left)/m.width,_=(D.clientY-m.top)/m.height,G=u.minX+y*(u.maxX-u.minX),F=u.minY+_*(u.maxY-u.minY),w=e.getCamera(),k=e.getContainer().getBoundingClientRect(),g=e.graphToViewport({x:G,y:F}),z=k.width/2,j=k.height/2,B=w.getState(),re=(g.x-z)/k.width,$=(g.y-j)/k.height;w.animate({x:B.x+re,y:B.y+$},{duration:250})});const E=e.getCamera(),T=()=>c();E.on("updated",T);const A=window.setInterval(v,800);return v(),{destroy(){window.clearInterval(A),E.off("updated",T),r.innerHTML=""}}}async function Cs(){if(window.GRAPH_DATA&&Array.isArray(window.GRAPH_DATA.nodes))return window.GRAPH_DATA;try{const r=await fetch("/sample-graph.json");if(!r.ok)throw new Error(`status ${r.status}`);return await r.json()}catch(r){return console.error("[starmap] failed to load sample-graph.json",r),{modules:[],cross_module_edges:[],nodes:[],edges:[]}}}function Ds(r){const n=r.nodes.some(Fe),e=r.nodes.some(Pe),t=r.edges.some(o=>typeof o.effect=="number"),i={strategy:n,operator:e,effect:t};document.querySelectorAll("#legend [data-legend-for]").forEach(o=>{const a=o.dataset.legendFor;o.classList.toggle("hidden",i[a]===!1)})}function Ls(r){const n=document.getElementById("root-banner"),e=r.nodes.filter(t=>!Fe(t)&&!Pe(t)&&t.exported===!0);if(!e.length){n.classList.add("hidden"),n.innerHTML="";return}n.classList.remove("hidden"),n.innerHTML=e.map(t=>{const i=ke(t.title||t.label||t.id),o=t.belief==null?"unknown":t.belief.toFixed(3);return`${i}${o}`}).join("")}function nt(r){const n=document.getElementById("status");n&&(r==null?n.classList.add("hidden"):(n.classList.remove("hidden"),n.textContent=r))}async function Gs(){nt("loading…");const r=await Cs();if(!r.nodes.length){nt("no graph data");return}nt(`building graph (${r.nodes.length} nodes, ${r.edges.length} edges)…`),Ds(r),Ls(r);const n=ys(r),e=document.getElementById("sigma-container"),t=ws(e,n);As(document.getElementById("search-host"),n,t),Rs(document.getElementById("filter-host"),n,t);const i=Ss(document.getElementById("panel-host"),n);ks(document.getElementById("minimap-host"),n,t),t.on("clickNode",({node:s})=>{i.show(s)}),t.on("clickStage",()=>i.hide()),document.getElementById("root-banner").addEventListener("click",s=>{const u=s.target.closest("[data-node-id]");u?.dataset.nodeId&&i.show(u.dataset.nodeId)});const o=t.getCamera();o.on("updated",()=>{const s=o.getState().ratio,u=Math.max(2,Math.min(10,6*s));t.setSetting("labelRenderedSizeThreshold",u)}),nt("running layout…");const a=bs(n,1e3);a.start(),window.setTimeout(()=>nt(null),1200),window.addEventListener("beforeunload",()=>a.stop())}Gs().catch(r=>{console.error(r),nt(`error: ${r.message}`)}); +
gaia · starmap
+
@@ -355,8 +356,10 @@

node

belief 0
belief 0.5 / unknown
belief 1
-
strategy
-
operator
+
strategy
+
operator
+
edge: supports conclusion
+
edge: lowers conclusion
loading…
diff --git a/tests/cli/test_graph_json.py b/tests/cli/test_graph_json.py index 02d32cc80..d9a9d0d85 100644 --- a/tests/cli/test_graph_json.py +++ b/tests/cli/test_graph_json.py @@ -319,3 +319,127 @@ def test_no_beliefs_or_params(): assert data["nodes"][0]["belief"] is None assert data["nodes"][0]["prior"] is None assert data["nodes"][0]["exported"] is False + + +def _two_claim_ir(strategy: dict) -> dict: + """IR with claims a -> (strategy) -> b, for strategy-field tests.""" + return _make_ir( + knowledges=[ + { + "id": "github:test_pkg::a", + "label": "a", + "type": "claim", + "content": "A.", + "module": "m1", + }, + { + "id": "github:test_pkg::b", + "label": "b", + "type": "claim", + "content": "B.", + "module": "m1", + }, + ], + strategies=[ + { + "type": "infer", + "premises": ["github:test_pkg::a"], + "conclusion": "github:test_pkg::b", + **strategy, + }, + ], + module_order=["m1"], + ) + + +def _only_strategy_node(data: dict) -> dict: + (node,) = [n for n in data["nodes"] if n["type"] == "strategy"] + return node + + +def test_strategy_reason_joined_from_steps(): + """Compiled IR stores the author's reasoning at steps[i].reasoning.""" + ir = _two_claim_ir({"steps": [{"reasoning": "First step."}, {"reasoning": "Second step."}]}) + node = _only_strategy_node(json.loads(generate_graph_json(ir))) + assert node["reason"] == "First step.\n\nSecond step." + + +def test_strategy_reason_from_metadata_reason(): + """Plain-string DSL reason= compiles into metadata['reason'].""" + ir = _two_claim_ir({"metadata": {"reason": "Plain-string reason."}}) + node = _only_strategy_node(json.loads(generate_graph_json(ir))) + assert node["reason"] == "Plain-string reason." + + +def test_strategy_reason_steps_win_over_metadata(): + ir = _two_claim_ir( + { + "steps": [{"reasoning": "Step text."}], + "metadata": {"reason": "Metadata text."}, + } + ) + node = _only_strategy_node(json.loads(generate_graph_json(ir))) + assert node["reason"] == "Step text." + + +def test_strategy_effect_from_conditional_probabilities(): + """Single-premise infer CPT [p(c|not p), p(c|p)] yields a signed effect.""" + ir = _two_claim_ir({"conditional_probabilities": [0.9, 0.09]}) + data = json.loads(generate_graph_json(ir)) + node = _only_strategy_node(data) + assert node["conditional_probabilities"] == [0.9, 0.09] + assert node["effect"] == pytest.approx(-0.81) + (conclusion_edge,) = [e for e in data["edges"] if e["role"] == "conclusion"] + assert conclusion_edge["effect"] == pytest.approx(-0.81) + + +def test_strategy_effect_none_without_cpt(): + """Strategy forms without an inline CPT stay unscored, not guessed.""" + ir = _two_claim_ir({"steps": [{"reasoning": "Some reasoning."}]}) + data = json.loads(generate_graph_json(ir)) + node = _only_strategy_node(data) + assert node["effect"] is None + assert node["conditional_probabilities"] is None + (conclusion_edge,) = [e for e in data["edges"] if e["role"] == "conclusion"] + assert conclusion_edge["effect"] is None + + +def test_strategy_effect_multi_given_gated_cpt(): + """The gated multi-premise CPT keeps the effect contrast in its last two slots. + + ``infer(evidence, hypothesis=..., given=[...])`` compiles to a 2^(k+1)-entry + CPT whose only non-MaxEnt slots are ``gate_mask`` (= index -2, all givens + true / premise false) and ``gate_mask|1`` (= index -1, all true) — see + ``_infer_conditional_probabilities``. Locks that ``cp[-1] - cp[-2]`` stays + the p(e|h) - p(e|not h) contrast at every premise count. + """ + ir = _make_ir( + knowledges=[ + { + "id": f"github:test_pkg::{name}", + "label": name, + "type": "claim", + "content": f"{name}.", + "module": "m1", + } + # Neutral names on purpose: the compiler orders premises as + # [hypothesis, *givens] (bit 0 = hypothesis), and the effect + # arithmetic below is order-insensitive either way. + for name in ("premise_a", "premise_b", "conclusion_c") + ], + strategies=[ + { + "type": "infer", + "premises": ["github:test_pkg::premise_a", "github:test_pkg::premise_b"], + "conclusion": "github:test_pkg::conclusion_c", + # given_count=1 layout: [0.5, 0.5, p(e|not h), p(e|h)] + "conditional_probabilities": [0.5, 0.5, 0.25, 0.75], + }, + ], + module_order=["m1"], + ) + data = json.loads(generate_graph_json(ir)) + node = _only_strategy_node(data) + assert node["effect"] == pytest.approx(0.5) + (conclusion_edge,) = [e for e in data["edges"] if e["role"] == "conclusion"] + assert conclusion_edge["effect"] == pytest.approx(0.5) diff --git a/tests/cli/test_starmap.py b/tests/cli/test_starmap.py index 3d5b531c0..391e6c8ef 100644 --- a/tests/cli/test_starmap.py +++ b/tests/cli/test_starmap.py @@ -648,6 +648,85 @@ def test_to_dot_contradiction_incident_edges_recolored(): assert "dir=none" in line +def _make_effect_fixture() -> str: + """Graph with three infer strategies: lowering, supporting, and unscored.""" + + def _claim(name: str) -> dict: + return {"id": f"p:m::{name}", "type": "claim", "label": name, "module": "m"} + + def _strategy(sid: str, effect: float | None) -> dict: + node: dict = {"id": sid, "type": "strategy", "strategy_type": "infer", "module": "m"} + node["effect"] = effect + return node + + return json.dumps( + { + "nodes": [ + _claim("a"), + _claim("lowered"), + _claim("raised"), + _claim("plain"), + _strategy("strat_low", -0.81), + _strategy("strat_up", 0.81), + _strategy("strat_plain", None), + ], + "edges": [ + {"source": "p:m::a", "target": "strat_low", "role": "premise"}, + { + "source": "strat_low", + "target": "p:m::lowered", + "role": "conclusion", + "effect": -0.81, + }, + {"source": "p:m::a", "target": "strat_up", "role": "premise"}, + { + "source": "strat_up", + "target": "p:m::raised", + "role": "conclusion", + "effect": 0.81, + }, + {"source": "p:m::a", "target": "strat_plain", "role": "premise"}, + { + "source": "strat_plain", + "target": "p:m::plain", + "role": "conclusion", + "effect": None, + }, + ], + } + ) + + +def test_to_dot_conclusion_edges_colored_by_effect(): + """Effect-scored conclusion edges render red (lowering) / green (support).""" + dot = to_dot(_make_effect_fixture()) + lowered = _edge_line(dot, "strat_low", "p:m::lowered") + assert 'color="#d5574a"' in lowered + assert "penwidth=2.27" in lowered + raised = _edge_line(dot, "strat_up", "p:m::raised") + assert 'color="#3fbf75"' in raised + assert "penwidth=2.27" in raised + # Effect styling replaces the theme role styling — no duplicate attrs. + assert lowered.count("penwidth") == 1 + assert lowered.count("color") == 1 + + +def test_to_dot_unscored_conclusion_edge_keeps_role_styling(): + """Edges without a computed effect keep the plain theme conclusion style.""" + dot = to_dot(_make_effect_fixture()) + plain = _edge_line(dot, "strat_plain", "p:m::plain") + assert "penwidth=1.2" in plain + assert "color" not in plain # light theme conclusion has no explicit color + + +def test_to_dot_premise_edges_unaffected_by_effect(): + """Effect only styles the strategy->conclusion edge, not premise edges.""" + dot = to_dot(_make_effect_fixture()) + premise = _edge_line(dot, "p:m::a", "strat_low") + assert "penwidth=1.0" in premise + assert "#d5574a" not in premise + + def test_starmap_cli_theme_flag(tmp_path): """`gaia inspect starmap --format dot --theme stellaris` produces dot with sfdp layout.""" pkg_dir = _prepare_inferred_package(tmp_path, name="starmap_theme") @@ -1122,6 +1201,77 @@ def test_inject_legend_includes_all_node_role_rows(): assert tname in out +def test_inject_legend_scopes_rows_to_present_types(): + """Type sets drop legend rows for glyphs the graph doesn't use.""" + from gaia.cli.commands._stellaris_svg import inject_legend + + out = inject_legend( + '', + strategy_types=frozenset({"deduction"}), + operator_types=frozenset({"contradiction"}), + ) + # Knowledge-box rows are unconditional. + assert "premise · no upstream strategy/operator" in out + assert "★ root claim · belief-prop seed" in out + # Present types keep their rows. + assert "∴ deduction" in out + assert "⊗ contradiction" in out + # Absent types are dropped. + assert "⊕ support" not in out + for absent in ("equivalence", "implication", "complement", "disjunction", "conjunction"): + assert absent not in out + + +def test_inject_legend_empty_type_sets_drop_all_glyph_rows(): + """A graph with zero operators/strategies gets no glyph rows at all.""" + from gaia.cli.commands._stellaris_svg import inject_legend + + out = inject_legend( + '', + strategy_types=frozenset(), + operator_types=frozenset(), + ) + assert "∴ deduction" not in out + assert "⊕ support" not in out + assert "contradiction" not in out + # Knowledge-box rows survive. + assert "derived · ≥1 upstream strategy/operator" in out + + +def test_inject_legend_effect_rows_when_graph_has_scored_edges(): + """include_effect_edges documents the green/red conclusion-edge colors.""" + from gaia.cli.commands._stellaris_svg import inject_legend + + out = inject_legend( + '', + include_effect_edges=True, + ) + assert "edge: supports conclusion" in out + assert "edge: lowers conclusion" in out + # Same swatch colors as the interactive HTML legend, so the two decoding + # surfaces stay consistent. + assert "#2ecc71" in out + assert "#e74c3c" in out + + +def test_inject_legend_effect_rows_off_by_default(): + """Legacy callers (no graph data) keep the legend unchanged.""" + from gaia.cli.commands._stellaris_svg import inject_legend + + out = inject_legend('') + assert "edge: supports conclusion" not in out + assert "edge: lowers conclusion" not in out + + +def test_has_effect_edges_detects_scored_conclusion_edges(): + from gaia.cli.commands.starmap import _has_effect_edges + + assert _has_effect_edges([{"role": "conclusion", "effect": -0.81}]) is True + assert _has_effect_edges([{"role": "conclusion", "effect": None}]) is False + assert _has_effect_edges([{"role": "premise"}]) is False + assert _has_effect_edges([]) is False + + def test_inject_legend_frontier_row_off_by_default(): """Without `include_frontier`, the legend has no fog row (starmap unchanged).""" from gaia.cli.commands._stellaris_svg import inject_legend diff --git a/viz/index.html b/viz/index.html index 6e436bbca..ca5067261 100644 --- a/viz/index.html +++ b/viz/index.html @@ -11,6 +11,7 @@
gaia · starmap
+
@@ -20,8 +21,10 @@
belief 0
belief 0.5 / unknown
belief 1
-
strategy
-
operator
+
strategy
+
operator
+
edge: supports conclusion
+
edge: lowers conclusion
loading…
diff --git a/viz/package-lock.json b/viz/package-lock.json index 84bec7f9f..5db2310c4 100644 --- a/viz/package-lock.json +++ b/viz/package-lock.json @@ -519,9 +519,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -536,9 +533,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -553,9 +547,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -570,9 +561,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -587,9 +575,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -604,9 +589,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -621,9 +603,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -638,9 +617,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -655,9 +631,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -672,9 +645,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -689,9 +659,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -706,9 +673,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -723,9 +687,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/viz/src/main.ts b/viz/src/main.ts index ab3219d50..ce2ab3052 100644 --- a/viz/src/main.ts +++ b/viz/src/main.ts @@ -1,7 +1,8 @@ import './style/style.css'; -import type { GraphData } from './types'; +import type { GraphData, KnowledgeNode } from './types'; +import { isStrategy, isOperator } from './types'; import { buildGraph, initSigma, runLayout } from './starmap'; -import { mountPanel } from './ui/panel'; +import { mountPanel, escapeHtml } from './ui/panel'; import { mountSearch } from './ui/search'; import { mountFilter } from './ui/filter'; import { mountMinimap } from './ui/minimap'; @@ -27,6 +28,61 @@ async function loadData(): Promise { } } +/** + * Hide legend rows for kinds/signals this graph doesn't actually have — + * e.g. the "operator" swatch is dead weight on a graph with zero operators + * (a common case: this portfolio's infer-based idea graphs never use + * operators at all). + */ +function updateLegend(data: GraphData) { + const hasStrategy = data.nodes.some(isStrategy); + const hasOperator = data.nodes.some(isOperator); + const hasEffect = data.edges.some((e) => typeof e.effect === 'number'); + const visibility: Record = { + strategy: hasStrategy, + operator: hasOperator, + effect: hasEffect, + }; + document.querySelectorAll('#legend [data-legend-for]').forEach((row) => { + const kind = row.dataset.legendFor!; + row.classList.toggle('hidden', visibility[kind] === false); + }); +} + +/** + * Populate the topbar's root-claim banner: one item per exported knowledge + * node (★, the belief-propagation root(s) — `_dot.py`'s gold "root" box + * uses the same `exported` flag), each showing the current posterior. A + * package can export more than one root claim, or none; the banner adapts + * to both. Clicking an item opens that node's side panel — the fuller + * detail (reason, effect, CPT) lives there rather than being crammed into + * the topbar. + */ +function renderRootBanner(data: GraphData): void { + const el = document.getElementById('root-banner')!; + const roots = data.nodes.filter( + (n) => !isStrategy(n) && !isOperator(n) && (n as KnowledgeNode).exported === true, + ) as KnowledgeNode[]; + + if (!roots.length) { + el.classList.add('hidden'); + el.innerHTML = ''; + return; + } + el.classList.remove('hidden'); + el.innerHTML = roots + .map((n) => { + const label = escapeHtml(n.title || n.label || n.id); + const belief = n.belief == null ? 'unknown' : n.belief.toFixed(3); + return ( + `` + + `${label}` + + `${belief}` + ); + }) + .join(''); +} + function setStatus(msg: string | null) { const el = document.getElementById('status'); if (!el) return; @@ -48,6 +104,8 @@ async function main() { } setStatus(`building graph (${data.nodes.length} nodes, ${data.edges.length} edges)…`); + updateLegend(data); + renderRootBanner(data); const graph = buildGraph(data); const container = document.getElementById('sigma-container')!; @@ -65,6 +123,11 @@ async function main() { }); // Click on empty space dismisses sigma.on('clickStage', () => panel.hide()); + // Root-claim banner items open the same panel as clicking the node. + document.getElementById('root-banner')!.addEventListener('click', (e) => { + const item = (e.target as HTMLElement).closest('[data-node-id]'); + if (item?.dataset.nodeId) panel.show(item.dataset.nodeId); + }); // Hover labels for low-importance nodes: // we leverage Sigma's built-in highlighted state — when the user hovers a diff --git a/viz/src/starmap.ts b/viz/src/starmap.ts index 4c1c6b003..a0594dfa6 100644 --- a/viz/src/starmap.ts +++ b/viz/src/starmap.ts @@ -2,8 +2,8 @@ import Graph from 'graphology'; import Sigma from 'sigma'; import forceAtlas2 from 'graphology-layout-forceatlas2'; import FA2Layout from 'graphology-layout-forceatlas2/worker'; -import type { GraphData, AnyNode, EdgeRole } from './types'; -import { isStrategy, isOperator } from './types'; +import type { GraphData, AnyNode, EdgeRole, GraphEdge } from './types'; +import { isStrategy, isOperator, isGeneratedHelper } from './types'; // -------------------------------------------------------------------------- // Color helpers @@ -27,9 +27,23 @@ export function beliefColor(belief: number): string { return hex(136 + (46 - 136) * t, 136 + (204 - 136) * t, 136 + (113 - 136) * t); } +/** + * Support/lowering color for a signed strategy effect in [-1, 1]: red = + * lowers the conclusion, grey = neutral, green = supports it. Reuses + * `beliefColor`'s red-grey-green ramp via `t = (effect + 1) / 2` so nodes + * (belief) and edges (effect) read as the same visual language. Mirrored in + * `gaia/cli/commands/_dot.py::_effect_color` for the static SVG — keep the + * two in sync. + */ +export function effectColor(effect: number): string { + return beliefColor((effect + 1) / 2); +} + const NEUTRAL = '#888888'; const STRATEGY_COLOR = '#5b8def'; const OPERATOR_COLOR = '#a266ff'; +/** Shrunk, dimmed size for generated/helper knowledge nodes (see isGeneratedHelper). */ +const GENERATED_HELPER_SIZE = 3; const EDGE_COLORS: Record = { premise: '#3a4a6c', @@ -45,6 +59,24 @@ const EDGE_SIZE: Record = { variable: 1.0, }; +/** + * Color + size for one edge. Conclusion edges with a computed `effect` + * (infer/noisy_and strategies — see `_graph_json._strategy_effect`) are + * colored by sign and thickened by magnitude instead of the flat role + * color, so support/lowering is legible without opening the side panel. + * Edges without an effect (unscored strategy forms, or non-conclusion + * roles) keep the plain role styling. + */ +function edgeStyle(e: GraphEdge): { color: string; size: number } { + if (e.role === 'conclusion' && typeof e.effect === 'number') { + return { + color: effectColor(e.effect), + size: EDGE_SIZE.conclusion * (1 + Math.abs(e.effect) * 1.1), + }; + } + return { color: EDGE_COLORS[e.role] || '#3a4a6c', size: EDGE_SIZE[e.role] || 1 }; +} + // -------------------------------------------------------------------------- // Build graphology Graph from GraphData // -------------------------------------------------------------------------- @@ -69,6 +101,7 @@ function nodeLabel(n: AnyNode): string { function nodeSize(n: AnyNode): number { if (isStrategy(n) || isOperator(n)) return 4; + if (isGeneratedHelper(n)) return GENERATED_HELPER_SIZE; // boost size by belief; default 6, peaks at 12 if belief high const k = n as { belief?: number | null }; const b = k.belief == null ? 0.5 : k.belief; @@ -86,12 +119,20 @@ function nodeColor(n: AnyNode): string { function nodeKind(n: AnyNode): string { if (isStrategy(n)) return 'strategy'; if (isOperator(n)) return 'operator'; + if (isGeneratedHelper(n)) return 'generated'; return n.type || 'unknown'; } export function buildGraph(data: GraphData): Graph { const g = new Graph({ multi: false, type: 'directed' }); + // Generated/helper knowledge nodes (compiler-minted provenance, e.g. the + // "likelihood" claim infer() auto-writes) start collapsed so they don't + // dominate the layout; the filter panel's "generated" toggle reveals them + // on demand. Track their ids so incident edges start collapsed too — + // otherwise sigma would draw edges dangling off an invisible endpoint. + const generatedIds = new Set(data.nodes.filter(isGeneratedHelper).map((n) => n.id)); + // seed positions on a circle so the worker has something non-degenerate const N = data.nodes.length || 1; data.nodes.forEach((n, i) => { @@ -106,7 +147,7 @@ export function buildGraph(data: GraphData): Graph { color: nodeColor(n), kind: nodeKind(n), raw: n, - hidden: false, + hidden: generatedIds.has(n.id), // dashed border if no belief and is a knowledge node borderColor: !isStrategy(n) && !isOperator(n) && (n as { belief?: number | null }).belief == null ? '#555' : undefined, @@ -121,12 +162,14 @@ export function buildGraph(data: GraphData): Graph { } const id = `e${i}`; if (g.hasEdge(id)) return; + const style = edgeStyle(e); g.addEdgeWithKey(id, e.source, e.target, { role: e.role, - color: EDGE_COLORS[e.role] || '#3a4a6c', - size: EDGE_SIZE[e.role] || 1, + effect: e.effect ?? null, + color: style.color, + size: style.size, type: e.role === 'background' ? 'arrow' : 'arrow', - hidden: false, + hidden: generatedIds.has(e.source) || generatedIds.has(e.target), }); }); if (dropped) console.warn(`[starmap] dropped ${dropped} edges with missing endpoints`); diff --git a/viz/src/style/style.css b/viz/src/style/style.css index 93c8d2c0c..413616a40 100644 --- a/viz/src/style/style.css +++ b/viz/src/style/style.css @@ -54,6 +54,20 @@ html, body, #app { text-transform: uppercase; font-size: 11px; } +#root-banner { + display: flex; + gap: 14px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 12px; + color: var(--fg); +} +#root-banner .item { display: flex; align-items: baseline; gap: 5px; overflow: hidden; text-overflow: ellipsis; } +#root-banner .star { color: #ffd24a; } +#root-banner .label { overflow: hidden; text-overflow: ellipsis; } +#root-banner .belief { font-weight: 600; color: var(--fg-dim); flex-shrink: 0; } +#root-banner.hidden { display: none; } #search-host { flex: 1; max-width: 360px; } #filter-host { margin-left: auto; } @@ -255,6 +269,8 @@ html, body, #app { display: inline-block; } .swatch.sq { border-radius: 2px; } +.swatch.line { width: 16px; height: 3px; border-radius: 2px; } +.legend-row.hidden { display: none; } /* ----- status ----- */ #status { diff --git a/viz/src/types.ts b/viz/src/types.ts index 3b61aca87..4b5f545e4 100644 --- a/viz/src/types.ts +++ b/viz/src/types.ts @@ -34,6 +34,15 @@ export interface StrategyNode { strategy_type: string; module?: string; reason?: string; + /** Full CPT for infer/noisy_and strategies; null/absent for other forms. */ + conditional_probabilities?: number[] | null; + /** + * Signed support/lowering effect on the conclusion, in [-1, 1]. + * Positive = the premise raises belief in the conclusion (support), + * negative = it lowers it. null when the strategy form (support/ + * deduction/...) carries no strategy-level CPT to derive this from. + */ + effect?: number | null; } export interface OperatorNode { @@ -49,6 +58,8 @@ export interface GraphEdge { source: string; target: string; role: EdgeRole; + /** Signed support/lowering effect, mirrored from the source strategy node. */ + effect?: number | null; } export interface GraphData { @@ -65,3 +76,17 @@ export function isStrategy(n: AnyNode): n is StrategyNode { export function isOperator(n: AnyNode): n is OperatorNode { return (n as OperatorNode).type === 'operator'; } + +/** + * True for knowledge nodes the compiler/reviewer minted as provenance — + * e.g. the "likelihood" claim `infer(...)` auto-generates to spell out + * p(e|h)/p(e|not h) in prose (see `gaia.engine.lang.dsl.infer_verb`) — as + * opposed to content a person actually authored. Useful provenance, but not + * meant to dominate the layout by default (see `docs/foundations/gaia-ir/ + * 04-helper-claims.md` for the broader "helper claim" contract). + */ +export function isGeneratedHelper(n: AnyNode): boolean { + if (isStrategy(n) || isOperator(n)) return false; + const metadata = (n as KnowledgeNode).metadata; + return metadata?.generated === true; +} diff --git a/viz/src/ui/filter.ts b/viz/src/ui/filter.ts index dc5a71310..3b510dde4 100644 --- a/viz/src/ui/filter.ts +++ b/viz/src/ui/filter.ts @@ -13,8 +13,14 @@ export function mountFilter(host: HTMLElement, graph: Graph, sigma: Sigma): Filt counts.set(k, (counts.get(k) ?? 0) + 1); }); + // Generated/helper knowledge nodes (see isGeneratedHelper) start off — + // buildGraph() already collapses them and their incident edges so they + // don't dominate the layout; this default just keeps the checkbox honest + // about that starting state instead of showing "checked" for a bucket + // that's actually hidden. + const defaultEnabled = (t: string) => t !== 'generated'; const types = Array.from(counts.keys()).sort(); - const enabled = new Map(types.map((t) => [t, true])); + const enabled = new Map(types.map((t) => [t, defaultEnabled(t)])); const root = document.createElement('div'); root.className = 'filter-panel'; @@ -28,7 +34,7 @@ export function mountFilter(host: HTMLElement, graph: Graph, sigma: Sigma): Filt .map( (t) => ` `, diff --git a/viz/src/ui/panel.ts b/viz/src/ui/panel.ts index e36f98e22..ab24e7b18 100644 --- a/viz/src/ui/panel.ts +++ b/viz/src/ui/panel.ts @@ -1,8 +1,9 @@ import type Graph from 'graphology'; -import type { AnyNode } from '../types'; +import type { AnyNode, StrategyNode } from '../types'; import { isStrategy, isOperator } from '../types'; +import { effectColor } from '../starmap'; -function escapeHtml(s: unknown): string { +export function escapeHtml(s: unknown): string { if (s == null) return ''; return String(s) .replace(/&/g, '&') @@ -17,6 +18,18 @@ function fmtBelief(b: number | null | undefined): string { return b.toFixed(3); } +/** "supports (+0.81)" / "lowers (-0.81)" / "neutral (0.00)" badge, colored to match the edge. */ +function fmtEffect(effect: number): string { + const verb = effect > 0.02 ? 'supports' : effect < -0.02 ? 'lowers' : 'neutral'; + const sign = effect > 0 ? '+' : ''; + const color = effectColor(effect); + return `${verb} (${sign}${effect.toFixed(2)})`; +} + +function fmtConditionalProbabilities(cp: number[]): string { + return cp.map((p) => p.toFixed(3)).join(', '); +} + export interface PanelHandle { show(nodeId: string): void; hide(): void; @@ -81,6 +94,11 @@ export function mountPanel(host: HTMLElement, graph: Graph): PanelHandle { ]; if (isStrategy(node)) { + const s = node as StrategyNode; + if (typeof s.effect === 'number') rows.push(['effect', fmtEffect(s.effect)]); + if (s.conditional_probabilities?.length) { + rows.push(['P(conclusion | premise)', fmtConditionalProbabilities(s.conditional_probabilities)]); + } if (node.reason) rows.push(['reason', escapeHtml(node.reason)]); } else if (isOperator(node)) { rows.push(['operator', escapeHtml(node.operator_type)]); diff --git a/viz/src/ui/search.ts b/viz/src/ui/search.ts index 46aeb71c7..3795d2b91 100644 --- a/viz/src/ui/search.ts +++ b/viz/src/ui/search.ts @@ -41,7 +41,14 @@ export function mountSearch(host: HTMLElement, graph: Graph, sigma: Sigma): Sear graph.forEachNode((id, attrs) => { const label = String(attrs.label || '').toLowerCase(); const idLow = id.toLowerCase(); - if (label.includes(query) || idLow.includes(query)) { + // Hidden nodes (filtered-out kinds, incl. the default-collapsed + // generated helpers) never MATCH — highlighting an invisible node and + // recentering the camera on it reads as a no-op — but they still take + // the restore branch, so a node highlighted by an earlier query and + // then hidden via the filter sheds its boosted state instead of + // reappearing highlighted when its bucket is re-enabled. + const visible = attrs.hidden !== true; + if (visible && (label.includes(query) || idLow.includes(query))) { matches.push(id); if (!originalSizes.has(id)) originalSizes.set(id, attrs.size as number); graph.setNodeAttribute(id, 'size', (attrs.size as number) * 1.6);