From 5cbf7eca53b2fc21f33b6e35b14680d44921486e Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 18:58:29 +0800 Subject: [PATCH 001/210] feat(lang): add v6 parameter and grounding primitives --- gaia/lang/runtime/grounding.py | 22 ++++++++++++ gaia/lang/runtime/param.py | 35 ++++++++++++++++++++ tests/gaia/lang/test_grounding.py | 20 +++++++++++ tests/gaia/lang/test_parameterized_claims.py | 12 +++++++ 4 files changed, 89 insertions(+) create mode 100644 gaia/lang/runtime/grounding.py create mode 100644 gaia/lang/runtime/param.py create mode 100644 tests/gaia/lang/test_grounding.py create mode 100644 tests/gaia/lang/test_parameterized_claims.py diff --git a/gaia/lang/runtime/grounding.py b/gaia/lang/runtime/grounding.py new file mode 100644 index 000000000..74fcdf5c1 --- /dev/null +++ b/gaia/lang/runtime/grounding.py @@ -0,0 +1,22 @@ +"""Grounding metadata for root Claims.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +_VALID_KINDS = frozenset({"assumption", "source_fact", "definition", "imported", "judgment", "open"}) + + +@dataclass +class Grounding: + """Explains why a root Claim can have a prior.""" + + kind: str + rationale: str = "" + source_refs: list[str] = field(default_factory=list) + + def __post_init__(self): + if self.kind not in _VALID_KINDS: + raise ValueError( + f"Invalid grounding kind {self.kind!r}. Must be one of: {sorted(_VALID_KINDS)}" + ) diff --git a/gaia/lang/runtime/param.py b/gaia/lang/runtime/param.py new file mode 100644 index 000000000..d7e724485 --- /dev/null +++ b/gaia/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 = None + + def __new__(cls): + 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/tests/gaia/lang/test_grounding.py b/tests/gaia/lang/test_grounding.py new file mode 100644 index 000000000..4af112b16 --- /dev/null +++ b/tests/gaia/lang/test_grounding.py @@ -0,0 +1,20 @@ +import pytest + +from gaia.lang.runtime.grounding import Grounding + + +def test_grounding_source_fact(): + g = Grounding(kind="source_fact", rationale="Extracted from Fig.2.") + assert g.kind == "source_fact" + assert g.rationale == "Extracted from Fig.2." + assert g.source_refs == [] + + +def test_grounding_with_source_refs(): + g = Grounding(kind="source_fact", rationale="From paper.", source_refs=["ctx_1"]) + assert g.source_refs == ["ctx_1"] + + +def test_grounding_invalid_kind(): + with pytest.raises(ValueError): + Grounding(kind="invalid_kind", rationale="bad") diff --git a/tests/gaia/lang/test_parameterized_claims.py b/tests/gaia/lang/test_parameterized_claims.py new file mode 100644 index 000000000..a758ffab1 --- /dev/null +++ b/tests/gaia/lang/test_parameterized_claims.py @@ -0,0 +1,12 @@ +from gaia.lang.runtime.param import Param, UNBOUND + + +def test_param_unbound_sentinel(): + p = Param(name="value", type=float) + assert p.value is UNBOUND + assert p.value is not None + + +def test_param_bound(): + p = Param(name="value", type=float, value=5000.0) + assert p.value == 5000.0 From 1b111232c10385c06455c9d7533d83152fa22909 Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 19:03:22 +0800 Subject: [PATCH 002/210] feat(lang): add v6 knowledge class hierarchy --- gaia/lang/runtime/__init__.py | 5 +- gaia/lang/runtime/knowledge.py | 183 +++++++++++++++++++++++++++ gaia/lang/runtime/nodes.py | 41 +----- gaia/lang/runtime/package.py | 3 +- tests/gaia/lang/test_knowledge_v6.py | 49 +++++++ 5 files changed, 239 insertions(+), 42 deletions(-) create mode 100644 gaia/lang/runtime/knowledge.py create mode 100644 tests/gaia/lang/test_knowledge_v6.py diff --git a/gaia/lang/runtime/__init__.py b/gaia/lang/runtime/__init__.py index eb89edd86..707c82856 100644 --- a/gaia/lang/runtime/__init__.py +++ b/gaia/lang/runtime/__init__.py @@ -1,3 +1,4 @@ -from gaia.lang.runtime.nodes import Knowledge, Operator, Step, Strategy +from gaia.lang.runtime.knowledge import Claim, Context, Knowledge, Question, Setting +from gaia.lang.runtime.nodes import Operator, Step, Strategy -__all__ = ["Knowledge", "Operator", "Step", "Strategy"] +__all__ = ["Claim", "Context", "Knowledge", "Operator", "Question", "Setting", "Step", "Strategy"] diff --git a/gaia/lang/runtime/knowledge.py b/gaia/lang/runtime/knowledge.py new file mode 100644 index 000000000..b19064da6 --- /dev/null +++ b/gaia/lang/runtime/knowledge.py @@ -0,0 +1,183 @@ +"""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.lang.runtime.grounding import Grounding +from gaia.lang.runtime.param import UNBOUND + +if TYPE_CHECKING: + from gaia.lang.runtime.package import CollectedPackage + +_current_package: ContextVar[CollectedPackage | None] = ContextVar("_current_package", default=None) + + +class _SafeFormatDict(dict): + """Return {key} for missing keys instead of raising KeyError.""" + + def __missing__(self, key): + return f"{{{key}}}" + + +@dataclass +class Knowledge: + """Base knowledge node. Plain text plus metadata.""" + + content: str + type: str = "knowledge" + 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: 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): + 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) + + +@dataclass(init=False) +class Context(Knowledge): + """Raw unformalized text. Does not enter BP.""" + + def __init__(self, content: str, **kwargs): + if "prior" in kwargs: + raise TypeError("Context cannot have a prior.") + super().__init__(content=content, type="context", **kwargs) + + +@dataclass(init=False) +class Setting(Knowledge): + """Formalized background. No probability.""" + + def __init__(self, content: str, **kwargs): + if "prior" in kwargs: + raise TypeError("Setting cannot have a prior.") + super().__init__(content=content, type="setting", **kwargs) + + +@dataclass(init=False) +class Claim(Knowledge): + """Proposition with prior. Participates in BP.""" + + prior: float | None = None + grounding: Grounding | None = None + supports: list[Any] = field(default_factory=list) + _param_fields: ClassVar[dict[str, Any]] = {} + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + base_fields = { + "content", + "type", + "title", + "background", + "parameters", + "provenance", + "metadata", + "label", + "strategy", + "prior", + "grounding", + "supports", + "targets", + } + cls._param_fields = { + name: ann + for name, ann in getattr(cls, "__annotations__", {}).items() + if name not in base_fields and not name.startswith("_") + } + + def __init__( + self, + content: str | None = None, + *, + prior: float | None = None, + grounding: Grounding | None = None, + supports: list[Any] | None = None, + **kwargs, + ): + param_fields = getattr(self.__class__, "_param_fields", {}) + 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 + + 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, + } + ) + + template = self.__class__.__doc__ or "" + if content is None and template and param_fields: + metadata = dict(knowledge_kwargs.get("metadata") or {}) + metadata["content_template"] = template + knowledge_kwargs["metadata"] = metadata + render_values: dict[str, Any] = {} + for name in param_fields: + val = param_values.get(name, UNBOUND) + if val is not UNBOUND: + if isinstance(val, Knowledge): + render_values[name] = f"[@{val.label or '?'}]" + elif isinstance(val, Enum): + render_values[name] = val.value + else: + render_values[name] = val + content = template.format_map(_SafeFormatDict(render_values)) + + 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.grounding = grounding + self.supports = list(supports or []) + + +@dataclass(init=False) +class Question(Knowledge): + """Open inquiry. Does not enter BP.""" + + targets: list[Claim] = field(default_factory=list) + + def __init__(self, content: str, **kwargs): + 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/lang/runtime/nodes.py index 716a9c4cc..a88724b9c 100644 --- a/gaia/lang/runtime/nodes.py +++ b/gaia/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.lang.runtime.knowledge import Knowledge, _current_package @dataclass diff --git a/gaia/lang/runtime/package.py b/gaia/lang/runtime/package.py index 5e174d0ce..153d78aa9 100644 --- a/gaia/lang/runtime/package.py +++ b/gaia/lang/runtime/package.py @@ -6,7 +6,8 @@ import sys from pathlib import Path -from gaia.lang.runtime.nodes import Knowledge, Operator, Strategy, _current_package +from gaia.lang.runtime.knowledge import Knowledge, _current_package +from gaia.lang.runtime.nodes import Operator, Strategy try: import tomllib diff --git a/tests/gaia/lang/test_knowledge_v6.py b/tests/gaia/lang/test_knowledge_v6.py new file mode 100644 index 000000000..c53291e9d --- /dev/null +++ b/tests/gaia/lang/test_knowledge_v6.py @@ -0,0 +1,49 @@ +import pytest + +from gaia.lang.runtime.grounding import Grounding +from gaia.lang.runtime.knowledge import Claim, Context, Question, Setting + + +def test_context_creation(): + ctx = Context("Raw experiment notes.") + assert ctx.content == "Raw experiment notes." + assert ctx.type == "context" + + +def test_setting_creation(): + s = Setting("Blackbody cavity at thermal equilibrium.") + assert s.type == "setting" + assert s.content == "Blackbody cavity at thermal equilibrium." + + +def test_claim_creation(): + c = Claim("Energy exchange is quantized.", prior=0.5) + assert c.type == "claim" + assert c.prior == 0.5 + assert c.supports == [] + + +def test_claim_no_prior(): + c = Claim("A proposition.") + assert c.prior is None + + +def test_claim_with_grounding(): + g = Grounding(kind="source_fact", rationale="From paper.") + c = Claim("UV data.", prior=0.95, grounding=g) + assert c.grounding.kind == "source_fact" + + +def test_question_creation(): + q = Question("Should we ship variant B?") + assert q.type == "question" + + +def test_context_cannot_have_prior(): + with pytest.raises(TypeError): + Context("raw text", prior=0.5) + + +def test_setting_cannot_have_prior(): + with pytest.raises(TypeError): + Setting("background", prior=0.5) From ecc50737dfb004ca6cc56adfeedbee626ac87390 Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 19:09:49 +0800 Subject: [PATCH 003/210] feat(lang): support parameterized claim templates --- gaia/lang/runtime/knowledge.py | 7 +- tests/gaia/lang/test_parameterized_claims.py | 71 ++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/gaia/lang/runtime/knowledge.py b/gaia/lang/runtime/knowledge.py index b19064da6..69938dc12 100644 --- a/gaia/lang/runtime/knowledge.py +++ b/gaia/lang/runtime/knowledge.py @@ -143,17 +143,20 @@ def __init__( 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 not UNBOUND: if isinstance(val, Knowledge): - render_values[name] = f"[@{val.label or '?'}]" + 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 - content = template.format_map(_SafeFormatDict(render_values)) + content = rendered_template.format_map(_SafeFormatDict(render_values)) for name, val in param_values.items(): object.__setattr__(self, name, val) diff --git a/tests/gaia/lang/test_parameterized_claims.py b/tests/gaia/lang/test_parameterized_claims.py index a758ffab1..558743eff 100644 --- a/tests/gaia/lang/test_parameterized_claims.py +++ b/tests/gaia/lang/test_parameterized_claims.py @@ -1,6 +1,36 @@ +from enum import Enum + +from gaia.lang.runtime.knowledge import Claim, Setting from gaia.lang.runtime.param import Param, UNBOUND +class MoleculeType(str, Enum): + DNA = "DNA" + RNA = "RNA" + PROTEIN = "protein" + + +class CavityTemperature(Claim): + """Cavity temperature is set to {value}K.""" + + value: float + + +class InfoTransfer(Claim): + """Information can transfer from {src} to {dst}.""" + + src: MoleculeType + dst: MoleculeType + + +class ABCounts(Claim): + """[@experiment] recorded {ctrl_k}/{ctrl_n} control conversions.""" + + experiment: Setting + ctrl_n: int + ctrl_k: int + + def test_param_unbound_sentinel(): p = Param(name="value", type=float) assert p.value is UNBOUND @@ -10,3 +40,44 @@ def test_param_unbound_sentinel(): def test_param_bound(): p = Param(name="value", type=float, value=5000.0) assert p.value == 5000.0 + + +def test_parameterized_claim_content_rendering(): + temp = CavityTemperature(value=5000.0) + assert temp.content == "Cavity temperature is set to 5000.0K." + + +def test_parameterized_claim_parameters(): + temp = CavityTemperature(value=5000.0) + assert len(temp.parameters) == 1 + assert temp.parameters[0]["name"] == "value" + assert temp.parameters[0]["value"] == 5000.0 + + +def test_parameterized_claim_enum(): + transfer = InfoTransfer(src=MoleculeType.DNA, dst=MoleculeType.RNA) + assert transfer.content == "Information can transfer from DNA to RNA." + + +def test_partial_binding(): + transfer = InfoTransfer(src=MoleculeType.DNA) + assert "{dst}" in transfer.content + assert "DNA" in transfer.content + + +def test_knowledge_parameter_ref_syntax(): + """Knowledge-typed params render as [@label].""" + exp = Setting("AB test exp_123.") + exp.label = "exp_123" + counts = ABCounts(experiment=exp, ctrl_n=10_000, ctrl_k=500) + assert "[@exp_123]" in counts.content + assert "500/10000" in counts.content + + +def test_knowledge_parameter_stored_as_reference(): + """Knowledge param value is the object, not a string.""" + exp = Setting("AB test.") + exp.label = "exp_123" + counts = ABCounts(experiment=exp, ctrl_n=10_000, ctrl_k=500) + param = [p for p in counts.parameters if p["name"] == "experiment"][0] + assert param["value"] is exp From 7ba36740ad43b7bdc47fa5a906ac021c6c511f9e Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 19:10:57 +0800 Subject: [PATCH 004/210] feat(lang): expose v6 knowledge types in DSL --- gaia/lang/__init__.py | 8 ++++++- gaia/lang/dsl/__init__.py | 3 ++- gaia/lang/dsl/knowledge.py | 26 ++++++++++++--------- tests/gaia/lang/test_knowledge_v6.py | 34 ++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/gaia/lang/__init__.py b/gaia/lang/__init__.py index fff919264..667b8f6e6 100644 --- a/gaia/lang/__init__.py +++ b/gaia/lang/__init__.py @@ -9,6 +9,7 @@ composite, complement, contradiction, + context, deduction, disjunction, elimination, @@ -23,11 +24,15 @@ setting, support, ) -from gaia.lang.runtime import Knowledge, Operator, Step, Strategy +from gaia.lang.runtime import Claim, Context, Knowledge, Operator, Question, Setting, Step, Strategy __all__ = [ + "Claim", + "Context", "Knowledge", "Operator", + "Question", + "Setting", "Step", "Strategy", "abduction", @@ -38,6 +43,7 @@ "composite", "complement", "contradiction", + "context", "deduction", "disjunction", "elimination", diff --git a/gaia/lang/dsl/__init__.py b/gaia/lang/dsl/__init__.py index e53b5dc1a..aa57bfc26 100644 --- a/gaia/lang/dsl/__init__.py +++ b/gaia/lang/dsl/__init__.py @@ -1,4 +1,4 @@ -from gaia.lang.dsl.knowledge import claim, question, setting +from gaia.lang.dsl.knowledge import claim, context, question, setting from gaia.lang.dsl.operators import complement, contradiction, disjunction, equivalence from gaia.lang.dsl.strategies import ( abduction, @@ -22,6 +22,7 @@ "analogy", "case_analysis", "claim", + "context", "compare", "composite", "complement", diff --git a/gaia/lang/dsl/knowledge.py b/gaia/lang/dsl/knowledge.py index db312c310..be2dd59ea 100644 --- a/gaia/lang/dsl/knowledge.py +++ b/gaia/lang/dsl/knowledge.py @@ -1,27 +1,32 @@ -"""Gaia Lang v5 — Knowledge DSL functions (claim, setting, question).""" +"""Gaia Lang v5/v6 — Knowledge DSL functions.""" -from gaia.lang.runtime import Knowledge +from gaia.lang.runtime import Claim, Context, Knowledge, Question, Setting -def setting(content: str, *, title: str | None = None, **metadata) -> Knowledge: +def context(content: str, **metadata) -> Context: + """Declare raw unformalized context text.""" + return Context(content.strip(), metadata=_flatten_metadata(metadata)) + + +def setting(content: str, *, title: str | None = None, **metadata) -> Setting: """Declare a background assumption. No probability, no BP participation.""" provenance = metadata.pop("provenance", None) - return Knowledge( + return Setting( content=content.strip(), - type="setting", title=title, provenance=provenance or [], metadata=_flatten_metadata(metadata), ) -def question(content: str, *, title: str | None = None, **metadata) -> Knowledge: +def question(content: str, *, title: str | None = None, **metadata) -> Question: """Declare a research question. No probability, no BP participation.""" provenance = metadata.pop("provenance", None) - return Knowledge( + targets = metadata.pop("targets", []) + return Question( content=content.strip(), - type="question", title=title, + targets=targets, provenance=provenance or [], metadata=_flatten_metadata(metadata), ) @@ -42,11 +47,10 @@ def claim( parameters: list[dict] | None = None, provenance: list[dict[str, str]] | None = None, **metadata, -) -> Knowledge: +) -> Claim: """Declare a scientific assertion. The only type carrying probability.""" - return Knowledge( + return Claim( content=content.strip(), - type="claim", title=title, background=background or [], parameters=parameters or [], diff --git a/tests/gaia/lang/test_knowledge_v6.py b/tests/gaia/lang/test_knowledge_v6.py index c53291e9d..f06bd8384 100644 --- a/tests/gaia/lang/test_knowledge_v6.py +++ b/tests/gaia/lang/test_knowledge_v6.py @@ -47,3 +47,37 @@ def test_context_cannot_have_prior(): def test_setting_cannot_have_prior(): with pytest.raises(TypeError): Setting("background", prior=0.5) + + +def test_context_dsl_function(): + from gaia.lang.dsl.knowledge import context + + ctx = context("Raw experiment notes.") + assert ctx.type == "context" + assert ctx.content == "Raw experiment notes." + assert isinstance(ctx, Context) + + +def test_v5_claim_still_works(): + """v5 claim() function returns a v6 Claim.""" + from gaia.lang import claim + + c = claim("A proposition.") + assert c.type == "claim" + assert isinstance(c, Claim) + + +def test_v5_setting_still_works(): + from gaia.lang import setting + + s = setting("Background info.") + assert s.type == "setting" + assert isinstance(s, Setting) + + +def test_v5_question_still_works(): + from gaia.lang import question + + q = question("Question?") + assert q.type == "question" + assert isinstance(q, Question) From e0b463bc1bc627dab6b6a3031cfe320dae6932f5 Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 19:11:58 +0800 Subject: [PATCH 005/210] feat(ir): add context knowledge and parameter values --- gaia/ir/knowledge.py | 2 ++ tests/ir/test_knowledge.py | 4 ++-- tests/ir/test_knowledge_context.py | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 tests/ir/test_knowledge_context.py diff --git a/gaia/ir/knowledge.py b/gaia/ir/knowledge.py index a50686781..507d6de17 100644 --- a/gaia/ir/knowledge.py +++ b/gaia/ir/knowledge.py @@ -31,6 +31,7 @@ class KnowledgeType(StrEnum): CLAIM = "claim" SETTING = "setting" QUESTION = "question" + CONTEXT = "context" class Parameter(BaseModel): @@ -38,6 +39,7 @@ class Parameter(BaseModel): name: str type: str + value: Any | None = None class PackageRef(BaseModel): diff --git a/tests/ir/test_knowledge.py b/tests/ir/test_knowledge.py index ec68986d8..50b8faf99 100644 --- a/tests/ir/test_knowledge.py +++ b/tests/ir/test_knowledge.py @@ -43,8 +43,8 @@ def test_invalid_uppercase(self): class TestKnowledgeType: - def test_three_types(self): - assert set(KnowledgeType) == {"claim", "setting", "question"} + def test_knowledge_types(self): + assert set(KnowledgeType) == {"claim", "setting", "question", "context"} def test_no_template(self): with pytest.raises(ValueError): diff --git a/tests/ir/test_knowledge_context.py b/tests/ir/test_knowledge_context.py new file mode 100644 index 000000000..a327db346 --- /dev/null +++ b/tests/ir/test_knowledge_context.py @@ -0,0 +1,15 @@ +from gaia.ir.knowledge import KnowledgeType, Parameter + + +def test_context_knowledge_type(): + assert KnowledgeType.CONTEXT == "context" + + +def test_parameter_value_field(): + p = Parameter(name="experiment", type="Setting", value="github:pkg::exp_123") + assert p.value == "github:pkg::exp_123" + + +def test_parameter_value_default_none(): + p = Parameter(name="x", type="int") + assert p.value is None From f25d09e422bbe387e3ca0b72af026c6b10943759 Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 19:14:38 +0800 Subject: [PATCH 006/210] feat(compiler): handle v6 knowledge metadata --- gaia/lang/compiler/compile.py | 22 +++++++++- tests/gaia/lang/test_compiler_v6.py | 68 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 tests/gaia/lang/test_compiler_v6.py diff --git a/gaia/lang/compiler/compile.py b/gaia/lang/compiler/compile.py index 2957a8efa..34a493c6b 100644 --- a/gaia/lang/compiler/compile.py +++ b/gaia/lang/compiler/compile.py @@ -5,7 +5,7 @@ import hashlib import json import re -from dataclasses import dataclass +from dataclasses import asdict, dataclass from typing import Any from gaia.ir import ( @@ -31,6 +31,7 @@ ) from gaia.lang.runtime import Knowledge, Operator from gaia.lang.runtime.package import CollectedPackage +from gaia.lang.runtime.param import UNBOUND _COMPILE_TIME_FORMAL_STRATEGIES = frozenset( { @@ -122,9 +123,22 @@ def _knowledge_id( def _knowledge_metadata(k: Knowledge) -> dict[str, Any] | None: metadata = dict(k.metadata) + grounding = getattr(k, "grounding", None) + if grounding is not None: + metadata["grounding"] = asdict(grounding) 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 + return IrParameter(**payload) + + def _knowledge_provenance(k: Knowledge) -> list[IrPackageRef] | None: if not k.provenance: return None @@ -286,6 +300,10 @@ def register_knowledge(k: Knowledge) -> None: return knowledge_nodes.append(k) seen_knowledge.add(key) + for param in k.parameters: + value = param.get("value") + if isinstance(value, Knowledge): + register_knowledge(value) def register_strategy_knowledge(strategy: Any) -> None: for premise in strategy.premises: @@ -337,7 +355,7 @@ def register_strategy_knowledge(strategy: Any) -> None: title=getattr(k, "title", None), type=k.type, content=k.content, - parameters=[IrParameter(**p) for p in k.parameters], + parameters=[_parameter_to_ir(p, knowledge_map) for p in k.parameters], provenance=_knowledge_provenance(k), metadata=_knowledge_metadata(k), module=getattr(k, "_source_module", None), diff --git a/tests/gaia/lang/test_compiler_v6.py b/tests/gaia/lang/test_compiler_v6.py new file mode 100644 index 000000000..a605eb6fb --- /dev/null +++ b/tests/gaia/lang/test_compiler_v6.py @@ -0,0 +1,68 @@ +from gaia.lang.compiler.compile import compile_package_artifact +from gaia.lang.runtime.grounding import Grounding +from gaia.lang.runtime.knowledge import Claim, Context, Setting +from gaia.lang.runtime.package import CollectedPackage + + +def test_compile_context_type(): + """Context Knowledge compiles with type='context'.""" + with CollectedPackage("v6_test") as pkg: + ctx = Context("Raw experiment notes.") + ctx.label = "ctx" + ir = compile_package_artifact(pkg).to_json() + node = next(k for k in ir["knowledges"] if k["label"] == "ctx") + assert node["type"] == "context" + + +def test_compile_grounding_in_metadata(): + """Grounding metadata appears in compiled IR.""" + with CollectedPackage("v6_test") as pkg: + claim = Claim( + "Measured spectrum deviates from Rayleigh-Jeans law.", + grounding=Grounding(kind="source_fact", rationale="Extracted from Fig.2."), + ) + claim.label = "uv_data" + ir = compile_package_artifact(pkg).to_json() + node = next(k for k in ir["knowledges"] if k["label"] == "uv_data") + assert node["metadata"]["grounding"]["kind"] == "source_fact" + assert "Fig.2" in node["metadata"]["grounding"]["rationale"] + + +def test_compile_parameterized_claim_template(): + """Parameterized Claim stores content_template in metadata.""" + + class TemperatureClaim(Claim): + """Cavity temperature is set to {value}K.""" + + value: float + + with CollectedPackage("v6_test") as pkg: + temp = TemperatureClaim(value=5000.0) + temp.label = "temp" + ir = compile_package_artifact(pkg).to_json() + node = next(k for k in ir["knowledges"] if k["label"] == "temp") + assert node["content"] == "Cavity temperature is set to 5000.0K." + assert node["metadata"]["content_template"] == "Cavity temperature is set to {value}K." + + +def test_compile_parameter_value(): + """Bound parameter values appear in compiled IR parameters.""" + + class ABCounts(Claim): + """[@experiment] recorded {ctrl_k}/{ctrl_n} control conversions.""" + + experiment: Setting + ctrl_n: int + ctrl_k: int + + with CollectedPackage("v6_test") as pkg: + exp = Setting("AB test exp_123.") + exp.label = "exp_123" + counts = ABCounts(experiment=exp, ctrl_n=10_000, ctrl_k=500) + counts.label = "counts" + ir = compile_package_artifact(pkg).to_json() + node = next(k for k in ir["knowledges"] if k["label"] == "counts") + params = {p["name"]: p for p in node["parameters"]} + assert params["ctrl_n"]["value"] == 10_000 + assert params["ctrl_k"]["value"] == 500 + assert params["experiment"]["value"] == "github:v6_test::exp_123" From 0bcd88249efbf541495e783114a2281ea94d94d6 Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 19:18:47 +0800 Subject: [PATCH 007/210] test: cover v6 knowledge CLI compilation --- gaia/lang/__init__.py | 13 ++++++- gaia/lang/runtime/__init__.py | 13 ++++++- gaia/lang/runtime/knowledge.py | 8 ++--- tests/cli/test_compile_v6.py | 53 ++++++++++++++++++++++++++++ tests/gaia/lang/test_knowledge_v6.py | 12 +++++++ 5 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 tests/cli/test_compile_v6.py diff --git a/gaia/lang/__init__.py b/gaia/lang/__init__.py index 667b8f6e6..ee4963e19 100644 --- a/gaia/lang/__init__.py +++ b/gaia/lang/__init__.py @@ -24,11 +24,22 @@ setting, support, ) -from gaia.lang.runtime import Claim, Context, Knowledge, Operator, Question, Setting, Step, Strategy +from gaia.lang.runtime import ( + Claim, + Context, + Grounding, + Knowledge, + Operator, + Question, + Setting, + Step, + Strategy, +) __all__ = [ "Claim", "Context", + "Grounding", "Knowledge", "Operator", "Question", diff --git a/gaia/lang/runtime/__init__.py b/gaia/lang/runtime/__init__.py index 707c82856..95bed1a3e 100644 --- a/gaia/lang/runtime/__init__.py +++ b/gaia/lang/runtime/__init__.py @@ -1,4 +1,15 @@ +from gaia.lang.runtime.grounding import Grounding from gaia.lang.runtime.knowledge import Claim, Context, Knowledge, Question, Setting from gaia.lang.runtime.nodes import Operator, Step, Strategy -__all__ = ["Claim", "Context", "Knowledge", "Operator", "Question", "Setting", "Step", "Strategy"] +__all__ = [ + "Claim", + "Context", + "Grounding", + "Knowledge", + "Operator", + "Question", + "Setting", + "Step", + "Strategy", +] diff --git a/gaia/lang/runtime/knowledge.py b/gaia/lang/runtime/knowledge.py index 69938dc12..ff6e74316 100644 --- a/gaia/lang/runtime/knowledge.py +++ b/gaia/lang/runtime/knowledge.py @@ -56,7 +56,7 @@ def __hash__(self) -> int: return id(self) -@dataclass(init=False) +@dataclass(init=False, eq=False) class Context(Knowledge): """Raw unformalized text. Does not enter BP.""" @@ -66,7 +66,7 @@ def __init__(self, content: str, **kwargs): super().__init__(content=content, type="context", **kwargs) -@dataclass(init=False) +@dataclass(init=False, eq=False) class Setting(Knowledge): """Formalized background. No probability.""" @@ -76,7 +76,7 @@ def __init__(self, content: str, **kwargs): super().__init__(content=content, type="setting", **kwargs) -@dataclass(init=False) +@dataclass(init=False, eq=False) class Claim(Knowledge): """Proposition with prior. Participates in BP.""" @@ -172,7 +172,7 @@ def __init__( self.supports = list(supports or []) -@dataclass(init=False) +@dataclass(init=False, eq=False) class Question(Knowledge): """Open inquiry. Does not enter BP.""" diff --git a/tests/cli/test_compile_v6.py b/tests/cli/test_compile_v6.py new file mode 100644 index 000000000..7abee069b --- /dev/null +++ b/tests/cli/test_compile_v6.py @@ -0,0 +1,53 @@ +"""End-to-end tests for v6 Knowledge types.""" + +import json + +from typer.testing import CliRunner + +from gaia.cli.main import app + +runner = CliRunner() + + +def test_v6_knowledge_types_compile(tmp_path): + """A package using v6 Knowledge types compiles to correct IR.""" + pkg_dir = tmp_path / "v6_pkg" + pkg_dir.mkdir() + (pkg_dir / "pyproject.toml").write_text( + '[project]\nname = "v6-pkg-gaia"\nversion = "1.0.0"\n' + 'description = "v6 test"\n\n' + '[tool.gaia]\nnamespace = "github"\ntype = "knowledge-package"\n' + ) + pkg_src = pkg_dir / "v6_pkg" + pkg_src.mkdir() + (pkg_src / "__init__.py").write_text( + "from gaia.lang import Claim, Context, Grounding, Setting\n\n" + "ctx = Context('Raw AB test data from dashboard.')\n" + "exp = Setting('AB test exp_123: 50/50 randomization.')\n" + "hyp = Claim(\n" + " 'Variant B is better.',\n" + " prior=0.5,\n" + " grounding=Grounding(kind='judgment', rationale='Uninformative prior.'),\n" + ")\n" + "__all__ = ['hyp']\n" + ) + (pkg_src / "priors.py").write_text( + "from . import hyp\n\n" + "PRIORS: dict = {\n" + ' hyp: (0.5, "uninformative"),\n' + "}\n" + ) + + result = runner.invoke(app, ["compile", str(pkg_dir)]) + assert result.exit_code == 0, f"Compile failed: {result.output}" + + ir_path = pkg_dir / ".gaia" / "ir.json" + assert ir_path.exists() + ir = json.loads(ir_path.read_text()) + + types = {k["type"] for k in ir["knowledges"]} + assert "context" in types + + hyp_node = [k for k in ir["knowledges"] if k.get("label") == "hyp"][0] + assert hyp_node["metadata"]["grounding"]["kind"] == "judgment" + assert hyp_node["metadata"]["grounding"]["rationale"] == "Uninformative prior." diff --git a/tests/gaia/lang/test_knowledge_v6.py b/tests/gaia/lang/test_knowledge_v6.py index f06bd8384..ac295170e 100644 --- a/tests/gaia/lang/test_knowledge_v6.py +++ b/tests/gaia/lang/test_knowledge_v6.py @@ -34,6 +34,12 @@ def test_claim_with_grounding(): assert c.grounding.kind == "source_fact" +def test_claim_is_hashable_for_priors_dict(): + c = Claim("A proposition.") + priors = {c: (0.5, "uninformative")} + assert priors[c] == (0.5, "uninformative") + + def test_question_creation(): q = Question("Should we ship variant B?") assert q.type == "question" @@ -81,3 +87,9 @@ def test_v5_question_still_works(): q = question("Question?") assert q.type == "question" assert isinstance(q, Question) + + +def test_grounding_public_export(): + from gaia.lang import Grounding as PublicGrounding + + assert PublicGrounding is Grounding From b6c4ffb57caab62f7f629a03378e73869991dccb Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 19:32:25 +0800 Subject: [PATCH 008/210] style: apply ruff formatting --- gaia/lang/runtime/grounding.py | 4 +++- tests/cli/test_compile_v6.py | 5 +---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/gaia/lang/runtime/grounding.py b/gaia/lang/runtime/grounding.py index 74fcdf5c1..2f534cecb 100644 --- a/gaia/lang/runtime/grounding.py +++ b/gaia/lang/runtime/grounding.py @@ -4,7 +4,9 @@ from dataclasses import dataclass, field -_VALID_KINDS = frozenset({"assumption", "source_fact", "definition", "imported", "judgment", "open"}) +_VALID_KINDS = frozenset( + {"assumption", "source_fact", "definition", "imported", "judgment", "open"} +) @dataclass diff --git a/tests/cli/test_compile_v6.py b/tests/cli/test_compile_v6.py index 7abee069b..560952242 100644 --- a/tests/cli/test_compile_v6.py +++ b/tests/cli/test_compile_v6.py @@ -32,10 +32,7 @@ def test_v6_knowledge_types_compile(tmp_path): "__all__ = ['hyp']\n" ) (pkg_src / "priors.py").write_text( - "from . import hyp\n\n" - "PRIORS: dict = {\n" - ' hyp: (0.5, "uninformative"),\n' - "}\n" + 'from . import hyp\n\nPRIORS: dict = {\n hyp: (0.5, "uninformative"),\n}\n' ) result = runner.invoke(app, ["compile", str(pkg_dir)]) From 0429cd573ab744ba13804366923a938ef805e2d8 Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 19:46:10 +0800 Subject: [PATCH 009/210] feat(lang): add v6 support actions --- gaia/lang/__init__.py | 24 ++++ gaia/lang/dsl/__init__.py | 4 + gaia/lang/dsl/support.py | 147 +++++++++++++++++++++++ gaia/lang/runtime/__init__.py | 20 +++ gaia/lang/runtime/action.py | 89 ++++++++++++++ gaia/lang/runtime/knowledge.py | 3 +- gaia/lang/runtime/package.py | 8 ++ tests/gaia/lang/test_action_hierarchy.py | 44 +++++++ tests/gaia/lang/test_compute_v6.py | 42 +++++++ tests/gaia/lang/test_derive.py | 63 ++++++++++ tests/gaia/lang/test_observe.py | 21 ++++ 11 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 gaia/lang/dsl/support.py create mode 100644 gaia/lang/runtime/action.py create mode 100644 tests/gaia/lang/test_action_hierarchy.py create mode 100644 tests/gaia/lang/test_compute_v6.py create mode 100644 tests/gaia/lang/test_derive.py create mode 100644 tests/gaia/lang/test_observe.py diff --git a/gaia/lang/__init__.py b/gaia/lang/__init__.py index ee4963e19..dafbe5cf0 100644 --- a/gaia/lang/__init__.py +++ b/gaia/lang/__init__.py @@ -10,7 +10,9 @@ complement, contradiction, context, + compute, deduction, + derive, disjunction, elimination, equivalence, @@ -20,32 +22,51 @@ infer, mathematical_induction, noisy_and, + observe, question, setting, support, ) from gaia.lang.runtime import ( + Action, Claim, + Compute, Context, + Contradict, + Derive, + Equal, Grounding, + Infer, Knowledge, + Observe, Operator, Question, + Relate, Setting, Step, Strategy, + Support, ) __all__ = [ + "Action", "Claim", + "Compute", "Context", + "Contradict", + "Derive", + "Equal", "Grounding", + "Infer", "Knowledge", + "Observe", "Operator", "Question", + "Relate", "Setting", "Step", "Strategy", + "Support", "abduction", "analogy", "case_analysis", @@ -55,7 +76,9 @@ "complement", "contradiction", "context", + "compute", "deduction", + "derive", "disjunction", "elimination", "equivalence", @@ -65,6 +88,7 @@ "infer", "mathematical_induction", "noisy_and", + "observe", "question", "setting", "support", diff --git a/gaia/lang/dsl/__init__.py b/gaia/lang/dsl/__init__.py index aa57bfc26..55ad1ffde 100644 --- a/gaia/lang/dsl/__init__.py +++ b/gaia/lang/dsl/__init__.py @@ -1,5 +1,6 @@ from gaia.lang.dsl.knowledge import claim, context, question, setting from gaia.lang.dsl.operators import complement, contradiction, disjunction, equivalence +from gaia.lang.dsl.support import compute, derive, observe from gaia.lang.dsl.strategies import ( abduction, analogy, @@ -23,11 +24,13 @@ "case_analysis", "claim", "context", + "compute", "compare", "composite", "complement", "contradiction", "deduction", + "derive", "disjunction", "elimination", "equivalence", @@ -37,6 +40,7 @@ "infer", "mathematical_induction", "noisy_and", + "observe", "question", "setting", "support", diff --git a/gaia/lang/dsl/support.py b/gaia/lang/dsl/support.py new file mode 100644 index 000000000..9a9525e18 --- /dev/null +++ b/gaia/lang/dsl/support.py @@ -0,0 +1,147 @@ +"""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 + +from gaia.lang.runtime.action import Compute, Derive, Observe +from gaia.lang.runtime.grounding import Grounding +from gaia.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 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) + action = Derive( + label=label, + rationale=rationale, + background=list(background or []), + conclusion=conclusion, + given=given_tuple, + ) + conclusion.supports.append(action) + return conclusion + + +def observe( + conclusion: Claim | str, + *, + given: Claim | tuple[Claim, ...] | list[Claim] | None = (), + background: list[Knowledge] | None = None, + rationale: str = "", + label: str | None = None, +) -> Claim: + """Empirical observation. A no-premise observation is still reviewable.""" + if isinstance(conclusion, str): + conclusion = Claim(conclusion) + given_tuple = _as_given_tuple(given) + action = Observe( + label=label, + rationale=rationale, + background=list(background or []), + conclusion=conclusion, + given=given_tuple, + ) + if not given_tuple and conclusion.grounding is None: + conclusion.grounding = Grounding(kind="source_fact", rationale=rationale) + conclusion.supports.append(action) + return conclusion + + +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 _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) + action = Compute( + label=label, + rationale=rationale, + background=list(background or []), + conclusion=conclusion, + given=given_tuple, + fn=fn, + ) + conclusion.supports.append(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, **kwargs) -> Claim: + result_value = wrapped_fn(*args, **kwargs) + conclusion = _wrap_result(return_type, result_value) + action = Compute( + label=label, + rationale=inspect.getdoc(wrapped_fn) or "", + background=list(background or []), + conclusion=conclusion, + given=tuple(args), + fn=wrapped_fn, + ) + conclusion.supports.append(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/lang/runtime/__init__.py b/gaia/lang/runtime/__init__.py index 95bed1a3e..0bec03469 100644 --- a/gaia/lang/runtime/__init__.py +++ b/gaia/lang/runtime/__init__.py @@ -1,15 +1,35 @@ +from gaia.lang.runtime.action import ( + Action, + Compute, + Contradict, + Derive, + Equal, + Infer, + Observe, + Relate, + Support, +) from gaia.lang.runtime.grounding import Grounding from gaia.lang.runtime.knowledge import Claim, Context, Knowledge, Question, Setting from gaia.lang.runtime.nodes import Operator, Step, Strategy __all__ = [ + "Action", "Claim", + "Compute", "Context", + "Contradict", + "Derive", + "Equal", "Grounding", + "Infer", "Knowledge", + "Observe", "Operator", "Question", + "Relate", "Setting", "Step", "Strategy", + "Support", ] diff --git a/gaia/lang/runtime/action.py b/gaia/lang/runtime/action.py new file mode 100644 index 000000000..ccdf15ee5 --- /dev/null +++ b/gaia/lang/runtime/action.py @@ -0,0 +1,89 @@ +"""Gaia Lang v6 Action class hierarchy.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable + + from gaia.lang.runtime.knowledge import Claim, Knowledge + + +@dataclass +class Action: + """Base reasoning action. Parallel to Knowledge, not a Knowledge subclass.""" + + label: str | None = None + rationale: str = "" + background: list[Knowledge] = field(default_factory=list) + warrants: list[Claim] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + from gaia.lang.runtime.knowledge import _current_package + + pkg = _current_package.get() + if pkg is None: + from gaia.lang.runtime.package import infer_package_from_callstack + + pkg = infer_package_from_callstack() + if pkg is not None: + pkg._register_action(self) + + +@dataclass +class Support(Action): + """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 Relate(Action): + """Logical constraint between two Claims.""" + + a: Claim | None = None + b: Claim | None = None + helper: Claim | None = None + + +@dataclass +class Equal(Relate): + """Declares two Claims equivalent.""" + + +@dataclass +class Contradict(Relate): + """Declares two Claims contradictory.""" + + +@dataclass +class Infer(Action): + """Bayesian inference: P(E|H) update.""" + + hypothesis: Claim | None = None + evidence: Claim | None = None + p_e_given_h: float = 0.5 + p_e_given_not_h: float = 0.5 + helper: Claim | None = None diff --git a/gaia/lang/runtime/knowledge.py b/gaia/lang/runtime/knowledge.py index ff6e74316..311e6e319 100644 --- a/gaia/lang/runtime/knowledge.py +++ b/gaia/lang/runtime/knowledge.py @@ -11,6 +11,7 @@ from gaia.lang.runtime.param import UNBOUND if TYPE_CHECKING: + from gaia.lang.runtime.action import Action from gaia.lang.runtime.package import CollectedPackage _current_package: ContextVar[CollectedPackage | None] = ContextVar("_current_package", default=None) @@ -82,7 +83,7 @@ class Claim(Knowledge): prior: float | None = None grounding: Grounding | None = None - supports: list[Any] = field(default_factory=list) + supports: list[Action] = field(default_factory=list) _param_fields: ClassVar[dict[str, Any]] = {} def __init_subclass__(cls, **kwargs): diff --git a/gaia/lang/runtime/package.py b/gaia/lang/runtime/package.py index 153d78aa9..2dab1e002 100644 --- a/gaia/lang/runtime/package.py +++ b/gaia/lang/runtime/package.py @@ -5,10 +5,14 @@ import inspect import sys from pathlib import Path +from typing import TYPE_CHECKING from gaia.lang.runtime.knowledge import Knowledge, _current_package from gaia.lang.runtime.nodes import Operator, Strategy +if TYPE_CHECKING: + from gaia.lang.runtime.action import Action + try: import tomllib except ImportError: @@ -25,6 +29,7 @@ def __init__(self, name: str, *, namespace: str = "github", version: str = "0.1. self.knowledge: list[Knowledge] = [] self.strategies: list[Strategy] = [] self.operators: list[Operator] = [] + self.actions: list[Action] = [] self._token = None self._module_counters: dict[str | None, int] = {} self._module_order: list[str] = [] @@ -54,6 +59,9 @@ def _register_strategy(self, s: Strategy): def _register_operator(self, o: Operator): self.operators.append(o) + def _register_action(self, a: Action): + self.actions.append(a) + @property def exported(self) -> list[str]: if self._exported_labels: diff --git a/tests/gaia/lang/test_action_hierarchy.py b/tests/gaia/lang/test_action_hierarchy.py new file mode 100644 index 000000000..600136cff --- /dev/null +++ b/tests/gaia/lang/test_action_hierarchy.py @@ -0,0 +1,44 @@ +from gaia.lang.runtime.action import ( + Action, + Compute, + Contradict, + Derive, + Equal, + Infer, + Observe, + Relate, + Support, +) + + +def test_action_base_has_label(): + action = Derive(label="my_step", rationale="test") + assert action.label == "my_step" + + +def test_derive_is_support(): + assert issubclass(Derive, Support) + assert issubclass(Support, Action) + + +def test_observe_is_support(): + assert issubclass(Observe, Support) + + +def test_compute_is_support(): + assert issubclass(Compute, Support) + + +def test_equal_is_relate(): + assert issubclass(Equal, Relate) + assert issubclass(Relate, Action) + + +def test_contradict_is_relate(): + assert issubclass(Contradict, Relate) + + +def test_infer_is_action(): + assert issubclass(Infer, Action) + assert not issubclass(Infer, Support) + assert not issubclass(Infer, Relate) diff --git a/tests/gaia/lang/test_compute_v6.py b/tests/gaia/lang/test_compute_v6.py new file mode 100644 index 000000000..077a82118 --- /dev/null +++ b/tests/gaia/lang/test_compute_v6.py @@ -0,0 +1,42 @@ +from gaia.lang import compute +from gaia.lang.runtime.action import Compute +from gaia.lang.runtime.knowledge import Claim + + +class IntClaim(Claim): + """Value is {value}.""" + + value: int + + +class SumResult(Claim): + """Sum is {value}.""" + + value: int + + +def test_compute_function(): + a = IntClaim(value=3) + b = IntClaim(value=4) + result = compute(SumResult, fn=lambda a, b: a.value + b.value, given=(a, b), rationale="Addition.") + assert isinstance(result, SumResult) + assert result.value == 7 + assert len(result.supports) == 1 + assert isinstance(result.supports[0], Compute) + assert result.supports[0].given == (a, b) + + +def test_compute_decorator(): + @compute + def add(a: IntClaim, b: IntClaim) -> SumResult: + """Add two integers.""" + return a.value + b.value + + a = IntClaim(value=3) + b = IntClaim(value=4) + result = add(a, b) + assert isinstance(result, SumResult) + assert result.value == 7 + assert len(result.supports) == 1 + assert isinstance(result.supports[0], Compute) + assert result.supports[0].rationale == "Add two integers." diff --git a/tests/gaia/lang/test_derive.py b/tests/gaia/lang/test_derive.py new file mode 100644 index 000000000..4db673682 --- /dev/null +++ b/tests/gaia/lang/test_derive.py @@ -0,0 +1,63 @@ +from gaia.lang import derive +from gaia.lang.runtime.action import Derive +from gaia.lang.runtime.knowledge import Claim, Setting +from gaia.lang.runtime.package import CollectedPackage + + +def test_derive_returns_conclusion(): + a = Claim("Premise A.") + b = Claim("Premise B.") + c = Claim("Conclusion.") + result = derive(c, given=(a, b), rationale="A and B imply C.") + assert result is c + + +def test_derive_str_creates_claim(): + a = Claim("Premise.") + c = derive("New conclusion.", given=a, rationale="Follows from A.") + assert isinstance(c, Claim) + assert c.content == "New conclusion." + + +def test_derive_attaches_to_supports(): + a = Claim("Premise.") + c = Claim("Conclusion.") + derive(c, given=a, rationale="Test.") + assert len(c.supports) == 1 + assert isinstance(c.supports[0], Derive) + + +def test_derive_multiple_supports(): + a = Claim("A.") + b = Claim("B.") + c = Claim("C.") + derive(c, given=a, rationale="From A.") + derive(c, given=b, rationale="From B.") + assert len(c.supports) == 2 + + +def test_derive_single_given_not_tuple(): + a = Claim("Premise.") + c = derive("Conclusion.", given=a, rationale="Test.") + assert isinstance(c.supports[0].given, tuple) + assert len(c.supports[0].given) == 1 + + +def test_derive_with_label(): + a = Claim("Premise.") + c = derive("Conclusion.", given=a, rationale="Test.", label="my_step") + assert c.supports[0].label == "my_step" + + +def test_derive_with_background(): + a = Claim("Premise.") + bg = Setting("Lab conditions.") + c = derive("Conclusion.", given=a, background=[bg], rationale="Test.") + assert c.supports[0].background == [bg] + + +def test_derive_registers_action_with_package(): + with CollectedPackage("v6_test") as pkg: + a = Claim("Premise.") + c = derive("Conclusion.", given=a, rationale="Test.") + assert pkg.actions == [c.supports[0]] diff --git a/tests/gaia/lang/test_observe.py b/tests/gaia/lang/test_observe.py new file mode 100644 index 000000000..613887505 --- /dev/null +++ b/tests/gaia/lang/test_observe.py @@ -0,0 +1,21 @@ +from gaia.lang import observe +from gaia.lang.runtime.action import Observe +from gaia.lang.runtime.knowledge import Claim + + +def test_observe_with_given(): + calibrated = Claim("Calibration OK.", prior=0.95) + data = observe("UV spectrum data.", given=calibrated, rationale="Measured.") + assert isinstance(data, Claim) + assert len(data.supports) == 1 + assert isinstance(data.supports[0], Observe) + assert data.supports[0].given == (calibrated,) + + +def test_observe_root_fact_adds_grounding_and_reviewable_action(): + data = observe("UV spectrum data.", rationale="Measured at 5 points.") + assert data.grounding is not None + assert data.grounding.kind == "source_fact" + assert len(data.supports) == 1 + assert isinstance(data.supports[0], Observe) + assert data.supports[0].given == () From 5e441cae1f5f5c05ae9820164b7402592657c10c Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 19:52:26 +0800 Subject: [PATCH 010/210] feat(lang): add v6 relate and infer verbs --- gaia/lang/__init__.py | 4 ++ gaia/lang/dsl/__init__.py | 5 +- gaia/lang/dsl/infer_verb.py | 71 +++++++++++++++++++++++++++ gaia/lang/dsl/relate.py | 34 +++++++++++++ tests/gaia/lang/test_contradict_v6.py | 29 +++++++++++ tests/gaia/lang/test_equal.py | 37 ++++++++++++++ tests/gaia/lang/test_infer.py | 66 +++++++++++++++++++++++++ 7 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 gaia/lang/dsl/infer_verb.py create mode 100644 gaia/lang/dsl/relate.py create mode 100644 tests/gaia/lang/test_contradict_v6.py create mode 100644 tests/gaia/lang/test_equal.py create mode 100644 tests/gaia/lang/test_infer.py diff --git a/gaia/lang/__init__.py b/gaia/lang/__init__.py index dafbe5cf0..ace61e11a 100644 --- a/gaia/lang/__init__.py +++ b/gaia/lang/__init__.py @@ -8,12 +8,14 @@ compare, composite, complement, + contradict, contradiction, context, compute, deduction, derive, disjunction, + equal, elimination, equivalence, extrapolation, @@ -74,12 +76,14 @@ "compare", "composite", "complement", + "contradict", "contradiction", "context", "compute", "deduction", "derive", "disjunction", + "equal", "elimination", "equivalence", "extrapolation", diff --git a/gaia/lang/dsl/__init__.py b/gaia/lang/dsl/__init__.py index 55ad1ffde..1a3a4a52d 100644 --- a/gaia/lang/dsl/__init__.py +++ b/gaia/lang/dsl/__init__.py @@ -1,5 +1,7 @@ from gaia.lang.dsl.knowledge import claim, context, question, setting +from gaia.lang.dsl.infer_verb import infer from gaia.lang.dsl.operators import complement, contradiction, disjunction, equivalence +from gaia.lang.dsl.relate import contradict, equal from gaia.lang.dsl.support import compute, derive, observe from gaia.lang.dsl.strategies import ( abduction, @@ -12,7 +14,6 @@ extrapolation, fills, induction, - infer, mathematical_induction, noisy_and, support, @@ -28,10 +29,12 @@ "compare", "composite", "complement", + "contradict", "contradiction", "deduction", "derive", "disjunction", + "equal", "elimination", "equivalence", "extrapolation", diff --git a/gaia/lang/dsl/infer_verb.py b/gaia/lang/dsl/infer_verb.py new file mode 100644 index 000000000..1307d7aca --- /dev/null +++ b/gaia/lang/dsl/infer_verb.py @@ -0,0 +1,71 @@ +"""Gaia Lang v6 Infer verb.""" + +from __future__ import annotations + +import warnings + +from gaia.lang.runtime.action import Infer as InferAction +from gaia.lang.runtime.knowledge import Claim, Knowledge + + +def _claim_ref(claim: Claim) -> str: + if claim.label: + return f"[@{claim.label}]" + return claim.content + + +def infer( + *args, + hypothesis: Claim | None = None, + evidence: Claim | None = None, + background: list[Knowledge] | None = None, + p_e_given_h: float | None = None, + p_e_given_not_h: float | None = None, + rationale: str = "", + label: str | None = None, + **legacy_kwargs, +) -> Claim: + """Bayesian inference. Returns a statistical-support helper Claim. + + The v6 shape is keyword-only. The old v5 ``infer([premises], conclusion, ...)`` + form is preserved as a deprecated compatibility path. + """ + if args: + if isinstance(args[0], (list, tuple)): + from gaia.lang.dsl.strategies import infer as legacy_infer + + warnings.warn( + "infer([premises], conclusion, ...) is deprecated; use keyword-only " + "infer(hypothesis=..., evidence=..., p_e_given_h=..., " + "p_e_given_not_h=...) instead", + DeprecationWarning, + stacklevel=2, + ) + return legacy_infer(*args, **legacy_kwargs) + raise TypeError("v6 infer() arguments are keyword-only") + + 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 p_e_given_not_h is None: + raise TypeError("infer() missing required keyword argument: 'p_e_given_not_h'") + + helper = Claim( + f"{_claim_ref(evidence)} statistically supports {_claim_ref(hypothesis)}.", + metadata={"generated": True, "helper_kind": "statistical_support", "review": True}, + ) + action = InferAction( + label=label, + rationale=rationale, + background=list(background or []), + hypothesis=hypothesis, + evidence=evidence, + p_e_given_h=p_e_given_h, + p_e_given_not_h=p_e_given_not_h, + helper=helper, + ) + action.warrants.append(helper) + return helper diff --git a/gaia/lang/dsl/relate.py b/gaia/lang/dsl/relate.py new file mode 100644 index 000000000..e7f027665 --- /dev/null +++ b/gaia/lang/dsl/relate.py @@ -0,0 +1,34 @@ +"""Gaia Lang v6 Relate verbs: equal, contradict.""" + +from __future__ import annotations + +from gaia.lang.runtime.action import Contradict, Equal +from gaia.lang.runtime.knowledge import Claim + + +def _claim_ref(claim: Claim) -> str: + if claim.label: + return f"[@{claim.label}]" + return claim.content + + +def equal(a: Claim, b: Claim, *, 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, a=a, b=b, helper=helper) + action.warrants.append(helper) + return helper + + +def contradict(a: Claim, b: Claim, *, 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, a=a, b=b, helper=helper) + action.warrants.append(helper) + return helper diff --git a/tests/gaia/lang/test_contradict_v6.py b/tests/gaia/lang/test_contradict_v6.py new file mode 100644 index 000000000..7b8e425ac --- /dev/null +++ b/tests/gaia/lang/test_contradict_v6.py @@ -0,0 +1,29 @@ +from gaia.lang import contradict +from gaia.lang.runtime.action import Contradict +from gaia.lang.runtime.knowledge import Claim +from gaia.lang.runtime.package import CollectedPackage + + +def test_contradict_returns_helper_claim(): + a = Claim("Classical prediction.") + b = Claim("Observation.") + helper = contradict(a, b, rationale="Classical theory fails.") + assert isinstance(helper, Claim) + assert helper.metadata.get("generated") is True + assert helper.metadata.get("helper_kind") == "contradiction_result" + assert helper.metadata.get("review") is True + + +def test_contradict_registers_action_and_warrant(): + with CollectedPackage("v6_test") as pkg: + a = Claim("Classical prediction.") + b = Claim("Observation.") + helper = contradict(a, b, rationale="Classical theory fails.", label="conflict") + assert len(pkg.actions) == 1 + action = pkg.actions[0] + assert isinstance(action, Contradict) + assert action.label == "conflict" + assert action.a is a + assert action.b is b + assert action.helper is helper + assert action.warrants == [helper] diff --git a/tests/gaia/lang/test_equal.py b/tests/gaia/lang/test_equal.py new file mode 100644 index 000000000..64304ba24 --- /dev/null +++ b/tests/gaia/lang/test_equal.py @@ -0,0 +1,37 @@ +from gaia.lang import derive, equal +from gaia.lang.runtime.action import Equal +from gaia.lang.runtime.knowledge import Claim +from gaia.lang.runtime.package import CollectedPackage + + +def test_equal_returns_helper_claim(): + a = Claim("Prediction matches.") + b = Claim("Observation matches.") + helper = equal(a, b, rationale="Theory agrees with data.") + assert isinstance(helper, Claim) + assert helper.metadata.get("generated") is True + assert helper.metadata.get("helper_kind") == "equivalence_result" + assert helper.metadata.get("review") is True + + +def test_equal_registers_action_and_warrant(): + with CollectedPackage("v6_test") as pkg: + a = Claim("Prediction matches.") + b = Claim("Observation matches.") + helper = equal(a, b, rationale="Theory agrees with data.", label="match") + assert len(pkg.actions) == 1 + action = pkg.actions[0] + assert isinstance(action, Equal) + assert action.label == "match" + assert action.a is a + assert action.b is b + assert action.helper is helper + assert action.warrants == [helper] + + +def test_equal_helper_usable_as_premise(): + a = Claim("Pred.") + b = Claim("Obs.") + helper = equal(a, b, rationale="Match.") + c = derive("Theory valid.", given=helper, rationale="Matches imply valid.") + assert c.supports[0].given == (helper,) diff --git a/tests/gaia/lang/test_infer.py b/tests/gaia/lang/test_infer.py new file mode 100644 index 000000000..08fe85ce5 --- /dev/null +++ b/tests/gaia/lang/test_infer.py @@ -0,0 +1,66 @@ +import pytest + +from gaia.lang import infer +from gaia.lang.runtime.action import Infer +from gaia.lang.runtime.knowledge import Claim, Setting +from gaia.lang.runtime.package import CollectedPackage + + +def test_infer_returns_statistical_support(): + h = Claim("Quantum theory is correct.", prior=0.5) + e = Claim("Planck spectrum observed.", prior=0.95) + support = infer( + hypothesis=h, + evidence=e, + p_e_given_h=0.9, + p_e_given_not_h=0.05, + rationale="Strong evidence.", + ) + assert isinstance(support, Claim) + assert support.metadata.get("generated") is True + assert support.metadata.get("helper_kind") == "statistical_support" + assert support.metadata.get("review") is True + + +def test_infer_all_keyword_only_for_v6_shape(): + h = Claim("H.") + e = Claim("E.") + with pytest.raises(TypeError): + infer(h, e, 0.9, 0.1) + + +def test_infer_registers_action_and_warrant(): + with CollectedPackage("v6_test") as pkg: + h = Claim("H.") + e = Claim("E.") + bg = Setting("Experiment conditions.") + helper = infer( + hypothesis=h, + evidence=e, + background=[bg], + p_e_given_h=0.8, + p_e_given_not_h=0.2, + rationale="Test.", + label="bayes_update", + ) + assert len(pkg.actions) == 1 + action = pkg.actions[0] + assert isinstance(action, Infer) + assert action.label == "bayes_update" + assert action.hypothesis is h + assert action.evidence is e + assert action.background == [bg] + assert action.p_e_given_h == 0.8 + assert action.p_e_given_not_h == 0.2 + assert action.helper is helper + assert action.warrants == [helper] + + +def test_infer_preserves_v5_positional_shape(): + a = Claim("A.") + b = Claim("B.") + c = Claim("C.") + strategy = infer([a, b], c, reason="custom CPT") + assert strategy.type == "infer" + assert strategy.premises == [a, b] + assert strategy.conclusion is c From dc500a0d918753c4a44cb832d612c0d81542748d Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 19:55:31 +0800 Subject: [PATCH 011/210] fix(lang): warn on v5 support compatibility path --- gaia/lang/dsl/strategies.py | 7 +++++++ tests/gaia/lang/test_v5_compat.py | 11 +++++++++++ 2 files changed, 18 insertions(+) create mode 100644 tests/gaia/lang/test_v5_compat.py diff --git a/gaia/lang/dsl/strategies.py b/gaia/lang/dsl/strategies.py index 28ebb5151..91dc5f76f 100644 --- a/gaia/lang/dsl/strategies.py +++ b/gaia/lang/dsl/strategies.py @@ -169,6 +169,13 @@ 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) diff --git a/tests/gaia/lang/test_v5_compat.py b/tests/gaia/lang/test_v5_compat.py new file mode 100644 index 000000000..cb11daed1 --- /dev/null +++ b/tests/gaia/lang/test_v5_compat.py @@ -0,0 +1,11 @@ +import pytest + +from gaia.lang import claim, support + + +def test_v5_support_preserves_prior_while_warning(): + a = claim("A.") + b = claim("B.") + with pytest.warns(DeprecationWarning, match="support\\(\\) is deprecated"): + strategy = support([a], b, reason="test", prior=0.9) + assert strategy.metadata["prior"] == 0.9 From bfa6d1878c2efe9f4871103844ed8533cab6a4f4 Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 20:03:14 +0800 Subject: [PATCH 012/210] feat(compiler): lower v6 actions to IR --- gaia/lang/compiler/compile.py | 242 ++++++++++++++++++++++- tests/gaia/lang/test_compiler_actions.py | 147 ++++++++++++++ 2 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 tests/gaia/lang/test_compiler_actions.py diff --git a/gaia/lang/compiler/compile.py b/gaia/lang/compiler/compile.py index 34a493c6b..8e8deb30e 100644 --- a/gaia/lang/compiler/compile.py +++ b/gaia/lang/compiler/compile.py @@ -3,9 +3,10 @@ from __future__ import annotations import hashlib +import inspect import json import re -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, field from typing import Any from gaia.ir import ( @@ -19,6 +20,7 @@ PackageRef as IrPackageRef, Step as IrStep, Strategy as IrStrategy, + StrategyParamRecord, formalize_named_strategy, make_qid, ) @@ -30,6 +32,15 @@ validate_groups, ) from gaia.lang.runtime import Knowledge, Operator +from gaia.lang.runtime.action import ( + Compute, + Contradict, + Equal, + Infer as InferAction, + Observe, + Relate, + Support, +) from gaia.lang.runtime.package import CollectedPackage from gaia.lang.runtime.param import UNBOUND @@ -55,6 +66,9 @@ class CompiledPackage: 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) + strategy_param_records: list[StrategyParamRecord] = field(default_factory=list) def to_json(self) -> dict[str, Any]: return self.graph.model_dump(mode="json", exclude_none=True, serialize_as_any=True) @@ -87,6 +101,10 @@ 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 _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 @@ -186,6 +204,14 @@ def _operator_id(o: Operator, knowledge_map: dict[int, str]) -> str: 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], @@ -236,6 +262,61 @@ def _compile_reason( 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_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 _collect_refs_from_text( text: str | None, label_table: dict[str, str], @@ -326,6 +407,31 @@ def register_strategy_knowledge(strategy: Any) -> None: for sub_strategy in strategy.sub_strategies: register_strategy_knowledge(sub_strategy) + def register_action_knowledge(action: Any) -> None: + for background in getattr(action, "background", []) or []: + register_knowledge(background) + for warrant in getattr(action, "warrants", []) or []: + register_knowledge(warrant) + if isinstance(action, Support): + for given in action.given: + register_knowledge(given) + if action.conclusion is not None: + register_knowledge(action.conclusion) + elif isinstance(action, Relate): + if action.a is not None: + register_knowledge(action.a) + if action.b is not None: + register_knowledge(action.b) + if action.helper is not None: + register_knowledge(action.helper) + elif isinstance(action, InferAction): + if action.hypothesis is not None: + register_knowledge(action.hypothesis) + if action.evidence is not None: + register_knowledge(action.evidence) + if action.helper is not None: + register_knowledge(action.helper) + for k in pkg.knowledge: if _is_composition_warrant(k): continue @@ -337,6 +443,8 @@ def register_strategy_knowledge(strategy: Any) -> None: register_knowledge(variable) if o.conclusion is not None: register_knowledge(o.conclusion) + for action in getattr(pkg, "actions", []): + register_action_knowledge(action) # Assign stable IDs to all knowledge nodes, preserving foreign package identity when known. knowledge_map: dict[int, str] = {} @@ -422,6 +530,125 @@ def compile_strategy(s) -> IrStrategy: compiled_strategies[strategy_key] = ir_strategy return ir_strategy + action_label_map: dict[str, str] = {} + target_action_labels_by_id: dict[str, str] = {} + strategy_param_records: list[StrategyParamRecord] = [] + + def _record_action_target(action_label: str, target_id: str | None) -> None: + if target_id is None: + return + action_label_map[action_label] = target_id + target_action_labels_by_id[target_id] = action_label + + def _compile_support_action(action: Support, action_index: int) -> IrStrategy: + if action.conclusion is None: + raise ValueError("Support action requires a conclusion") + premise_ids = [knowledge_map[id(given)] for given in action.given] + conclusion_id = knowledge_map[id(action.conclusion)] + background_ids = [knowledge_map[id(bg)] for bg in action.background] or None + if isinstance(action, Observe): + pattern = "observation" + elif isinstance(action, Compute): + pattern = "computation" + else: + pattern = "derivation" + extra = {"compute": _compute_metadata(action.fn)} if isinstance(action, Compute) else None + action_label, metadata = _action_metadata( + action, + pkg, + action_index, + pattern=pattern, + extra=extra, + ) + + if premise_ids: + result = formalize_named_strategy( + scope="local", + type_="deduction", + premises=premise_ids, + conclusion=conclusion_id, + namespace=pkg.namespace, + package_name=pkg.name, + background=background_ids, + steps=_action_steps(action.rationale), + metadata=metadata, + ) + _mark_formal_action_reviews(result.knowledges) + generated_knowledges.extend(result.knowledges) + strategy = result.strategy + else: + strategy = IrStrategy( + scope="local", + type="deduction", + premises=[], + conclusion=conclusion_id, + background=background_ids, + steps=_action_steps(action.rationale), + metadata=metadata, + ) + _record_action_target(action_label, strategy.strategy_id) + return strategy + + def _compile_relate_action(action: Relate, action_index: int) -> IrOperator: + if action.a is None or action.b is None or action.helper is None: + raise ValueError("Relate action requires a, b, and helper") + if isinstance(action, Equal): + operator = "equivalence" + pattern = "equivalence" + elif isinstance(action, Contradict): + operator = "contradiction" + pattern = "contradiction" + else: + raise ValueError(f"Unsupported Relate action: {type(action).__name__}") + action_label, metadata = _action_metadata(action, pkg, action_index, pattern=pattern) + if action.rationale: + metadata["reason"] = action.rationale + variables = [knowledge_map[id(action.a)], knowledge_map[id(action.b)]] + conclusion = 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, + ) + _record_action_target(action_label, ir_operator.operator_id) + return ir_operator + + def _compile_infer_action(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, pkg, action_index, pattern="inference") + strategy = IrStrategy( + scope="local", + type="infer", + premises=[knowledge_map[id(action.hypothesis)]], + conclusion=knowledge_map[id(action.evidence)], + background=[knowledge_map[id(bg)] for bg in action.background] or None, + steps=_action_steps(action.rationale), + metadata=metadata, + ) + strategy_param_records.append( + StrategyParamRecord( + strategy_id=strategy.strategy_id, + conditional_probabilities=[action.p_e_given_not_h, action.p_e_given_h], + source_id="author", + justification=action.rationale, + ) + ) + _record_action_target(action_label, strategy.strategy_id) + return strategy + + def compile_action(action: Any, action_index: int) -> IrStrategy | IrOperator: + if isinstance(action, Support): + return _compile_support_action(action, action_index) + if isinstance(action, Relate): + return _compile_relate_action(action, action_index) + if isinstance(action, InferAction): + return _compile_infer_action(action, action_index) + raise ValueError(f"Unsupported action type: {type(action).__name__}") + emitted_strategies: set[int] = set() for s in pkg.strategies: strategy_key = id(s) @@ -430,6 +657,14 @@ def compile_strategy(s) -> IrStrategy: ir_strategies.append(compile_strategy(s)) emitted_strategies.add(strategy_key) + action_operators: list[IrOperator] = [] + for action_index, action in enumerate(getattr(pkg, "actions", [])): + target = compile_action(action, action_index) + if isinstance(target, IrOperator): + action_operators.append(target) + else: + ir_strategies.append(target) + # 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: @@ -536,7 +771,7 @@ def _handle(text: str | None) -> None: namespace=pkg.namespace, package_name=pkg.name, knowledges=[*ir_knowledges, *generated_knowledges], - operators=ir_operators, + operators=[*ir_operators, *action_operators], strategies=ir_strategies, module_order=module_order, module_titles=module_titles if module_titles else None, @@ -546,6 +781,9 @@ def _handle(text: str | None) -> None: graph=graph, knowledge_ids_by_object=dict(knowledge_map), strategies_by_object=dict(compiled_strategies), + action_label_map=action_label_map, + target_action_labels_by_id=target_action_labels_by_id, + strategy_param_records=strategy_param_records, ) diff --git a/tests/gaia/lang/test_compiler_actions.py b/tests/gaia/lang/test_compiler_actions.py new file mode 100644 index 000000000..2ae88d1ed --- /dev/null +++ b/tests/gaia/lang/test_compiler_actions.py @@ -0,0 +1,147 @@ +from gaia.lang import Claim, compute, contradict, derive, equal, infer, observe +from gaia.lang.compiler import compile_package_artifact +from gaia.lang.runtime.package import CollectedPackage + + +class IntClaim(Claim): + """Value is {value}.""" + + value: int + + +class SumResult(Claim): + """Sum is {value}.""" + + value: int + + +def _knowledge_by_label(compiled): + return {k.label: k for k in compiled.graph.knowledges if k.label} + + +def test_compile_derive_action_to_deduction_formal_strategy(): + with CollectedPackage("v6_actions") as pkg: + a = Claim("A.") + a.label = "a" + b = Claim("B.") + b.label = "b" + c = derive("C.", given=(a, b), rationale="A and B imply C.", label="derive_c") + c.label = "c" + + compiled = compile_package_artifact(pkg) + assert compiled.action_label_map["github:v6_actions::action::derive_c"].startswith("lcs_") + strategy = compiled.graph.strategies[0] + assert strategy.type == "deduction" + assert strategy.metadata["action_label"] == "github:v6_actions::action::derive_c" + assert strategy.metadata["pattern"] == "derivation" + assert strategy.steps[0].reasoning == "A and B imply C." + + implication_helpers = [ + k + for k in compiled.graph.knowledges + if (k.metadata or {}).get("helper_kind") == "implication_result" + ] + conjunction_helpers = [ + k + for k in compiled.graph.knowledges + if (k.metadata or {}).get("helper_kind") == "conjunction_result" + ] + assert implication_helpers[0].metadata["review"] is True + assert conjunction_helpers[0].metadata["review"] is False + + +def test_compile_root_observe_action_to_reviewable_strategy_and_grounding(): + with CollectedPackage("v6_actions") as pkg: + data = observe("UV spectrum data.", rationale="Measured.", label="observe_uv") + data.label = "uv" + + compiled = compile_package_artifact(pkg) + strategy = compiled.graph.strategies[0] + assert strategy.type == "deduction" + assert strategy.premises == [] + assert strategy.conclusion == "github:v6_actions::uv" + assert strategy.metadata["pattern"] == "observation" + assert strategy.metadata["action_label"] == "github:v6_actions::action::observe_uv" + + uv = _knowledge_by_label(compiled)["uv"] + assert uv.metadata["grounding"]["kind"] == "source_fact" + + +def test_compile_compute_action_to_deduction_with_compute_metadata(): + with CollectedPackage("v6_actions") as pkg: + a = IntClaim(value=3) + a.label = "a" + b = IntClaim(value=4) + b.label = "b" + result = compute( + SumResult, + fn=lambda a, b: a.value + b.value, + given=(a, b), + rationale="Addition.", + label="sum", + ) + result.label = "sum_result" + + compiled = compile_package_artifact(pkg) + strategy = compiled.graph.strategies[0] + assert strategy.type == "deduction" + assert strategy.metadata["pattern"] == "computation" + assert strategy.metadata["action_label"] == "github:v6_actions::action::sum" + assert "compute" in strategy.metadata + assert strategy.metadata["compute"]["function_ref"] + + +def test_compile_equal_and_contradict_actions_to_operators(): + with CollectedPackage("v6_actions") as pkg: + a = Claim("A.") + a.label = "a" + b = Claim("B.") + b.label = "b" + eq = equal(a, b, rationale="Same.", label="same") + eq.label = "same_helper" + conflict = contradict(a, b, rationale="Conflict.", label="conflict") + conflict.label = "conflict_helper" + + compiled = compile_package_artifact(pkg) + by_operator = {op.operator: op for op in compiled.graph.operators} + assert by_operator["equivalence"].metadata["action_label"] == "github:v6_actions::action::same" + assert by_operator["equivalence"].conclusion == "github:v6_actions::same_helper" + assert by_operator["contradiction"].metadata["action_label"] == ( + "github:v6_actions::action::conflict" + ) + assert by_operator["contradiction"].conclusion == "github:v6_actions::conflict_helper" + + +def test_compile_infer_action_to_strategy_and_cpt_record(): + with CollectedPackage("v6_actions") as pkg: + h = Claim("H.") + h.label = "h" + e = Claim("E.") + e.label = "e" + bg = Claim("Measurement reliable.") + bg.label = "reliable" + helper = infer( + hypothesis=h, + evidence=e, + background=[bg], + p_e_given_h=0.8, + p_e_given_not_h=0.2, + rationale="Bayes.", + label="bayes_update", + ) + helper.label = "stat_support" + + compiled = compile_package_artifact(pkg) + strategy = compiled.graph.strategies[0] + assert strategy.type == "infer" + assert strategy.premises == ["github:v6_actions::h"] + assert strategy.conclusion == "github:v6_actions::e" + assert strategy.background == ["github:v6_actions::reliable"] + assert strategy.metadata["action_label"] == "github:v6_actions::action::bayes_update" + assert strategy.steps[0].reasoning == "Bayes." + assert compiled.strategy_param_records[0].strategy_id == strategy.strategy_id + assert compiled.strategy_param_records[0].conditional_probabilities == [0.2, 0.8] + + stat_support = _knowledge_by_label(compiled)["stat_support"] + assert stat_support.metadata["helper_kind"] == "statistical_support" + assert stat_support.metadata["review"] is True From 160ebe69b41c9d71f792332f30f6f6a059a180ed Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 20:07:07 +0800 Subject: [PATCH 013/210] test(cli): cover v6 action compilation --- tests/cli/test_compile_v6_actions.py | 72 ++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/cli/test_compile_v6_actions.py diff --git a/tests/cli/test_compile_v6_actions.py b/tests/cli/test_compile_v6_actions.py new file mode 100644 index 000000000..0a7dc14fc --- /dev/null +++ b/tests/cli/test_compile_v6_actions.py @@ -0,0 +1,72 @@ +import json + +from typer.testing import CliRunner + +from gaia.cli.main import app + +runner = CliRunner() + + +def test_compile_v6_actions_package(tmp_path): + pkg_dir = tmp_path / "v6-actions-gaia" + pkg_dir.mkdir() + (pkg_dir / "pyproject.toml").write_text( + '[project]\nname = "v6-actions-gaia"\nversion = "0.1.0"\n\n' + '[tool.gaia]\nnamespace = "github"\ntype = "knowledge-package"\n' + ) + pkg_src = pkg_dir / "v6_actions" + pkg_src.mkdir() + (pkg_src / "__init__.py").write_text( + "from gaia.lang import claim, contradict, derive, equal, infer, observe\n\n" + 'calibrated = claim("Spectrometer is calibrated.")\n' + 'data = observe("UV spectrum is finite.", rationale="Measured.", label="observe_uv")\n' + 'prediction = claim("Planck model predicts finite UV spectrum.")\n' + 'classical = claim("Classical model predicts divergent UV spectrum.")\n' + 'agreement = equal(prediction, data, rationale="Prediction matches data.", label="match")\n' + 'conflict = contradict(classical, data, rationale="Prediction conflicts.", label="conflict")\n' + "stat_support = infer(\n" + " hypothesis=prediction,\n" + " evidence=data,\n" + " background=[calibrated],\n" + " p_e_given_h=0.9,\n" + " p_e_given_not_h=0.1,\n" + ' rationale="Bayesian update.",\n' + ' label="bayes_update",\n' + ")\n" + "favored = derive(\n" + ' "Planck model is favored.",\n' + " given=(agreement, conflict, stat_support),\n" + ' rationale="Agreement, conflict, and Bayes support favor Planck.",\n' + ' label="favor_planck",\n' + ")\n" + '__all__ = ["favored"]\n' + ) + + result = runner.invoke(app, ["compile", str(pkg_dir)]) + assert result.exit_code == 0, result.output + + ir = json.loads((pkg_dir / ".gaia" / "ir.json").read_text()) + strategy_patterns = { + s["metadata"]["pattern"] + for s in ir["strategies"] + if s.get("metadata") and "pattern" in s["metadata"] + } + assert {"observation", "derivation", "inference"} <= strategy_patterns + + operator_types = {op["operator"] for op in ir["operators"]} + assert {"equivalence", "contradiction"} <= operator_types + + action_labels = [ + s["metadata"]["action_label"] + for s in ir["strategies"] + if s.get("metadata") and "action_label" in s["metadata"] + ] + action_labels.extend( + op["metadata"]["action_label"] + for op in ir["operators"] + if op.get("metadata") and "action_label" in op["metadata"] + ) + assert "github:v6_actions::action::observe_uv" in action_labels + assert "github:v6_actions::action::favor_planck" in action_labels + assert "github:v6_actions::action::match" in action_labels + assert "github:v6_actions::action::conflict" in action_labels From 946a57fca8c85d90801246d9797985bf27aad2f6 Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 20:16:41 +0800 Subject: [PATCH 014/210] style: format v6 compute test --- tests/gaia/lang/test_compute_v6.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/gaia/lang/test_compute_v6.py b/tests/gaia/lang/test_compute_v6.py index 077a82118..5ef4f0f6d 100644 --- a/tests/gaia/lang/test_compute_v6.py +++ b/tests/gaia/lang/test_compute_v6.py @@ -18,7 +18,9 @@ class SumResult(Claim): def test_compute_function(): a = IntClaim(value=3) b = IntClaim(value=4) - result = compute(SumResult, fn=lambda a, b: a.value + b.value, given=(a, b), rationale="Addition.") + result = compute( + SumResult, fn=lambda a, b: a.value + b.value, given=(a, b), rationale="Addition." + ) assert isinstance(result, SumResult) assert result.value == 7 assert len(result.supports) == 1 From 6fa66b5769054766a41ec01b7dbe441ad4b57b53 Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 23:14:49 +0800 Subject: [PATCH 015/210] fix(lang): preserve compute keyword inputs --- gaia/lang/dsl/support.py | 18 +++++++++++++++++- tests/gaia/lang/test_compute_v6.py | 23 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/gaia/lang/dsl/support.py b/gaia/lang/dsl/support.py index 9a9525e18..243278e8f 100644 --- a/gaia/lang/dsl/support.py +++ b/gaia/lang/dsl/support.py @@ -74,6 +74,22 @@ def _wrap_result(return_type: type[Claim], result_value: Any) -> Claim: return return_type(value=result_value) +def _bound_given(sig: inspect.Signature, *args, **kwargs) -> tuple[Knowledge, ...]: + 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 tuple(given) + + def _compute_call( conclusion_type: type[Claim], *, @@ -127,7 +143,7 @@ def wrapper(*args, **kwargs) -> Claim: rationale=inspect.getdoc(wrapped_fn) or "", background=list(background or []), conclusion=conclusion, - given=tuple(args), + given=_bound_given(sig, *args, **kwargs), fn=wrapped_fn, ) conclusion.supports.append(action) diff --git a/tests/gaia/lang/test_compute_v6.py b/tests/gaia/lang/test_compute_v6.py index 5ef4f0f6d..3422d0726 100644 --- a/tests/gaia/lang/test_compute_v6.py +++ b/tests/gaia/lang/test_compute_v6.py @@ -1,6 +1,8 @@ from gaia.lang import compute +from gaia.lang.compiler import compile_package_artifact from gaia.lang.runtime.action import Compute from gaia.lang.runtime.knowledge import Claim +from gaia.lang.runtime.package import CollectedPackage class IntClaim(Claim): @@ -42,3 +44,24 @@ def add(a: IntClaim, b: IntClaim) -> SumResult: assert len(result.supports) == 1 assert isinstance(result.supports[0], Compute) assert result.supports[0].rationale == "Add two integers." + + +def test_compute_decorator_keyword_args_record_given_claims(): + @compute + def add(a: IntClaim, b: IntClaim) -> SumResult: + """Add two integers.""" + return a.value + b.value + + with CollectedPackage("kw_compute") as pkg: + a = IntClaim(value=3) + a.label = "a" + b = IntClaim(value=4) + b.label = "b" + result = add(a=a, b=b) + result.label = "sum" + + assert result.supports[0].given == (a, b) + + compiled = compile_package_artifact(pkg) + strategy = compiled.graph.strategies[0] + assert strategy.premises == ["github:kw_compute::a", "github:kw_compute::b"] From 17ba7778b05a021733ba1bfc24b7ecf53331eefa Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 20:31:12 +0800 Subject: [PATCH 016/210] feat(ir): add qualitative review manifest models --- gaia/ir/__init__.py | 5 +++ gaia/ir/review.py | 45 +++++++++++++++++++++++++ tests/gaia/ir/test_review.py | 65 ++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 gaia/ir/review.py create mode 100644 tests/gaia/ir/test_review.py diff --git a/gaia/ir/__init__.py b/gaia/ir/__init__.py index 1c5854cd5..751d61003 100644 --- a/gaia/ir/__init__.py +++ b/gaia/ir/__init__.py @@ -33,6 +33,7 @@ ResolutionPolicy, StrategyParamRecord, ) +from gaia.ir.review import Review, ReviewManifest, ReviewStatus __all__ = [ # Knowledge @@ -62,4 +63,8 @@ "PriorRecord", "ResolutionPolicy", "StrategyParamRecord", + # Review + "Review", + "ReviewManifest", + "ReviewStatus", ] diff --git a/gaia/ir/review.py b/gaia/ir/review.py new file mode 100644 index 000000000..cfa8f1571 --- /dev/null +++ b/gaia/ir/review.py @@ -0,0 +1,45 @@ +"""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): + UNREVIEWED = "unreviewed" + ACCEPTED = "accepted" + REJECTED = "rejected" + NEEDS_INPUTS = "needs_inputs" + + +class Review(BaseModel): + """Qualitative review record for a compiled Strategy or Operator target.""" + + model_config = ConfigDict(extra="forbid") + + review_id: str + action_label: str + target_kind: Literal["strategy", "operator"] + 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: + 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/tests/gaia/ir/test_review.py b/tests/gaia/ir/test_review.py new file mode 100644 index 000000000..91b9b3790 --- /dev/null +++ b/tests/gaia/ir/test_review.py @@ -0,0 +1,65 @@ +import pytest +from pydantic import ValidationError + +from gaia.ir.review import Review, ReviewManifest, ReviewStatus + + +def test_review_status_enum(): + assert ReviewStatus.UNREVIEWED == "unreviewed" + assert ReviewStatus.ACCEPTED == "accepted" + assert ReviewStatus.REJECTED == "rejected" + assert ReviewStatus.NEEDS_INPUTS == "needs_inputs" + + +def test_review_creation(): + review = Review( + review_id="rev_001", + action_label="github:blackbody::action::planck_resolves", + target_kind="strategy", + target_id="lcs_abc123", + status=ReviewStatus.UNREVIEWED, + audit_question="Do premises suffice to establish [@quantum_hyp]?", + round=1, + ) + assert review.status == "unreviewed" + assert review.action_label == "github:blackbody::action::planck_resolves" + + +def test_review_manifest_latest_status(): + r1 = Review( + review_id="rev_001", + action_label="a", + target_kind="strategy", + target_id="lcs_1", + status="unreviewed", + audit_question="?", + round=1, + ) + r2 = Review( + review_id="rev_002", + action_label="a", + target_kind="strategy", + target_id="lcs_1", + status="accepted", + audit_question="?", + round=2, + ) + manifest = ReviewManifest(reviews=[r1, r2]) + assert manifest.latest_status("lcs_1") == "accepted" + + +def test_review_manifest_missing_status(): + assert ReviewManifest().latest_status("missing") is None + + +def test_review_rejects_probability_fields(): + with pytest.raises(ValidationError): + Review( + review_id="rev_bad", + action_label="a", + target_kind="strategy", + target_id="lcs_1", + status="accepted", + audit_question="?", + prior=0.9, + ) From 745e471fe8d97a22f3cb97c48110a9c525d64330 Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 20:34:16 +0800 Subject: [PATCH 017/210] feat(lang): generate review manifests for v6 actions --- gaia/lang/review/__init__.py | 6 ++ gaia/lang/review/manifest.py | 113 ++++++++++++++++++++++++ gaia/lang/review/templates.py | 26 ++++++ tests/gaia/lang/test_review_manifest.py | 67 ++++++++++++++ 4 files changed, 212 insertions(+) create mode 100644 gaia/lang/review/__init__.py create mode 100644 gaia/lang/review/manifest.py create mode 100644 gaia/lang/review/templates.py create mode 100644 tests/gaia/lang/test_review_manifest.py diff --git a/gaia/lang/review/__init__.py b/gaia/lang/review/__init__.py new file mode 100644 index 000000000..aef91c244 --- /dev/null +++ b/gaia/lang/review/__init__.py @@ -0,0 +1,6 @@ +"""Review helpers for Gaia Lang v6.""" + +from gaia.lang.review.manifest import generate_review_manifest +from gaia.lang.review.templates import generate_audit_question + +__all__ = ["generate_audit_question", "generate_review_manifest"] diff --git a/gaia/lang/review/manifest.py b/gaia/lang/review/manifest.py new file mode 100644 index 000000000..49472d7cf --- /dev/null +++ b/gaia/lang/review/manifest.py @@ -0,0 +1,113 @@ +"""Generate ReviewManifest records from compiled v6 action targets.""" + +from __future__ import annotations + +import hashlib +from typing import Any + +from gaia.ir import Review, ReviewManifest, ReviewStatus +from gaia.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.operator == "equivalence": + return "equal" + if operator.operator == "contradiction": + return "contradict" + 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 "" + return generate_audit_question( + "infer", + hypothesis_label=labels.get(hypothesis, hypothesis), + evidence_label=labels.get(strategy.conclusion, strategy.conclusion or "?"), + ) + return generate_audit_question( + action_type, + conclusion_label=labels.get(strategy.conclusion, strategy.conclusion or "?"), + ) + + +def _operator_question(operator: Any, action_type: str, labels: dict[str, str]) -> str: + 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 generate_review_manifest(compiled: Any) -> ReviewManifest: + """Generate unreviewed Review records for each v6 action target.""" + labels = _labels_by_id(compiled) + reviews: list[Review] = [] + + for strategy in compiled.graph.strategies: + metadata = strategy.metadata or {} + action_label = metadata.get("action_label") + if not action_label or not strategy.strategy_id: + continue + action_type = _strategy_action_type(strategy) + reviews.append( + 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, + ) + ) + + for operator in compiled.graph.operators: + metadata = operator.metadata or {} + action_label = metadata.get("action_label") + if not action_label or not operator.operator_id: + continue + action_type = _operator_action_type(operator) + reviews.append( + 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, + ) + ) + + return ReviewManifest(reviews=reviews) diff --git a/gaia/lang/review/templates.py b/gaia/lang/review/templates.py new file mode 100644 index 000000000..6eea66f35 --- /dev/null +++ b/gaia/lang/review/templates.py @@ -0,0 +1,26 @@ +"""Audit-question templates for Gaia Lang v6 review targets.""" + +from __future__ import annotations + + +class _MissingLabelDict(dict): + def __missing__(self, key): + 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?", + "infer": ( + "Is the statistical association between [@{hypothesis_label}] and " + "[@{evidence_label}] valid at the stated probabilities?" + ), + "equal": "Are [@{a_label}] and [@{b_label}] truly equivalent?", + "contradict": "Do [@{a_label}] and [@{b_label}] truly contradict?", +} + + +def generate_audit_question(action_type: str, **labels) -> str: + template = _TEMPLATES.get(action_type, "Is this reasoning step valid?") + return template.format_map(_MissingLabelDict(labels)) diff --git a/tests/gaia/lang/test_review_manifest.py b/tests/gaia/lang/test_review_manifest.py new file mode 100644 index 000000000..c66fa59b2 --- /dev/null +++ b/tests/gaia/lang/test_review_manifest.py @@ -0,0 +1,67 @@ +from gaia.lang import Claim, contradict, derive, equal, infer, observe +from gaia.lang.compiler import compile_package_artifact +from gaia.lang.review.manifest import generate_review_manifest +from gaia.lang.review.templates import generate_audit_question +from gaia.lang.runtime.package import CollectedPackage + + +def test_audit_question_for_derive(): + question = generate_audit_question("derive", conclusion_label="quantum_hyp") + assert "[@quantum_hyp]" in question + assert "premises" in question.lower() + + +def test_audit_question_for_observe(): + question = generate_audit_question("observe", conclusion_label="uv_data") + assert "[@uv_data]" in question + assert "observation" in question.lower() or "reliable" in question.lower() + + +def test_audit_question_for_infer(): + question = generate_audit_question( + "infer", hypothesis_label="quantum_hyp", evidence_label="spectrum" + ) + assert "[@quantum_hyp]" in question + assert "[@spectrum]" in question + + +def test_audit_question_for_equal(): + question = generate_audit_question("equal", a_label="pred", b_label="obs") + assert "[@pred]" in question + assert "[@obs]" in question + + +def test_generate_review_manifest_for_v6_actions(): + with CollectedPackage("review_pkg") as pkg: + a = Claim("A.") + a.label = "a" + b = Claim("B.") + b.label = "b" + c = derive("C.", given=(a, b), rationale="A and B imply C.", label="derive_c") + c.label = "c" + data = observe("Observation.", rationale="Measured.", label="observe_data") + data.label = "data" + eq = equal(c, data, rationale="Same.", label="same") + eq.label = "same_helper" + conflict = contradict(a, data, rationale="Conflict.", label="conflict") + conflict.label = "conflict_helper" + infer( + hypothesis=c, + evidence=data, + p_e_given_h=0.8, + p_e_given_not_h=0.2, + rationale="Bayes.", + label="bayes_update", + ) + + compiled = compile_package_artifact(pkg) + manifest = generate_review_manifest(compiled) + assert len(manifest.reviews) == 5 + assert {review.status for review in manifest.reviews} == {"unreviewed"} + + by_action = {review.action_label: review for review in manifest.reviews} + assert by_action["github:review_pkg::action::derive_c"].target_kind == "strategy" + assert "[@c]" in by_action["github:review_pkg::action::derive_c"].audit_question + assert by_action["github:review_pkg::action::same"].target_kind == "operator" + assert "[@a]" in by_action["github:review_pkg::action::conflict"].audit_question + assert "[@data]" in by_action["github:review_pkg::action::bayes_update"].audit_question From 6152b51cf48e06e2c499e8969e10a344334b953b Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 20:53:34 +0800 Subject: [PATCH 018/210] feat(bp): gate v6 actions by review manifest --- gaia/bp/lowering.py | 40 +++++++++++++++- tests/gaia/bp/test_review_gating.py | 72 +++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 tests/gaia/bp/test_review_gating.py diff --git a/gaia/bp/lowering.py b/gaia/bp/lowering.py index c48a6e28c..a5bb7cc38 100644 --- a/gaia/bp/lowering.py +++ b/gaia/bp/lowering.py @@ -12,6 +12,7 @@ from gaia.ir.graphs import LocalCanonicalGraph from gaia.ir.knowledge import KnowledgeType from gaia.ir.operator import Operator, OperatorType +from gaia.ir.review import ReviewManifest, ReviewStatus from gaia.ir.strategy import ( _FORMAL_STRATEGY_TYPES, CompositeStrategy, @@ -59,6 +60,20 @@ def _next_fid(prefix: str, i: list[int]) -> str: return f"{prefix}_f{i[0]}" +def _review_target_allowed( + target_id: str | None, + metadata: dict | 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 lower_local_graph( canonical: LocalCanonicalGraph, *, @@ -66,6 +81,7 @@ def lower_local_graph( 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. @@ -84,6 +100,11 @@ def lower_local_graph( 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 {} # Auto-formalized helper claims (labels starting with ``__``, e.g. @@ -108,8 +129,14 @@ def lower_local_graph( fg = FactorGraph() ctr = [0] + lowerable_operators = [ + op + for op in canonical.operators + if _review_target_allowed(op.operator_id, op.metadata, review_manifest) + ] + relation_concl_ids: set[str] = set() - for op in canonical.operators: + for op in lowerable_operators: if op.operator in _RELATION_OPS: relation_concl_ids.add(op.conclusion) @@ -130,7 +157,7 @@ def lower_local_graph( strat_by_id = {s.strategy_id: s for s in canonical.strategies if s.strategy_id} - for op in canonical.operators: + for op in lowerable_operators: fid = _next_fid("op", ctr) ft = _OPERATOR_MAP[op.operator] for vid in op.variables: @@ -143,6 +170,8 @@ def lower_local_graph( seen_strategies: set[str] = set() for s in canonical.strategies: + if not _review_target_allowed(s.strategy_id, s.metadata, review_manifest): + continue _lower_strategy( fg, s, @@ -157,6 +186,7 @@ def lower_local_graph( canonical.namespace, canonical.package_name, seen_strategies=seen_strategies, + review_manifest=review_manifest, ) return fg @@ -221,7 +251,11 @@ def _lower_strategy( 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 + # Dedup: when ``seen_strategies`` is provided (lower_local_graph # passes a fresh set), skip strategies that have already been # lowered. Composite strategies recursively lower their @@ -253,6 +287,7 @@ def _lower_strategy( namespace, package_name, seen_strategies=seen_strategies, + review_manifest=review_manifest, ) return @@ -438,6 +473,7 @@ def _lower_strategy( namespace, package_name, seen_strategies=seen_strategies, + review_manifest=review_manifest, ) return diff --git a/tests/gaia/bp/test_review_gating.py b/tests/gaia/bp/test_review_gating.py new file mode 100644 index 000000000..4eb887e61 --- /dev/null +++ b/tests/gaia/bp/test_review_gating.py @@ -0,0 +1,72 @@ +from gaia.bp import lower_local_graph +from gaia.ir import ReviewManifest, ReviewStatus +from gaia.lang import Claim, derive, equal +from gaia.lang.compiler import compile_package_artifact +from gaia.lang.review.manifest import generate_review_manifest +from gaia.lang.runtime.package import CollectedPackage + + +def _accepted_manifest(manifest: ReviewManifest) -> ReviewManifest: + return ReviewManifest( + reviews=[ + review.model_copy(update={"status": ReviewStatus.ACCEPTED}) + for review in manifest.reviews + ] + ) + + +def test_unreviewed_strategy_excluded_from_bp(): + with CollectedPackage("review_bp") as pkg: + a = Claim("A.") + a.label = "a" + b = derive("B.", given=a, rationale="A implies B.", label="derive_b") + b.label = "b" + + compiled = compile_package_artifact(pkg) + manifest = generate_review_manifest(compiled) + factor_graph = lower_local_graph(compiled.graph, review_manifest=manifest) + assert not factor_graph.factors + + +def test_accepted_strategy_included_in_bp(): + with CollectedPackage("review_bp") as pkg: + a = Claim("A.") + a.label = "a" + b = derive("B.", given=a, rationale="A implies B.", label="derive_b") + b.label = "b" + + compiled = compile_package_artifact(pkg) + manifest = _accepted_manifest(generate_review_manifest(compiled)) + factor_graph = lower_local_graph(compiled.graph, review_manifest=manifest) + assert factor_graph.factors + + +def test_unreviewed_operator_excluded_from_bp(): + with CollectedPackage("review_bp") as pkg: + a = Claim("A.") + a.label = "a" + b = Claim("B.") + b.label = "b" + helper = equal(a, b, rationale="Same.", label="same") + helper.label = "same_helper" + + compiled = compile_package_artifact(pkg) + manifest = generate_review_manifest(compiled) + factor_graph = lower_local_graph(compiled.graph, review_manifest=manifest) + assert not factor_graph.factors + assert factor_graph.variables["github:review_bp::same_helper"] == 0.5 + + +def test_accepted_review_does_not_set_priors(): + with CollectedPackage("review_bp") as pkg: + a = Claim("A.") + a.label = "a" + b = Claim("B.") + b.label = "b" + helper = equal(a, b, rationale="Same.", label="same") + helper.label = "same_helper" + + compiled = compile_package_artifact(pkg) + manifest = _accepted_manifest(generate_review_manifest(compiled)) + factor_graph = lower_local_graph(compiled.graph, review_manifest=manifest) + assert factor_graph.variables["github:review_bp::same_helper"] == 1.0 - 1e-3 From 6a5097fe3fb91dea00f955636b4b133d96e478e5 Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 22:35:36 +0800 Subject: [PATCH 019/210] feat(cli): expose v6 review warrants --- gaia/cli/commands/check.py | 38 ++++++++++++++ gaia/cli/commands/infer.py | 18 +++++-- gaia/lang/compiler/compile.py | 8 ++- tests/cli/test_check_warrants.py | 68 +++++++++++++++++++++++++ tests/cli/test_infer.py | 35 +++++++++++++ tests/gaia/lang/test_review_manifest.py | 13 +++++ 6 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 tests/cli/test_check_warrants.py diff --git a/gaia/cli/commands/check.py b/gaia/cli/commands/check.py index 1a2ae2bc8..44b2690bc 100644 --- a/gaia/cli/commands/check.py +++ b/gaia/cli/commands/check.py @@ -12,6 +12,7 @@ from gaia.cli.commands._classify import classify_ir, node_role from gaia.ir import LocalCanonicalGraph from gaia.ir.validator import validate_local_graph +from gaia.lang.review.manifest import generate_review_manifest def _get_prior(k: dict) -> float | None: @@ -153,6 +154,27 @@ def _hole_report(ir: dict) -> list[str]: return lines +def _warrant_report(compiled, *, blind: bool = False) -> list[str]: + manifest = compiled.review or generate_review_manifest(compiled) + reviews = sorted(manifest.reviews, key=lambda review: review.action_label) + lines: list[str] = [] + lines.append("") + lines.append(f"Review warrants: {len(reviews)}") + if not reviews: + lines.append(" No reviewable v6 actions.") + return lines + + for review in reviews: + lines.append(f" - {review.action_label}") + lines.append(f" target: {review.target_kind} {review.target_id}") + if blind: + lines.append(" status:") + else: + lines.append(f" status: {review.status.value}") + lines.append(f" question: {review.audit_question}") + return lines + + def check_command( path: str = typer.Argument(".", help="Path to knowledge package directory"), brief: bool = typer.Option( @@ -169,6 +191,16 @@ def check_command( "--hole", help="Show detailed prior review report for all independent claims", ), + warrants: bool = typer.Option( + False, + "--warrants", + help="Show v6 ReviewManifest warrants with audit questions", + ), + blind: bool = typer.Option( + False, + "--blind", + help="With --warrants, omit status values and prior diagnostics", + ), ) -> None: """Validate structure and artifact consistency for a Gaia knowledge package.""" try: @@ -227,6 +259,12 @@ def check_command( f"{len(ir['operators'])} operators" ) + if warrants: + for line in _warrant_report(compiled, blind=blind): + typer.echo(line) + if blind: + return + for line in _knowledge_diagnostics(ir): typer.echo(line) diff --git a/gaia/cli/commands/infer.py b/gaia/cli/commands/infer.py index de1c84b20..385405703 100644 --- a/gaia/cli/commands/infer.py +++ b/gaia/cli/commands/infer.py @@ -21,6 +21,7 @@ load_gaia_package, ) from gaia.ir.validator import validate_local_graph +from gaia.lang.review.manifest import generate_review_manifest def _write_json(path, payload) -> None: @@ -39,8 +40,9 @@ def infer_command( """Run BP inference on a compiled knowledge package. Priors come from claim metadata (set by priors.py and reason+prior - DSL pairing during compilation). The lowering layer reads - metadata["prior"] directly — no review sidecar needed. + DSL pairing during compilation). For v6 action-backed Strategy/Operator + targets, ReviewManifest gates whether the target participates in BP; + review status is qualitative and never supplies numeric priors. With ``--depth N`` (N>0), dependency packages' factor graphs are merged for joint cross-package inference instead of using flat @@ -94,7 +96,7 @@ def infer_command( dep_factor_graphs: list[tuple[str, FactorGraph, str]] = [] for dep in dep_compiled: - dep_fg = lower_local_graph(dep.graph) + dep_fg = lower_local_graph(dep.graph, review_manifest=generate_review_manifest(dep)) dep_prefix = f"{dep.graph.namespace}:{dep.graph.package_name}::" dep_factor_graphs.append((dep.import_name, dep_fg, dep_prefix)) typer.echo( @@ -105,7 +107,8 @@ def infer_command( # Lower local graph WITHOUT foreign node priors — the dep graphs # provide the full reasoning structure instead of flat priors - local_fg = lower_local_graph(compiled.graph) + local_review_manifest = compiled.review or generate_review_manifest(compiled) + local_fg = lower_local_graph(compiled.graph, review_manifest=local_review_manifest) local_prefix = f"{compiled.graph.namespace}:{compiled.graph.package_name}::" if dep_factor_graphs: @@ -123,7 +126,12 @@ def infer_command( foreign_priors = collect_foreign_node_priors(compiled.graph, loaded.pkg_path) if foreign_priors: typer.echo(f"Loaded {len(foreign_priors)} upstream belief(s) for foreign nodes") - factor_graph = lower_local_graph(compiled.graph, node_priors=foreign_priors or None) + review_manifest = compiled.review or generate_review_manifest(compiled) + factor_graph = lower_local_graph( + compiled.graph, + node_priors=foreign_priors or None, + review_manifest=review_manifest, + ) fg_errors = factor_graph.validate() if fg_errors: for error in fg_errors: diff --git a/gaia/lang/compiler/compile.py b/gaia/lang/compiler/compile.py index 8e8deb30e..ca68eff94 100644 --- a/gaia/lang/compiler/compile.py +++ b/gaia/lang/compiler/compile.py @@ -18,6 +18,7 @@ Operator as IrOperator, Parameter as IrParameter, PackageRef as IrPackageRef, + ReviewManifest, Step as IrStep, Strategy as IrStrategy, StrategyParamRecord, @@ -69,6 +70,7 @@ class CompiledPackage: action_label_map: dict[str, str] = field(default_factory=dict) target_action_labels_by_id: dict[str, str] = field(default_factory=dict) strategy_param_records: list[StrategyParamRecord] = field(default_factory=list) + review: ReviewManifest | None = None def to_json(self) -> dict[str, Any]: return self.graph.model_dump(mode="json", exclude_none=True, serialize_as_any=True) @@ -777,7 +779,7 @@ def _handle(text: str | None) -> None: module_titles=module_titles if module_titles else None, ) - return CompiledPackage( + compiled = CompiledPackage( graph=graph, knowledge_ids_by_object=dict(knowledge_map), strategies_by_object=dict(compiled_strategies), @@ -785,6 +787,10 @@ def _handle(text: str | None) -> None: target_action_labels_by_id=target_action_labels_by_id, strategy_param_records=strategy_param_records, ) + from gaia.lang.review.manifest import generate_review_manifest + + compiled.review = generate_review_manifest(compiled) + return compiled def compile_package( diff --git a/tests/cli/test_check_warrants.py b/tests/cli/test_check_warrants.py new file mode 100644 index 000000000..69b5ece4b --- /dev/null +++ b/tests/cli/test_check_warrants.py @@ -0,0 +1,68 @@ +from typer.testing import CliRunner + +from gaia.cli.main import app + +runner = CliRunner() + + +def _write_v6_warrant_package(pkg_dir, *, with_prior: bool = False) -> None: + pkg_dir.mkdir() + (pkg_dir / "pyproject.toml").write_text( + '[project]\nname = "check-warrants-gaia"\nversion = "0.1.0"\n\n' + '[tool.gaia]\nnamespace = "github"\ntype = "knowledge-package"\n' + ) + pkg_src = pkg_dir / "check_warrants" + pkg_src.mkdir() + (pkg_src / "__init__.py").write_text( + "from gaia.lang import claim, derive, equal\n\n" + 'premise = claim("Evidence A is reliable.")\n' + 'evidence = claim("Evidence B matches Evidence A.")\n' + 'same = equal(premise, evidence, rationale="The two evidence records match.", label="same_evidence")\n' + "conclusion = derive(\n" + ' "The hypothesis is supported.",\n' + " given=(premise, same),\n" + ' rationale="The matched evidence supports the hypothesis.",\n' + ' label="derive_conclusion",\n' + ")\n" + '__all__ = ["conclusion"]\n' + ) + if with_prior: + (pkg_src / "priors.py").write_text( + "from . import premise\n\n" + 'PRIORS = {premise: (0.83, "Author confidence in the observed evidence.")}\n' + ) + + +def test_check_warrants_outputs_review_list(tmp_path): + pkg_dir = tmp_path / "check_warrants" + _write_v6_warrant_package(pkg_dir) + + compile_result = runner.invoke(app, ["compile", str(pkg_dir)]) + assert compile_result.exit_code == 0, compile_result.output + + result = runner.invoke(app, ["check", "--warrants", str(pkg_dir)]) + assert result.exit_code == 0, result.output + assert "Review warrants:" in result.output + assert "github:check_warrants::action::derive_conclusion" in result.output + assert "github:check_warrants::action::same_evidence" in result.output + assert "Do the listed premises suffice" in result.output + assert "Are [@premise] and [@evidence] truly equivalent?" in result.output + assert "status: unreviewed" in result.output + + +def test_check_warrants_blind_omits_author_priors_and_status_values(tmp_path): + pkg_dir = tmp_path / "check_warrants" + _write_v6_warrant_package(pkg_dir, with_prior=True) + + compile_result = runner.invoke(app, ["compile", str(pkg_dir)]) + assert compile_result.exit_code == 0, compile_result.output + + result = runner.invoke(app, ["check", "--warrants", "--blind", str(pkg_dir)]) + assert result.exit_code == 0, result.output + assert "Review warrants:" in result.output + assert "github:check_warrants::action::derive_conclusion" in result.output + assert "Do the listed premises suffice" in result.output + assert "status:" in result.output + assert "status: unreviewed" not in result.output + assert "0.83" not in result.output + assert "prior=" not in result.output diff --git a/tests/cli/test_infer.py b/tests/cli/test_infer.py index b9b31488e..0e193f3cf 100644 --- a/tests/cli/test_infer.py +++ b/tests/cli/test_infer.py @@ -4,6 +4,7 @@ import json +import pytest from typer.testing import CliRunner from gaia.cli.main import app @@ -122,6 +123,40 @@ def test_infer_with_deduction_strategy(tmp_path): assert result.exit_code == 0, result.output +def test_infer_gates_unreviewed_v6_actions(tmp_path): + """Unreviewed v6 actions do not update beliefs during infer.""" + pkg_dir = tmp_path / "v6_review_infer" + _write_base_package(pkg_dir, name="v6_review_infer") + (pkg_dir / "v6_review_infer" / "__init__.py").write_text( + "from gaia.lang import claim, derive\n\n" + 'evidence = claim("Evidence.")\n' + "hypothesis = derive(\n" + ' "Hypothesis.",\n' + " given=evidence,\n" + ' rationale="Evidence supports hypothesis.",\n' + ' label="support_hypothesis",\n' + ")\n" + '__all__ = ["evidence", "hypothesis"]\n' + ) + (pkg_dir / "v6_review_infer" / "priors.py").write_text( + "from . import evidence, hypothesis\n\n" + "PRIORS = {\n" + ' evidence: (0.9, "Direct observation."),\n' + ' hypothesis: (0.4, "Base rate."),\n' + "}\n" + ) + + compile_result = runner.invoke(app, ["compile", str(pkg_dir)]) + assert compile_result.exit_code == 0, compile_result.output + + result = runner.invoke(app, ["infer", str(pkg_dir)]) + assert result.exit_code == 0, result.output + + beliefs = json.loads((pkg_dir / ".gaia" / "beliefs.json").read_text()) + belief_by_label = {item["label"]: item["belief"] for item in beliefs["beliefs"]} + assert belief_by_label["hypothesis"] == pytest.approx(0.4) + + def test_infer_loads_upstream_beliefs_for_foreign_nodes(tmp_path, monkeypatch): """When dep_beliefs are present, foreign nodes use upstream beliefs as priors.""" # Create upstream dependency package diff --git a/tests/gaia/lang/test_review_manifest.py b/tests/gaia/lang/test_review_manifest.py index c66fa59b2..950ebe857 100644 --- a/tests/gaia/lang/test_review_manifest.py +++ b/tests/gaia/lang/test_review_manifest.py @@ -65,3 +65,16 @@ def test_generate_review_manifest_for_v6_actions(): assert by_action["github:review_pkg::action::same"].target_kind == "operator" assert "[@a]" in by_action["github:review_pkg::action::conflict"].audit_question assert "[@data]" in by_action["github:review_pkg::action::bayes_update"].audit_question + + +def test_compiled_package_carries_review_manifest_outside_graph_json(): + with CollectedPackage("review_pkg") as pkg: + a = Claim("A.") + a.label = "a" + c = derive("C.", given=a, rationale="A implies C.", label="derive_c") + c.label = "c" + + compiled = compile_package_artifact(pkg) + assert compiled.review is not None + assert len(compiled.review.reviews) == 1 + assert "review" not in compiled.to_json() From 02e665bb428fe6bdf9e3ea2d6b9e5dd59273daa8 Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 22:46:31 +0800 Subject: [PATCH 020/210] feat(cli): load persisted review manifests --- gaia/cli/commands/_review_manifest.py | 61 +++++++++++++++++++++++++++ gaia/cli/commands/check.py | 17 +++++--- gaia/cli/commands/infer.py | 10 ++--- tests/cli/test_infer.py | 54 ++++++++++++++++++++++++ tests/cli/test_review_manifest_io.py | 47 +++++++++++++++++++++ 5 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 gaia/cli/commands/_review_manifest.py create mode 100644 tests/cli/test_review_manifest_io.py diff --git a/gaia/cli/commands/_review_manifest.py b/gaia/cli/commands/_review_manifest.py new file mode 100644 index 000000000..d1c4b8945 --- /dev/null +++ b/gaia/cli/commands/_review_manifest.py @@ -0,0 +1,61 @@ +"""Shared ReviewManifest loading helpers for CLI commands.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from pydantic import ValidationError + +from gaia.cli._packages import GaiaCliError +from gaia.ir import Review, ReviewManifest +from gaia.lang.review.manifest import generate_review_manifest + +REVIEW_MANIFEST_REL_PATH = Path(".gaia") / "review_manifest.json" + + +def _generated_manifest(compiled) -> 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. Stale persisted targets are ignored because they no longer map to + the compiled package. + """ + + generated_target_ids = {review.target_id for review in generated.reviews} + reviews = list(generated.reviews) + reviews.extend( + review for review in persisted.reviews if review.target_id in generated_target_ids + ) + return ReviewManifest(reviews=reviews) + + +def latest_reviews(manifest: ReviewManifest) -> list[Review]: + 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) -> ReviewManifest: + 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 GaiaCliError(f"Error: {path} is not a valid ReviewManifest: {exc}") from exc + return merge_review_manifests(generated, persisted) diff --git a/gaia/cli/commands/check.py b/gaia/cli/commands/check.py index 44b2690bc..db4a5283d 100644 --- a/gaia/cli/commands/check.py +++ b/gaia/cli/commands/check.py @@ -10,9 +10,12 @@ from gaia.cli._packages import apply_package_priors from gaia.cli._packages import compile_loaded_package_artifact from gaia.cli.commands._classify import classify_ir, node_role +from gaia.cli.commands._review_manifest import ( + latest_reviews, + load_or_generate_review_manifest, +) from gaia.ir import LocalCanonicalGraph from gaia.ir.validator import validate_local_graph -from gaia.lang.review.manifest import generate_review_manifest def _get_prior(k: dict) -> float | None: @@ -154,9 +157,8 @@ def _hole_report(ir: dict) -> list[str]: return lines -def _warrant_report(compiled, *, blind: bool = False) -> list[str]: - manifest = compiled.review or generate_review_manifest(compiled) - reviews = sorted(manifest.reviews, key=lambda review: review.action_label) +def _warrant_report(manifest, *, blind: bool = False) -> list[str]: + reviews = latest_reviews(manifest) lines: list[str] = [] lines.append("") lines.append(f"Review warrants: {len(reviews)}") @@ -260,7 +262,12 @@ def check_command( ) if warrants: - for line in _warrant_report(compiled, blind=blind): + try: + review_manifest = load_or_generate_review_manifest(loaded.pkg_path, compiled) + except GaiaCliError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) + for line in _warrant_report(review_manifest, blind=blind): typer.echo(line) if blind: return diff --git a/gaia/cli/commands/infer.py b/gaia/cli/commands/infer.py index 385405703..dc77318f9 100644 --- a/gaia/cli/commands/infer.py +++ b/gaia/cli/commands/infer.py @@ -20,8 +20,8 @@ load_dependency_compiled_graphs, load_gaia_package, ) +from gaia.cli.commands._review_manifest import load_or_generate_review_manifest from gaia.ir.validator import validate_local_graph -from gaia.lang.review.manifest import generate_review_manifest def _write_json(path, payload) -> None: @@ -53,6 +53,7 @@ def infer_command( loaded = load_gaia_package(path) apply_package_priors(loaded) compiled = compile_loaded_package_artifact(loaded) + review_manifest = load_or_generate_review_manifest(loaded.pkg_path, compiled) except GaiaCliError as exc: typer.echo(str(exc), err=True) raise typer.Exit(1) @@ -96,7 +97,8 @@ def infer_command( dep_factor_graphs: list[tuple[str, FactorGraph, str]] = [] for dep in dep_compiled: - dep_fg = lower_local_graph(dep.graph, review_manifest=generate_review_manifest(dep)) + 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)) typer.echo( @@ -107,8 +109,7 @@ def infer_command( # Lower local graph WITHOUT foreign node priors — the dep graphs # provide the full reasoning structure instead of flat priors - local_review_manifest = compiled.review or generate_review_manifest(compiled) - local_fg = lower_local_graph(compiled.graph, review_manifest=local_review_manifest) + local_fg = lower_local_graph(compiled.graph, review_manifest=review_manifest) local_prefix = f"{compiled.graph.namespace}:{compiled.graph.package_name}::" if dep_factor_graphs: @@ -126,7 +127,6 @@ def infer_command( foreign_priors = collect_foreign_node_priors(compiled.graph, loaded.pkg_path) if foreign_priors: typer.echo(f"Loaded {len(foreign_priors)} upstream belief(s) for foreign nodes") - review_manifest = compiled.review or generate_review_manifest(compiled) factor_graph = lower_local_graph( compiled.graph, node_priors=foreign_priors or None, diff --git a/tests/cli/test_infer.py b/tests/cli/test_infer.py index 0e193f3cf..ef194705b 100644 --- a/tests/cli/test_infer.py +++ b/tests/cli/test_infer.py @@ -157,6 +157,60 @@ def test_infer_gates_unreviewed_v6_actions(tmp_path): assert belief_by_label["hypothesis"] == pytest.approx(0.4) +def test_infer_uses_accepted_review_manifest(tmp_path): + """Accepted persisted reviews allow v6 actions to participate in infer.""" + from gaia.cli._packages import ( + apply_package_priors, + compile_loaded_package_artifact, + load_gaia_package, + ) + from gaia.ir import ReviewManifest, ReviewStatus + + pkg_dir = tmp_path / "v6_review_infer" + _write_base_package(pkg_dir, name="v6_review_infer") + (pkg_dir / "v6_review_infer" / "__init__.py").write_text( + "from gaia.lang import claim, derive\n\n" + 'evidence = claim("Evidence.")\n' + "hypothesis = derive(\n" + ' "Hypothesis.",\n' + " given=evidence,\n" + ' rationale="Evidence supports hypothesis.",\n' + ' label="support_hypothesis",\n' + ")\n" + '__all__ = ["evidence", "hypothesis"]\n' + ) + (pkg_dir / "v6_review_infer" / "priors.py").write_text( + "from . import evidence, hypothesis\n\n" + "PRIORS = {\n" + ' evidence: (0.9, "Direct observation."),\n' + ' hypothesis: (0.4, "Base rate."),\n' + "}\n" + ) + + compile_result = runner.invoke(app, ["compile", str(pkg_dir)]) + assert compile_result.exit_code == 0, compile_result.output + + loaded = load_gaia_package(pkg_dir) + apply_package_priors(loaded) + compiled = compile_loaded_package_artifact(loaded) + assert compiled.review is not None + accepted = [ + review.model_copy(update={"status": ReviewStatus.ACCEPTED, "round": 2}) + for review in compiled.review.reviews + ] + review_path = pkg_dir / ".gaia" / "review_manifest.json" + review_path.write_text( + json.dumps(ReviewManifest(reviews=accepted).model_dump(mode="json"), indent=2) + ) + + result = runner.invoke(app, ["infer", str(pkg_dir)]) + assert result.exit_code == 0, result.output + + beliefs = json.loads((pkg_dir / ".gaia" / "beliefs.json").read_text()) + belief_by_label = {item["label"]: item["belief"] for item in beliefs["beliefs"]} + assert belief_by_label["hypothesis"] > 0.4 + + def test_infer_loads_upstream_beliefs_for_foreign_nodes(tmp_path, monkeypatch): """When dep_beliefs are present, foreign nodes use upstream beliefs as priors.""" # Create upstream dependency package diff --git a/tests/cli/test_review_manifest_io.py b/tests/cli/test_review_manifest_io.py new file mode 100644 index 000000000..6caa3a40b --- /dev/null +++ b/tests/cli/test_review_manifest_io.py @@ -0,0 +1,47 @@ +import json + +from gaia.cli.commands._review_manifest import load_or_generate_review_manifest +from gaia.ir import ReviewManifest, ReviewStatus +from gaia.lang import Claim, derive +from gaia.lang.compiler import compile_package_artifact +from gaia.lang.runtime.package import CollectedPackage + + +def _compiled_with_reviewable_action(): + with CollectedPackage("review_io") as pkg: + a = Claim("A.") + a.label = "a" + c = derive("C.", given=a, rationale="A implies C.", label="derive_c") + c.label = "c" + return compile_package_artifact(pkg) + + +def test_load_or_generate_review_manifest_uses_generated_default(tmp_path): + compiled = _compiled_with_reviewable_action() + + manifest = load_or_generate_review_manifest(tmp_path, compiled) + + assert isinstance(manifest, ReviewManifest) + assert len(manifest.reviews) == 1 + assert manifest.reviews[0].status == ReviewStatus.UNREVIEWED + + +def test_load_or_generate_review_manifest_merges_persisted_latest_status(tmp_path): + compiled = _compiled_with_reviewable_action() + generated = compiled.review + assert generated is not None + accepted_review = generated.reviews[0].model_copy( + update={"status": ReviewStatus.ACCEPTED, "round": 2} + ) + review_path = tmp_path / ".gaia" / "review_manifest.json" + review_path.parent.mkdir() + review_path.write_text( + json.dumps( + ReviewManifest(reviews=[accepted_review]).model_dump(mode="json"), + indent=2, + ) + ) + + manifest = load_or_generate_review_manifest(tmp_path, compiled) + + assert manifest.latest_status(accepted_review.target_id) == ReviewStatus.ACCEPTED From 2a536072b487ae501224a1ea5f556b96fd7d770f Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 22:50:05 +0800 Subject: [PATCH 021/210] feat(cli): add inquiry state view --- gaia/cli/commands/_inquiry.py | 192 ++++++++++++++++++++++++++++++++++ gaia/cli/commands/check.py | 17 ++- tests/cli/test_inquiry.py | 94 +++++++++++++++++ 3 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 gaia/cli/commands/_inquiry.py create mode 100644 tests/cli/test_inquiry.py diff --git a/gaia/cli/commands/_inquiry.py b/gaia/cli/commands/_inquiry.py new file mode 100644 index 000000000..fdd3be257 --- /dev/null +++ b/gaia/cli/commands/_inquiry.py @@ -0,0 +1,192 @@ +"""InquiryState rendering for goal-oriented Gaia package review.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from gaia.ir import ReviewManifest + + +@dataclass +class InquiryEdge: + kind: str + label: str + target_id: str | None + status: str | None + inputs: list["InquiryNode"] = field(default_factory=list) + + +@dataclass +class InquiryNode: + knowledge_id: str + label: str + content: str + incoming: list[InquiryEdge] = field(default_factory=list) + + @property + def is_hole(self) -> bool: + return not self.incoming + + +def _knowledge_label(knowledge: dict[str, Any]) -> str: + return knowledge.get("label") or knowledge.get("id", "").split("::")[-1] + + +def _action_label(metadata: dict[str, Any] | None, fallback: str) -> str: + label = (metadata or {}).get("action_label") or fallback + if "::action::" in label: + return label.split("::action::", 1)[1] + return label + + +def _review_status(manifest: ReviewManifest, target_id: str | None) -> str | None: + if target_id is None: + return None + status = manifest.latest_status(target_id) + return status.value if status is not None else None + + +def _exported_claim_ids(ir: dict[str, Any]) -> set[str]: + return { + knowledge["id"] + for knowledge in ir.get("knowledges", []) + if knowledge.get("id") and knowledge.get("type") == "claim" and knowledge.get("exported") + } + + +def build_goal_trees( + ir: dict[str, Any], + review_manifest: ReviewManifest, + exported_ids: set[str] | None = None, +) -> list[InquiryNode]: + """Build dependency trees by walking backward from exported Claims.""" + + goal_ids = exported_ids or _exported_claim_ids(ir) + knowledge_by_id = { + knowledge["id"]: knowledge + for knowledge in ir.get("knowledges", []) + if knowledge.get("id") and knowledge.get("type") == "claim" + } + + strategies_by_conclusion: dict[str, list[dict[str, Any]]] = {} + for strategy in ir.get("strategies", []): + conclusion = strategy.get("conclusion") + if conclusion: + strategies_by_conclusion.setdefault(conclusion, []).append(strategy) + + operators_by_conclusion: dict[str, list[dict[str, Any]]] = {} + for operator in ir.get("operators", []): + conclusion = operator.get("conclusion") + if conclusion: + operators_by_conclusion.setdefault(conclusion, []).append(operator) + + def build_node(knowledge_id: str, seen: set[str]) -> InquiryNode: + knowledge = knowledge_by_id.get(knowledge_id, {"id": knowledge_id, "content": ""}) + node = InquiryNode( + knowledge_id=knowledge_id, + label=_knowledge_label(knowledge), + content=knowledge.get("content", ""), + ) + if knowledge_id in seen: + return node + next_seen = {*seen, knowledge_id} + + for strategy in strategies_by_conclusion.get(knowledge_id, []): + strategy_id = strategy.get("strategy_id") + edge = InquiryEdge( + kind="strategy", + label=_action_label(strategy.get("metadata"), strategy_id or "strategy"), + target_id=strategy_id, + status=_review_status(review_manifest, strategy_id), + inputs=[ + build_node(premise, next_seen) + for premise in strategy.get("premises", []) + if premise + ], + ) + node.incoming.append(edge) + + for operator in operators_by_conclusion.get(knowledge_id, []): + operator_id = operator.get("operator_id") + edge = InquiryEdge( + kind="operator", + label=_action_label(operator.get("metadata"), operator_id or "operator"), + target_id=operator_id, + status=_review_status(review_manifest, operator_id), + inputs=[ + build_node(variable, next_seen) + for variable in operator.get("variables", []) + if variable + ], + ) + node.incoming.append(edge) + + return node + + return [ + build_node(goal_id, set()) for goal_id in sorted(goal_ids) if goal_id in knowledge_by_id + ] + + +def _walk(node: InquiryNode): + yield node + for edge in node.incoming: + yield edge + for child in edge.inputs: + yield from _walk(child) + + +def _summary(trees: list[InquiryNode]) -> dict[str, int]: + holes: set[str] = set() + edge_statuses: dict[str, str] = {} + for tree in trees: + for item in _walk(tree): + if isinstance(item, InquiryNode) and item.is_hole: + holes.add(item.knowledge_id) + elif isinstance(item, InquiryEdge) and item.target_id and item.status: + edge_statuses[item.target_id] = item.status + return { + "goals": len(trees), + "accepted": sum(1 for status in edge_statuses.values() if status == "accepted"), + "unreviewed": sum(1 for status in edge_statuses.values() if status == "unreviewed"), + "blocked": sum( + 1 for status in edge_statuses.values() if status in {"rejected", "needs_inputs"} + ), + "holes": len(holes), + } + + +def _render_node(lines: list[str], node: InquiryNode, indent: int) -> None: + pad = " " * indent + marker = " [hole]" if node.is_hole else "" + lines.append(f"{pad}- {node.label}{marker}") + for edge in node.incoming: + status = f" [{edge.status}]" if edge.status else "" + lines.append(f"{pad} <- {edge.label}{status}") + for child in edge.inputs: + _render_node(lines, child, indent + 5) + + +def render_inquiry(trees: list[InquiryNode]) -> str: + counts = _summary(trees) + lines = [ + "Inquiry", + "Summary:", + f" Goals: {counts['goals']}", + f" Accepted warrants: {counts['accepted']}", + f" Unreviewed: {counts['unreviewed']}", + f" Blocked: {counts['blocked']}", + f" Structural holes: {counts['holes']}", + "", + ] + for index, tree in enumerate(trees, start=1): + marker = " [hole]" if tree.is_hole else "" + lines.append(f"Goal {index}: {tree.label}{marker}") + for edge in tree.incoming: + status = f" [{edge.status}]" if edge.status else "" + lines.append(f" <- {edge.label}{status}") + for child in edge.inputs: + _render_node(lines, child, 5) + lines.append("") + return "\n".join(lines).rstrip() diff --git a/gaia/cli/commands/check.py b/gaia/cli/commands/check.py index db4a5283d..bce876303 100644 --- a/gaia/cli/commands/check.py +++ b/gaia/cli/commands/check.py @@ -203,6 +203,11 @@ def check_command( "--blind", help="With --warrants, omit status values and prior diagnostics", ), + inquiry: bool = typer.Option( + False, + "--inquiry", + help="Show goal-oriented reasoning progress and review status", + ), ) -> None: """Validate structure and artifact consistency for a Gaia knowledge package.""" try: @@ -261,17 +266,27 @@ def check_command( f"{len(ir['operators'])} operators" ) - if warrants: + review_manifest = None + if warrants or inquiry: try: review_manifest = load_or_generate_review_manifest(loaded.pkg_path, compiled) except GaiaCliError as exc: typer.echo(str(exc), err=True) raise typer.Exit(1) + + if warrants: for line in _warrant_report(review_manifest, blind=blind): typer.echo(line) if blind: return + if inquiry: + from gaia.cli.commands._inquiry import build_goal_trees, render_inquiry + + trees = build_goal_trees(ir, review_manifest) + typer.echo("") + typer.echo(render_inquiry(trees)) + for line in _knowledge_diagnostics(ir): typer.echo(line) diff --git a/tests/cli/test_inquiry.py b/tests/cli/test_inquiry.py new file mode 100644 index 000000000..ae19319e8 --- /dev/null +++ b/tests/cli/test_inquiry.py @@ -0,0 +1,94 @@ +from typer.testing import CliRunner + +from gaia.cli.main import app +from gaia.ir import ReviewManifest, ReviewStatus +from gaia.lang import Claim, derive, observe +from gaia.lang.compiler import compile_package_artifact +from gaia.lang.review.manifest import generate_review_manifest +from gaia.lang.runtime.package import CollectedPackage + +runner = CliRunner() + + +def _write_inquiry_package(pkg_dir) -> None: + pkg_dir.mkdir() + (pkg_dir / "pyproject.toml").write_text( + '[project]\nname = "inquiry-demo-gaia"\nversion = "0.1.0"\n\n' + '[tool.gaia]\nnamespace = "github"\ntype = "knowledge-package"\n' + ) + pkg_src = pkg_dir / "inquiry_demo" + pkg_src.mkdir() + (pkg_src / "__init__.py").write_text( + "from gaia.lang import claim, derive\n\n" + 'a = claim("A.")\n' + 'b = claim("B.")\n' + 'c = derive("C.", given=(a, b), rationale="A and B imply C.", label="derive_c")\n' + 'hole = claim("Unwarranted exported claim.")\n' + '__all__ = ["c", "hole"]\n' + ) + + +def test_inquiry_shows_exported_goals_and_holes(tmp_path): + pkg_dir = tmp_path / "inquiry_demo" + _write_inquiry_package(pkg_dir) + + compile_result = runner.invoke(app, ["compile", str(pkg_dir)]) + assert compile_result.exit_code == 0, compile_result.output + + result = runner.invoke(app, ["check", "--inquiry", str(pkg_dir)]) + assert result.exit_code == 0, result.output + assert "Goal 1:" in result.output + assert "Goal 2:" in result.output + assert "hole [hole]" in result.output + assert "Structural holes:" in result.output + + +def test_inquiry_shows_warrant_status(): + from gaia.cli.commands._inquiry import build_goal_trees, render_inquiry + + with CollectedPackage("inquiry_pkg") as pkg: + a = Claim("A.") + a.label = "a" + data = observe("Observation.", rationale="Measured.", label="observe_data") + data.label = "data" + c = derive("C.", given=(a, data), rationale="A and data imply C.", label="derive_c") + c.label = "c" + pkg._exported_labels = {"c"} + + compiled = compile_package_artifact(pkg) + generated = generate_review_manifest(compiled) + accepted = generated.reviews[0].model_copy(update={"status": ReviewStatus.ACCEPTED, "round": 2}) + manifest = ReviewManifest(reviews=[*generated.reviews, accepted]) + + trees = build_goal_trees(compiled.to_json(), manifest) + output = render_inquiry(trees) + + assert "[accepted]" in output + assert "[unreviewed]" in output + + +def test_inquiry_shows_support_tree(tmp_path): + pkg_dir = tmp_path / "inquiry_demo" + _write_inquiry_package(pkg_dir) + + compile_result = runner.invoke(app, ["compile", str(pkg_dir)]) + assert compile_result.exit_code == 0, compile_result.output + + result = runner.invoke(app, ["check", "--inquiry", str(pkg_dir)]) + assert result.exit_code == 0, result.output + assert "derive_c [unreviewed]" in result.output + assert "- a [hole]" in result.output + assert "- b [hole]" in result.output + + +def test_check_inquiry_flag(tmp_path): + pkg_dir = tmp_path / "inquiry_demo" + _write_inquiry_package(pkg_dir) + + compile_result = runner.invoke(app, ["compile", str(pkg_dir)]) + assert compile_result.exit_code == 0, compile_result.output + + result = runner.invoke(app, ["check", str(pkg_dir), "--inquiry"]) + assert result.exit_code == 0, result.output + assert "Inquiry" in result.output + assert "Summary" in result.output From 816abe0c99daf626b7d9f549d3acea7b8ed04387 Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 22:52:29 +0800 Subject: [PATCH 022/210] feat(cli): add quality gate checks --- gaia/cli/commands/_quality_gate.py | 107 +++++++++++++++++++++ gaia/cli/commands/check.py | 30 +++++- tests/cli/test_quality_gate.py | 144 +++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 gaia/cli/commands/_quality_gate.py create mode 100644 tests/cli/test_quality_gate.py diff --git a/gaia/cli/commands/_quality_gate.py b/gaia/cli/commands/_quality_gate.py new file mode 100644 index 000000000..310b56e1a --- /dev/null +++ b/gaia/cli/commands/_quality_gate.py @@ -0,0 +1,107 @@ +"""Quality gate checks for Gaia packages.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from gaia.cli._packages import GaiaCliError +from gaia.cli.commands._inquiry import InquiryNode, build_goal_trees +from gaia.cli.commands._review_manifest import latest_reviews +from gaia.ir import ReviewManifest, ReviewStatus + + +@dataclass +class QualityConfig: + min_posterior: float | None = None + allow_holes: bool = False + + +def load_quality_config(tool_gaia_quality: dict[str, Any] | None) -> QualityConfig: + config = tool_gaia_quality or {} + min_posterior = config.get("min_posterior") + return QualityConfig( + min_posterior=float(min_posterior) if min_posterior is not None else None, + allow_holes=bool(config.get("allow_holes", False)), + ) + + +def load_beliefs(pkg_path: str | Path) -> dict[str, Any] | None: + path = Path(pkg_path) / ".gaia" / "beliefs.json" + if not path.exists(): + return None + try: + return json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as exc: + raise GaiaCliError(f"Error: {path} is not valid JSON: {exc}") from exc + + +def _exported_claim_ids(ir: dict[str, Any]) -> set[str]: + return { + knowledge["id"] + for knowledge in ir.get("knowledges", []) + if knowledge.get("id") and knowledge.get("type") == "claim" and knowledge.get("exported") + } + + +def _walk(node: InquiryNode): + yield node + for edge in node.incoming: + yield edge + for child in edge.inputs: + yield from _walk(child) + + +def _structural_holes(trees: list[InquiryNode]) -> list[InquiryNode]: + holes: dict[str, InquiryNode] = {} + for tree in trees: + for item in _walk(tree): + if isinstance(item, InquiryNode) and item.is_hole: + holes[item.knowledge_id] = item + return sorted(holes.values(), key=lambda node: node.label) + + +def check_quality_gate( + ir: dict[str, Any], + beliefs: dict[str, Any] | None, + review_manifest: ReviewManifest, + config: QualityConfig, + exported_ids: set[str] | None = None, +) -> list[str]: + failures: list[str] = [] + goals = exported_ids or _exported_claim_ids(ir) + trees = build_goal_trees(ir, review_manifest, goals) + + if not config.allow_holes: + for hole in _structural_holes(trees): + failures.append(f"Structural hole: {hole.label} has no warrant chain") + + for review in latest_reviews(review_manifest): + if review.status != ReviewStatus.ACCEPTED: + failures.append( + f"Unreviewed/rejected: {review.action_label} (status={review.status.value})" + ) + + if config.min_posterior is not None: + if beliefs is None: + failures.append("Missing beliefs: run `gaia infer` before using min_posterior") + else: + belief_by_id = { + entry.get("knowledge_id"): float(entry.get("belief")) + for entry in beliefs.get("beliefs", []) + if isinstance(entry, dict) + and isinstance(entry.get("knowledge_id"), str) + and isinstance(entry.get("belief"), int | float) + } + for knowledge_id in sorted(goals): + belief = belief_by_id.get(knowledge_id) + if belief is None: + failures.append(f"Missing belief: {knowledge_id}") + elif belief < config.min_posterior: + failures.append( + f"Low posterior: {knowledge_id} = {belief:.3f} < {config.min_posterior}" + ) + + return failures diff --git a/gaia/cli/commands/check.py b/gaia/cli/commands/check.py index bce876303..1dbc73c0d 100644 --- a/gaia/cli/commands/check.py +++ b/gaia/cli/commands/check.py @@ -208,6 +208,11 @@ def check_command( "--inquiry", help="Show goal-oriented reasoning progress and review status", ), + gate: bool = typer.Option( + False, + "--gate", + help="Run quality gate checks and exit non-zero on failure", + ), ) -> None: """Validate structure and artifact consistency for a Gaia knowledge package.""" try: @@ -267,7 +272,7 @@ def check_command( ) review_manifest = None - if warrants or inquiry: + if warrants or inquiry or gate: try: review_manifest = load_or_generate_review_manifest(loaded.pkg_path, compiled) except GaiaCliError as exc: @@ -287,6 +292,29 @@ def check_command( typer.echo("") typer.echo(render_inquiry(trees)) + if gate: + from gaia.cli.commands._quality_gate import ( + check_quality_gate, + load_beliefs, + load_quality_config, + ) + + try: + config = load_quality_config(loaded.gaia_config.get("quality")) + beliefs = load_beliefs(loaded.pkg_path) + failures = check_quality_gate(ir, beliefs, review_manifest, config) + except GaiaCliError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) + if failures: + typer.echo("") + typer.echo("Quality gate failed:") + for failure in failures: + typer.echo(f" - {failure}") + raise typer.Exit(1) + typer.echo("") + typer.echo("Quality gate passed") + for line in _knowledge_diagnostics(ir): typer.echo(line) diff --git a/tests/cli/test_quality_gate.py b/tests/cli/test_quality_gate.py new file mode 100644 index 000000000..ecb7829aa --- /dev/null +++ b/tests/cli/test_quality_gate.py @@ -0,0 +1,144 @@ +import json + +from typer.testing import CliRunner + +from gaia.cli.main import app + +runner = CliRunner() + + +def test_quality_gate_default_config(): + from gaia.cli.commands._quality_gate import load_quality_config + + config = load_quality_config({}) + assert config.allow_holes is False + assert config.min_posterior is None + + +def test_quality_gate_custom_config(): + from gaia.cli.commands._quality_gate import load_quality_config + + config = load_quality_config({"min_posterior": 0.7}) + assert config.min_posterior == 0.7 + assert config.allow_holes is False + + +def _write_gate_package(pkg_dir, source: str, *, quality: str = "") -> None: + pkg_dir.mkdir() + (pkg_dir / "pyproject.toml").write_text( + '[project]\nname = "gate-demo-gaia"\nversion = "0.1.0"\n\n' + '[tool.gaia]\nnamespace = "github"\ntype = "knowledge-package"\n' + f"{quality}" + ) + pkg_src = pkg_dir / "gate_demo" + pkg_src.mkdir() + (pkg_src / "__init__.py").write_text(source) + + +def _accept_all_reviews(pkg_dir) -> None: + from gaia.cli._packages import compile_loaded_package_artifact, load_gaia_package + from gaia.ir import ReviewManifest, ReviewStatus + + loaded = load_gaia_package(pkg_dir) + compiled = compile_loaded_package_artifact(loaded) + assert compiled.review is not None + accepted = [ + review.model_copy(update={"status": ReviewStatus.ACCEPTED, "round": 2}) + for review in compiled.review.reviews + ] + (pkg_dir / ".gaia" / "review_manifest.json").write_text( + json.dumps(ReviewManifest(reviews=accepted).model_dump(mode="json"), indent=2) + ) + + +def test_gate_fails_on_structural_hole(tmp_path): + pkg_dir = tmp_path / "gate_demo" + _write_gate_package( + pkg_dir, + "from gaia.lang import claim\n\n" + 'hole = claim("Unwarranted exported claim.")\n' + '__all__ = ["hole"]\n', + ) + + result = runner.invoke(app, ["check", str(pkg_dir), "--gate"]) + assert result.exit_code != 0 + assert "structural hole" in result.output.lower() + + +def test_gate_fails_on_unreviewed(tmp_path): + pkg_dir = tmp_path / "gate_demo" + _write_gate_package( + pkg_dir, + "from gaia.lang import derive, observe\n\n" + 'data = observe("Data.", rationale="Measured.", label="observe_data")\n' + 'conclusion = derive("Conclusion.", given=data, rationale="Data implies conclusion.", label="derive_c")\n' + '__all__ = ["conclusion"]\n', + ) + + result = runner.invoke(app, ["check", str(pkg_dir), "--gate"]) + assert result.exit_code != 0 + assert "unreviewed" in result.output.lower() + + +def test_gate_fails_on_unreviewed_root_observe(tmp_path): + pkg_dir = tmp_path / "gate_demo" + _write_gate_package( + pkg_dir, + "from gaia.lang import observe\n\n" + 'root = observe("Root fact.", rationale="Measured.", label="root_obs")\n' + '__all__ = ["root"]\n', + ) + + result = runner.invoke(app, ["check", str(pkg_dir), "--gate"]) + assert result.exit_code != 0 + assert "unreviewed" in result.output.lower() + + +def test_gate_fails_on_low_posterior(tmp_path): + pkg_dir = tmp_path / "gate_demo" + _write_gate_package( + pkg_dir, + "from gaia.lang import observe\n\n" + 'root = observe("Root fact.", rationale="Measured.", label="root_obs")\n' + '__all__ = ["root"]\n', + quality="\n[tool.gaia.quality]\nmin_posterior = 0.9\n", + ) + compile_result = runner.invoke(app, ["compile", str(pkg_dir)]) + assert compile_result.exit_code == 0, compile_result.output + _accept_all_reviews(pkg_dir) + (pkg_dir / ".gaia" / "beliefs.json").write_text( + json.dumps( + { + "beliefs": [ + { + "knowledge_id": "github:gate_demo::root", + "label": "root", + "belief": 0.7, + } + ] + }, + indent=2, + ) + ) + + result = runner.invoke(app, ["check", str(pkg_dir), "--gate"]) + assert result.exit_code != 0 + assert "low posterior" in result.output.lower() + + +def test_gate_passes_when_all_met(tmp_path): + pkg_dir = tmp_path / "gate_demo" + _write_gate_package( + pkg_dir, + "from gaia.lang import derive, observe\n\n" + 'data = observe("Data.", rationale="Measured.", label="observe_data")\n' + 'conclusion = derive("Conclusion.", given=data, rationale="Data implies conclusion.", label="derive_c")\n' + '__all__ = ["conclusion"]\n', + ) + compile_result = runner.invoke(app, ["compile", str(pkg_dir)]) + assert compile_result.exit_code == 0, compile_result.output + _accept_all_reviews(pkg_dir) + + result = runner.invoke(app, ["check", str(pkg_dir), "--gate"]) + assert result.exit_code == 0, result.output + assert "Quality gate passed" in result.output From dee8d693e4385617d7157178e1a14eadd6dc8d67 Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 23:16:58 +0800 Subject: [PATCH 023/210] fix(cli): keep blind warrants from bypassing gate --- gaia/cli/commands/_quality_gate.py | 14 ++++++- gaia/cli/commands/check.py | 10 +++-- tests/cli/test_quality_gate.py | 59 ++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/gaia/cli/commands/_quality_gate.py b/gaia/cli/commands/_quality_gate.py index 310b56e1a..446dae35d 100644 --- a/gaia/cli/commands/_quality_gate.py +++ b/gaia/cli/commands/_quality_gate.py @@ -8,7 +8,7 @@ from typing import Any from gaia.cli._packages import GaiaCliError -from gaia.cli.commands._inquiry import InquiryNode, build_goal_trees +from gaia.cli.commands._inquiry import InquiryEdge, InquiryNode, build_goal_trees from gaia.cli.commands._review_manifest import latest_reviews from gaia.ir import ReviewManifest, ReviewStatus @@ -63,6 +63,15 @@ def _structural_holes(trees: list[InquiryNode]) -> list[InquiryNode]: return sorted(holes.values(), key=lambda node: node.label) +def _reachable_review_targets(trees: list[InquiryNode]) -> set[str]: + targets: set[str] = set() + for tree in trees: + for item in _walk(tree): + if isinstance(item, InquiryEdge) and item.target_id: + targets.add(item.target_id) + return targets + + def check_quality_gate( ir: dict[str, Any], beliefs: dict[str, Any] | None, @@ -73,12 +82,15 @@ def check_quality_gate( failures: list[str] = [] goals = exported_ids or _exported_claim_ids(ir) trees = build_goal_trees(ir, review_manifest, goals) + reachable_targets = _reachable_review_targets(trees) if not config.allow_holes: for hole in _structural_holes(trees): failures.append(f"Structural hole: {hole.label} has no warrant chain") for review in latest_reviews(review_manifest): + if review.target_id not in reachable_targets: + continue if review.status != ReviewStatus.ACCEPTED: failures.append( f"Unreviewed/rejected: {review.action_label} (status={review.status.value})" diff --git a/gaia/cli/commands/check.py b/gaia/cli/commands/check.py index 1dbc73c0d..73a627f76 100644 --- a/gaia/cli/commands/check.py +++ b/gaia/cli/commands/check.py @@ -282,8 +282,6 @@ def check_command( if warrants: for line in _warrant_report(review_manifest, blind=blind): typer.echo(line) - if blind: - return if inquiry: from gaia.cli.commands._inquiry import build_goal_trees, render_inquiry @@ -315,8 +313,12 @@ def check_command( typer.echo("") typer.echo("Quality gate passed") - for line in _knowledge_diagnostics(ir): - typer.echo(line) + if warrants and blind and not (brief or show or hole): + return + + if not (warrants and blind): + for line in _knowledge_diagnostics(ir): + typer.echo(line) if brief or show: from gaia.cli.commands._brief import ( diff --git a/tests/cli/test_quality_gate.py b/tests/cli/test_quality_gate.py index ecb7829aa..a17f6c8a0 100644 --- a/tests/cli/test_quality_gate.py +++ b/tests/cli/test_quality_gate.py @@ -51,6 +51,31 @@ def _accept_all_reviews(pkg_dir) -> None: ) +def _accept_reviews_except(pkg_dir, action_label_fragment: str) -> None: + from gaia.cli._packages import compile_loaded_package_artifact, load_gaia_package + from gaia.ir import ReviewManifest, ReviewStatus + + loaded = load_gaia_package(pkg_dir) + compiled = compile_loaded_package_artifact(loaded) + assert compiled.review is not None + reviews = [ + review.model_copy( + update={ + "status": ( + ReviewStatus.UNREVIEWED + if action_label_fragment in review.action_label + else ReviewStatus.ACCEPTED + ), + "round": 2, + } + ) + for review in compiled.review.reviews + ] + (pkg_dir / ".gaia" / "review_manifest.json").write_text( + json.dumps(ReviewManifest(reviews=reviews).model_dump(mode="json"), indent=2) + ) + + def test_gate_fails_on_structural_hole(tmp_path): pkg_dir = tmp_path / "gate_demo" _write_gate_package( @@ -80,6 +105,21 @@ def test_gate_fails_on_unreviewed(tmp_path): assert "unreviewed" in result.output.lower() +def test_gate_still_runs_with_blind_warrant_report(tmp_path): + pkg_dir = tmp_path / "gate_demo" + _write_gate_package( + pkg_dir, + "from gaia.lang import observe\n\n" + 'root = observe("Root fact.", rationale="Measured.", label="root_obs")\n' + '__all__ = ["root"]\n', + ) + + result = runner.invoke(app, ["check", str(pkg_dir), "--warrants", "--blind", "--gate"]) + assert result.exit_code != 0 + assert "quality gate failed" in result.output.lower() + assert "root_obs" in result.output + + def test_gate_fails_on_unreviewed_root_observe(tmp_path): pkg_dir = tmp_path / "gate_demo" _write_gate_package( @@ -142,3 +182,22 @@ def test_gate_passes_when_all_met(tmp_path): result = runner.invoke(app, ["check", str(pkg_dir), "--gate"]) assert result.exit_code == 0, result.output assert "Quality gate passed" in result.output + + +def test_gate_ignores_unexported_unreachable_draft_actions(tmp_path): + pkg_dir = tmp_path / "gate_demo" + _write_gate_package( + pkg_dir, + "from gaia.lang import derive, observe\n\n" + 'data = observe("Data.", rationale="Measured.", label="observe_data")\n' + 'conclusion = derive("Conclusion.", given=data, rationale="Data implies conclusion.", label="derive_c")\n' + 'draft = observe("Unrelated draft measurement.", rationale="Draft.", label="draft_obs")\n' + '__all__ = ["conclusion"]\n', + ) + compile_result = runner.invoke(app, ["compile", str(pkg_dir)]) + assert compile_result.exit_code == 0, compile_result.output + _accept_reviews_except(pkg_dir, "draft_obs") + + result = runner.invoke(app, ["check", str(pkg_dir), "--gate"]) + assert result.exit_code == 0, result.output + assert "Quality gate passed" in result.output From 99194b37986b9f1957748379a67dae841656d43e Mon Sep 17 00:00:00 2001 From: kunchen Date: Tue, 21 Apr 2026 23:41:15 +0800 Subject: [PATCH 024/210] fix(lang): lower root observes as grounding reviews --- gaia/cli/commands/_inquiry.py | 27 +++++++++++++++ gaia/ir/review.py | 2 +- gaia/lang/compiler/compile.py | 38 ++++++++++++++++++-- gaia/lang/review/manifest.py | 33 ++++++++++++++++++ tests/cli/test_compile_v6_actions.py | 15 +++++++- tests/cli/test_infer.py | 44 ++++++++++++++++++++++++ tests/gaia/lang/test_compiler_actions.py | 13 ++++--- tests/gaia/lang/test_review_manifest.py | 4 +++ 8 files changed, 165 insertions(+), 11 deletions(-) diff --git a/gaia/cli/commands/_inquiry.py b/gaia/cli/commands/_inquiry.py index fdd3be257..abf239527 100644 --- a/gaia/cli/commands/_inquiry.py +++ b/gaia/cli/commands/_inquiry.py @@ -47,6 +47,20 @@ def _review_status(manifest: ReviewManifest, target_id: str | None) -> str | Non return status.value if status is not None else None +def _grounding_action_label(knowledge: dict[str, Any]) -> str | None: + metadata = knowledge.get("metadata") or {} + grounding = metadata.get("grounding") + if not isinstance(grounding, dict): + return None + action_label = grounding.get("action_label") + return action_label if isinstance(action_label, str) and action_label else None + + +def _has_grounding(knowledge: dict[str, Any]) -> bool: + metadata = knowledge.get("metadata") or {} + return isinstance(metadata.get("grounding"), dict) + + def _exported_claim_ids(ir: dict[str, Any]) -> set[str]: return { knowledge["id"] @@ -92,6 +106,19 @@ def build_node(knowledge_id: str, seen: set[str]) -> InquiryNode: return node next_seen = {*seen, knowledge_id} + if _has_grounding(knowledge): + action_label = _grounding_action_label(knowledge) + target_id = knowledge_id if action_label else None + node.incoming.append( + InquiryEdge( + kind="grounding", + label=_action_label({"action_label": action_label}, "grounding"), + target_id=target_id, + status=_review_status(review_manifest, target_id), + inputs=[], + ) + ) + for strategy in strategies_by_conclusion.get(knowledge_id, []): strategy_id = strategy.get("strategy_id") edge = InquiryEdge( diff --git a/gaia/ir/review.py b/gaia/ir/review.py index cfa8f1571..67f43b988 100644 --- a/gaia/ir/review.py +++ b/gaia/ir/review.py @@ -22,7 +22,7 @@ class Review(BaseModel): review_id: str action_label: str - target_kind: Literal["strategy", "operator"] + target_kind: Literal["strategy", "operator", "knowledge"] target_id: str status: ReviewStatus audit_question: str diff --git a/gaia/lang/compiler/compile.py b/gaia/lang/compiler/compile.py index ca68eff94..30310653b 100644 --- a/gaia/lang/compiler/compile.py +++ b/gaia/lang/compiler/compile.py @@ -542,7 +542,29 @@ def _record_action_target(action_label: str, target_id: str | None) -> None: action_label_map[action_label] = target_id target_action_labels_by_id[target_id] = action_label - def _compile_support_action(action: Support, action_index: int) -> IrStrategy: + def _attach_grounding_action( + action: Support, + *, + action_label: str, + conclusion_id: str, + background_ids: list[str] | None, + ) -> None: + for i, ir_k in enumerate(ir_knowledges): + if ir_k.id != conclusion_id: + continue + metadata = dict(ir_k.metadata) if ir_k.metadata else {} + grounding = dict(metadata.get("grounding") or {}) + grounding["action_label"] = action_label + grounding["pattern"] = "observation" + if background_ids: + grounding["background"] = background_ids + if action.rationale and not grounding.get("rationale"): + grounding["rationale"] = action.rationale + metadata["grounding"] = grounding + ir_knowledges[i] = ir_k.model_copy(update={"metadata": metadata}) + return + + def _compile_support_action(action: Support, action_index: int) -> IrStrategy | None: if action.conclusion is None: raise ValueError("Support action requires a conclusion") premise_ids = [knowledge_map[id(given)] for given in action.given] @@ -563,6 +585,16 @@ def _compile_support_action(action: Support, action_index: int) -> IrStrategy: extra=extra, ) + if isinstance(action, Observe) and not premise_ids: + _attach_grounding_action( + action, + action_label=action_label, + conclusion_id=conclusion_id, + background_ids=background_ids, + ) + _record_action_target(action_label, conclusion_id) + return None + if premise_ids: result = formalize_named_strategy( scope="local", @@ -642,7 +674,7 @@ def _compile_infer_action(action: InferAction, action_index: int) -> IrStrategy: _record_action_target(action_label, strategy.strategy_id) return strategy - def compile_action(action: Any, action_index: int) -> IrStrategy | IrOperator: + def compile_action(action: Any, action_index: int) -> IrStrategy | IrOperator | None: if isinstance(action, Support): return _compile_support_action(action, action_index) if isinstance(action, Relate): @@ -662,6 +694,8 @@ def compile_action(action: Any, action_index: int) -> IrStrategy | IrOperator: action_operators: list[IrOperator] = [] for action_index, action in enumerate(getattr(pkg, "actions", [])): target = compile_action(action, action_index) + if target is None: + continue if isinstance(target, IrOperator): action_operators.append(target) else: diff --git a/gaia/lang/review/manifest.py b/gaia/lang/review/manifest.py index 49472d7cf..f8b1844e4 100644 --- a/gaia/lang/review/manifest.py +++ b/gaia/lang/review/manifest.py @@ -59,6 +59,23 @@ def _strategy_question(strategy: Any, action_type: str, labels: dict[str, str]) ) +def _grounding_action_label(knowledge: Any) -> str | None: + metadata = knowledge.metadata or {} + grounding = metadata.get("grounding") + if not isinstance(grounding, dict): + return None + action_label = grounding.get("action_label") + return action_label if isinstance(action_label, str) and action_label else None + + +def _grounding_question(knowledge: Any, labels: dict[str, str]) -> str: + knowledge_id = knowledge.id or "" + return generate_audit_question( + "observe", + 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: a = operator.variables[0] if operator.variables else "" b = operator.variables[1] if len(operator.variables) > 1 else "" @@ -74,6 +91,22 @@ def generate_review_manifest(compiled: Any) -> ReviewManifest: labels = _labels_by_id(compiled) reviews: list[Review] = [] + for knowledge in compiled.graph.knowledges: + action_label = _grounding_action_label(knowledge) + if not action_label or not knowledge.id: + continue + 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=_grounding_question(knowledge, labels), + round=1, + ) + ) + for strategy in compiled.graph.strategies: metadata = strategy.metadata or {} action_label = metadata.get("action_label") diff --git a/tests/cli/test_compile_v6_actions.py b/tests/cli/test_compile_v6_actions.py index 0a7dc14fc..2ce101cac 100644 --- a/tests/cli/test_compile_v6_actions.py +++ b/tests/cli/test_compile_v6_actions.py @@ -51,7 +51,14 @@ def test_compile_v6_actions_package(tmp_path): for s in ir["strategies"] if s.get("metadata") and "pattern" in s["metadata"] } - assert {"observation", "derivation", "inference"} <= strategy_patterns + assert {"derivation", "inference"} <= strategy_patterns + + grounding_patterns = { + k["metadata"]["grounding"]["pattern"] + for k in ir["knowledges"] + if (k.get("metadata") or {}).get("grounding") and "pattern" in k["metadata"]["grounding"] + } + assert "observation" in grounding_patterns operator_types = {op["operator"] for op in ir["operators"]} assert {"equivalence", "contradiction"} <= operator_types @@ -66,6 +73,12 @@ def test_compile_v6_actions_package(tmp_path): for op in ir["operators"] if op.get("metadata") and "action_label" in op["metadata"] ) + action_labels.extend( + k["metadata"]["grounding"]["action_label"] + for k in ir["knowledges"] + if (k.get("metadata") or {}).get("grounding") + and "action_label" in k["metadata"]["grounding"] + ) assert "github:v6_actions::action::observe_uv" in action_labels assert "github:v6_actions::action::favor_planck" in action_labels assert "github:v6_actions::action::match" in action_labels diff --git a/tests/cli/test_infer.py b/tests/cli/test_infer.py index ef194705b..fb574856f 100644 --- a/tests/cli/test_infer.py +++ b/tests/cli/test_infer.py @@ -211,6 +211,50 @@ def test_infer_uses_accepted_review_manifest(tmp_path): assert belief_by_label["hypothesis"] > 0.4 +def test_infer_with_accepted_root_observe_review(tmp_path): + """Accepted no-premise observe reviews should not lower as empty deductions.""" + from gaia.cli._packages import ( + apply_package_priors, + compile_loaded_package_artifact, + load_gaia_package, + ) + from gaia.ir import ReviewManifest, ReviewStatus + + pkg_dir = tmp_path / "root_observe_infer" + _write_base_package(pkg_dir, name="root_observe_infer") + (pkg_dir / "root_observe_infer" / "__init__.py").write_text( + "from gaia.lang import observe\n\n" + 'root = observe("Root measurement.", rationale="Measured.", label="root_obs")\n' + '__all__ = ["root"]\n' + ) + (pkg_dir / "root_observe_infer" / "priors.py").write_text( + 'from . import root\n\nPRIORS = {\n root: (0.82, "Measurement reliability."),\n}\n' + ) + + compile_result = runner.invoke(app, ["compile", str(pkg_dir)]) + assert compile_result.exit_code == 0, compile_result.output + + loaded = load_gaia_package(pkg_dir) + apply_package_priors(loaded) + compiled = compile_loaded_package_artifact(loaded) + assert compiled.review is not None + assert [review.target_kind for review in compiled.review.reviews] == ["knowledge"] + accepted = [ + review.model_copy(update={"status": ReviewStatus.ACCEPTED, "round": 2}) + for review in compiled.review.reviews + ] + (pkg_dir / ".gaia" / "review_manifest.json").write_text( + json.dumps(ReviewManifest(reviews=accepted).model_dump(mode="json"), indent=2) + ) + + result = runner.invoke(app, ["infer", str(pkg_dir)]) + assert result.exit_code == 0, result.output + + beliefs = json.loads((pkg_dir / ".gaia" / "beliefs.json").read_text()) + belief_by_label = {item["label"]: item["belief"] for item in beliefs["beliefs"]} + assert belief_by_label["root"] == pytest.approx(0.82) + + def test_infer_loads_upstream_beliefs_for_foreign_nodes(tmp_path, monkeypatch): """When dep_beliefs are present, foreign nodes use upstream beliefs as priors.""" # Create upstream dependency package diff --git a/tests/gaia/lang/test_compiler_actions.py b/tests/gaia/lang/test_compiler_actions.py index 2ae88d1ed..545ab7060 100644 --- a/tests/gaia/lang/test_compiler_actions.py +++ b/tests/gaia/lang/test_compiler_actions.py @@ -50,21 +50,20 @@ def test_compile_derive_action_to_deduction_formal_strategy(): assert conjunction_helpers[0].metadata["review"] is False -def test_compile_root_observe_action_to_reviewable_strategy_and_grounding(): +def test_compile_root_observe_action_to_reviewable_grounding(): with CollectedPackage("v6_actions") as pkg: data = observe("UV spectrum data.", rationale="Measured.", label="observe_uv") data.label = "uv" compiled = compile_package_artifact(pkg) - strategy = compiled.graph.strategies[0] - assert strategy.type == "deduction" - assert strategy.premises == [] - assert strategy.conclusion == "github:v6_actions::uv" - assert strategy.metadata["pattern"] == "observation" - assert strategy.metadata["action_label"] == "github:v6_actions::action::observe_uv" + assert compiled.graph.strategies == [] + assert compiled.action_label_map["github:v6_actions::action::observe_uv"] == ( + "github:v6_actions::uv" + ) uv = _knowledge_by_label(compiled)["uv"] assert uv.metadata["grounding"]["kind"] == "source_fact" + assert uv.metadata["grounding"]["action_label"] == "github:v6_actions::action::observe_uv" def test_compile_compute_action_to_deduction_with_compute_metadata(): diff --git a/tests/gaia/lang/test_review_manifest.py b/tests/gaia/lang/test_review_manifest.py index 950ebe857..78e53ce41 100644 --- a/tests/gaia/lang/test_review_manifest.py +++ b/tests/gaia/lang/test_review_manifest.py @@ -62,6 +62,10 @@ def test_generate_review_manifest_for_v6_actions(): by_action = {review.action_label: review for review in manifest.reviews} assert by_action["github:review_pkg::action::derive_c"].target_kind == "strategy" assert "[@c]" in by_action["github:review_pkg::action::derive_c"].audit_question + assert by_action["github:review_pkg::action::observe_data"].target_kind == "knowledge" + assert by_action["github:review_pkg::action::observe_data"].target_id == ( + "github:review_pkg::data" + ) assert by_action["github:review_pkg::action::same"].target_kind == "operator" assert "[@a]" in by_action["github:review_pkg::action::conflict"].audit_question assert "[@data]" in by_action["github:review_pkg::action::bayes_update"].audit_question From 7ed20dce05eb59929d8a22a1eca428b3c906be7c Mon Sep 17 00:00:00 2001 From: kunchen Date: Wed, 22 Apr 2026 00:14:37 +0800 Subject: [PATCH 025/210] fix(cli): pass v6 infer cpts into BP --- gaia/cli/_packages.py | 9 +++++ gaia/cli/commands/infer.py | 9 ++++- gaia/cli/commands/register.py | 7 +++- tests/cli/test_infer.py | 56 +++++++++++++++++++++++++++ tests/cli/test_register.py | 72 +++++++++++++++++++++++++++++++++++ 5 files changed, 151 insertions(+), 2 deletions(-) diff --git a/gaia/cli/_packages.py b/gaia/cli/_packages.py index 01e7adc15..e3ff5b8e8 100644 --- a/gaia/cli/_packages.py +++ b/gaia/cli/_packages.py @@ -903,6 +903,15 @@ def collect_foreign_node_priors( return foreign_priors +def collect_strategy_conditional_params(compiled) -> dict[str, list[float]]: + """Collect v6 strategy CPT records keyed by strategy id for BP lowering.""" + return { + record.strategy_id: list(record.conditional_probabilities) + for record in compiled.strategy_param_records + if record.strategy_id + } + + @dataclass class DependencyGraph: """A dependency's compiled IR loaded from disk.""" diff --git a/gaia/cli/commands/infer.py b/gaia/cli/commands/infer.py index dc77318f9..663170d57 100644 --- a/gaia/cli/commands/infer.py +++ b/gaia/cli/commands/infer.py @@ -14,6 +14,7 @@ GaiaCliError, apply_package_priors, collect_foreign_node_priors, + collect_strategy_conditional_params, compile_loaded_package_artifact, ensure_package_env, gaia_lang_version, @@ -85,6 +86,7 @@ def infer_command( raise typer.Exit(1) if depth != 0: + strategy_params = collect_strategy_conditional_params(compiled) # Joint cross-package inference: merge dependency factor graphs try: dep_compiled = load_dependency_compiled_graphs(loaded.project_config, depth=depth) @@ -109,7 +111,11 @@ def infer_command( # Lower local graph WITHOUT foreign node priors — the dep graphs # provide the full reasoning structure instead of flat priors - local_fg = lower_local_graph(compiled.graph, review_manifest=review_manifest) + local_fg = lower_local_graph( + compiled.graph, + strategy_conditional_params=strategy_params, + review_manifest=review_manifest, + ) local_prefix = f"{compiled.graph.namespace}:{compiled.graph.package_name}::" if dep_factor_graphs: @@ -130,6 +136,7 @@ def infer_command( factor_graph = lower_local_graph( compiled.graph, node_priors=foreign_priors or None, + strategy_conditional_params=collect_strategy_conditional_params(compiled), review_manifest=review_manifest, ) fg_errors = factor_graph.validate() diff --git a/gaia/cli/commands/register.py b/gaia/cli/commands/register.py index 87fc8d903..72b2c8ee2 100644 --- a/gaia/cli/commands/register.py +++ b/gaia/cli/commands/register.py @@ -14,6 +14,7 @@ GaiaCliError, apply_package_priors, build_package_manifests, + collect_strategy_conditional_params, load_gaia_package, ) from gaia.cli._packages import compile_loaded_package_artifact @@ -390,7 +391,11 @@ def register_command( # foreign nodes instead of falling back to 0.5. # Mirror infer_command: load dep_beliefs so foreign nodes get upstream priors. foreign_priors = collect_foreign_node_priors(compiled.graph, loaded.pkg_path) - factor_graph = lower_local_graph(compiled.graph, node_priors=foreign_priors or None) + factor_graph = lower_local_graph( + compiled.graph, + node_priors=foreign_priors or None, + strategy_conditional_params=collect_strategy_conditional_params(compiled), + ) fg_errors = factor_graph.validate() if fg_errors: for error in fg_errors: diff --git a/tests/cli/test_infer.py b/tests/cli/test_infer.py index fb574856f..ae06338cc 100644 --- a/tests/cli/test_infer.py +++ b/tests/cli/test_infer.py @@ -211,6 +211,62 @@ def test_infer_uses_accepted_review_manifest(tmp_path): assert belief_by_label["hypothesis"] > 0.4 +def test_infer_uses_v6_infer_action_cpt(tmp_path): + """gaia infer must pass v6 InferAction CPT records into BP lowering.""" + from gaia.cli._packages import ( + apply_package_priors, + compile_loaded_package_artifact, + load_gaia_package, + ) + from gaia.ir import ReviewManifest, ReviewStatus + + pkg_dir = tmp_path / "v6_cpt_infer" + _write_base_package(pkg_dir, name="v6_cpt_infer") + (pkg_dir / "v6_cpt_infer" / "__init__.py").write_text( + "from gaia.lang import claim, infer\n\n" + 'hypothesis = claim("Hypothesis.")\n' + 'evidence = claim("Evidence.")\n' + "infer(\n" + " hypothesis=hypothesis,\n" + " evidence=evidence,\n" + " p_e_given_h=0.95,\n" + " p_e_given_not_h=0.05,\n" + ' rationale="Hypothesis strongly predicts evidence.",\n' + ' label="bayes_update",\n' + ")\n" + '__all__ = ["hypothesis", "evidence"]\n' + ) + (pkg_dir / "v6_cpt_infer" / "priors.py").write_text( + "from . import evidence, hypothesis\n\n" + "PRIORS = {\n" + ' hypothesis: (0.2, "Low base rate."),\n' + ' evidence: (0.9, "Observed evidence."),\n' + "}\n" + ) + + compile_result = runner.invoke(app, ["compile", str(pkg_dir)]) + assert compile_result.exit_code == 0, compile_result.output + + loaded = load_gaia_package(pkg_dir) + apply_package_priors(loaded) + compiled = compile_loaded_package_artifact(loaded) + assert compiled.review is not None + accepted = [ + review.model_copy(update={"status": ReviewStatus.ACCEPTED, "round": 2}) + for review in compiled.review.reviews + ] + (pkg_dir / ".gaia" / "review_manifest.json").write_text( + json.dumps(ReviewManifest(reviews=accepted).model_dump(mode="json"), indent=2) + ) + + result = runner.invoke(app, ["infer", str(pkg_dir)]) + assert result.exit_code == 0, result.output + + beliefs = json.loads((pkg_dir / ".gaia" / "beliefs.json").read_text()) + belief_by_label = {item["label"]: item["belief"] for item in beliefs["beliefs"]} + assert belief_by_label["hypothesis"] > 0.5 + + def test_infer_with_accepted_root_observe_review(tmp_path): """Accepted no-premise observe reviews should not lower as empty deductions.""" from gaia.cli._packages import ( diff --git a/tests/cli/test_register.py b/tests/cli/test_register.py index 9830e257c..fef0e86de 100644 --- a/tests/cli/test_register.py +++ b/tests/cli/test_register.py @@ -99,6 +99,47 @@ def _write_package_with_local_hole_and_bridge(pkg_dir) -> None: ) +def _write_package_with_v6_infer(pkg_dir) -> None: + pkg_dir.mkdir() + (pkg_dir / ".gitignore").write_text(".gaia/\nuv.lock\n") + (pkg_dir / "pyproject.toml").write_text( + "[project]\n" + 'name = "register-infer-gaia"\n' + 'version = "1.2.0"\n' + 'description = "Registration demo with v6 infer CPTs"\n' + "dependencies = [\n" + ' "gaia-lang>=0.1.0",\n' + "]\n\n" + "[tool.gaia]\n" + 'namespace = "github"\n' + 'type = "knowledge-package"\n' + 'uuid = "8fae1bcb-6c5c-5d91-9de7-b76f0689c8ff"\n' + ) + pkg_src = pkg_dir / "register_infer" + pkg_src.mkdir() + (pkg_src / "__init__.py").write_text( + "from gaia.lang import claim, infer\n\n" + 'hypothesis = claim("Hypothesis.")\n' + 'evidence = claim("Evidence.")\n' + "infer(\n" + " hypothesis=hypothesis,\n" + " evidence=evidence,\n" + " p_e_given_h=0.95,\n" + " p_e_given_not_h=0.05,\n" + ' rationale="Hypothesis strongly predicts evidence.",\n' + ' label="bayes_update",\n' + ")\n" + '__all__ = ["hypothesis", "evidence"]\n' + ) + (pkg_src / "priors.py").write_text( + "from . import evidence, hypothesis\n\n" + "PRIORS = {\n" + ' hypothesis: (0.2, "Low base rate."),\n' + ' evidence: (0.9, "Observed evidence."),\n' + "}\n" + ) + + def _init_git_repo(pkg_dir, remote_dir) -> None: _run(["git", "init"], cwd=pkg_dir) _run(["git", "config", "user.name", "Gaia Test"], cwd=pkg_dir) @@ -174,6 +215,37 @@ def test_register_dry_run_emits_registration_plan(tmp_path): assert "exported_claim" in exported_labels +def test_register_beliefs_use_v6_infer_action_cpt(tmp_path): + """register-generated beliefs.json must use v6 InferAction CPT records.""" + pkg_dir = tmp_path / "register_infer" + remote_dir = tmp_path / "register_infer_remote.git" + _write_package_with_v6_infer(pkg_dir) + _init_git_repo(pkg_dir, remote_dir) + + compile_result = runner.invoke(app, ["compile", str(pkg_dir)]) + assert compile_result.exit_code == 0, compile_result.output + + _run(["git", "tag", "v1.2.0"], cwd=pkg_dir) + _run(["git", "push", "origin", "v1.2.0"], cwd=pkg_dir) + + result = runner.invoke( + app, + [ + "register", + str(pkg_dir), + "--repo", + "https://github.com/example/RegisterInfer.gaia", + ], + ) + assert result.exit_code == 0, result.output + + plan = json.loads(result.output) + release_dir = "packages/register-infer/releases/1.2.0" + beliefs_manifest = json.loads(plan["files"][f"{release_dir}/beliefs.json"]) + belief_by_label = {item["label"]: item["belief"] for item in beliefs_manifest["beliefs"]} + assert belief_by_label["hypothesis"] > 0.5 + + def test_register_writes_registry_metadata_to_local_checkout(tmp_path): pkg_dir = tmp_path / "register_demo" remote_dir = tmp_path / "register_demo_remote.git" From 38a8246d22fbaa3b6ed74c919fa8f52b2119d006 Mon Sep 17 00:00:00 2001 From: kunchen Date: Wed, 22 Apr 2026 00:26:17 +0800 Subject: [PATCH 026/210] fix(lang): store infer cpts on strategies --- gaia/bp/lowering.py | 4 ++-- gaia/cli/_packages.py | 9 --------- gaia/cli/commands/infer.py | 4 ---- gaia/cli/commands/register.py | 2 -- gaia/ir/strategy.py | 16 ++++++++++++++++ gaia/lang/compiler/compile.py | 13 +------------ tests/cli/test_compile_v6_actions.py | 7 +++++++ tests/cli/test_infer.py | 2 +- tests/cli/test_register.py | 2 +- tests/gaia/lang/test_compiler_actions.py | 6 +++--- 10 files changed, 31 insertions(+), 34 deletions(-) diff --git a/gaia/bp/lowering.py b/gaia/bp/lowering.py index a5bb7cc38..784a606e7 100644 --- a/gaia/bp/lowering.py +++ b/gaia/bp/lowering.py @@ -350,7 +350,7 @@ def _lower_strategy( _ensure_claim_var(fg, p, priors, claim_ids) if s.type == StrategyType.INFER: - cpt = strat_params.get(s.strategy_id) + cpt = s.conditional_probabilities or strat_params.get(s.strategy_id) if not cpt: cpt = [0.5] * (1 << len(s.premises)) if infer_degraded: @@ -401,7 +401,7 @@ def _lower_strategy( return if s.type == StrategyType.NOISY_AND: - raw = strat_params.get(s.strategy_id) or [0.5] + raw = s.conditional_probabilities or strat_params.get(s.strategy_id) or [0.5] p = float(raw[0]) premises = list(s.premises) if len(premises) == 1: diff --git a/gaia/cli/_packages.py b/gaia/cli/_packages.py index e3ff5b8e8..01e7adc15 100644 --- a/gaia/cli/_packages.py +++ b/gaia/cli/_packages.py @@ -903,15 +903,6 @@ def collect_foreign_node_priors( return foreign_priors -def collect_strategy_conditional_params(compiled) -> dict[str, list[float]]: - """Collect v6 strategy CPT records keyed by strategy id for BP lowering.""" - return { - record.strategy_id: list(record.conditional_probabilities) - for record in compiled.strategy_param_records - if record.strategy_id - } - - @dataclass class DependencyGraph: """A dependency's compiled IR loaded from disk.""" diff --git a/gaia/cli/commands/infer.py b/gaia/cli/commands/infer.py index 663170d57..ee7a20a29 100644 --- a/gaia/cli/commands/infer.py +++ b/gaia/cli/commands/infer.py @@ -14,7 +14,6 @@ GaiaCliError, apply_package_priors, collect_foreign_node_priors, - collect_strategy_conditional_params, compile_loaded_package_artifact, ensure_package_env, gaia_lang_version, @@ -86,7 +85,6 @@ def infer_command( raise typer.Exit(1) if depth != 0: - strategy_params = collect_strategy_conditional_params(compiled) # Joint cross-package inference: merge dependency factor graphs try: dep_compiled = load_dependency_compiled_graphs(loaded.project_config, depth=depth) @@ -113,7 +111,6 @@ def infer_command( # provide the full reasoning structure instead of flat priors local_fg = lower_local_graph( compiled.graph, - strategy_conditional_params=strategy_params, review_manifest=review_manifest, ) local_prefix = f"{compiled.graph.namespace}:{compiled.graph.package_name}::" @@ -136,7 +133,6 @@ def infer_command( factor_graph = lower_local_graph( compiled.graph, node_priors=foreign_priors or None, - strategy_conditional_params=collect_strategy_conditional_params(compiled), review_manifest=review_manifest, ) fg_errors = factor_graph.validate() diff --git a/gaia/cli/commands/register.py b/gaia/cli/commands/register.py index 72b2c8ee2..28c37ab6e 100644 --- a/gaia/cli/commands/register.py +++ b/gaia/cli/commands/register.py @@ -14,7 +14,6 @@ GaiaCliError, apply_package_priors, build_package_manifests, - collect_strategy_conditional_params, load_gaia_package, ) from gaia.cli._packages import compile_loaded_package_artifact @@ -394,7 +393,6 @@ def register_command( factor_graph = lower_local_graph( compiled.graph, node_priors=foreign_priors or None, - strategy_conditional_params=collect_strategy_conditional_params(compiled), ) fg_errors = factor_graph.validate() if fg_errors: diff --git a/gaia/ir/strategy.py b/gaia/ir/strategy.py index 916bac555..bafb11f1a 100644 --- a/gaia/ir/strategy.py +++ b/gaia/ir/strategy.py @@ -147,6 +147,7 @@ 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 # traceability metadata: dict[str, Any] | None = None @@ -204,6 +205,21 @@ def _compute_id_and_validate(self) -> Strategy: self.conclusion, structure_hash=self._structure_hash(), ) + if self.conditional_probabilities is not None: + clamped = [max(1e-3, min(1 - 1e-3, float(p))) for p in self.conditional_probabilities] + if self.type == StrategyType.INFER: + expected = 1 << len(self.premises) + if len(clamped) != expected: + raise ValueError( + f"infer strategy with {len(self.premises)} premises requires " + f"{expected} conditional_probabilities, got {len(clamped)}" + ) + elif self.type == StrategyType.NOISY_AND: + if len(clamped) != 1: + raise ValueError( + f"noisy_and strategy requires 1 conditional_probability, got {len(clamped)}" + ) + object.__setattr__(self, "conditional_probabilities", clamped) return self # No leaf type restriction — per §3.5.1, named strategies (deduction, abduction, diff --git a/gaia/lang/compiler/compile.py b/gaia/lang/compiler/compile.py index 30310653b..d22ae9459 100644 --- a/gaia/lang/compiler/compile.py +++ b/gaia/lang/compiler/compile.py @@ -21,7 +21,6 @@ ReviewManifest, Step as IrStep, Strategy as IrStrategy, - StrategyParamRecord, formalize_named_strategy, make_qid, ) @@ -69,7 +68,6 @@ class CompiledPackage: 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) - strategy_param_records: list[StrategyParamRecord] = field(default_factory=list) review: ReviewManifest | None = None def to_json(self) -> dict[str, Any]: @@ -534,7 +532,6 @@ def compile_strategy(s) -> IrStrategy: action_label_map: dict[str, str] = {} target_action_labels_by_id: dict[str, str] = {} - strategy_param_records: list[StrategyParamRecord] = [] def _record_action_target(action_label: str, target_id: str | None) -> None: if target_id is None: @@ -661,16 +658,9 @@ def _compile_infer_action(action: InferAction, action_index: int) -> IrStrategy: conclusion=knowledge_map[id(action.evidence)], background=[knowledge_map[id(bg)] for bg in action.background] or None, steps=_action_steps(action.rationale), + conditional_probabilities=[action.p_e_given_not_h, action.p_e_given_h], metadata=metadata, ) - strategy_param_records.append( - StrategyParamRecord( - strategy_id=strategy.strategy_id, - conditional_probabilities=[action.p_e_given_not_h, action.p_e_given_h], - source_id="author", - justification=action.rationale, - ) - ) _record_action_target(action_label, strategy.strategy_id) return strategy @@ -819,7 +809,6 @@ def _handle(text: str | None) -> None: strategies_by_object=dict(compiled_strategies), action_label_map=action_label_map, target_action_labels_by_id=target_action_labels_by_id, - strategy_param_records=strategy_param_records, ) from gaia.lang.review.manifest import generate_review_manifest diff --git a/tests/cli/test_compile_v6_actions.py b/tests/cli/test_compile_v6_actions.py index 2ce101cac..026182332 100644 --- a/tests/cli/test_compile_v6_actions.py +++ b/tests/cli/test_compile_v6_actions.py @@ -52,6 +52,13 @@ def test_compile_v6_actions_package(tmp_path): if s.get("metadata") and "pattern" in s["metadata"] } assert {"derivation", "inference"} <= strategy_patterns + infer_strategy = next( + s + for s in ir["strategies"] + if (s.get("metadata") or {}).get("action_label") + == "github:v6_actions::action::bayes_update" + ) + assert infer_strategy["conditional_probabilities"] == [0.1, 0.9] grounding_patterns = { k["metadata"]["grounding"]["pattern"] diff --git a/tests/cli/test_infer.py b/tests/cli/test_infer.py index ae06338cc..1d8d00b35 100644 --- a/tests/cli/test_infer.py +++ b/tests/cli/test_infer.py @@ -212,7 +212,7 @@ def test_infer_uses_accepted_review_manifest(tmp_path): def test_infer_uses_v6_infer_action_cpt(tmp_path): - """gaia infer must pass v6 InferAction CPT records into BP lowering.""" + """gaia infer must lower v6 InferAction CPTs from the compiled IR strategy.""" from gaia.cli._packages import ( apply_package_priors, compile_loaded_package_artifact, diff --git a/tests/cli/test_register.py b/tests/cli/test_register.py index fef0e86de..9838c2763 100644 --- a/tests/cli/test_register.py +++ b/tests/cli/test_register.py @@ -216,7 +216,7 @@ def test_register_dry_run_emits_registration_plan(tmp_path): def test_register_beliefs_use_v6_infer_action_cpt(tmp_path): - """register-generated beliefs.json must use v6 InferAction CPT records.""" + """register-generated beliefs.json must use v6 InferAction CPTs from IR strategies.""" pkg_dir = tmp_path / "register_infer" remote_dir = tmp_path / "register_infer_remote.git" _write_package_with_v6_infer(pkg_dir) diff --git a/tests/gaia/lang/test_compiler_actions.py b/tests/gaia/lang/test_compiler_actions.py index 545ab7060..11e986af0 100644 --- a/tests/gaia/lang/test_compiler_actions.py +++ b/tests/gaia/lang/test_compiler_actions.py @@ -111,7 +111,7 @@ def test_compile_equal_and_contradict_actions_to_operators(): assert by_operator["contradiction"].conclusion == "github:v6_actions::conflict_helper" -def test_compile_infer_action_to_strategy_and_cpt_record(): +def test_compile_infer_action_to_strategy_cpt(): with CollectedPackage("v6_actions") as pkg: h = Claim("H.") h.label = "h" @@ -138,8 +138,8 @@ def test_compile_infer_action_to_strategy_and_cpt_record(): assert strategy.background == ["github:v6_actions::reliable"] assert strategy.metadata["action_label"] == "github:v6_actions::action::bayes_update" assert strategy.steps[0].reasoning == "Bayes." - assert compiled.strategy_param_records[0].strategy_id == strategy.strategy_id - assert compiled.strategy_param_records[0].conditional_probabilities == [0.2, 0.8] + assert strategy.conditional_probabilities == [0.2, 0.8] + assert not hasattr(compiled, "strategy_param_records") stat_support = _knowledge_by_label(compiled)["stat_support"] assert stat_support.metadata["helper_kind"] == "statistical_support" From 9dae613997dd91c2fca35ebc5c633d22a82681b2 Mon Sep 17 00:00:00 2001 From: kunchen Date: Wed, 22 Apr 2026 16:39:20 +0800 Subject: [PATCH 027/210] docs: add probabilistic correlation relation idea --- .../probabilistic-correlation-relation.md | 353 ++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 docs/ideas/probabilistic-correlation-relation.md diff --git a/docs/ideas/probabilistic-correlation-relation.md b/docs/ideas/probabilistic-correlation-relation.md new file mode 100644 index 000000000..acc5b3bd7 --- /dev/null +++ b/docs/ideas/probabilistic-correlation-relation.md @@ -0,0 +1,353 @@ +# Probabilistic Correlation Relation for Gaia v0.5 + +> **Status:** Idea +> +> This note records a possible replacement for the current v6 `infer()` +> surface. It is not an implementation plan. The goal is to clarify the +> semantic center before changing the DSL, IR, or BP lowering. + +## 1. Motivation + +The current v6 design exposes statistical evidence as: + +```python +infer( + hypothesis=H, + evidence=E, + p_e_given_h=..., + p_e_given_not_h=..., +) +``` + +This is mathematically standard when there is a generative model, but it is +often awkward for LLM-assisted scientific formalization: + +- `not H` is usually an under-specified contrast class. +- `not E` is often not a clean logical complement of the evidence event. +- LLMs are usually better at judging positive relationships than open-ended + complement classes. +- The word `infer` sounds like a reasoning action, while the object being + authored is really a probabilistic relation between two Claims. + +The design question is whether Gaia should treat this as a `Relate`-like +primitive rather than a `Support`-like or `Infer`-like primitive. + +## 2. Jaynes Interpretation + +In Jaynes-style probability, probabilities are always conditional on an +information state: + +```text +P(A | I) +``` + +For Gaia, `I` must be a declared, reviewable context, not the LLM's hidden chain +of thought. It may include: + +- the definitions of the two Claims, +- accepted background Claims and Settings, +- source excerpts or data tables, +- measurement protocol, +- sample scope or population, +- parameter ranges and error models, +- calibration rubric. + +The LLM's private deliberation is not part of `I`. If a chain-of-thought step +uses a load-bearing assumption, that assumption should be promoted into a Claim, +Setting, or Context item before it can condition a Gaia probability. + +## 3. Core Idea + +Replace the conceptual primitive: + +```text +infer H from E +``` + +with: + +```text +A and B have a calibrated probabilistic relation under context I. +``` + +A possible Lang surface is: + +```python +relation = correlate( + A, + B, + context=[...], + p_a_given_b=..., + p_b_given_a=..., + p_a=..., # or p_b=... + rationale="...", +) +``` + +This declares a binary probabilistic relation: + +```text +P(A | B, I) +P(B | A, I) +P(A | I) or P(B | I) +``` + +At least one base-rate anchor is required, because `P(A|B,I)` and `P(B|A,I)` +alone only determine the ratio: + +```text +P(A | I) / P(B | I) = P(A | B, I) / P(B | A, I) +``` + +If both base rates are provided, Gaia checks coherence: + +```text +P(A | B, I) * P(B | I) ~= P(B | A, I) * P(A | I) +``` + +## 4. Why This Is Better Than Bare `infer` + +The relation is not fundamentally: + +```text +H -> E +``` + +or: + +```text +E -> H +``` + +Those are two coordinate systems for the same joint distribution. The authored +object is the joint relation between two claims under a declared context. + +For scientific model-based cases, users may still prefer likelihood language: + +```text +P(E | H, I) +P(E | A, I) +``` + +where `A` is an explicit contrast or baseline. For LLM-assisted judgment, users +may prefer positive-direction questions: + +```text +P(E | H, I) +P(H | E, I) +P(E | I) +``` + +Both can lower to a binary factor after coherence checks. The key improvement is +that Gaia no longer forces authors to name `not H` or `not E` unless those +complements are actually meaningful in the domain. + +## 5. Helper Claim Semantics + +`correlate()` should return a relation helper Claim, not a conclusion Claim: + +```text +"A and B have a calibrated probabilistic relation under I." +``` + +The helper is reviewable. It represents the assertion that the relation is +well-defined and calibrated, not that either `A` or `B` is true. + +Gaia may classify the relation shape from the conditional probabilities and +base rates: + +```text +positive_association +negative_association +near_independence +approximate_equivalence +asymmetric_evidence_for_a +asymmetric_evidence_for_b +incoherent +``` + +Classification should be based on relative update, not only absolute +conditional probabilities. + +For example, `B` is evidence for `A` when: + +```text +P(A | B, I) > P(A | I) +``` + +or, in odds form: + +```text +logit P(A | B, I) - logit P(A | I) > 0 +``` + +This matters for rare hypotheses. `P(A|B,I)` can be small in absolute terms but +still be strong evidence if it greatly exceeds `P(A|I)`. + +## 6. Relation to Soft Logical Relations + +The probabilities can also be read as soft implications: + +```text +P(B | A, I) ~= A softly implies B +P(A | B, I) ~= B softly implies A +``` + +Therefore: + +```text +approximate_equal(A, B) +``` + +is a special case where both directions are strong and the base rates are +compatible. + +Likewise, an approximate contradiction should not be inferred merely from low +`P(A|B,I)` or low `P(B|A,I)`. It is only well-defined when the relevant +negative propositions are themselves clear: + +```text +A softly implies not B +B softly implies not A +``` + +Because `not A` and `not B` are often hard to define, `approximate_contradict` +should be a derived or guarded relation, not the default interpretation of a +low correlation. + +## 7. `evidence_for` as a Wrapper + +The user-facing scientific language may still want: + +```python +evidence_for( + hypothesis=H, + evidence=E, + context=[...], + calibration=..., +) +``` + +This can be a wrapper over `correlate(H, E, ...)`: + +```text +A = H +B = E +P(A | B, I) = P(H | E, I) +P(B | A, I) = P(E | H, I) +``` + +It returns a relation helper Claim such as: + +```text +"E is evidence for H under I." +``` + +This helper is more specific than the generic correlate helper, but the +probabilistic core is the same. + +## 8. Relation to A/B Tests + +A/B tests are not the primitive; they are calibration helpers for this +primitive. + +For example: + +```python +evidence_for( + hypothesis=treatment_improves_conversion, + evidence=observed_lift, + context=[experiment_design], + calibration=ABTest( + control_successes=..., + control_total=..., + treatment_successes=..., + treatment_total=..., + prior_alpha=1.0, + prior_beta=1.0, + min_lift=0.0, + ), +) +``` + +The A/B helper computes quantities such as: + +```text +P(treatment_rate > control_rate | data, I) +Bayes factor or likelihood ratio, if a contrast model is specified +credible interval for the lift +``` + +The relation helper Claim is still the same kind of object: a reviewable +probabilistic relation between the hypothesis Claim and the evidence Claim. + +## 9. Possible Lowering + +Short term, `correlate()` can lower to the existing `Strategy(type="infer")` +conditional factor after converting the supplied calibration into a CPT. The IR +can preserve the author-facing calibration in metadata: + +```json +{ + "pattern": "probabilistic_relation", + "relation_kind": "correlate", + "calibration": { + "kind": "conditional_pair", + "p_a_given_b": 0.8, + "p_b_given_a": 0.7, + "p_a": 0.3 + }, + "compiled": { + "premises": ["A"], + "conclusion": "B", + "conditional_probabilities": [...] + } +} +``` + +Medium term, Gaia may introduce a more direct BP factor for calibrated binary +relations. That would avoid forcing the relation into a directional `premises` +and `conclusion` shape. + +## 10. Review Questions + +ReviewManifest entries for `correlate()` should not ask whether a derivation is +valid. They should ask whether the probabilistic relation is well-defined: + +- Are `A`, `B`, and context `I` defined clearly? +- Is `I` declared publicly, without hidden chain-of-thought assumptions? +- Are the conditional probabilities estimated under the same `I`? +- Is the provided base-rate anchor reasonable? +- Are the probability entries coherent? +- Does the rationale hide load-bearing assumptions that should be promoted to + Claims or Settings? +- Is the inferred helper shape, such as evidence-for or approximate-equivalence, + justified by relative update rather than absolute probability alone? + +## 11. Non-goals + +This note does not propose: + +- replacing `equal()` or `contradict()` for deterministic logical relations, +- adding a full causal modeling language, +- requiring LLMs to produce exact probabilities when qualitative calibration + would be more reliable, +- treating hidden chain-of-thought as Gaia context, +- committing to a new IR top-level primitive before the relation semantics are + stable. + +## 12. Working Recommendation + +For v0.5 iteration, the cleanest path is: + +1. Treat the current `infer()` concept as a probabilistic relation, not a + support action. +2. Prototype a Lang surface named `correlate()` that returns a reviewable + relation helper Claim. +3. Provide `evidence_for()` as a scientific wrapper over `correlate()`. +4. Keep the existing `Strategy(type="infer")` lowering path initially, but + preserve the original calibration form in metadata. +5. Classify helper relation shape from relative update against base rates. + +This keeps the semantics Jaynes-compatible while making LLM-assisted +formalization ask easier, positive-direction probability questions. From 6ed7bc4c7c432d0902c8456a026095a9068e2bfc Mon Sep 17 00:00:00 2001 From: kunchen Date: Wed, 22 Apr 2026 17:00:29 +0800 Subject: [PATCH 028/210] feat(lang): return evidence from v6 infer --- .../probabilistic-correlation-relation.md | 34 +++++++------ docs/specs/2026-04-21-gaia-lang-v6-design.md | 22 +++++--- gaia/lang/dsl/infer_verb.py | 51 ++++++++++++------- tests/cli/test_compile_v6_actions.py | 8 +-- tests/cli/test_infer.py | 2 +- tests/cli/test_register.py | 2 +- tests/gaia/lang/test_compiler_actions.py | 7 ++- tests/gaia/lang/test_infer.py | 46 +++++++++++++---- tests/gaia/lang/test_review_manifest.py | 2 +- 9 files changed, 115 insertions(+), 59 deletions(-) diff --git a/docs/ideas/probabilistic-correlation-relation.md b/docs/ideas/probabilistic-correlation-relation.md index acc5b3bd7..734eca093 100644 --- a/docs/ideas/probabilistic-correlation-relation.md +++ b/docs/ideas/probabilistic-correlation-relation.md @@ -1,10 +1,11 @@ # Probabilistic Correlation Relation for Gaia v0.5 -> **Status:** Idea +> **Status:** Idea, narrowed after follow-up discussion > -> This note records a possible replacement for the current v6 `infer()` -> surface. It is not an implementation plan. The goal is to clarify the -> semantic center before changing the DSL, IR, or BP lowering. +> This note records a possible future `correlate()` / `evidence_for()` +> relation surface. It is no longer the preferred replacement for `infer()`: +> `infer()` remains a directional probabilistic action where a hypothesis +> predicts an evidence-shaped conclusion. ## 1. Motivation @@ -12,8 +13,8 @@ The current v6 design exposes statistical evidence as: ```python infer( + E, hypothesis=H, - evidence=E, p_e_given_h=..., p_e_given_not_h=..., ) @@ -29,8 +30,9 @@ often awkward for LLM-assisted scientific formalization: - The word `infer` sounds like a reasoning action, while the object being authored is really a probabilistic relation between two Claims. -The design question is whether Gaia should treat this as a `Relate`-like -primitive rather than a `Support`-like or `Infer`-like primitive. +The design question for this note is whether Gaia also needs a separate +`Relate`-like primitive for symmetric or bidirectional probabilistic +correlations. That primitive should not replace `infer()`. ## 2. Jaynes Interpretation @@ -338,16 +340,18 @@ This note does not propose: ## 12. Working Recommendation -For v0.5 iteration, the cleanest path is: +For future iteration, if Gaia needs an explicit probabilistic relation surface, +the cleanest path is: -1. Treat the current `infer()` concept as a probabilistic relation, not a - support action. -2. Prototype a Lang surface named `correlate()` that returns a reviewable +1. Keep `infer()` as a directional probabilistic action: `H` predicts `E`, and + observed `E` updates belief in `H`. +2. Prototype a separate Lang surface named `correlate()` that returns a reviewable relation helper Claim. 3. Provide `evidence_for()` as a scientific wrapper over `correlate()`. -4. Keep the existing `Strategy(type="infer")` lowering path initially, but - preserve the original calibration form in metadata. +4. Lower `correlate()` separately, or temporarily through `Strategy(type="infer")` + only when a directional factor is explicitly chosen. 5. Classify helper relation shape from relative update against base rates. -This keeps the semantics Jaynes-compatible while making LLM-assisted -formalization ask easier, positive-direction probability questions. +This keeps `infer()` aligned with the action hierarchy while preserving a place +to explore easier, positive-direction probability questions for LLM-assisted +formalization. diff --git a/docs/specs/2026-04-21-gaia-lang-v6-design.md b/docs/specs/2026-04-21-gaia-lang-v6-design.md index f93fb627a..413493ffe 100644 --- a/docs/specs/2026-04-21-gaia-lang-v6-design.md +++ b/docs/specs/2026-04-21-gaia-lang-v6-design.md @@ -509,26 +509,29 @@ derive( ### 7.1 infer -Statistical evidence update based on Jaynes/Bayes framework. All parameters are keyword-only. +Statistical evidence update based on Jaynes/Bayes framework. The first +argument is the evidence or prediction-shaped Claim `E`, aligning `infer()` with +other action verbs that return their conclusion Claim. Probability arguments +remain keyword-only to avoid swapping the two conditional probabilities. ```python infer( + evidence: Claim | str, *, hypothesis: Claim, - evidence: Claim, background: list[Setting | Claim] = [], p_e_given_h: float, p_e_given_not_h: float, rationale: str = "", -) -> StatisticalSupport +) -> Claim ``` No `given` parameter — assumptions and conditions go in `background` (not in BP). If assumptions are questionable, reviewer rejects the Strategy via ReviewManifest. ```python -support = infer( +evidence = infer( + spectrum_data, hypothesis=quantum_hyp, - evidence=spectrum_data, background=[exp_setting, reliable_measurement, calibrated], p_e_given_h=0.9, p_e_given_not_h=0.05, @@ -536,11 +539,16 @@ support = infer( ) ``` -Returns `StatisticalSupport` helper Claim. Compiles to `Strategy(type="infer", premises=[H], conclusion=E)` + CPT `[p_e_given_not_h, p_e_given_h]`. +Returns the evidence Claim `E`. Internally, the action still creates a +reviewable `StatisticalSupport` helper Claim so ReviewManifest can audit the +probabilistic warrant. Compiles to `Strategy(type="infer", premises=[H], +conclusion=E)` + CPT `[p_e_given_not_h, p_e_given_h]`. ### 7.2 Semantics -`infer()` creates a bidirectional factor between hypothesis and evidence: +`infer()` creates a directional predictive factor from hypothesis to evidence. +Because BP messages flow both ways, an accepted/observed `E` can still update +belief in `H`: ``` odds(H) *= P(E|H) / P(E|¬H) diff --git a/gaia/lang/dsl/infer_verb.py b/gaia/lang/dsl/infer_verb.py index 1307d7aca..b5cbebb72 100644 --- a/gaia/lang/dsl/infer_verb.py +++ b/gaia/lang/dsl/infer_verb.py @@ -15,9 +15,10 @@ def _claim_ref(claim: Claim) -> str: def infer( + evidence_or_legacy=None, *args, hypothesis: Claim | None = None, - evidence: Claim | None = None, + evidence: Claim | str | None = None, background: list[Knowledge] | None = None, p_e_given_h: float | None = None, p_e_given_not_h: float | None = None, @@ -25,24 +26,33 @@ def infer( label: str | None = None, **legacy_kwargs, ) -> Claim: - """Bayesian inference. Returns a statistical-support helper Claim. + """Bayesian inference. Returns the evidence Claim. - The v6 shape is keyword-only. The old v5 ``infer([premises], conclusion, ...)`` - form is preserved as a deprecated compatibility path. + The canonical v6 shape is ``infer(evidence, hypothesis=..., ...)``. The old + v5 ``infer([premises], conclusion, ...)`` form is preserved as a deprecated + compatibility path. """ - if args: - if isinstance(args[0], (list, tuple)): - from gaia.lang.dsl.strategies import infer as legacy_infer + if isinstance(evidence_or_legacy, (list, tuple)): + from gaia.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(evidence_or_legacy, *args, **legacy_kwargs) - warnings.warn( - "infer([premises], conclusion, ...) is deprecated; use keyword-only " - "infer(hypothesis=..., evidence=..., p_e_given_h=..., " - "p_e_given_not_h=...) instead", - DeprecationWarning, - stacklevel=2, - ) - return legacy_infer(*args, **legacy_kwargs) - raise TypeError("v6 infer() arguments are keyword-only") + 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}'") + if evidence_or_legacy is not None: + if evidence is not None: + raise TypeError("infer() got evidence both positionally and by keyword") + evidence = evidence_or_legacy if hypothesis is None: raise TypeError("infer() missing required keyword argument: 'hypothesis'") @@ -52,6 +62,12 @@ def infer( raise TypeError("infer() missing required keyword argument: 'p_e_given_h'") if p_e_given_not_h is None: raise TypeError("infer() missing required keyword argument: 'p_e_given_not_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") helper = Claim( f"{_claim_ref(evidence)} statistically supports {_claim_ref(hypothesis)}.", @@ -68,4 +84,5 @@ def infer( helper=helper, ) action.warrants.append(helper) - return helper + evidence.supports.append(action) + return evidence diff --git a/tests/cli/test_compile_v6_actions.py b/tests/cli/test_compile_v6_actions.py index 026182332..59a7804fd 100644 --- a/tests/cli/test_compile_v6_actions.py +++ b/tests/cli/test_compile_v6_actions.py @@ -24,9 +24,9 @@ def test_compile_v6_actions_package(tmp_path): 'classical = claim("Classical model predicts divergent UV spectrum.")\n' 'agreement = equal(prediction, data, rationale="Prediction matches data.", label="match")\n' 'conflict = contradict(classical, data, rationale="Prediction conflicts.", label="conflict")\n' - "stat_support = infer(\n" + "data = infer(\n" + " data,\n" " hypothesis=prediction,\n" - " evidence=data,\n" " background=[calibrated],\n" " p_e_given_h=0.9,\n" " p_e_given_not_h=0.1,\n" @@ -35,8 +35,8 @@ def test_compile_v6_actions_package(tmp_path): ")\n" "favored = derive(\n" ' "Planck model is favored.",\n' - " given=(agreement, conflict, stat_support),\n" - ' rationale="Agreement, conflict, and Bayes support favor Planck.",\n' + " given=(agreement, conflict, data),\n" + ' rationale="Agreement, conflict, and observed data favor Planck.",\n' ' label="favor_planck",\n' ")\n" '__all__ = ["favored"]\n' diff --git a/tests/cli/test_infer.py b/tests/cli/test_infer.py index 1d8d00b35..5cfdd48a6 100644 --- a/tests/cli/test_infer.py +++ b/tests/cli/test_infer.py @@ -227,8 +227,8 @@ def test_infer_uses_v6_infer_action_cpt(tmp_path): 'hypothesis = claim("Hypothesis.")\n' 'evidence = claim("Evidence.")\n' "infer(\n" + " evidence,\n" " hypothesis=hypothesis,\n" - " evidence=evidence,\n" " p_e_given_h=0.95,\n" " p_e_given_not_h=0.05,\n" ' rationale="Hypothesis strongly predicts evidence.",\n' diff --git a/tests/cli/test_register.py b/tests/cli/test_register.py index 9838c2763..6a1251d3f 100644 --- a/tests/cli/test_register.py +++ b/tests/cli/test_register.py @@ -122,8 +122,8 @@ def _write_package_with_v6_infer(pkg_dir) -> None: 'hypothesis = claim("Hypothesis.")\n' 'evidence = claim("Evidence.")\n' "infer(\n" + " evidence,\n" " hypothesis=hypothesis,\n" - " evidence=evidence,\n" " p_e_given_h=0.95,\n" " p_e_given_not_h=0.05,\n" ' rationale="Hypothesis strongly predicts evidence.",\n' diff --git a/tests/gaia/lang/test_compiler_actions.py b/tests/gaia/lang/test_compiler_actions.py index 11e986af0..60e51d067 100644 --- a/tests/gaia/lang/test_compiler_actions.py +++ b/tests/gaia/lang/test_compiler_actions.py @@ -119,15 +119,18 @@ def test_compile_infer_action_to_strategy_cpt(): e.label = "e" bg = Claim("Measurement reliable.") bg.label = "reliable" - helper = infer( + result = infer( + e, hypothesis=h, - evidence=e, background=[bg], p_e_given_h=0.8, p_e_given_not_h=0.2, rationale="Bayes.", label="bayes_update", ) + assert result is e + helper = pkg.actions[0].helper + assert helper is not None helper.label = "stat_support" compiled = compile_package_artifact(pkg) diff --git a/tests/gaia/lang/test_infer.py b/tests/gaia/lang/test_infer.py index 08fe85ce5..929f9654f 100644 --- a/tests/gaia/lang/test_infer.py +++ b/tests/gaia/lang/test_infer.py @@ -6,23 +6,45 @@ from gaia.lang.runtime.package import CollectedPackage -def test_infer_returns_statistical_support(): +def test_infer_returns_positional_evidence_and_keeps_helper_on_action(): + with CollectedPackage("v6_test") as pkg: + h = Claim("Quantum theory is correct.", prior=0.5) + e = Claim("Planck spectrum observed.", prior=0.95) + result = infer( + e, + hypothesis=h, + p_e_given_h=0.9, + p_e_given_not_h=0.05, + rationale="Strong evidence.", + ) + + assert result is e + action = pkg.actions[0] + assert action.evidence is e + assert action.hypothesis is h + assert action in e.supports + assert action.helper is not None + assert action.helper is not e + assert action.helper.metadata.get("generated") is True + assert action.helper.metadata.get("helper_kind") == "statistical_support" + assert action.helper.metadata.get("review") is True + assert action.warrants == [action.helper] + + +def test_infer_keyword_evidence_also_returns_evidence(): h = Claim("Quantum theory is correct.", prior=0.5) e = Claim("Planck spectrum observed.", prior=0.95) - support = infer( + result = infer( hypothesis=h, evidence=e, p_e_given_h=0.9, p_e_given_not_h=0.05, rationale="Strong evidence.", ) - assert isinstance(support, Claim) - assert support.metadata.get("generated") is True - assert support.metadata.get("helper_kind") == "statistical_support" - assert support.metadata.get("review") is True + assert result is e -def test_infer_all_keyword_only_for_v6_shape(): +def test_infer_rejects_ambiguous_extra_positional_v6_shape(): h = Claim("H.") e = Claim("E.") with pytest.raises(TypeError): @@ -34,15 +56,16 @@ def test_infer_registers_action_and_warrant(): h = Claim("H.") e = Claim("E.") bg = Setting("Experiment conditions.") - helper = infer( + result = infer( + e, hypothesis=h, - evidence=e, background=[bg], p_e_given_h=0.8, p_e_given_not_h=0.2, rationale="Test.", label="bayes_update", ) + assert result is e assert len(pkg.actions) == 1 action = pkg.actions[0] assert isinstance(action, Infer) @@ -52,8 +75,9 @@ def test_infer_registers_action_and_warrant(): assert action.background == [bg] assert action.p_e_given_h == 0.8 assert action.p_e_given_not_h == 0.2 - assert action.helper is helper - assert action.warrants == [helper] + assert action in e.supports + assert action.helper is not None + assert action.warrants == [action.helper] def test_infer_preserves_v5_positional_shape(): diff --git a/tests/gaia/lang/test_review_manifest.py b/tests/gaia/lang/test_review_manifest.py index 78e53ce41..65dfcccd6 100644 --- a/tests/gaia/lang/test_review_manifest.py +++ b/tests/gaia/lang/test_review_manifest.py @@ -46,8 +46,8 @@ def test_generate_review_manifest_for_v6_actions(): conflict = contradict(a, data, rationale="Conflict.", label="conflict") conflict.label = "conflict_helper" infer( + data, hypothesis=c, - evidence=data, p_e_given_h=0.8, p_e_given_not_h=0.2, rationale="Bayes.", From 49076245f2fb07c6e443e318b507bf353c4bf0da Mon Sep 17 00:00:00 2001 From: kunchen Date: Wed, 22 Apr 2026 17:11:15 +0800 Subject: [PATCH 029/210] test(lang): cover infer string evidence --- gaia/lang/review/templates.py | 4 ++-- tests/gaia/lang/test_infer.py | 21 +++++++++++++++++++++ tests/gaia/lang/test_review_manifest.py | 2 ++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/gaia/lang/review/templates.py b/gaia/lang/review/templates.py index 6eea66f35..c6bac103d 100644 --- a/gaia/lang/review/templates.py +++ b/gaia/lang/review/templates.py @@ -13,8 +13,8 @@ def __missing__(self, key): "observe": "Is the observation of [@{conclusion_label}] reliable under the stated conditions?", "compute": "Is the computation of [@{conclusion_label}] correctly implemented?", "infer": ( - "Is the statistical association between [@{hypothesis_label}] and " - "[@{evidence_label}] valid at the stated probabilities?" + "Does [@{hypothesis_label}] predict [@{evidence_label}] at the stated " + "conditional probabilities?" ), "equal": "Are [@{a_label}] and [@{b_label}] truly equivalent?", "contradict": "Do [@{a_label}] and [@{b_label}] truly contradict?", diff --git a/tests/gaia/lang/test_infer.py b/tests/gaia/lang/test_infer.py index 929f9654f..1880930ab 100644 --- a/tests/gaia/lang/test_infer.py +++ b/tests/gaia/lang/test_infer.py @@ -44,6 +44,27 @@ def test_infer_keyword_evidence_also_returns_evidence(): assert result is e +def test_infer_string_evidence_creates_and_returns_evidence_claim(): + with CollectedPackage("v6_test") as pkg: + h = Claim("Quantum theory is correct.", prior=0.5) + result = infer( + "Planck spectrum observed.", + hypothesis=h, + p_e_given_h=0.9, + p_e_given_not_h=0.05, + rationale="Strong evidence.", + ) + + assert isinstance(result, Claim) + assert result.content == "Planck spectrum observed." + action = pkg.actions[0] + assert action.evidence is result + assert action.hypothesis is h + assert action in result.supports + assert action.helper is not None + assert action.warrants == [action.helper] + + def test_infer_rejects_ambiguous_extra_positional_v6_shape(): h = Claim("H.") e = Claim("E.") diff --git a/tests/gaia/lang/test_review_manifest.py b/tests/gaia/lang/test_review_manifest.py index 65dfcccd6..a22d5904d 100644 --- a/tests/gaia/lang/test_review_manifest.py +++ b/tests/gaia/lang/test_review_manifest.py @@ -23,6 +23,8 @@ def test_audit_question_for_infer(): ) assert "[@quantum_hyp]" in question assert "[@spectrum]" in question + assert "predict" in question.lower() + assert "association" not in question.lower() def test_audit_question_for_equal(): From bb01913480e598e0a782ec4e9811b714ab8a8d3e Mon Sep 17 00:00:00 2001 From: kunchen Date: Wed, 22 Apr 2026 19:26:57 +0800 Subject: [PATCH 030/210] Unify context knowledge as notes --- docs/specs/2026-04-21-gaia-ir-v6-design.md | 30 +++++--- docs/specs/2026-04-21-gaia-lang-v6-design.md | 62 ++++++++--------- gaia/cli/_packages.py | 4 +- gaia/cli/commands/_brief.py | 18 ++--- gaia/cli/commands/_classify.py | 13 +++- gaia/cli/commands/_detailed_reasoning.py | 24 ++++--- gaia/cli/commands/_obsidian.py | 16 +++-- gaia/cli/commands/_simplified_mermaid.py | 4 +- gaia/cli/commands/check.py | 6 +- gaia/cli/commands/init.py | 4 +- .../pages/src/components/ModuleOverview.tsx | 2 +- .../pages/src/components/ModuleSubgraph.tsx | 2 +- .../pages/src/components/NodeRenderer.tsx | 10 ++- .../templates/pages/src/hooks/useElkLayout.ts | 2 +- gaia/cli/templates/pages/src/types.ts | 9 ++- gaia/ir/coarsen.py | 2 +- gaia/ir/knowledge.py | 13 +++- gaia/lang/__init__.py | 4 ++ gaia/lang/compiler/compile.py | 5 +- gaia/lang/dsl/__init__.py | 3 +- gaia/lang/dsl/knowledge.py | 69 ++++++++++++++++--- gaia/lang/runtime/__init__.py | 3 +- gaia/lang/runtime/knowledge.py | 32 ++++++--- tests/cli/test_brief.py | 6 +- tests/cli/test_compile_v6.py | 12 ++-- tests/cli/test_detailed_reasoning.py | 4 +- tests/cli/test_init.py | 4 +- tests/gaia/lang/test_compiler_v6.py | 24 +++++-- tests/gaia/lang/test_core.py | 14 +++- tests/gaia/lang/test_knowledge_v6.py | 65 +++++++++++++---- tests/ir/test_knowledge.py | 14 +++- tests/ir/test_knowledge_context.py | 1 + 32 files changed, 335 insertions(+), 146 deletions(-) diff --git a/docs/specs/2026-04-21-gaia-ir-v6-design.md b/docs/specs/2026-04-21-gaia-ir-v6-design.md index ef2eac6b2..701b21d02 100644 --- a/docs/specs/2026-04-21-gaia-ir-v6-design.md +++ b/docs/specs/2026-04-21-gaia-ir-v6-design.md @@ -22,7 +22,7 @@ Strategy — unchanged schema and adds: ``` -Knowledge — new type "context", grounding metadata, parameterized claim rendering +Knowledge — new type "note", format field, grounding metadata, parameterized claim rendering ReviewManifest / Review — package-level review layer (new) ``` @@ -40,7 +40,7 @@ New interpretation: ### Core rules -1. **Only `Claim` carries epistemic probability.** Setting, Context, Question do not. +1. **Only `Claim` carries epistemic probability.** Note and Question do not. 2. **Strategy carries no probability.** Uncertainty is expressed through explicit premise Claims. 3. **Operator remains deterministic** and continues to produce `conclusion` helper Claims. 4. **Generated helper Claims are qualitative audit targets** unless their owning Strategy/Operator is reviewed and accepted. @@ -51,18 +51,25 @@ New interpretation: ## 1. Knowledge Extensions -### 1.1 New Knowledge type: `context` +### 1.1 New Knowledge type: `note` ```python Knowledge.type ∈ { "claim", - "setting", + "note", "question", - "context", # NEW + "setting", # legacy input compatibility + "context", # legacy input compatibility } ``` -`context` stores raw, not-yet-formalized text or artifact excerpts (paper paragraphs, lab notes, dashboard data). Does not participate in BP. Provides traceability for later formalization. +`note` stores non-probabilistic context: raw text, artifact excerpts, definitions, +conditions, scope notes, units, or other material that does not participate in +BP. Legacy `setting` and `context` nodes may be accepted by readers, but new v6 +compiler output should emit `note`. + +All Knowledge nodes carry `format: str = "markdown"`. The format records the +content representation and participates in the content hash. ### 1.2 Parameter.value @@ -77,10 +84,10 @@ class Parameter: The `value` participates in the content hash of a ground parameterized Claim. -When a parameter's type is a Knowledge type (e.g., `Setting`, `Claim`), the `value` stores the referenced node's QID: +When a parameter's type is a Knowledge type (e.g., `Note`, `Claim`), the `value` stores the referenced node's QID: ```python -Parameter(name="experiment", type="Setting", value="github:my_package::exp_123") +Parameter(name="experiment", type="Note", value="github:my_package::exp_123") ``` ### 1.3 Grounding metadata @@ -238,8 +245,8 @@ Templates are rendered to concrete questions using the referenced Claims' labels | Lang construct | IR compilation target | |---|---| -| `Context(...)` | `Knowledge(type="context")` | -| `Setting(...)` | `Knowledge(type="setting")` | +| `Note(...)` | `Knowledge(type="note", format=...)` | +| `Context(...)` / `Setting(...)` | Deprecated aliases compiling to `Knowledge(type="note", metadata.legacy_kind=...)` | | `Claim(...)` / subclasses | `Knowledge(type="claim")` + bound parameters | | `Question(...)` | `Knowledge(type="question")` | | `derive(...)` | `FormalStrategy(type="deduction")` + conjunction + implication helpers | @@ -260,7 +267,8 @@ Templates are rendered to concrete questions using the referenced Claims' labels ### 5.1 Modified -- `Knowledge.type` adds `"context"` value +- `Knowledge.type` adds `"note"` value; `"setting"` and `"context"` remain legacy-compatible inputs +- `Knowledge.format` defaults to `"markdown"` and participates in content hashing - `Parameter.value: JsonValue | None` — bound parameter values (including QID references) ### 5.2 New metadata conventions diff --git a/docs/specs/2026-04-21-gaia-lang-v6-design.md b/docs/specs/2026-04-21-gaia-lang-v6-design.md index 413493ffe..feaa2c7aa 100644 --- a/docs/specs/2026-04-21-gaia-lang-v6-design.md +++ b/docs/specs/2026-04-21-gaia-lang-v6-design.md @@ -26,43 +26,41 @@ ``` Knowledge ← Plain text, not in reasoning graph -├── Context ← Raw unformalized text (lab notes, paper excerpts) -├── Setting ← Formalized background (definitions, conditions), no probability +├── Note ← Non-probabilistic context (lab notes, definitions, conditions) ├── Claim ← Proposition with prior, participates in BP │ └── User subclasses ← Parameterized domain types └── Question ← Open inquiry, organizes investigation ``` -### 1.2 Context +All Knowledge nodes also carry `format: str = "markdown"`. This records the +content representation (`"markdown"`, `"text"`, `"csv"`, `"latex"`, etc.) and +participates in the content hash. -Stores raw text or artifact excerpts that have not yet been formalized into Claims. +### 1.2 Note + +Stores non-probabilistic context: raw text, artifact excerpts, conventions, +definitions, scope notes, units, or experimental conditions. ```python -ctx = Context(""" +ctx = Note(""" Experiment exp_123 ran from March 1 to March 14. Control A had 10,000 users and 500 conversions. Treatment B had 10,000 users and 550 conversions. """) -``` - -- Does not enter BP. -- Cannot be a Strategy premise. -- Claims may reference Context via Grounding `source_refs`. - -### 1.3 Setting -Formalized background context, convention, scope, or units. - -```python -lab = Setting("Blackbody cavity experiment at thermal equilibrium.") -exp = Setting("AB test exp_123: 50/50 hash-based randomization, March 1-14.") +lab = Note("Blackbody cavity experiment at thermal equilibrium.") +exp = Note("AB test exp_123: 50/50 hash-based randomization, March 1-14.") ``` - No prior/posterior. - May appear in Strategy `background`. -- If a background proposition is uncertain, it should be a Claim, not a Setting. +- Cannot be a Strategy premise. +- Claims may reference Notes via Grounding `source_refs`. +- If a background proposition is uncertain, it should be a Claim, not a Note. + +`Context` and `Setting` remain deprecated compatibility aliases for `Note`. -### 1.4 Claim +### 1.3 Claim The only user-facing object with prior/posterior belief. @@ -72,7 +70,7 @@ quantum_hyp = Claim("Energy exchange is quantized.", prior=0.5) Core rule: **every exported or non-root Claim needs a warrant** — a Strategy or Relation connecting it to the reasoning graph. A Claim with a prior but no warrant is a structural hole. -### 1.5 Question +### 1.4 Question Inquiry lens. Organizes exploration but does not enter BP. @@ -109,12 +107,12 @@ T = CavityTemperature(value=5000.0) ### 2.2 Two kinds of parameters - **Value parameters** (`int`, `float`, `str`, `Enum`): use `{param_name}` template substitution. -- **Knowledge parameters** (`Setting`, `Claim`, or subclasses): use `[@param_name]` reference syntax. Compiler resolves to the referenced node's QID. +- **Knowledge parameters** (`Note`, `Claim`, or subclasses): use `[@param_name]` reference syntax. Compiler resolves to the referenced node's QID. ```python class ABCounts(Claim): """[@experiment] recorded {ctrl_k}/{ctrl_n} control and {treat_k}/{treat_n} treatment conversions.""" - experiment: Setting # Knowledge parameter — [@experiment] + experiment: Note # Knowledge parameter — [@experiment] ctrl_n: int # value parameter — {ctrl_n} ctrl_k: int treat_n: int @@ -244,7 +242,7 @@ Deferred: class Action: label: str | None = None # human-readable label, compiles to QID-style ID rationale: str - background: list[Setting | Claim] = [] + background: list[Note | Claim] = [] warrants: list[Claim] = [] # helper claims needing review ``` @@ -341,7 +339,7 @@ All verbs share: | Parameter | Type | Meaning | |---|---|---| | `given` | `Claim \| tuple[Claim, ...]` | Probabilistic conditions. Tuple auto-compiles to conjunction. | -| `background` | `list[Setting]` | Non-probabilistic context. Not in BP. | +| `background` | `list[Note]` | Non-probabilistic context. Not in BP. | | `rationale` | `str` | Required. Why this step is valid. | No verb has a `prior` parameter. Uncertainty is on Claims. @@ -358,7 +356,7 @@ Logical derivation. The most common support verb. First positional argument is t derive( Claim, # conclusion (positional) given: Claim | tuple[Claim, ...], - background: list[Setting] = [], + background: list[Note] = [], rationale: str = "", ) ``` @@ -387,7 +385,7 @@ Empirical observation or measurement. Structurally identical to deduction, but w observe( Claim, # conclusion (positional) given: Claim | tuple[Claim, ...] = (), - background: list[Setting] = [], + background: list[Note] = [], rationale: str = "", ) ``` @@ -519,7 +517,7 @@ infer( evidence: Claim | str, *, hypothesis: Claim, - background: list[Setting | Claim] = [], + background: list[Note | Claim] = [], p_e_given_h: float, p_e_given_not_h: float, rationale: str = "", @@ -532,7 +530,7 @@ No `given` parameter — assumptions and conditions go in `background` (not in B evidence = infer( spectrum_data, hypothesis=quantum_hyp, - background=[exp_setting, reliable_measurement, calibrated], + background=[exp_note, reliable_measurement, calibrated], p_e_given_h=0.9, p_e_given_not_h=0.05, rationale="Planck spectrum is highly expected under quantum theory, very unlikely under alternatives.", @@ -650,7 +648,7 @@ gaia check --gate # Quality gate $ gaia check --inquiry Package: blackbody-radiation-gaia - Context: Planck's analysis of blackbody radiation spectrum (1900)... + Notes: Planck's analysis of blackbody radiation spectrum (1900)... ━━━ Goal 1: quantum_hyp (exported) ━━━ Status: WARRANTED (2/3 accepted) @@ -731,8 +729,8 @@ knowledge.metadata["gaia"]["provenance"] = { | v6 DSL | IR compilation target | |---|---| -| `Context(...)` | `Knowledge(type="context")` | -| `Setting(...)` | `Knowledge(type="setting")` | +| `Note(...)` | `Knowledge(type="note", format=...)` | +| `Context(...)` / `Setting(...)` | Deprecated aliases compiling to `Knowledge(type="note", metadata.legacy_kind=...)` | | `Claim(...)` / subclasses | `Knowledge(type="claim")` + bound parameters | | `Question(...)` | `Knowledge(type="question")` | | `derive(...)` | `FormalStrategy(type="deduction")` + conjunction + implication helpers | @@ -756,7 +754,7 @@ knowledge.metadata["gaia"]["provenance"] = { | v5 | v6 | Notes | |---|---|---| | `claim("...")` | `Claim("...")` or subclass | Uppercase, class style | -| `setting("...")` | `Setting("...")` | Uppercase | +| `setting("...")` / `context("...")` | `note("...")` or `Note("...")` | Deprecated compatibility wrappers | | `question("...")` | `Question("...")` | Uppercase | | `support([a], b, prior=0.9)` | Deprecated compat wrapper | Emits `DeprecationWarning`; must preserve existing v5 support-prior behavior until a migration tool rewrites it | | `deduction([a], b)` | `derive(b, given=a, rationale=...)` | No prior, no type= | diff --git a/gaia/cli/_packages.py b/gaia/cli/_packages.py index 01e7adc15..f225baef0 100644 --- a/gaia/cli/_packages.py +++ b/gaia/cli/_packages.py @@ -269,7 +269,7 @@ def apply_package_priors(loaded: LoadedGaiaPackage) -> None: suffix = " ..." if len(new_knowledge) > 5 else "" raise GaiaCliError( "Error: priors.py must not declare new Knowledge objects; it may only " - "reference claims/settings/questions already declared by the package. " + "reference claims/notes/questions already declared by the package. " f"New declarations: {names}{suffix}." ) @@ -285,7 +285,7 @@ def apply_package_priors(loaded: LoadedGaiaPackage) -> None: if not isinstance(key, Knowledge): raise GaiaCliError( f"Error: PRIORS key {key!r} is not a Knowledge object. " - "Keys must be claim/setting/question objects from the package." + "Keys must be claim/note/question objects from the package." ) if id(key) not in existing_knowledge_ids: raise GaiaCliError( diff --git a/gaia/cli/commands/_brief.py b/gaia/cli/commands/_brief.py index b931695cc..9b5ba6a38 100644 --- a/gaia/cli/commands/_brief.py +++ b/gaia/cli/commands/_brief.py @@ -4,7 +4,7 @@ from collections import defaultdict -from gaia.cli.commands._classify import classify_ir, node_role +from gaia.cli.commands._classify import classify_ir, is_note_type, node_role def _truncate(text: str, max_len: int = 80) -> str: @@ -154,13 +154,13 @@ def generate_brief_overview(ir: dict) -> list[str]: lines.append("") nodes = by_module.get(mod, []) - settings = [k for k in nodes if k["type"] == "setting"] + notes = [k for k in nodes if is_note_type(k["type"])] claims = [k for k in nodes if k["type"] == "claim"] questions = [k for k in nodes if k["type"] == "question"] - if settings: - lines.append(" Settings:") - for k in settings: + if notes: + lines.append(" Notes:") + for k in notes: label = k.get("label", "?") content = _truncate(k.get("content", ""), 60) lines.append(f' {label}: "{content}"') @@ -259,13 +259,13 @@ def generate_brief_module(ir: dict, module_name: str) -> list[str]: lines.append(f"\u2550\u2550 Module: {module_name} (expanded) " + "\u2550" * 30) lines.append("") - settings = [k for k in nodes if k["type"] == "setting"] + notes = [k for k in nodes if is_note_type(k["type"])] claims = [k for k in nodes if k["type"] == "claim"] questions = [k for k in nodes if k["type"] == "question"] - if settings: - lines.append(" Settings:") - for k in settings: + if notes: + lines.append(" Notes:") + for k in notes: label = k.get("label", "?") content = k.get("content", "") lines.append(f" {label}:") diff --git a/gaia/cli/commands/_classify.py b/gaia/cli/commands/_classify.py index 109abf2e6..0c9f12290 100644 --- a/gaia/cli/commands/_classify.py +++ b/gaia/cli/commands/_classify.py @@ -4,6 +4,8 @@ from dataclasses import dataclass, field +NOTE_TYPES = frozenset({"note", "setting", "context"}) + @dataclass class KnowledgeClassification: @@ -34,11 +36,16 @@ def classify_ir(ir: dict) -> KnowledgeClassification: 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, + """Return the role of a knowledge node: note, question, derived, structural, independent, background, or orphaned.""" - if ktype == "setting": - return "setting" + if is_note_type(ktype): + return "note" if ktype == "question": return "question" if kid in c.operator_conclusions: diff --git a/gaia/cli/commands/_detailed_reasoning.py b/gaia/cli/commands/_detailed_reasoning.py index d72e7be84..19bc6500b 100644 --- a/gaia/cli/commands/_detailed_reasoning.py +++ b/gaia/cli/commands/_detailed_reasoning.py @@ -4,7 +4,7 @@ from collections import defaultdict -from gaia.cli.commands._classify import classify_ir, node_role +from gaia.cli.commands._classify import classify_ir, is_note_type, node_role def topo_layers(ir: dict) -> dict[str, int]: @@ -53,6 +53,10 @@ def _module_key(k: dict) -> str: return module if module else "Root" +def _display_knowledge_type(ktype: str) -> str: + return "note" if is_note_type(ktype) else ktype + + def _module_segments(nodes: list[dict]) -> list[tuple[str, list[dict]]]: segments: list[tuple[str, list[dict]]] = [] for node in nodes: @@ -67,7 +71,7 @@ def _module_segments(nodes: list[dict]) -> list[tuple[str, list[dict]]]: # ── Mermaid rendering ── _MERMAID_STYLES = """\ - classDef setting fill:#f0f0f0,stroke:#999,color:#333 + classDef note fill:#f0f0f0,stroke:#999,color:#333 classDef premise fill:#ddeeff,stroke:#4488bb,color:#333 classDef derived fill:#ddffdd,stroke:#44bb44,color:#333 classDef question fill:#fff3dd,stroke:#cc9944,color:#333 @@ -79,7 +83,7 @@ def _module_segments(nodes: list[dict]) -> list[tuple[str, list[dict]]]: # Map node_role() output to Mermaid CSS class names _ROLE_TO_CSS = { - "setting": "setting", + "note": "note", "question": "question", "derived": "derived", "structural": "derived", # operator conclusions display like derived @@ -313,7 +317,7 @@ def sort_key(k): ktype = k["type"] if ktype == "question": return (999, 0, k.get("label", "")) - if ktype == "setting": + if is_note_type(ktype): return (-1, 0, k.get("label", "")) return (layers.get(kid, 0), 1, k.get("label", "")) @@ -352,9 +356,13 @@ def _render_node( lines.append("") # Type + label badge line - type_emoji = {"setting": "\U0001f4cb", "claim": "\U0001f4cc", "question": "\u2753"}.get( - ktype, "" - ) + type_emoji = { + "note": "\U0001f4cb", + "setting": "\U0001f4cb", + "context": "\U0001f4cb", + "claim": "\U0001f4cc", + "question": "\u2753", + }.get(ktype, "") badge_parts = [f"{type_emoji} `{label}`"] if kid in priors: badge_parts.append(f"Prior: {priors[kid]:.2f}") @@ -577,7 +585,7 @@ def render_knowledge_nodes( sections.append("") current_type = None for k in ordered: - ktype = k["type"] + ktype = _display_knowledge_type(k["type"]) if ktype != current_type: current_type = ktype sections.append(f"### {ktype.title()}s") diff --git a/gaia/cli/commands/_obsidian.py b/gaia/cli/commands/_obsidian.py index fb8c341f1..d5d46141f 100644 --- a/gaia/cli/commands/_obsidian.py +++ b/gaia/cli/commands/_obsidian.py @@ -13,7 +13,7 @@ import json -from gaia.cli.commands._classify import classify_ir, node_role +from gaia.cli.commands._classify import classify_ir, is_note_type, node_role from gaia.cli.commands._detailed_reasoning import render_mermaid, topo_layers from gaia.cli.commands._simplified_mermaid import render_simplified_mermaid @@ -316,10 +316,10 @@ def _generate_index( lines.append("| Metric | Count |") lines.append("|--------|-------|") n_claims = sum(1 for k in all_k if k["type"] == "claim") - n_settings = sum(1 for k in all_k if k["type"] == "setting") + n_notes = sum(1 for k in all_k if is_note_type(k["type"])) n_questions = sum(1 for k in all_k if k["type"] == "question") lines.append( - f"| Knowledge nodes | {len(all_k)} ({n_claims} claims, {n_settings} settings, {n_questions} questions) |" + f"| Knowledge nodes | {len(all_k)} ({n_claims} claims, {n_notes} notes, {n_questions} questions) |" ) lines.append(f"| Strategies | {len(ir.get('strategies', []))} |") lines.append(f"| Operators | {len(ir.get('operators', []))} |") @@ -392,7 +392,7 @@ def _generate_obsidian_config() -> str: "hideUnresolved": False, "colorGroups": [ {"query": "tag:#claim", "color": {"a": 1, "rgb": 5025616}}, - {"query": "tag:#setting", "color": {"a": 1, "rgb": 8421504}}, + {"query": "tag:#note", "color": {"a": 1, "rgb": 8421504}}, {"query": "tag:#question", "color": {"a": 1, "rgb": 16750848}}, {"query": "tag:#module", "color": {"a": 1, "rgb": 65280}}, {"query": "tag:#evidence", "color": {"a": 1, "rgb": 255}}, @@ -450,7 +450,7 @@ def generate_obsidian_vault( _ROLE_TO_DIR = { "independent": "holes", # premise but not conclusion — true holes "derived": "intermediate", # conclusion of strategy, not exported - "setting": "context", # background settings + "note": "context", # non-probabilistic notes "background": "context", # background knowledge "structural": "context", # operator conclusions "orphaned": "context", # not referenced by any strategy @@ -558,7 +558,11 @@ def _claim_role(k: dict) -> str: # Open Questions section — leaf premises (holes) + questions sec_num += 1 conclusion_ids = {s.get("conclusion") for s in ir.get("strategies", []) if s.get("conclusion")} - leaves = [k for k in all_claims if k["id"] not in conclusion_ids and k["type"] != "setting"] + leaves = [ + k + for k in all_claims + if k["id"] not in conclusion_ids and not is_note_type(k["type"]) + ] questions = [k for k in all_claims if k["type"] == "question"] oq_lines = [ "---", diff --git a/gaia/cli/commands/_simplified_mermaid.py b/gaia/cli/commands/_simplified_mermaid.py index 4d3d3982a..e9e7abcc5 100644 --- a/gaia/cli/commands/_simplified_mermaid.py +++ b/gaia/cli/commands/_simplified_mermaid.py @@ -11,7 +11,7 @@ # ── Mermaid CSS class definitions (self-contained, not imported from _detailed_reasoning) ── _MERMAID_STYLES = """\ - classDef setting fill:#f0f0f0,stroke:#999,color:#333 + classDef note fill:#f0f0f0,stroke:#999,color:#333 classDef premise fill:#ddeeff,stroke:#4488bb,color:#333 classDef derived fill:#ddffdd,stroke:#44bb44,color:#333 classDef question fill:#fff3dd,stroke:#cc9944,color:#333 @@ -22,7 +22,7 @@ classDef contra fill:#ffebee,stroke:#c62828,color:#333""" _ROLE_TO_CSS = { - "setting": "setting", + "note": "note", "question": "question", "derived": "derived", "structural": "derived", diff --git a/gaia/cli/commands/check.py b/gaia/cli/commands/check.py index 73a627f76..39cb4e36f 100644 --- a/gaia/cli/commands/check.py +++ b/gaia/cli/commands/check.py @@ -9,7 +9,7 @@ from gaia.cli._packages import GaiaCliError, load_gaia_package, validate_fills_relations from gaia.cli._packages import apply_package_priors from gaia.cli._packages import compile_loaded_package_artifact -from gaia.cli.commands._classify import classify_ir, node_role +from gaia.cli.commands._classify import classify_ir, is_note_type, node_role from gaia.cli.commands._review_manifest import ( latest_reviews, load_or_generate_review_manifest, @@ -29,7 +29,7 @@ def _knowledge_diagnostics(ir: dict) -> list[str]: lines: list[str] = [] claims = {k["id"]: k for k in ir["knowledges"] if k["type"] == "claim"} - settings = {k["id"]: k for k in ir["knowledges"] if k["type"] == "setting"} + notes = {k["id"]: k for k in ir["knowledges"] if is_note_type(k["type"])} questions = {k["id"]: k for k in ir["knowledges"] if k["type"] == "question"} c = classify_ir(ir) @@ -58,7 +58,7 @@ def _knowledge_diagnostics(ir: dict) -> list[str]: # Summary lines.append("") - lines.append(f" Settings: {len(settings)}") + lines.append(f" Notes: {len(notes)}") lines.append(f" Questions: {len(questions)}") lines.append(f" Claims: {len(claims)}") lines.append(f" Independent (need prior): {len(independent)}") diff --git a/gaia/cli/commands/init.py b/gaia/cli/commands/init.py index 1f782d5e3..b602f97d1 100644 --- a/gaia/cli/commands/init.py +++ b/gaia/cli/commands/init.py @@ -11,9 +11,9 @@ from gaia.cli._packages import GaiaCliError _DSL_TEMPLATE = """\ -from gaia.lang import claim, setting, noisy_and +from gaia.lang import claim, note, noisy_and -context = setting("Background context for this package.") +context = note("Background context for this package.") hypothesis = claim("A scientific hypothesis.") evidence = claim("Supporting evidence.") _strat = noisy_and([hypothesis], evidence, reason="Hypothesis supports evidence.") diff --git a/gaia/cli/templates/pages/src/components/ModuleOverview.tsx b/gaia/cli/templates/pages/src/components/ModuleOverview.tsx index e061e589d..cc79736b9 100644 --- a/gaia/cli/templates/pages/src/components/ModuleOverview.tsx +++ b/gaia/cli/templates/pages/src/components/ModuleOverview.tsx @@ -22,7 +22,7 @@ export default function ModuleOverview({ modules, crossModuleEdges, onSelectModu const pNodes: GraphNode[] = modules.map(m => ({ id: m.id, label: m.id, - type: 'setting' as const, + type: 'note' as const, module: m.id, content: '', exported: false, diff --git a/gaia/cli/templates/pages/src/components/ModuleSubgraph.tsx b/gaia/cli/templates/pages/src/components/ModuleSubgraph.tsx index 9766092e9..aebada215 100644 --- a/gaia/cli/templates/pages/src/components/ModuleSubgraph.tsx +++ b/gaia/cli/templates/pages/src/components/ModuleSubgraph.tsx @@ -458,7 +458,7 @@ export default function ModuleSubgraph({ const extNodes: GraphNode[] = refs.map(r => ({ id: r.id, label: `↗ ${r.label}`, - type: 'setting' as const, + type: 'note' as const, module: r.sourceModule, content: '', exported: false, diff --git a/gaia/cli/templates/pages/src/components/NodeRenderer.tsx b/gaia/cli/templates/pages/src/components/NodeRenderer.tsx index 34be22dc2..871151253 100644 --- a/gaia/cli/templates/pages/src/components/NodeRenderer.tsx +++ b/gaia/cli/templates/pages/src/components/NodeRenderer.tsx @@ -36,22 +36,26 @@ const OP_SYMBOLS: Record = { implication: '\u2192', } +function isNoteNodeType(type: string): boolean { + return type === 'note' || type === 'setting' || type === 'context' +} + 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' + : isNoteNodeType(node.type) ? '#f0f0f0' : node.type === 'question' ? '#fff3dd' : '#ddeeff' const stroke = isExternal ? '#aaa' - : node.type === 'setting' ? '#999' + : isNoteNodeType(node.type) ? '#999' : node.type === 'question' ? '#cc9944' : '#4488bb' const dashArray = isExternal ? '5,3' : undefined const rx = isExternal ? 8 - : node.type === 'setting' ? 2 + : isNoteNodeType(node.type) ? 2 : node.type === 'question' ? height / 2 : 8 const label = node.title || node.label diff --git a/gaia/cli/templates/pages/src/hooks/useElkLayout.ts b/gaia/cli/templates/pages/src/hooks/useElkLayout.ts index 1128a4279..b26b4e638 100644 --- a/gaia/cli/templates/pages/src/hooks/useElkLayout.ts +++ b/gaia/cli/templates/pages/src/hooks/useElkLayout.ts @@ -33,7 +33,7 @@ export interface LayoutResult { 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 + // KnowledgeNode: claim, note, question, action const label = node.label const charWidth = 8 const padding = 32 diff --git a/gaia/cli/templates/pages/src/types.ts b/gaia/cli/templates/pages/src/types.ts index 2f493685b..6fce6d6df 100644 --- a/gaia/cli/templates/pages/src/types.ts +++ b/gaia/cli/templates/pages/src/types.ts @@ -4,7 +4,7 @@ export interface KnowledgeNode { id: string label: string title?: string - type: 'claim' | 'setting' | 'question' | 'action' + type: 'claim' | 'note' | 'setting' | 'context' | 'question' | 'action' module?: string content: string prior?: number | null @@ -71,7 +71,12 @@ export interface MetaData { // --- Type guards --- export function isKnowledgeNode(n: GraphNode): n is KnowledgeNode { - return n.type === 'claim' || n.type === 'setting' || n.type === 'question' || n.type === 'action' + return n.type === 'claim' + || n.type === 'note' + || n.type === 'setting' + || n.type === 'context' + || n.type === 'question' + || n.type === 'action' } export function isStrategyNode(n: GraphNode): n is StrategyNode { diff --git a/gaia/ir/coarsen.py b/gaia/ir/coarsen.py index fdd63aabf..985a1540f 100644 --- a/gaia/ir/coarsen.py +++ b/gaia/ir/coarsen.py @@ -30,7 +30,7 @@ def coarsen_ir(ir: dict, exported_ids: set[str]) -> dict: all_concluded = strat_conclusions | op_conclusions # 2. Identify leaf premises: claims not concluded by any strategy/operator, - # excluding helpers and settings + # excluding helpers and notes leaf_ids: set[str] = set() for k in ir["knowledges"]: kid = k["id"] diff --git a/gaia/ir/knowledge.py b/gaia/ir/knowledge.py index 507d6de17..baab49547 100644 --- a/gaia/ir/knowledge.py +++ b/gaia/ir/knowledge.py @@ -29,6 +29,8 @@ class KnowledgeType(StrEnum): """Knowledge types (§1.2).""" CLAIM = "claim" + NOTE = "note" + # Legacy non-probabilistic types accepted for backwards compatibility. SETTING = "setting" QUESTION = "question" CONTEXT = "context" @@ -53,14 +55,16 @@ 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: +def _compute_content_hash( + type_: str, content: str, parameters: list[Parameter], format_: str +) -> str: """Content fingerprint: SHA-256(type + 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_}|{content}|{sorted_params}" + payload = f"{type_}|{format_}|{content}|{sorted_params}" return _sha256_hex(payload, length=64) @@ -74,6 +78,7 @@ 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] = [] @@ -96,7 +101,9 @@ def _compute_derived_fields(self) -> Knowledge: # 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") self.content_hash = expected_content_hash diff --git a/gaia/lang/__init__.py b/gaia/lang/__init__.py index ace61e11a..e44297083 100644 --- a/gaia/lang/__init__.py +++ b/gaia/lang/__init__.py @@ -24,6 +24,7 @@ infer, mathematical_induction, noisy_and, + note, observe, question, setting, @@ -40,6 +41,7 @@ Grounding, Infer, Knowledge, + Note, Observe, Operator, Question, @@ -61,6 +63,7 @@ "Grounding", "Infer", "Knowledge", + "Note", "Observe", "Operator", "Question", @@ -92,6 +95,7 @@ "infer", "mathematical_induction", "noisy_and", + "note", "observe", "question", "setting", diff --git a/gaia/lang/compiler/compile.py b/gaia/lang/compiler/compile.py index d22ae9459..6c64fdb1c 100644 --- a/gaia/lang/compiler/compile.py +++ b/gaia/lang/compiler/compile.py @@ -75,9 +75,9 @@ def to_json(self) -> dict[str, Any]: def _content_hash(k: Knowledge) -> str: - """SHA-256(type + content + sorted(parameters)).""" + """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}|{k.content}|{params_str}" + raw = f"{k.type}|{getattr(k, 'format', 'markdown')}|{k.content}|{params_str}" return hashlib.sha256(raw.encode()).hexdigest() @@ -462,6 +462,7 @@ def register_action_knowledge(action: Any) -> None: label=k.label, title=getattr(k, "title", None), type=k.type, + format=getattr(k, "format", "markdown"), content=k.content, parameters=[_parameter_to_ir(p, knowledge_map) for p in k.parameters], provenance=_knowledge_provenance(k), diff --git a/gaia/lang/dsl/__init__.py b/gaia/lang/dsl/__init__.py index 1a3a4a52d..085ff4bed 100644 --- a/gaia/lang/dsl/__init__.py +++ b/gaia/lang/dsl/__init__.py @@ -1,4 +1,4 @@ -from gaia.lang.dsl.knowledge import claim, context, question, setting +from gaia.lang.dsl.knowledge import claim, context, note, question, setting from gaia.lang.dsl.infer_verb import infer from gaia.lang.dsl.operators import complement, contradiction, disjunction, equivalence from gaia.lang.dsl.relate import contradict, equal @@ -43,6 +43,7 @@ "infer", "mathematical_induction", "noisy_and", + "note", "observe", "question", "setting", diff --git a/gaia/lang/dsl/knowledge.py b/gaia/lang/dsl/knowledge.py index be2dd59ea..2c0a5313f 100644 --- a/gaia/lang/dsl/knowledge.py +++ b/gaia/lang/dsl/knowledge.py @@ -1,30 +1,81 @@ """Gaia Lang v5/v6 — Knowledge DSL functions.""" -from gaia.lang.runtime import Claim, Context, Knowledge, Question, Setting +from gaia.lang.runtime import Claim, Knowledge, Note, Question -def context(content: str, **metadata) -> Context: - """Declare raw unformalized context text.""" - return Context(content.strip(), metadata=_flatten_metadata(metadata)) +def _metadata_with_legacy_kind(metadata: dict, legacy_kind: str) -> dict: + flattened = dict(_flatten_metadata(metadata)) + flattened.setdefault("legacy_kind", legacy_kind) + return flattened -def setting(content: str, *, title: str | None = None, **metadata) -> Setting: - """Declare a background assumption. No probability, no BP participation.""" +def note( + content: str, + *, + title: str | None = None, + format: str = "markdown", + **metadata, +) -> Note: + """Declare non-probabilistic contextual material.""" provenance = metadata.pop("provenance", None) - return Setting( + return Note( content=content.strip(), + format=format, title=title, provenance=provenance or [], metadata=_flatten_metadata(metadata), ) -def question(content: str, *, title: str | None = None, **metadata) -> Question: +def context( + content: str, + *, + title: str | None = None, + format: str = "markdown", + **metadata, +) -> Note: + """Deprecated compatibility wrapper for note().""" + 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, +) -> Note: + """Deprecated compatibility wrapper for note().""" + 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, +) -> 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 [], @@ -43,6 +94,7 @@ def claim( content: str, *, title: str | None = None, + format: str = "markdown", background: list[Knowledge] | None = None, parameters: list[dict] | None = None, provenance: list[dict[str, str]] | None = None, @@ -51,6 +103,7 @@ def claim( """Declare a scientific assertion. The only type carrying probability.""" return Claim( content=content.strip(), + format=format, title=title, background=background or [], parameters=parameters or [], diff --git a/gaia/lang/runtime/__init__.py b/gaia/lang/runtime/__init__.py index 0bec03469..e15f6c7fe 100644 --- a/gaia/lang/runtime/__init__.py +++ b/gaia/lang/runtime/__init__.py @@ -10,7 +10,7 @@ Support, ) from gaia.lang.runtime.grounding import Grounding -from gaia.lang.runtime.knowledge import Claim, Context, Knowledge, Question, Setting +from gaia.lang.runtime.knowledge import Claim, Context, Knowledge, Note, Question, Setting from gaia.lang.runtime.nodes import Operator, Step, Strategy __all__ = [ @@ -24,6 +24,7 @@ "Grounding", "Infer", "Knowledge", + "Note", "Observe", "Operator", "Question", diff --git a/gaia/lang/runtime/knowledge.py b/gaia/lang/runtime/knowledge.py index 311e6e319..a79a978b0 100644 --- a/gaia/lang/runtime/knowledge.py +++ b/gaia/lang/runtime/knowledge.py @@ -29,6 +29,7 @@ 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) @@ -58,23 +59,37 @@ def __hash__(self) -> int: @dataclass(init=False, eq=False) -class Context(Knowledge): - """Raw unformalized text. Does not enter BP.""" +class Note(Knowledge): + """Non-probabilistic contextual material. Does not enter BP.""" - def __init__(self, content: str, **kwargs): + def __init__(self, content: str, *, format: str = "markdown", **kwargs): + 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): if "prior" in kwargs: raise TypeError("Context cannot have a prior.") - super().__init__(content=content, type="context", **kwargs) + 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(Knowledge): - """Formalized background. No probability.""" +class Setting(Note): + """Deprecated compatibility alias for Note.""" - def __init__(self, content: str, **kwargs): + def __init__(self, content: str, *, format: str = "markdown", **kwargs): if "prior" in kwargs: raise TypeError("Setting cannot have a prior.") - super().__init__(content=content, type="setting", **kwargs) + metadata = dict(kwargs.pop("metadata", {}) or {}) + metadata.setdefault("legacy_kind", "setting") + super().__init__(content=content, format=format, metadata=metadata, **kwargs) @dataclass(init=False, eq=False) @@ -90,6 +105,7 @@ def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) base_fields = { "content", + "format", "type", "title", "background", diff --git a/tests/cli/test_brief.py b/tests/cli/test_brief.py index 39925244f..1aaea1c83 100644 --- a/tests/cli/test_brief.py +++ b/tests/cli/test_brief.py @@ -461,8 +461,8 @@ def test_brief_overview_shows_questions(tmp_path): assert "What is the mechanism?" in text -def test_brief_module_shows_settings_and_questions(tmp_path): - """Exercise settings and questions sections in module expansion.""" +def test_brief_module_shows_notes_and_questions(tmp_path): + """Exercise notes and questions sections in module expansion.""" pkg_dir = tmp_path / "question_demo" _write_question_package(pkg_dir) _compile(pkg_dir) @@ -479,7 +479,7 @@ def test_brief_module_shows_settings_and_questions(tmp_path): lines = generate_brief_module(ir, "content") text = "\n".join(lines) - assert "Settings:" in text + assert "Notes:" in text assert "Test environment." in text assert "Questions:" in text assert "What is the mechanism?" in text diff --git a/tests/cli/test_compile_v6.py b/tests/cli/test_compile_v6.py index 560952242..308fb0070 100644 --- a/tests/cli/test_compile_v6.py +++ b/tests/cli/test_compile_v6.py @@ -21,9 +21,9 @@ def test_v6_knowledge_types_compile(tmp_path): pkg_src = pkg_dir / "v6_pkg" pkg_src.mkdir() (pkg_src / "__init__.py").write_text( - "from gaia.lang import Claim, Context, Grounding, Setting\n\n" - "ctx = Context('Raw AB test data from dashboard.')\n" - "exp = Setting('AB test exp_123: 50/50 randomization.')\n" + "from gaia.lang import Claim, Grounding, Note\n\n" + "ctx = Note('Raw AB test data from dashboard.', format='text')\n" + "exp = Note('AB test exp_123: 50/50 randomization.')\n" "hyp = Claim(\n" " 'Variant B is better.',\n" " prior=0.5,\n" @@ -43,8 +43,12 @@ def test_v6_knowledge_types_compile(tmp_path): ir = json.loads(ir_path.read_text()) types = {k["type"] for k in ir["knowledges"]} - assert "context" in types + assert "note" in types + + ctx_node = [k for k in ir["knowledges"] if k.get("label") == "ctx"][0] + assert ctx_node["format"] == "text" hyp_node = [k for k in ir["knowledges"] if k.get("label") == "hyp"][0] + assert hyp_node["format"] == "markdown" assert hyp_node["metadata"]["grounding"]["kind"] == "judgment" assert hyp_node["metadata"]["grounding"]["rationale"] == "Uninformative prior." diff --git a/tests/cli/test_detailed_reasoning.py b/tests/cli/test_detailed_reasoning.py index 5ee880569..20e06cad2 100644 --- a/tests/cli/test_detailed_reasoning.py +++ b/tests/cli/test_detailed_reasoning.py @@ -98,7 +98,7 @@ def test_mermaid_basic(): assert "obs --> strat_0" in md assert "strat_0 --> hyp" in md assert ":::weak" in md # noisy_and is a weakpoint - assert ":::setting" in md + assert ":::note" in md def test_mermaid_hides_helper_claims(): @@ -637,7 +637,7 @@ def test_single_file_fallback_has_global_graph(): md = render_knowledge_nodes(ir) assert "## Knowledge Graph" in md assert "```mermaid" in md - assert "### Settings" in md + assert "### Notes" in md assert "### Claims" in md diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py index 83780d96a..16dc2ca4e 100644 --- a/tests/cli/test_init.py +++ b/tests/cli/test_init.py @@ -107,8 +107,8 @@ def test_init_creates_package(tmp_path, monkeypatch): # __init__.py has DSL template init_py = import_dir / "__init__.py" content = init_py.read_text() - assert "from gaia.lang import claim, setting" in content - assert "context = setting(" in content + assert "from gaia.lang import claim, note" in content + assert "context = note(" in content assert "hypothesis = claim(" in content assert "evidence = claim(" in content assert '__all__ = ["context", "hypothesis", "evidence"]' in content diff --git a/tests/gaia/lang/test_compiler_v6.py b/tests/gaia/lang/test_compiler_v6.py index a605eb6fb..091b14ec5 100644 --- a/tests/gaia/lang/test_compiler_v6.py +++ b/tests/gaia/lang/test_compiler_v6.py @@ -1,17 +1,29 @@ from gaia.lang.compiler.compile import compile_package_artifact from gaia.lang.runtime.grounding import Grounding -from gaia.lang.runtime.knowledge import Claim, Context, Setting +from gaia.lang.runtime.knowledge import Claim, Context, Note from gaia.lang.runtime.package import CollectedPackage -def test_compile_context_type(): - """Context Knowledge compiles with type='context'.""" +def test_compile_note_type_and_format(): + """Note Knowledge compiles with type='note' and first-class format.""" + with CollectedPackage("v6_test") as pkg: + note = Note("Raw experiment notes.", format="text") + note.label = "ctx" + ir = compile_package_artifact(pkg).to_json() + node = next(k for k in ir["knowledges"] if k["label"] == "ctx") + assert node["type"] == "note" + assert node["format"] == "text" + + +def test_compile_legacy_context_as_note(): with CollectedPackage("v6_test") as pkg: ctx = Context("Raw experiment notes.") ctx.label = "ctx" ir = compile_package_artifact(pkg).to_json() node = next(k for k in ir["knowledges"] if k["label"] == "ctx") - assert node["type"] == "context" + assert node["type"] == "note" + assert node["format"] == "markdown" + assert node["metadata"]["legacy_kind"] == "context" def test_compile_grounding_in_metadata(): @@ -51,12 +63,12 @@ def test_compile_parameter_value(): class ABCounts(Claim): """[@experiment] recorded {ctrl_k}/{ctrl_n} control conversions.""" - experiment: Setting + experiment: Note ctrl_n: int ctrl_k: int with CollectedPackage("v6_test") as pkg: - exp = Setting("AB test exp_123.") + exp = Note("AB test exp_123.") exp.label = "exp_123" counts = ABCounts(experiment=exp, ctrl_n=10_000, ctrl_k=500) counts.label = "counts" diff --git a/tests/gaia/lang/test_core.py b/tests/gaia/lang/test_core.py index b9cbf6e5a..40847bc07 100644 --- a/tests/gaia/lang/test_core.py +++ b/tests/gaia/lang/test_core.py @@ -1,10 +1,18 @@ from gaia.lang import __all__ as gaia_lang_exports -from gaia.lang import claim, question, setting +from gaia.lang import claim, note, question, setting -def test_setting_creates_knowledge(): +def test_note_creates_knowledge(): + n = note("Background assumption.") + assert n.type == "note" + assert n.format == "markdown" + assert n.content == "Background assumption." + + +def test_setting_creates_note_compat_knowledge(): s = setting("Background assumption.") - assert s.type == "setting" + assert s.type == "note" + assert s.metadata["legacy_kind"] == "setting" assert s.content == "Background assumption." diff --git a/tests/gaia/lang/test_knowledge_v6.py b/tests/gaia/lang/test_knowledge_v6.py index ac295170e..87f8b1ae7 100644 --- a/tests/gaia/lang/test_knowledge_v6.py +++ b/tests/gaia/lang/test_knowledge_v6.py @@ -1,28 +1,46 @@ import pytest from gaia.lang.runtime.grounding import Grounding -from gaia.lang.runtime.knowledge import Claim, Context, Question, Setting +from gaia.lang.runtime.knowledge import Claim, Context, Note, Question, Setting -def test_context_creation(): - ctx = Context("Raw experiment notes.") - assert ctx.content == "Raw experiment notes." - assert ctx.type == "context" +def test_note_creation_defaults_to_markdown(): + note = Note("Raw experiment notes.") + assert note.content == "Raw experiment notes." + assert note.type == "note" + assert note.format == "markdown" -def test_setting_creation(): - s = Setting("Blackbody cavity at thermal equilibrium.") - assert s.type == "setting" - assert s.content == "Blackbody cavity at thermal equilibrium." +def test_note_accepts_format(): + note = Note("plain text", format="text") + assert note.type == "note" + assert note.format == "text" + + +def test_context_and_setting_are_deprecated_note_compat_classes(): + ctx = Context("Raw experiment notes.") + setting = Setting("Blackbody cavity at thermal equilibrium.") + assert isinstance(ctx, Note) + assert isinstance(setting, Note) + assert ctx.type == "note" + assert setting.type == "note" + assert ctx.metadata["legacy_kind"] == "context" + assert setting.metadata["legacy_kind"] == "setting" def test_claim_creation(): c = Claim("Energy exchange is quantized.", prior=0.5) assert c.type == "claim" + assert c.format == "markdown" assert c.prior == 0.5 assert c.supports == [] +def test_claim_accepts_format(): + c = Claim("E = mc^2", format="latex") + assert c.format == "latex" + + def test_claim_no_prior(): c = Claim("A proposition.") assert c.prior is None @@ -45,6 +63,11 @@ def test_question_creation(): assert q.type == "question" +def test_note_cannot_have_prior(): + with pytest.raises(TypeError): + Note("raw text", prior=0.5) + + def test_context_cannot_have_prior(): with pytest.raises(TypeError): Context("raw text", prior=0.5) @@ -55,13 +78,24 @@ def test_setting_cannot_have_prior(): Setting("background", prior=0.5) -def test_context_dsl_function(): +def test_note_dsl_function(): + from gaia.lang.dsl.knowledge import note + + n = note("Raw experiment notes.", format="text") + assert n.type == "note" + assert n.format == "text" + assert n.content == "Raw experiment notes." + assert isinstance(n, Note) + + +def test_context_dsl_function_returns_note_compat(): from gaia.lang.dsl.knowledge import context ctx = context("Raw experiment notes.") - assert ctx.type == "context" + assert ctx.type == "note" assert ctx.content == "Raw experiment notes." - assert isinstance(ctx, Context) + assert isinstance(ctx, Note) + assert ctx.metadata["legacy_kind"] == "context" def test_v5_claim_still_works(): @@ -73,12 +107,13 @@ def test_v5_claim_still_works(): assert isinstance(c, Claim) -def test_v5_setting_still_works(): +def test_v5_setting_still_works_as_note_compat(): from gaia.lang import setting s = setting("Background info.") - assert s.type == "setting" - assert isinstance(s, Setting) + assert s.type == "note" + assert isinstance(s, Note) + assert s.metadata["legacy_kind"] == "setting" def test_v5_question_still_works(): diff --git a/tests/ir/test_knowledge.py b/tests/ir/test_knowledge.py index 50b8faf99..bb3cc4d71 100644 --- a/tests/ir/test_knowledge.py +++ b/tests/ir/test_knowledge.py @@ -44,7 +44,7 @@ def test_invalid_uppercase(self): class TestKnowledgeType: def test_knowledge_types(self): - assert set(KnowledgeType) == {"claim", "setting", "question", "context"} + assert set(KnowledgeType) == {"claim", "note", "setting", "question", "context"} def test_no_template(self): with pytest.raises(ValueError): @@ -71,6 +71,7 @@ def test_content_hash_auto_computed_with_id(self): k = Knowledge(id="github:pkg::x", type="claim", content="test", label="x") assert k.content_hash is not None assert len(k.content_hash) == 64 + assert k.format == "markdown" def test_content_hash_auto_computed_with_label_only(self): k = Knowledge(label="x", type="claim", content="test") @@ -103,6 +104,17 @@ def test_different_type_different_hash(self): k2 = Knowledge(id="github:pkg::b", type="setting", content="X", label="b") assert k1.content_hash != k2.content_hash + def test_different_format_different_hash(self): + k1 = Knowledge(id="github:pkg::a", type="note", content="a|b\n1|2", label="a") + k2 = Knowledge( + id="github:pkg::b", + type="note", + content="a|b\n1|2", + label="b", + format="csv", + ) + assert k1.content_hash != k2.content_hash + class TestKnowledgeParameters: def test_closed_claim_empty_params(self): diff --git a/tests/ir/test_knowledge_context.py b/tests/ir/test_knowledge_context.py index a327db346..002f71c2e 100644 --- a/tests/ir/test_knowledge_context.py +++ b/tests/ir/test_knowledge_context.py @@ -2,6 +2,7 @@ def test_context_knowledge_type(): + assert KnowledgeType.NOTE == "note" assert KnowledgeType.CONTEXT == "context" From b5fff15546df6b305ff452760f67ecb4c5a5639f Mon Sep 17 00:00:00 2001 From: kunchen Date: Wed, 22 Apr 2026 19:45:44 +0800 Subject: [PATCH 031/210] fix: enforce note prior invariants --- gaia/cli/_packages.py | 15 +++++++++----- gaia/ir/knowledge.py | 19 ++++++++++++++++-- tests/cli/test_compile.py | 29 ++++++++++++++++++++++++++++ tests/cli/test_github_integration.py | 5 ++--- tests/ir/test_knowledge.py | 27 ++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 10 deletions(-) diff --git a/gaia/cli/_packages.py b/gaia/cli/_packages.py index f225baef0..046409e7d 100644 --- a/gaia/cli/_packages.py +++ b/gaia/cli/_packages.py @@ -17,7 +17,7 @@ from types import ModuleType from typing import Any -from gaia.lang.runtime import Knowledge, Strategy +from gaia.lang.runtime import Claim, Knowledge, Strategy from gaia.lang.runtime.package import CollectedPackage from gaia.lang.runtime.package import pyproject_for_module from gaia.lang.runtime.package import get_inferred_package, reset_inferred_package @@ -243,7 +243,7 @@ def _validate_prior_value(value: Any, *, label: str) -> float: def apply_package_priors(loaded: LoadedGaiaPackage) -> None: """Discover priors.py and inject prior+justification into Knowledge metadata. - The priors.py module must export a ``PRIORS`` dict mapping Knowledge objects + The priors.py module must export a ``PRIORS`` dict mapping Claim objects to ``(prior_value, justification_string)`` tuples. Each entry is injected into the Knowledge object's ``.metadata`` dict as ``prior`` and ``prior_justification`` before compilation, so lowering can read them from @@ -269,14 +269,14 @@ def apply_package_priors(loaded: LoadedGaiaPackage) -> None: suffix = " ..." if len(new_knowledge) > 5 else "" raise GaiaCliError( "Error: priors.py must not declare new Knowledge objects; it may only " - "reference claims/notes/questions already declared by the package. " + "reference claims already declared by the package. " f"New declarations: {names}{suffix}." ) priors_dict = getattr(module, "PRIORS", None) if priors_dict is None: raise GaiaCliError( - "Error: priors.py must export PRIORS = {Knowledge: (prior, justification), ...}." + "Error: priors.py must export PRIORS = {Claim: (prior, justification), ...}." ) if not isinstance(priors_dict, dict): raise GaiaCliError("Error: priors.py PRIORS must be a dict.") @@ -285,13 +285,18 @@ def apply_package_priors(loaded: LoadedGaiaPackage) -> None: if not isinstance(key, Knowledge): raise GaiaCliError( f"Error: PRIORS key {key!r} is not a Knowledge object. " - "Keys must be claim/note/question objects from the package." + "Keys must be claim objects from the package." ) if id(key) not in existing_knowledge_ids: raise GaiaCliError( f"Error: PRIORS key {_knowledge_display_name(key)!r} is not an " "already-declared Knowledge object from this package." ) + if not isinstance(key, Claim): + raise GaiaCliError( + f"Error: PRIORS key {_knowledge_display_name(key)!r} is a " + f"{key.type!r} Knowledge object. PRIORS may only annotate claims." + ) if not isinstance(value, tuple) or len(value) != 2: raise GaiaCliError( f"Error: PRIORS[{key.label or key.content!r}] must be a (prior, justification) tuple, " diff --git a/gaia/ir/knowledge.py b/gaia/ir/knowledge.py index baab49547..db4d255bd 100644 --- a/gaia/ir/knowledge.py +++ b/gaia/ir/knowledge.py @@ -58,7 +58,7 @@ def _sha256_hex(data: str, length: int = 16) -> str: def _compute_content_hash( type_: str, content: str, parameters: list[Parameter], format_: str ) -> str: - """Content fingerprint: SHA-256(type + content + sorted(parameters)), no package_id. + """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. @@ -68,6 +68,13 @@ def _compute_content_hash( 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) + + class Knowledge(BaseModel): """Knowledge node — a proposition in the Gaia reasoning hypergraph. @@ -98,6 +105,9 @@ 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.") + # Content_hash is a derived fingerprint and must stay consistent # with the node's actual content. if self.content is not None: @@ -105,7 +115,12 @@ def _compute_derived_fields(self) -> Knowledge: 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/tests/cli/test_compile.py b/tests/cli/test_compile.py index e48d97b6c..7a00fbb4b 100644 --- a/tests/cli/test_compile.py +++ b/tests/cli/test_compile.py @@ -1154,6 +1154,35 @@ def test_compile_priors_py_invalid_key_raises(tmp_path): assert "Knowledge" in result.output or "PRIORS" in result.output +def test_compile_priors_py_note_key_raises(tmp_path): + """PRIORS may only annotate probabilistic claims, not notes.""" + pkg_dir = tmp_path / "note_prior_pkg" + pkg_dir.mkdir() + (pkg_dir / "pyproject.toml").write_text( + '[project]\nname = "note-prior-pkg-gaia"\nversion = "1.0.0"\n\n' + '[tool.gaia]\nnamespace = "github"\ntype = "knowledge-package"\n' + ) + pkg_src = pkg_dir / "note_prior_pkg" + pkg_src.mkdir() + (pkg_src / "__init__.py").write_text( + "from gaia.lang import claim, note\n\n" + 'ctx = note("Non-probabilistic context.")\n' + 'hyp = claim("Probabilistic hypothesis.")\n' + '__all__ = ["ctx", "hyp"]\n' + ) + (pkg_src / "priors.py").write_text( + "from . import ctx\n\n" + "PRIORS = {\n" + ' ctx: (0.8, "Notes are context, not probabilistic claims."),\n' + "}\n" + ) + + result = runner.invoke(app, ["compile", str(pkg_dir)]) + assert result.exit_code != 0 + assert "claim" in result.output.lower() + assert "note" in result.output.lower() + + def test_compile_priors_py_new_knowledge_raises(tmp_path): """priors.py may only annotate Knowledge objects already declared by the package.""" pkg_dir = tmp_path / "prior_ghost_pkg" diff --git a/tests/cli/test_github_integration.py b/tests/cli/test_github_integration.py index ecb74bb1a..57e8ab115 100644 --- a/tests/cli/test_github_integration.py +++ b/tests/cli/test_github_integration.py @@ -335,12 +335,11 @@ def test_render_github_with_real_package(tmp_path): '__all__ = ["obs_equal_time", "galileo_hyp"]\n' ) - # Priors — required for render + # Priors — required for render; notes/settings are non-probabilistic. (pkg_src / "priors.py").write_text( - "from .motivation import context, obs_equal_time\n" + "from .motivation import obs_equal_time\n" "from .analysis import aristotle_hyp, galileo_hyp\n\n" "PRIORS: dict = {\n" - ' context: (0.95, "ok"),\n' ' obs_equal_time: (0.9, "ok"),\n' ' aristotle_hyp: (0.2, "ok"),\n' ' galileo_hyp: (0.6, "ok"),\n' diff --git a/tests/ir/test_knowledge.py b/tests/ir/test_knowledge.py index bb3cc4d71..398dd1702 100644 --- a/tests/ir/test_knowledge.py +++ b/tests/ir/test_knowledge.py @@ -1,5 +1,7 @@ """Tests for Knowledge data model.""" +import hashlib + import pytest from gaia.ir import Knowledge, KnowledgeType, Parameter, PackageRef from gaia.ir.knowledge import make_qid, is_qid @@ -115,6 +117,31 @@ def test_different_format_different_hash(self): ) assert k1.content_hash != k2.content_hash + def test_legacy_content_hash_without_format_is_accepted_and_normalized(self): + old_hash = hashlib.sha256("claim|test|[]".encode()).hexdigest() + expected = Knowledge(id="github:pkg::expected", type="claim", content="test").content_hash + + k = Knowledge( + id="github:pkg::x", + type="claim", + content="test", + label="x", + content_hash=old_hash, + ) + + assert k.format == "markdown" + assert k.content_hash == expected + assert k.content_hash != old_hash + + def test_note_metadata_prior_rejected(self): + with pytest.raises(ValueError, match="prior"): + Knowledge( + id="github:pkg::ctx", + type="note", + content="Context only.", + metadata={"prior": 0.5}, + ) + class TestKnowledgeParameters: def test_closed_claim_empty_params(self): From e924b647ffc168e27577bd5f176636ef2f6536ca Mon Sep 17 00:00:00 2001 From: Kun Chen Date: Thu, 23 Apr 2026 00:07:58 +0800 Subject: [PATCH 032/210] [codex] Add propositional logic support to Gaia Lang (#474) * Add propositional operators to Gaia Lang * Add propositional logic analysis backend * Fix propositional helper priors and formal logic expansion --------- Co-authored-by: kunchen --- docs/for-users/language-reference.md | 67 ++++++- .../bp/formal-strategy-lowering.md | 8 +- docs/foundations/bp/potentials.md | 3 +- docs/foundations/gaia-ir/02-gaia-ir.md | 14 +- docs/foundations/gaia-lang/dsl.md | 41 +++- docs/specs/2026-04-21-gaia-ir-v6-design.md | 2 +- docs/specs/2026-04-21-gaia-lang-v6-design.md | 2 +- gaia/bp/contraction.py | 7 + gaia/bp/exact.py | 8 + gaia/bp/factor_graph.py | 6 + gaia/bp/lowering.py | 25 ++- gaia/bp/potentials.py | 10 + gaia/cli/commands/_detailed_reasoning.py | 1 + gaia/cli/commands/_github.py | 2 + gaia/cli/commands/_simplified_mermaid.py | 1 + gaia/ir/formalize.py | 1 + gaia/ir/knowledge.py | 20 ++ gaia/ir/operator.py | 5 + gaia/ir/validator.py | 16 +- gaia/lang/__init__.py | 10 + gaia/lang/compiler/compile.py | 6 +- gaia/lang/dsl/__init__.py | 7 +- gaia/lang/dsl/propositional.py | 55 ++++++ gaia/lang/dsl/relate.py | 15 +- gaia/lang/review/manifest.py | 2 + gaia/lang/review/templates.py | 1 + gaia/lang/runtime/__init__.py | 2 + gaia/lang/runtime/action.py | 5 + gaia/lang/runtime/knowledge.py | 25 +++ gaia/logic/__init__.py | 25 +++ gaia/logic/propositional.py | 177 ++++++++++++++++++ pyproject.toml | 3 +- tests/gaia/bp/test_factor_graph.py | 16 ++ tests/gaia/bp/test_potentials.py | 24 +++ tests/gaia/lang/test_compiler_actions.py | 10 +- tests/gaia/lang/test_exclusive.py | 34 ++++ tests/gaia/lang/test_propositional.py | 72 +++++++ tests/gaia/lang/test_review_manifest.py | 15 +- tests/gaia/logic/test_propositional.py | 132 +++++++++++++ tests/ir/test_operator.py | 14 ++ tests/ir/test_validator.py | 35 ++++ tests/test_contraction.py | 16 ++ tests/test_lowering.py | 58 ++++++ 43 files changed, 969 insertions(+), 29 deletions(-) create mode 100644 gaia/lang/dsl/propositional.py create mode 100644 gaia/logic/__init__.py create mode 100644 gaia/logic/propositional.py create mode 100644 tests/gaia/lang/test_exclusive.py create mode 100644 tests/gaia/lang/test_propositional.py create mode 100644 tests/gaia/logic/test_propositional.py diff --git a/docs/for-users/language-reference.md b/docs/for-users/language-reference.md index 70355448d..72aae04ff 100644 --- a/docs/for-users/language-reference.md +++ b/docs/for-users/language-reference.md @@ -54,7 +54,9 @@ from .s3_results import * ```python from gaia.lang import ( claim, setting, question, # Knowledge - contradiction, equivalence, complement, disjunction, # Operators + not_, and_, or_, # Propositional expressions + contradict, equal, exclusive, # Reviewable relations + contradiction, equivalence, complement, disjunction, # v5 compatibility support, compare, deduction, abduction, induction, # Strategies analogy, extrapolation, elimination, case_analysis, mathematical_induction, composite, infer, fills, @@ -104,6 +106,69 @@ titled = claim("H = p^2/2m + V(x)", title="Hamiltonian of the system") ## Operators (Deterministic Constraints) +### Propositional expressions + +Claims can be combined into helper claims with ordinary propositional structure: + +| Syntax | Function | Semantics | Review | +|--------|----------|-----------|--------| +| `~a` | `not_(a)` | NOT A | no | +| `a & b` | `and_(a, b)` | A AND B | no | +| `a | b` | `or_(a, b)` | A OR B | no | + +```python +not_classical = ~classical_prediction +joint_case = evidence_a & evidence_b +either_mechanism = mech_a | mech_b +``` + +These helpers are structural expression nodes. They do not create review warrants. Python keywords `not`, `and`, and `or` cannot be overloaded; use `~`, `&`, and `|` instead. `Claim` objects intentionally reject Python truth-value checks such as `if claim:`. + +### Propositional analysis + +Compiled operator graphs can be analyzed with `gaia.logic`. The API keeps Gaia IR as the stored representation and uses a mature Boolean backend for normalization and checks: + +```python +from gaia.logic import ( + are_equivalent, + is_satisfiable, + simplify_proposition, + to_cnf_proposition, +) + +graph = compile_package_artifact(pkg).graph + +simplified = simplify_proposition(graph, "github:pkg::double_negation") +cnf = to_cnf_proposition(graph, "github:pkg::formula", simplify=True) +same = are_equivalent(graph, "github:pkg::left", "github:pkg::right") +consistent = is_satisfiable(graph, "github:pkg::formula") +``` + +This is useful for lints, formula comparison, and checking whether a composed expression is internally inconsistent. The returned expression is a backend object; it is not persisted into Gaia IR. + +### Reviewable relations + +Relation verbs declare semantic judgments between claims. They return warrant helper claims and are included in review manifests: + +| Function | Semantics | Meaning | +|----------|-----------|---------| +| `contradict(a, b)` | NOT (A AND B) | both cannot be true | +| `equal(a, b)` | A = B | same truth value | +| `exclusive(a, b)` | A XOR B | closed binary partition, exactly one true | + +```python +not_both = contradict(hypothesis_a, hypothesis_b, + rationale="Incompatible mechanisms.") + +same = equal(prediction, observation, + rationale="The predicted and observed signatures match.") + +one_of = exclusive(conventional_sc, unconventional_sc, + rationale="This package treats the two cases as an exhaustive binary split.") +``` + +### v5 compatibility operators + All operators take Knowledge inputs and optional `reason` + `prior` (must be paired: both or neither). Each returns a helper claim. | Function | Semantics | Meaning | diff --git a/docs/foundations/bp/formal-strategy-lowering.md b/docs/foundations/bp/formal-strategy-lowering.md index cf4bc2e05..7898e2d17 100644 --- a/docs/foundations/bp/formal-strategy-lowering.md +++ b/docs/foundations/bp/formal-strategy-lowering.md @@ -26,6 +26,8 @@ $$\psi = \text{cpt}[idx] \text{ 当 } H=1, \quad \psi = 1 - \text{cpt}[idx] \tex 实际 lowering 使用 Cromwell 软化($0 \to \varepsilon$,$1 \to 1-\varepsilon$)。 +一元 negation 使用二值 CPT:$P(N=1\mid A=0)=1$,$P(N=1\mid A=1)=0$。 + **不需要** EQUIVALENCE / CONTRADICTION / COMPLEMENT 等特化 FactorType。命名的算子类型只是 CPT 模板(syntactic sugar),在因子图层面全部归约为 CONDITIONAL。 ### 1.2 因子图中无 premise / conclusion 之分 @@ -57,7 +59,7 @@ $H = (A \leftrightarrow B)$ 说的是 "A 和 B 真值一致"——这个信息** ### 2.2 计算型(Directed operator) -**conjunction / disjunction / implication**:conclusion $M$ 是 variables 的**确定性函数值**。 +**negation / conjunction / disjunction**:conclusion $M$ 是 variables 的**确定性函数值**。 $M = A \wedge B$ 可以从 $\pi(A)$ 和 $\pi(B)$ 直接算出(在独立假设下)。设 $\pi(M) = 1 - \varepsilon$ 会引入与 $\pi(A)$、$\pi(B)$ 重复的信息。 @@ -333,8 +335,8 @@ for op in formal_expr.operators: | Operator 类别 | conclusion 先验 | 理由 | |--------------|----------------|------| -| Relation(equivalence, contradiction, complement) | $1 - \varepsilon$ | 断言:算子的存在 = 关系成立 | -| Directed(conjunction, disjunction, implication) | $0.5$ | 计算:belief 由 variables 决定 | +| Relation(equivalence, contradiction, complement, implication warrant) | $1 - \varepsilon$ | 断言:算子的存在 = 关系成立 | +| Expression(negation, conjunction, disjunction) | $0.5$ | 计算:belief 由 variables 决定 | 判定规则:conclusion 的 $P(H\!=\!1)$ 能否从 $\pi(\text{variables})$ 推导出来?能 → 0.5(计算型);不能 → $1-\varepsilon$(断言型)。 diff --git a/docs/foundations/bp/potentials.md b/docs/foundations/bp/potentials.md index e60f35612..30d2ab94d 100644 --- a/docs/foundations/bp/potentials.md +++ b/docs/foundations/bp/potentials.md @@ -21,13 +21,14 @@ | FactorType | 语义 | 理论参照 | |------------|------|---------| | **IMPLICATION** | `variables=[A, B]`, `conclusion=H`:H=1 当 A→B 成立(禁止 A=1 且 B=0);H=0 当违反 | 06-factor-graphs §3.3 | +| **NEGATION** | `variables=[A]`, `conclusion=N`:N = ¬A | §3.1 | | **CONJUNCTION** | `variables=[A₁,…,Aₖ]`, `conclusion=M`:M = ∧ Aᵢ | §3.2 | | **DISJUNCTION** | `variables=[A₁,…,Aₖ]`, `conclusion=D`:D = ∨ Aᵢ | §3.6 补充 | | **EQUIVALENCE** | `variables=[A,B]`, `conclusion=H`:H = 1 当且仅当 A=B | §3.4 | | **CONTRADICTION** | `variables=[A,B]`, `conclusion=H`:H = 0 当且仅当 A=B=1;否则 H=1 | §3.5 | | **COMPLEMENT** | `variables=[A,B]`, `conclusion=H`:H = XOR(A,B) | §3.1 / §3.6 | -所有确定性算子在因子图中统一为 **CONDITIONAL 三元因子**,上表中的真值语义对应各自的 CPT 模板。Conclusion 的先验决定其角色:**relation operator**(EQUIVALENCE / CONTRADICTION / COMPLEMENT / IMPLICATION)的 conclusion 是断言($\pi = 1-\varepsilon$,激活约束);**computation operator**(CONJUNCTION / DISJUNCTION)的 conclusion 是计算输出($\pi = 0.5$,belief 由 variables 决定)。详见 [formal-strategy-lowering.md §2](formal-strategy-lowering.md)。 +所有确定性算子在因子图中统一为无自由参数的条件势函数,上表中的真值语义对应各自的 CPT 模板。Conclusion 的先验决定其角色:**relation operator**(EQUIVALENCE / CONTRADICTION / COMPLEMENT / IMPLICATION)的 conclusion 是断言($\pi = 1-\varepsilon$,激活约束);**expression operator**(NEGATION / CONJUNCTION / DISJUNCTION)的 conclusion 是计算输出($\pi = 0.5$,belief 由 variables 决定)。详见 [formal-strategy-lowering.md §2](formal-strategy-lowering.md)。 ## SOFT_ENTAILMENT(软蕴含 ↝) diff --git a/docs/foundations/gaia-ir/02-gaia-ir.md b/docs/foundations/gaia-ir/02-gaia-ir.md index 08118d3b3..139b2e955 100644 --- a/docs/foundations/gaia-ir/02-gaia-ir.md +++ b/docs/foundations/gaia-ir/02-gaia-ir.md @@ -147,7 +147,7 @@ metadata: {schema: universal_law, domain: "凝聚态物理"} Helper claim **不是新的 Knowledge 类型**。它仍然是普通的 `claim`,只是承担了结构结果节点的角色。 -当前文档里的 `helper claim` 专指**结构型 result claim**,例如 `conjunction` 结果 `M`,以及 `equivalence` / `contradiction` / `complement` / `disjunction` 的标准结果 claim。 +当前文档里的 `helper claim` 专指**结构型 result claim**,例如 `negation` / `conjunction` / `disjunction` 的表达式结果,以及 `equivalence` / `contradiction` / `complement` 的标准关系结果 claim。 在当前 contract 下: @@ -183,14 +183,15 @@ Operator: `conclusion` 的语义是:**该 Operator 在图中的标准结果 claim**。 -- 对 `implication` / `conjunction`,它延续原有语义,表示 operator 的输出 claim -- 对 `equivalence` / `contradiction` / `complement` / `disjunction`,它是结构型 helper claim,使这些关系本身也能被后续结构直接引用 +- 对 `negation` / `conjunction` / `disjunction`,它表示可复用的命题表达式输出 claim +- 对 `equivalence` / `contradiction` / `complement`,它是结构型关系 helper claim,使这些关系本身也能被后续结构直接引用 ### 2.2 算子类型与真值表 | operator | 符号 | variables | conclusion | 真值约束 | 说明 | |----------|------|-----------|------------|---------|------| | **implication** | → | [A] | B | A=1 时 B 必须=1 | A 成立则 B 必须成立 | +| **negation** | ¬ | [A] | helper claim(如 `not(A)`) | helper=¬A | 一元否定表达式 | | **equivalence** | ↔ | [A, B] | helper claim(如 `same_truth(A,B)`) | A=B | 真值必须一致 | | **contradiction** | ⊗ | [A, B] | helper claim(如 `not_both_true(A,B)`) | ¬(A=1 ∧ B=1) | 不能同时为真 | | **complement** | ⊕ | [A, B] | helper claim(如 `opposite_truth(A,B)`) | A≠B | 真值必须相反(XOR) | @@ -199,6 +200,8 @@ Operator: **关键性质:** Operator 没有概率参数——它编码的是逻辑结构("A 和 B 矛盾"),不是推理判断("作者认为 A 蕴含 B")。后者由 Strategy 承载。 +命题逻辑的化简、CNF/DNF/NNF 规范化、等价检查和可满足性检查属于 **analysis backend**,不改变 IR 的持久结构。实现可以把这些 Operator 临时翻译到成熟布尔逻辑库中求解,但 IR 中仍只保存 Gaia 自己的 `Operator` 和 `Knowledge`。 + ### 2.3 存在位置 Operator 可以出现在两个位置: @@ -220,8 +223,9 @@ Operator 分为两类: | 类别 | Operator 类型 | conclusion 语义 | |------|-------------|----------------| -| **Directed**(有向) | `implication`, `conjunction` | 输出 claim(如蕴含的结果、合取结果 M) | -| **Relation**(关系) | `equivalence`, `contradiction`, `complement`, `disjunction` | 结构型 helper claim | +| **Directed**(有向) | `implication` | 输出 claim 或 implication helper(取决于 formalization 模板) | +| **Expression**(命题表达式) | `negation`, `conjunction`, `disjunction` | 结构型计算结果 helper claim | +| **Relation**(关系) | `equivalence`, `contradiction`, `complement` | 结构型 warrant helper claim | 具体规则: diff --git a/docs/foundations/gaia-lang/dsl.md b/docs/foundations/gaia-lang/dsl.md index 343f106e9..e4e2a709e 100644 --- a/docs/foundations/gaia-lang/dsl.md +++ b/docs/foundations/gaia-lang/dsl.md @@ -13,7 +13,9 @@ Gaia Lang is a Python 3.12+ internal DSL for declarative knowledge authoring. Pa ```python from gaia.lang import ( claim, setting, question, # Knowledge - contradiction, equivalence, complement, disjunction, # Operators + not_, and_, or_, # Propositional expressions + contradict, equal, exclusive, # Reviewable relations + contradiction, equivalence, complement, disjunction, # v5 compatibility support, compare, deduction, abduction, induction, # Strategies analogy, extrapolation, elimination, case_analysis, mathematical_induction, composite, infer, fills, @@ -98,6 +100,43 @@ open_problem = question("What is the maximum Tc in hydrogen-rich superconductors Operators declare deterministic logical constraints between claims. Each function creates an `Operator` (auto-registered) and returns a helper claim usable in further reasoning. For formal definitions and truth tables, see [../gaia-ir/02-gaia-ir.md](../gaia-ir/02-gaia-ir.md), Section 2. +### Propositional Expression Helpers + +Use `~a`, `a & b`, and `a | b` for direct Boolean construction. These return structural helper claims and do not create review warrants. + +```python +not_a = ~a # same as not_(a) +both = a & b # same as and_(a, b) +either = a | b # same as or_(a, b) +``` + +The explicit functions `not_(a)`, `and_(a, b, ...)`, and `or_(a, b, ...)` are also exported. Python keywords `not`, `and`, and `or` cannot be overloaded; `Claim.__bool__` raises to prevent accidental Python control-flow truth tests. + +### Propositional Analysis Helpers + +`gaia.logic` provides non-persistent analysis helpers over compiled Gaia operator graphs: + +- `simplify_proposition(graph, knowledge_id)` +- `to_cnf_proposition(graph, knowledge_id, simplify=False)` +- `to_dnf_proposition(graph, knowledge_id, simplify=False)` +- `to_nnf_proposition(graph, knowledge_id)` +- `are_equivalent(graph, left_knowledge_id, right_knowledge_id)` +- `is_satisfiable(graph, knowledge_id)` + +These helpers recursively expand deterministic IR operators into a Boolean backend representation for formula simplification, normal-form conversion, equivalence checks, and satisfiability checks. The backend expression is an analysis artifact only; Gaia IR remains the source of truth. + +### Reviewable Relation Verbs + +Use v6 relation verbs when the author is making a semantic judgment that reviewers should inspect: + +- `equal(a, b, *, rationale="", label=None)` declares equivalent truth. +- `contradict(a, b, *, rationale="", label=None)` declares the claims cannot both be true. +- `exclusive(a, b, *, rationale="", label=None)` declares a closed binary partition, exactly one true. + +Each relation returns a reviewable warrant helper claim and compiles to the corresponding deterministic IR operator. + +### v5 Compatibility Operators + ### `contradiction(a, b, *, reason="", prior=None)` `not(A and B)`. Returns helper claim `not_both_true(A, B)`. diff --git a/docs/specs/2026-04-21-gaia-ir-v6-design.md b/docs/specs/2026-04-21-gaia-ir-v6-design.md index 701b21d02..7d1c09ec1 100644 --- a/docs/specs/2026-04-21-gaia-ir-v6-design.md +++ b/docs/specs/2026-04-21-gaia-ir-v6-design.md @@ -160,7 +160,7 @@ Existing types are reused: ### 2.3 Operator — No Change -Existing Operator schema and types (conjunction, disjunction, equivalence, contradiction, complement, implication) are unchanged. +Existing Operator schema is unchanged. Operator types include conjunction, disjunction, negation, equivalence, contradiction, complement, and implication. `equal()` compiles to `Operator(type="equivalence")`. `contradict()` compiles to `Operator(type="contradiction")`. diff --git a/docs/specs/2026-04-21-gaia-lang-v6-design.md b/docs/specs/2026-04-21-gaia-lang-v6-design.md index feaa2c7aa..6c07491bb 100644 --- a/docs/specs/2026-04-21-gaia-lang-v6-design.md +++ b/docs/specs/2026-04-21-gaia-lang-v6-design.md @@ -779,7 +779,7 @@ The following are explicitly out of scope for v6.0: 1. **Composition**: `induction()`, `abduction()`, `compose()` — users write chains manually 2. **Standard inference library**: `ab_test()`, `binomial_test()`, `t_test()` — convenience wrappers 3. **`exhaust()` relation**: Disjunction + mutual exclusion combined -4. **Claim operator overloading**: `A & B`, `A | B`, `~A` — syntactic sugar for conjunction/disjunction/complement. `given=(A, B)` tuple suffices for conjunction in v6. +4. **Python keyword operators**: `not A`, `A and B`, `A or B` — Python does not permit overloading these into Gaia expressions; use `~A`, `A & B`, and `A | B` instead. 5. **Nested quantifiers**: `∀x ∃y. P(x,y)` — needs Skolemization 5. **Lifted inference**: Large domains without grounding 6. **Interactive InquiryState**: Lean-style tactic REPL diff --git a/gaia/bp/contraction.py b/gaia/bp/contraction.py index e29da7c1d..ec5fc41e4 100644 --- a/gaia/bp/contraction.py +++ b/gaia/bp/contraction.py @@ -97,6 +97,13 @@ def factor_to_tensor(f: Factor) -> tuple[np.ndarray, list[str]]: t = np.where(grids[2].astype(bool) == target, _HIGH, _LOW).astype(np.float64) return t, axes + if ft == FactorType.NEGATION: + grids = np.indices(shape) + # Helper concl == NOT(A) + target = grids[0] == 0 + t = np.where(grids[1].astype(bool) == target, _HIGH, _LOW).astype(np.float64) + return t, axes + if ft == FactorType.COMPLEMENT: grids = np.indices(shape) # Helper concl == (A XOR B) diff --git a/gaia/bp/exact.py b/gaia/bp/exact.py index d4012ffda..b584b1209 100644 --- a/gaia/bp/exact.py +++ b/gaia/bp/exact.py @@ -78,6 +78,14 @@ def _factor_log_potentials( pot = np.where(ok, h, lo) return np.log(pot) + 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 + pot = np.where(ok, h, lo) + return np.log(pot) + if ft == FactorType.COMPLEMENT: a_idx = var_idx[vids[0]] b_idx = var_idx[vids[1]] diff --git a/gaia/bp/factor_graph.py b/gaia/bp/factor_graph.py index b7de011c3..7608e0d7b 100644 --- a/gaia/bp/factor_graph.py +++ b/gaia/bp/factor_graph.py @@ -25,6 +25,7 @@ def _cromwell_clamp(value: float, label: str = "") -> float: class FactorType(Enum): IMPLICATION = auto() + NEGATION = auto() CONJUNCTION = auto() DISJUNCTION = auto() EQUIVALENCE = auto() @@ -117,6 +118,7 @@ def add_factor( if ft in ( FactorType.IMPLICATION, + FactorType.NEGATION, FactorType.CONJUNCTION, FactorType.DISJUNCTION, FactorType.EQUIVALENCE, @@ -183,6 +185,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)}." diff --git a/gaia/bp/lowering.py b/gaia/bp/lowering.py index 784a606e7..7dafc33a5 100644 --- a/gaia/bp/lowering.py +++ b/gaia/bp/lowering.py @@ -10,7 +10,7 @@ from gaia.bp.factor_graph import CROMWELL_EPS, FactorGraph, FactorType from gaia.ir.formalize import formalize_named_strategy from gaia.ir.graphs import LocalCanonicalGraph -from gaia.ir.knowledge import KnowledgeType +from gaia.ir.knowledge import KnowledgeType, is_structural_expression_helper from gaia.ir.operator import Operator, OperatorType from gaia.ir.review import ReviewManifest, ReviewStatus from gaia.ir.strategy import ( @@ -47,6 +47,7 @@ _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, @@ -118,12 +119,19 @@ def lower_local_graph( helper_ids = { k.id for k in canonical.knowledges if k.id and k.label and k.label.startswith("__") } - if helper_ids: - priors = {k: v for k, v in priors.items() if k not in helper_ids} + expression_helper_ids = { + k.id for k in canonical.knowledges if k.id and is_structural_expression_helper(k) + } + no_user_prior_ids = helper_ids | expression_helper_ids + if no_user_prior_ids: + priors = {k: v for k, v in priors.items() if k not in no_user_prior_ids} metadata_priors = { k.id: float(k.metadata["prior"]) for k in canonical.knowledges - if k.id and k.metadata and "prior" in k.metadata + if k.id + and k.metadata + and "prior" in k.metadata + and k.id not in expression_helper_ids } strat_params = strategy_conditional_params or {} fg = FactorGraph() @@ -146,7 +154,9 @@ def lower_local_graph( continue # Priority: node_priors > metadata["prior"] > structural default metadata_prior = (k.metadata or {}).get("prior") if k.metadata else None - if k.id in relation_concl_ids and k.id not in priors: + if k.id in expression_helper_ids: + fg.add_variable(k.id, 0.5) + elif k.id in relation_concl_ids and k.id not in priors: fg.add_variable(k.id, 1.0 - CROMWELL_EPS) elif k.id in priors: fg.add_variable(k.id, priors[k.id]) @@ -164,7 +174,10 @@ def lower_local_graph( _ensure_claim_var(fg, vid, priors, claim_ids) concl = op.conclusion if concl not in fg.variables: - default = 1.0 - CROMWELL_EPS if op.operator in _RELATION_OPS else 0.5 + if concl in expression_helper_ids: + default = 0.5 + else: + default = 1.0 - CROMWELL_EPS if op.operator in _RELATION_OPS else 0.5 fg.add_variable(concl, priors.get(concl, default)) fg.add_factor(fid, ft, op.variables, concl) diff --git a/gaia/bp/potentials.py b/gaia/bp/potentials.py index fd1e932a1..7a0ec8a74 100644 --- a/gaia/bp/potentials.py +++ b/gaia/bp/potentials.py @@ -6,6 +6,7 @@ __all__ = [ "implication_potential", + "negation_potential", "conjunction_potential", "disjunction_potential", "equivalence_potential", @@ -47,6 +48,12 @@ def conjunction_potential(assignment: Assignment, inputs: list[str], conclusion: return _HIGH if ok else _LOW +def negation_potential(assignment: Assignment, a: str, conclusion: str) -> float: + """N = NOT(A).""" + target = 0 if assignment[a] == 1 else 1 + return _HIGH if assignment[conclusion] == target else _LOW + + def disjunction_potential(assignment: Assignment, inputs: list[str], conclusion: str) -> float: """D = OR(inputs).""" any_one = any(assignment[v] == 1 for v in inputs) @@ -115,6 +122,9 @@ def evaluate_potential(factor: Factor, assignment: Assignment) -> float: 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) diff --git a/gaia/cli/commands/_detailed_reasoning.py b/gaia/cli/commands/_detailed_reasoning.py index 19bc6500b..e6c93f921 100644 --- a/gaia/cli/commands/_detailed_reasoning.py +++ b/gaia/cli/commands/_detailed_reasoning.py @@ -108,6 +108,7 @@ def _module_segments(nodes: list[dict]) -> list[tuple[str, list[dict]]]: "contradiction": "\u2297", "equivalence": "\u2261", "complement": "\u2295", + "negation": "\u00ac", "disjunction": "\u2228", "conjunction": "\u2227", "implication": "\u2192", diff --git a/gaia/cli/commands/_github.py b/gaia/cli/commands/_github.py index df25e708d..e88b82bb4 100644 --- a/gaia/cli/commands/_github.py +++ b/gaia/cli/commands/_github.py @@ -386,7 +386,9 @@ def _render_coarse_mermaid( "contradiction": "\u2297", "equivalence": "\u2261", "complement": "\u2295", + "negation": "\u00ac", "disjunction": "\u2228", + "conjunction": "\u2227", "implication": "\u2192", } _UNDIRECTED = {"equivalence", "contradiction", "complement", "implication"} diff --git a/gaia/cli/commands/_simplified_mermaid.py b/gaia/cli/commands/_simplified_mermaid.py index e9e7abcc5..359686a0c 100644 --- a/gaia/cli/commands/_simplified_mermaid.py +++ b/gaia/cli/commands/_simplified_mermaid.py @@ -38,6 +38,7 @@ "contradiction": "\u2297", "equivalence": "\u2261", "complement": "\u2295", + "negation": "\u00ac", "disjunction": "\u2228", "conjunction": "\u2227", "implication": "\u2192", diff --git a/gaia/ir/formalize.py b/gaia/ir/formalize.py index b2d27f93b..0e41bf302 100644 --- a/gaia/ir/formalize.py +++ b/gaia/ir/formalize.py @@ -23,6 +23,7 @@ ) _HELPER_KIND_BY_OPERATOR = { + "negation": "negation_result", "conjunction": "conjunction_result", "disjunction": "disjunction_result", "equivalence": "equivalence_result", diff --git a/gaia/ir/knowledge.py b/gaia/ir/knowledge.py index db4d255bd..bc9145856 100644 --- a/gaia/ir/knowledge.py +++ b/gaia/ir/knowledge.py @@ -36,6 +36,26 @@ class KnowledgeType(StrEnum): 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): """Quantified variable in a universal claim.""" diff --git a/gaia/ir/operator.py b/gaia/ir/operator.py index 7d47fae57..f42bf173e 100644 --- a/gaia/ir/operator.py +++ b/gaia/ir/operator.py @@ -15,6 +15,7 @@ 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) @@ -62,6 +63,10 @@ def _validate_invariants(self) -> Operator: if len(self.variables) != 2: raise ValueError("operator=implication requires exactly 2 variables (inputs)") + elif self.operator == OperatorType.NEGATION: + if len(self.variables) != 1: + raise ValueError("operator=negation requires exactly 1 variable (input)") + elif self.operator == OperatorType.CONJUNCTION: if len(self.variables) < 2: raise ValueError("operator=conjunction requires at least 2 variables (inputs)") diff --git a/gaia/ir/validator.py b/gaia/ir/validator.py index 84f841513..5cb5217ad 100644 --- a/gaia/ir/validator.py +++ b/gaia/ir/validator.py @@ -9,7 +9,12 @@ import math from dataclasses import dataclass, field -from gaia.ir.knowledge import Knowledge, KnowledgeType, is_qid +from gaia.ir.knowledge import ( + Knowledge, + KnowledgeType, + is_qid, + is_structural_expression_helper, +) from gaia.ir.operator import Operator, OperatorType from gaia.ir.strategy import Strategy, CompositeStrategy, FormalStrategy, StrategyType from gaia.ir.graphs import LocalCanonicalGraph, _canonical_json @@ -34,6 +39,7 @@ def _parse_qid(qid: str) -> tuple[str, str, str] | None: _PARAMETERIZED_TYPES = {StrategyType.INFER, StrategyType.NOISY_AND} _STRUCTURAL_HELPER_OPERATOR_TYPES = { OperatorType.CONJUNCTION, + OperatorType.NEGATION, OperatorType.DISJUNCTION, OperatorType.EQUIVALENCE, OperatorType.CONTRADICTION, @@ -187,6 +193,14 @@ def _validate_operators( 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: diff --git a/gaia/lang/__init__.py b/gaia/lang/__init__.py index e44297083..fddae5b63 100644 --- a/gaia/lang/__init__.py +++ b/gaia/lang/__init__.py @@ -3,6 +3,7 @@ from gaia.lang.dsl import ( abduction, analogy, + and_, case_analysis, claim, compare, @@ -17,6 +18,7 @@ disjunction, equal, elimination, + exclusive, equivalence, extrapolation, fills, @@ -24,8 +26,10 @@ infer, mathematical_induction, noisy_and, + not_, note, observe, + or_, question, setting, support, @@ -38,6 +42,7 @@ Contradict, Derive, Equal, + Exclusive, Grounding, Infer, Knowledge, @@ -60,6 +65,7 @@ "Contradict", "Derive", "Equal", + "Exclusive", "Grounding", "Infer", "Knowledge", @@ -74,6 +80,7 @@ "Support", "abduction", "analogy", + "and_", "case_analysis", "claim", "compare", @@ -88,6 +95,7 @@ "disjunction", "equal", "elimination", + "exclusive", "equivalence", "extrapolation", "fills", @@ -95,8 +103,10 @@ "infer", "mathematical_induction", "noisy_and", + "not_", "note", "observe", + "or_", "question", "setting", "support", diff --git a/gaia/lang/compiler/compile.py b/gaia/lang/compiler/compile.py index 6c64fdb1c..e9e546f9c 100644 --- a/gaia/lang/compiler/compile.py +++ b/gaia/lang/compiler/compile.py @@ -36,6 +36,7 @@ Compute, Contradict, Equal, + Exclusive, Infer as InferAction, Observe, Relate, @@ -494,7 +495,7 @@ def compile_strategy(s) -> IrStrategy: "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, + "conclusion": knowledge_map[id(s.conclusion)] if s.conclusion is not None else None, "background": [knowledge_map[id(b)] for b in s.background] or None, "steps": steps, "metadata": _metadata_with_reason(s.metadata, s.reason), @@ -630,6 +631,9 @@ def _compile_relate_action(action: Relate, action_index: int) -> IrOperator: elif isinstance(action, Contradict): operator = "contradiction" pattern = "contradiction" + elif isinstance(action, Exclusive): + operator = "complement" + pattern = "exclusive" else: raise ValueError(f"Unsupported Relate action: {type(action).__name__}") action_label, metadata = _action_metadata(action, pkg, action_index, pattern=pattern) diff --git a/gaia/lang/dsl/__init__.py b/gaia/lang/dsl/__init__.py index 085ff4bed..e209d0a39 100644 --- a/gaia/lang/dsl/__init__.py +++ b/gaia/lang/dsl/__init__.py @@ -1,7 +1,8 @@ from gaia.lang.dsl.knowledge import claim, context, note, question, setting from gaia.lang.dsl.infer_verb import infer from gaia.lang.dsl.operators import complement, contradiction, disjunction, equivalence -from gaia.lang.dsl.relate import contradict, equal +from gaia.lang.dsl.propositional import and_, not_, or_ +from gaia.lang.dsl.relate import contradict, equal, exclusive from gaia.lang.dsl.support import compute, derive, observe from gaia.lang.dsl.strategies import ( abduction, @@ -36,6 +37,7 @@ "disjunction", "equal", "elimination", + "exclusive", "equivalence", "extrapolation", "fills", @@ -43,8 +45,11 @@ "infer", "mathematical_induction", "noisy_and", + "and_", + "not_", "note", "observe", + "or_", "question", "setting", "support", diff --git a/gaia/lang/dsl/propositional.py b/gaia/lang/dsl/propositional.py new file mode 100644 index 000000000..2b64a7a93 --- /dev/null +++ b/gaia/lang/dsl/propositional.py @@ -0,0 +1,55 @@ +"""Gaia Lang v6 propositional expression helpers.""" + +from __future__ import annotations + +from gaia.lang.runtime.knowledge import Claim +from gaia.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 not_(claim: Claim) -> Claim: + """Construct the Boolean negation expression ``not claim``.""" + _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.""" + 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.""" + 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/lang/dsl/relate.py b/gaia/lang/dsl/relate.py index e7f027665..f5e29860c 100644 --- a/gaia/lang/dsl/relate.py +++ b/gaia/lang/dsl/relate.py @@ -1,8 +1,8 @@ -"""Gaia Lang v6 Relate verbs: equal, contradict.""" +"""Gaia Lang v6 Relate verbs: equal, contradict, exclusive.""" from __future__ import annotations -from gaia.lang.runtime.action import Contradict, Equal +from gaia.lang.runtime.action import Contradict, Equal, Exclusive from gaia.lang.runtime.knowledge import Claim @@ -32,3 +32,14 @@ def contradict(a: Claim, b: Claim, *, rationale: str = "", label: str | None = N action = Contradict(label=label, rationale=rationale, a=a, b=b, helper=helper) action.warrants.append(helper) return helper + + +def exclusive(a: Claim, b: Claim, *, 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, a=a, b=b, helper=helper) + action.warrants.append(helper) + return helper diff --git a/gaia/lang/review/manifest.py b/gaia/lang/review/manifest.py index f8b1844e4..10481d0ac 100644 --- a/gaia/lang/review/manifest.py +++ b/gaia/lang/review/manifest.py @@ -42,6 +42,8 @@ def _operator_action_type(operator: Any) -> str: return "equal" if operator.operator == "contradiction": return "contradict" + if operator.operator == "complement": + return "exclusive" return str(operator.operator) diff --git a/gaia/lang/review/templates.py b/gaia/lang/review/templates.py index c6bac103d..79aee73c7 100644 --- a/gaia/lang/review/templates.py +++ b/gaia/lang/review/templates.py @@ -18,6 +18,7 @@ def __missing__(self, key): ), "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?", } diff --git a/gaia/lang/runtime/__init__.py b/gaia/lang/runtime/__init__.py index e15f6c7fe..087a528da 100644 --- a/gaia/lang/runtime/__init__.py +++ b/gaia/lang/runtime/__init__.py @@ -4,6 +4,7 @@ Contradict, Derive, Equal, + Exclusive, Infer, Observe, Relate, @@ -21,6 +22,7 @@ "Contradict", "Derive", "Equal", + "Exclusive", "Grounding", "Infer", "Knowledge", diff --git a/gaia/lang/runtime/action.py b/gaia/lang/runtime/action.py index ccdf15ee5..fc833ee78 100644 --- a/gaia/lang/runtime/action.py +++ b/gaia/lang/runtime/action.py @@ -78,6 +78,11 @@ class Contradict(Relate): """Declares two Claims contradictory.""" +@dataclass +class Exclusive(Relate): + """Declares two Claims form a closed binary partition.""" + + @dataclass class Infer(Action): """Bayesian inference: P(E|H) update.""" diff --git a/gaia/lang/runtime/knowledge.py b/gaia/lang/runtime/knowledge.py index a79a978b0..8622202e6 100644 --- a/gaia/lang/runtime/knowledge.py +++ b/gaia/lang/runtime/knowledge.py @@ -125,6 +125,31 @@ def __init_subclass__(cls, **kwargs): if name not in base_fields and not name.startswith("_") } + def __bool__(self) -> bool: + raise TypeError( + "Claim objects do not have Python truth values. Use ~A, A & B, " + "or A | B to build Gaia logical expressions." + ) + + def __invert__(self) -> Claim: + from gaia.lang.dsl.propositional import not_ + + return not_(self) + + def __and__(self, other: Claim) -> Claim: + if not isinstance(other, Claim): + return NotImplemented + from gaia.lang.dsl.propositional import and_ + + return and_(self, other) + + def __or__(self, other: Claim) -> Claim: + if not isinstance(other, Claim): + return NotImplemented + from gaia.lang.dsl.propositional import or_ + + return or_(self, other) + def __init__( self, content: str | None = None, diff --git a/gaia/logic/__init__.py b/gaia/logic/__init__.py new file mode 100644 index 000000000..65feb9036 --- /dev/null +++ b/gaia/logic/__init__.py @@ -0,0 +1,25 @@ +"""Logic utilities for Gaia graphs. + +These helpers use external logic libraries as computation backends while keeping +Gaia IR as the persistent semantic contract. +""" + +from gaia.logic.propositional import ( + are_equivalent, + is_satisfiable, + simplify_proposition, + to_cnf_proposition, + to_dnf_proposition, + to_nnf_proposition, + to_sympy_proposition, +) + +__all__ = [ + "are_equivalent", + "is_satisfiable", + "simplify_proposition", + "to_cnf_proposition", + "to_dnf_proposition", + "to_nnf_proposition", + "to_sympy_proposition", +] diff --git a/gaia/logic/propositional.py b/gaia/logic/propositional.py new file mode 100644 index 000000000..cbdefda58 --- /dev/null +++ b/gaia/logic/propositional.py @@ -0,0 +1,177 @@ +"""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 +from sympy.logic.boolalg import simplify_logic as _sympy_simplify_logic +from sympy.logic.boolalg import to_cnf, to_dnf, to_nnf +from sympy.logic.inference import satisfiable + +from gaia.ir.graphs import LocalCanonicalGraph +from gaia.ir.operator import Operator, OperatorType +from gaia.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/pyproject.toml b/pyproject.toml index 82473c546..bf1389d6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "typer[all]>=0.12", "numpy>=1.26,<2.4", # numpy 2.4 breaks pytest-cov (double-import C extension) "opt-einsum>=3.3", + "sympy>=1.13,<2", "httpx>=0.27", "faiss-cpu>=1.7", ] @@ -43,7 +44,7 @@ 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.ir*", "gaia.lang*", "gaia.logic*", "gaia.bp*", "gaia.cli*", "gaia.review*"] [tool.setuptools.package-data] "gaia.cli.templates.pages" = ["**/*"] diff --git a/tests/gaia/bp/test_factor_graph.py b/tests/gaia/bp/test_factor_graph.py index 595610247..96889f4cd 100644 --- a/tests/gaia/bp/test_factor_graph.py +++ b/tests/gaia/bp/test_factor_graph.py @@ -67,6 +67,22 @@ def test_add_conjunction_factor(): assert fg.factors[0].variables == ["A", "B"] +def test_add_negation_factor(): + fg = FactorGraph() + for v in ["A", "N"]: + fg.add_variable(v, 0.5) + fg.add_factor("f1", FactorType.NEGATION, ["A"], "N") + assert fg.factors[0].variables == ["A"] + + +def test_negation_factor_rejects_two_variables(): + fg = FactorGraph() + for v in ["A", "B", "N"]: + fg.add_variable(v, 0.5) + with pytest.raises(ValueError, match="requires exactly 1 variable"): + fg.add_factor("f1", FactorType.NEGATION, ["A", "B"], "N") + + def test_add_soft_entailment(): fg = FactorGraph() fg.add_variable("M", 0.5) diff --git a/tests/gaia/bp/test_potentials.py b/tests/gaia/bp/test_potentials.py index b27c7b797..b1fd12e12 100644 --- a/tests/gaia/bp/test_potentials.py +++ b/tests/gaia/bp/test_potentials.py @@ -12,6 +12,7 @@ equivalence_potential, evaluate_potential, implication_potential, + negation_potential, soft_entailment_potential, ) @@ -68,6 +69,19 @@ def test_conjunction_one_false_m1(): assert conjunction_potential({"A": 1, "B": 0, "M": 1}, ["A", "B"], "M") < EPS +# ── negation: N = NOT(A) ── + + +def test_negation_true_false(): + assert negation_potential({"A": 1, "N": 0}, "A", "N") > 1 - EPS + assert negation_potential({"A": 1, "N": 1}, "A", "N") < EPS + + +def test_negation_false_true(): + assert negation_potential({"A": 0, "N": 1}, "A", "N") > 1 - EPS + assert negation_potential({"A": 0, "N": 0}, "A", "N") < EPS + + # ── disjunction: D = OR(inputs) ── @@ -179,6 +193,16 @@ def test_evaluate_potential_routes_correctly(): assert evaluate_potential(factor, {"A": 1, "B": 0, "H": 0}) > 1 - EPS +def test_evaluate_potential_routes_negation(): + factor = Factor( + factor_id="f1", + factor_type=FactorType.NEGATION, + variables=["A"], + conclusion="N", + ) + assert evaluate_potential(factor, {"A": 0, "N": 1}) > 1 - EPS + + def test_evaluate_potential_soft_entailment(): factor = Factor( factor_id="f1", diff --git a/tests/gaia/lang/test_compiler_actions.py b/tests/gaia/lang/test_compiler_actions.py index 60e51d067..98bd3545c 100644 --- a/tests/gaia/lang/test_compiler_actions.py +++ b/tests/gaia/lang/test_compiler_actions.py @@ -1,4 +1,4 @@ -from gaia.lang import Claim, compute, contradict, derive, equal, infer, observe +from gaia.lang import Claim, compute, contradict, derive, equal, exclusive, infer, observe from gaia.lang.compiler import compile_package_artifact from gaia.lang.runtime.package import CollectedPackage @@ -90,7 +90,7 @@ def test_compile_compute_action_to_deduction_with_compute_metadata(): assert strategy.metadata["compute"]["function_ref"] -def test_compile_equal_and_contradict_actions_to_operators(): +def test_compile_equal_contradict_and_exclusive_actions_to_operators(): with CollectedPackage("v6_actions") as pkg: a = Claim("A.") a.label = "a" @@ -100,6 +100,8 @@ def test_compile_equal_and_contradict_actions_to_operators(): eq.label = "same_helper" conflict = contradict(a, b, rationale="Conflict.", label="conflict") conflict.label = "conflict_helper" + one = exclusive(a, b, rationale="Closed binary partition.", label="exclusive") + one.label = "exclusive_helper" compiled = compile_package_artifact(pkg) by_operator = {op.operator: op for op in compiled.graph.operators} @@ -109,6 +111,10 @@ def test_compile_equal_and_contradict_actions_to_operators(): "github:v6_actions::action::conflict" ) assert by_operator["contradiction"].conclusion == "github:v6_actions::conflict_helper" + assert by_operator["complement"].metadata["action_label"] == ( + "github:v6_actions::action::exclusive" + ) + assert by_operator["complement"].conclusion == "github:v6_actions::exclusive_helper" def test_compile_infer_action_to_strategy_cpt(): diff --git a/tests/gaia/lang/test_exclusive.py b/tests/gaia/lang/test_exclusive.py new file mode 100644 index 000000000..ca89ee6a8 --- /dev/null +++ b/tests/gaia/lang/test_exclusive.py @@ -0,0 +1,34 @@ +from gaia.lang import exclusive +from gaia.lang.runtime.action import Exclusive +from gaia.lang.runtime.knowledge import Claim +from gaia.lang.runtime.package import CollectedPackage + + +def test_exclusive_returns_reviewable_warrant_claim(): + a = Claim("Case A.") + b = Claim("Case B.") + helper = exclusive(a, b, rationale="The cases form a closed binary partition.") + assert isinstance(helper, Claim) + assert helper.metadata.get("generated") is True + assert helper.metadata.get("helper_kind") == "complement_result" + assert helper.metadata.get("review") is True + + +def test_exclusive_registers_action_and_warrant(): + with CollectedPackage("v6_test") as pkg: + a = Claim("Case A.") + b = Claim("Case B.") + helper = exclusive( + a, + b, + rationale="The cases form a closed binary partition.", + label="binary_cases", + ) + assert len(pkg.actions) == 1 + action = pkg.actions[0] + assert isinstance(action, Exclusive) + assert action.label == "binary_cases" + assert action.a is a + assert action.b is b + assert action.helper is helper + assert action.warrants == [helper] diff --git a/tests/gaia/lang/test_propositional.py b/tests/gaia/lang/test_propositional.py new file mode 100644 index 000000000..6b9815bf9 --- /dev/null +++ b/tests/gaia/lang/test_propositional.py @@ -0,0 +1,72 @@ +import pytest + +from gaia.lang import Claim, and_, or_ +from gaia.lang.compiler import compile_package_artifact +from gaia.lang.runtime.package import CollectedPackage + + +def test_claim_boolean_truth_value_is_not_allowed(): + a = Claim("A.") + with pytest.raises(TypeError, match="Gaia logical expressions"): + bool(a) + + +def test_claim_logical_operator_overloads_create_expression_helpers(): + a = Claim("A.") + b = Claim("B.") + + not_a = ~a + both = a & b + either = a | b + + assert not_a.metadata["helper_kind"] == "negation_result" + assert both.metadata["helper_kind"] == "conjunction_result" + assert either.metadata["helper_kind"] == "disjunction_result" + assert not_a.metadata["review"] is False + assert both.metadata["review"] is False + assert either.metadata["review"] is False + + +def test_explicit_and_or_functions_accept_multiple_claims(): + a = Claim("A.") + b = Claim("B.") + c = Claim("C.") + + both = and_(a, b, c) + either = or_(a, b, c) + + assert both.metadata["helper_kind"] == "conjunction_result" + assert either.metadata["helper_kind"] == "disjunction_result" + + +def test_propositional_functions_reject_non_claim_inputs(): + a = Claim("A.") + with pytest.raises(TypeError, match="Claim"): + and_(a, object()) + with pytest.raises(TypeError, match="Claim"): + or_(a, object()) + + +def test_compile_propositional_expression_helpers_to_nonreviewed_operators(): + with CollectedPackage("prop_pkg") as pkg: + a = Claim("A.") + a.label = "a" + b = Claim("B.") + b.label = "b" + not_a = ~a + not_a.label = "not_a" + both = a & b + both.label = "both" + either = a | b + either.label = "either" + + compiled = compile_package_artifact(pkg) + by_conclusion = {op.conclusion: op for op in compiled.graph.operators} + + assert by_conclusion["github:prop_pkg::not_a"].operator == "negation" + assert by_conclusion["github:prop_pkg::not_a"].variables == ["github:prop_pkg::a"] + assert by_conclusion["github:prop_pkg::both"].operator == "conjunction" + assert by_conclusion["github:prop_pkg::either"].operator == "disjunction" + assert all("action_label" not in (op.metadata or {}) for op in compiled.graph.operators) + assert compiled.review is not None + assert compiled.review.reviews == [] diff --git a/tests/gaia/lang/test_review_manifest.py b/tests/gaia/lang/test_review_manifest.py index a22d5904d..71eb941a9 100644 --- a/tests/gaia/lang/test_review_manifest.py +++ b/tests/gaia/lang/test_review_manifest.py @@ -1,4 +1,4 @@ -from gaia.lang import Claim, contradict, derive, equal, infer, observe +from gaia.lang import Claim, contradict, derive, equal, exclusive, infer, observe from gaia.lang.compiler import compile_package_artifact from gaia.lang.review.manifest import generate_review_manifest from gaia.lang.review.templates import generate_audit_question @@ -33,6 +33,13 @@ def test_audit_question_for_equal(): assert "[@obs]" in question +def test_audit_question_for_exclusive(): + question = generate_audit_question("exclusive", a_label="case_a", b_label="case_b") + assert "[@case_a]" in question + assert "[@case_b]" in question + assert "exactly one" in question.lower() + + def test_generate_review_manifest_for_v6_actions(): with CollectedPackage("review_pkg") as pkg: a = Claim("A.") @@ -47,6 +54,8 @@ def test_generate_review_manifest_for_v6_actions(): eq.label = "same_helper" conflict = contradict(a, data, rationale="Conflict.", label="conflict") conflict.label = "conflict_helper" + one = exclusive(a, b, rationale="Closed binary partition.", label="exclusive") + one.label = "exclusive_helper" infer( data, hypothesis=c, @@ -58,7 +67,7 @@ def test_generate_review_manifest_for_v6_actions(): compiled = compile_package_artifact(pkg) manifest = generate_review_manifest(compiled) - assert len(manifest.reviews) == 5 + assert len(manifest.reviews) == 6 assert {review.status for review in manifest.reviews} == {"unreviewed"} by_action = {review.action_label: review for review in manifest.reviews} @@ -69,7 +78,9 @@ def test_generate_review_manifest_for_v6_actions(): "github:review_pkg::data" ) assert by_action["github:review_pkg::action::same"].target_kind == "operator" + assert by_action["github:review_pkg::action::exclusive"].target_kind == "operator" assert "[@a]" in by_action["github:review_pkg::action::conflict"].audit_question + assert "exactly one" in by_action["github:review_pkg::action::exclusive"].audit_question.lower() assert "[@data]" in by_action["github:review_pkg::action::bayes_update"].audit_question diff --git a/tests/gaia/logic/test_propositional.py b/tests/gaia/logic/test_propositional.py new file mode 100644 index 000000000..6fe1c1112 --- /dev/null +++ b/tests/gaia/logic/test_propositional.py @@ -0,0 +1,132 @@ +from sympy import Symbol + +from gaia.lang import Claim, exclusive +from gaia.ir import FormalExpr, FormalStrategy, Knowledge, LocalCanonicalGraph, Operator +from gaia.lang.compiler import compile_package_artifact +from gaia.lang.runtime.package import CollectedPackage +from gaia.logic import ( + are_equivalent, + is_satisfiable, + simplify_proposition, + to_cnf_proposition, +) + + +def _kid(package: str, label: str) -> str: + return f"github:{package}::{label}" + + +def test_simplify_proposition_collapses_double_negation(): + with CollectedPackage("logic_double_negation") as pkg: + a = Claim("A.") + a.label = "a" + double = ~~a + double.label = "double" + + graph = compile_package_artifact(pkg).graph + + assert simplify_proposition(graph, _kid("logic_double_negation", "double")) == Symbol( + _kid("logic_double_negation", "a") + ) + + +def test_cnf_and_equivalence_use_demorgan_law(): + with CollectedPackage("logic_demorgan") as pkg: + a = Claim("A.") + a.label = "a" + b = Claim("B.") + b.label = "b" + both = a & b + both.label = "both" + left = ~both + left.label = "left" + not_a = ~a + not_a.label = "not_a" + not_b = ~b + not_b.label = "not_b" + right = not_a | not_b + right.label = "right" + + graph = compile_package_artifact(pkg).graph + + assert are_equivalent(graph, _kid("logic_demorgan", "left"), _kid("logic_demorgan", "right")) + assert str(to_cnf_proposition(graph, _kid("logic_demorgan", "left"), simplify=True)) in { + "~github:logic_demorgan::a | ~github:logic_demorgan::b", + "~github:logic_demorgan::b | ~github:logic_demorgan::a", + } + + +def test_satisfiability_detects_contradictory_formula(): + with CollectedPackage("logic_unsat") as pkg: + a = Claim("A.") + a.label = "a" + not_a = ~a + not_a.label = "not_a" + impossible = a & not_a + impossible.label = "impossible" + + graph = compile_package_artifact(pkg).graph + + assert is_satisfiable(graph, _kid("logic_unsat", "a")) + assert not is_satisfiable(graph, _kid("logic_unsat", "impossible")) + + +def test_exclusive_relation_is_equivalent_to_or_and_not_both(): + with CollectedPackage("logic_exclusive") as pkg: + a = Claim("A.") + a.label = "a" + b = Claim("B.") + b.label = "b" + one_of = exclusive(a, b, rationale="closed binary split") + one_of.label = "one_of" + either = a | b + either.label = "either" + both = a & b + both.label = "both" + not_both = ~both + not_both.label = "not_both" + formula = either & not_both + formula.label = "formula" + + graph = compile_package_artifact(pkg).graph + + assert are_equivalent(graph, _kid("logic_exclusive", "one_of"), _kid("logic_exclusive", "formula")) + + +def test_formal_strategy_operator_conclusions_are_expanded(): + graph = LocalCanonicalGraph( + namespace="github", + package_name="logic_formal_expr", + knowledges=[ + Knowledge(id="github:logic_formal_expr::a", type="claim", content="A"), + Knowledge(id="github:logic_formal_expr::b", type="claim", content="B"), + Knowledge(id="github:logic_formal_expr::same", type="claim", content="same"), + ], + strategies=[ + FormalStrategy( + scope="local", + type="deduction", + premises=["github:logic_formal_expr::a", "github:logic_formal_expr::b"], + conclusion="github:logic_formal_expr::same", + formal_expr=FormalExpr( + operators=[ + Operator( + operator="equivalence", + variables=[ + "github:logic_formal_expr::a", + "github:logic_formal_expr::b", + ], + conclusion="github:logic_formal_expr::same", + ) + ] + ), + ) + ], + ) + + assert str(to_cnf_proposition(graph, "github:logic_formal_expr::same", simplify=True)) in { + "(github:logic_formal_expr::a | ~github:logic_formal_expr::b) & " + "(github:logic_formal_expr::b | ~github:logic_formal_expr::a)", + "(github:logic_formal_expr::b | ~github:logic_formal_expr::a) & " + "(github:logic_formal_expr::a | ~github:logic_formal_expr::b)", + } diff --git a/tests/ir/test_operator.py b/tests/ir/test_operator.py index 439b26abd..365191016 100644 --- a/tests/ir/test_operator.py +++ b/tests/ir/test_operator.py @@ -8,6 +8,7 @@ class TestOperatorType: def test_six_types(self): assert set(OperatorType) == { "implication", + "negation", "equivalence", "contradiction", "complement", @@ -33,6 +34,11 @@ def test_conjunction(self): assert op.variables == ["gcn_a", "gcn_b"] assert op.conclusion == "gcn_m" + def test_negation(self): + op = Operator(operator="negation", variables=["gcn_a"], conclusion="gcn_not_a") + assert op.variables == ["gcn_a"] + assert op.conclusion == "gcn_not_a" + def test_equivalence(self): op = Operator(operator="equivalence", variables=["gcn_a", "gcn_b"], conclusion="gcn_h") assert op.conclusion == "gcn_h" @@ -105,6 +111,14 @@ def test_conjunction_rejects_one_variable(self): with pytest.raises(ValueError, match="at least 2 variables"): Operator(operator="conjunction", variables=["a"], conclusion="m") + def test_negation_rejects_zero_variables(self): + with pytest.raises(ValueError, match="exactly 1 variable"): + Operator(operator="negation", variables=[], conclusion="h") + + def test_negation_rejects_two_variables(self): + with pytest.raises(ValueError, match="exactly 1 variable"): + Operator(operator="negation", variables=["a", "b"], conclusion="h") + def test_equivalence_rejects_three_variables(self): with pytest.raises(ValueError, match="exactly 2 variables"): Operator(operator="equivalence", variables=["a", "b", "c"], conclusion="h") diff --git a/tests/ir/test_validator.py b/tests/ir/test_validator.py index 273e41073..7b05f7acb 100644 --- a/tests/ir/test_validator.py +++ b/tests/ir/test_validator.py @@ -86,6 +86,41 @@ def test_metadata_prior_must_be_in_cromwell_bounds(self): assert not r.valid assert any("metadata prior" in e and "Cromwell bounds" in e for e in r.errors) + def test_structural_helper_metadata_prior_is_prohibited(self): + g = _local_graph( + knowledges=[ + _claim("github:test::a"), + _claim("github:test::b"), + Knowledge( + id="github:test::both", + type=KnowledgeType.CLAIM, + content="A and B", + metadata={ + "generated": True, + "helper_kind": "conjunction_result", + "review": False, + "prior": 0.9, + }, + ), + ], + operators=[ + Operator( + operator_id="lco_both", + scope="local", + operator="conjunction", + variables=["github:test::a", "github:test::b"], + conclusion="github:test::both", + ), + ], + ) + + r = validate_local_graph(g) + + assert not r.valid + assert any( + "github:test::both" in e and "structural helper claim" in e for e in r.errors + ) + def test_duplicate_label_rejected(self): g = _local_graph( knowledges=[ diff --git a/tests/test_contraction.py b/tests/test_contraction.py index b3d531e1f..da14e8543 100644 --- a/tests/test_contraction.py +++ b/tests/test_contraction.py @@ -100,6 +100,22 @@ def test_factor_to_tensor_disjunction(): assert _almost(t[1, 1, 0], _LOW) +def test_factor_to_tensor_negation(): + f = Factor( + factor_id="f1", + factor_type=FactorType.NEGATION, + variables=["A"], + conclusion="N", + ) + t, axes = factor_to_tensor(f) + assert axes == ["A", "N"] + # N == NOT(A) + assert _almost(t[0, 1], _HIGH) + assert _almost(t[0, 0], _LOW) + assert _almost(t[1, 0], _HIGH) + assert _almost(t[1, 1], _LOW) + + def test_factor_to_tensor_equivalence(): f = Factor( factor_id="f1", diff --git a/tests/test_lowering.py b/tests/test_lowering.py index ea98776e8..a336e39d1 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -80,6 +80,64 @@ def test_contradiction_default_prior_near_one(): assert fg.variables["github:lowertest::r"] == pytest.approx(1.0 - CROMWELL_EPS) +def test_negation_default_prior_is_neutral(): + """Compositional negation conclusion defaults to 0.5, not assertion true.""" + g = _lg( + knowledges=[ + Knowledge(id="github:lowertest::x", type="claim", content="X"), + Knowledge(id="github:lowertest::not_x", type="claim", content="not X"), + ], + operators=[ + Operator( + operator="negation", + variables=["github:lowertest::x"], + conclusion="github:lowertest::not_x", + ), + ], + ) + fg = lower_local_graph(g) + assert fg.variables["github:lowertest::not_x"] == pytest.approx(0.5) + assert fg.factors[0].factor_type == FactorType.NEGATION + + +def test_structural_expression_metadata_prior_is_ignored(): + """Expression helper beliefs must be determined by operators, not independent priors.""" + g = _lg( + knowledges=[ + Knowledge(id="github:lowertest::x", type="claim", content="X"), + Knowledge(id="github:lowertest::y", type="claim", content="Y"), + Knowledge( + id="github:lowertest::both", + type="claim", + content="X and Y", + metadata={ + "generated": True, + "helper_kind": "conjunction_result", + "review": False, + "prior": 0.9, + }, + ), + ], + operators=[ + Operator( + operator="conjunction", + variables=["github:lowertest::x", "github:lowertest::y"], + conclusion="github:lowertest::both", + ), + ], + ) + + fg = lower_local_graph(g) + + assert fg.variables["github:lowertest::both"] == pytest.approx(0.5) + + fg_with_node_prior = lower_local_graph( + g, + node_priors={"github:lowertest::both": 0.9}, + ) + assert fg_with_node_prior.variables["github:lowertest::both"] == pytest.approx(0.5) + + def test_contradiction_actually_constrains(): """With prior ~1.0 on helper, CONTRADICTION suppresses joint X=Y=1.""" g = _lg( From e7dab099744ab75ceb39b1f73c17ed15764efd17 Mon Sep 17 00:00:00 2001 From: kunchen Date: Thu, 23 Apr 2026 16:28:50 +0800 Subject: [PATCH 033/210] Import GPT Pro Gaia idea specs --- docs/ideas/README.md | 22 + docs/ideas/foundation-specs/00-index.md | 22 + ...obability-logic-backend-foundation-spec.md | 988 ++++++++++++ ...entific-formal-language-foundation-spec.md | 1029 ++++++++++++ docs/ideas/gaia-upgrade-specs/00-index.md | 67 + .../01-v0.5.x-contract-freeze.md | 210 +++ .../02-v0.6-evidence-contract.md | 510 ++++++ .../03-v0.7-evidence-model-adapters.md | 388 +++++ .../04-v0.8-context-reproducibility.md | 353 ++++ .../05-v0.9-quantity-unit-measurement.md | 349 ++++ .../06-v0.10-explain-sensitivity-audit.md | 349 ++++ .../07-v0.11-cross-package-reasoning.md | 327 ++++ .../08-v1.0-stable-kernel.md | 346 ++++ .../09-python-ecosystem-integration-spec.md | 1437 +++++++++++++++++ 14 files changed, 6397 insertions(+) create mode 100644 docs/ideas/README.md create mode 100644 docs/ideas/foundation-specs/00-index.md create mode 100644 docs/ideas/foundation-specs/jaynes-probability-logic-backend-foundation-spec.md create mode 100644 docs/ideas/foundation-specs/scientific-formal-language-foundation-spec.md create mode 100644 docs/ideas/gaia-upgrade-specs/00-index.md create mode 100644 docs/ideas/gaia-upgrade-specs/01-v0.5.x-contract-freeze.md create mode 100644 docs/ideas/gaia-upgrade-specs/02-v0.6-evidence-contract.md create mode 100644 docs/ideas/gaia-upgrade-specs/03-v0.7-evidence-model-adapters.md create mode 100644 docs/ideas/gaia-upgrade-specs/04-v0.8-context-reproducibility.md create mode 100644 docs/ideas/gaia-upgrade-specs/05-v0.9-quantity-unit-measurement.md create mode 100644 docs/ideas/gaia-upgrade-specs/06-v0.10-explain-sensitivity-audit.md create mode 100644 docs/ideas/gaia-upgrade-specs/07-v0.11-cross-package-reasoning.md create mode 100644 docs/ideas/gaia-upgrade-specs/08-v1.0-stable-kernel.md create mode 100644 docs/ideas/gaia-upgrade-specs/09-python-ecosystem-integration-spec.md diff --git a/docs/ideas/README.md b/docs/ideas/README.md new file mode 100644 index 000000000..c129e921b --- /dev/null +++ b/docs/ideas/README.md @@ -0,0 +1,22 @@ +# Gaia Ideas + +This directory is a staging area for design notes, exploratory specs, and +roadmap proposals that are not yet part of the canonical Gaia foundation docs. + +Canonical user-facing and foundation documents live under `docs/for-users/`, +`docs/for-visitors/`, and `docs/foundations/`. Ideas may later be promoted +there after implementation and review. + +## Current Idea Bundles + +| Path | Scope | +|---|---| +| `gaia-upgrade-specs/` | Draft v0.5.x to v1.0 upgrade roadmap, evidence contracts, context reproducibility, quantity/unit semantics, audit, cross-package reasoning, and Python ecosystem integration. | +| `foundation-specs/` | Draft foundation specs for a scientific formal language and Jaynesian probability logic backend. | +| `related-work/` | Related-systems survey and Gaia positioning notes. | + +## Existing Notes + +The remaining top-level files capture focused design questions such as +anti-double-counting, case analysis, elimination, mathematical induction, +negation, correlation relations, and type-system direction. diff --git a/docs/ideas/foundation-specs/00-index.md b/docs/ideas/foundation-specs/00-index.md new file mode 100644 index 000000000..93f8aeb04 --- /dev/null +++ b/docs/ideas/foundation-specs/00-index.md @@ -0,0 +1,22 @@ +# Foundation Docs Index + +This bundle contains two foundational design documents: + +1. `scientific-formal-language-foundation-spec.md` — core architecture for a scientific formal language. +2. `jaynes-probability-logic-backend-foundation-spec.md` — Jaynesian probability logic backend design. + +Status: idea-stage draft, imported under `docs/ideas/foundation-specs/`. + +Current repository placement: + +```text +docs/ideas/foundation-specs/scientific-formal-language-foundation-spec.md +docs/ideas/foundation-specs/jaynes-probability-logic-backend-foundation-spec.md +``` + +Possible promotion target after review: + +```text +docs/foundations/scientific-formal-language.md +docs/foundations/jaynes-probability-logic-backend.md +``` diff --git a/docs/ideas/foundation-specs/jaynes-probability-logic-backend-foundation-spec.md b/docs/ideas/foundation-specs/jaynes-probability-logic-backend-foundation-spec.md new file mode 100644 index 000000000..48d5e8d24 --- /dev/null +++ b/docs/ideas/foundation-specs/jaynes-probability-logic-backend-foundation-spec.md @@ -0,0 +1,988 @@ +# Jaynes 概率逻辑后端 Foundation Spec + +**文档状态**:Foundation Design Spec +**适用范围**:面向 Gaia / Scientific Formal Language 的概率语义、evidence contract、BeliefContext、BeliefState 与推理后端设计 +**建议文件名**:`docs/foundations/jaynes-probability-logic-backend.md` +**核心不变量**:概率不是命题的孤立属性,而是在信息状态 `I` 下对命题 `A` 的合理可信度:`P(A | I)`。 + +--- + +## 1. 文档目标 + +本文档定义 Jaynes 风格概率逻辑后端的基础设计。它的目标不是实现一个普通 Bayesian statistics library,而是为科学形式化语言提供一个统一语义层,用来表达: + +```text +在什么信息状态下,某个科学命题有多可信? +新证据如何更新这个可信度? +先验从何而来? +模型如何比较? +推理结果如何复现和审查? +``` + +核心形式: + +```text +P(Proposition | InformationContext) +``` + +也就是: + +```text +P(A | I) +``` + +其中: + +```text +A = 命题 +I = 背景信息、定义、假设、模型、先验、观测、证据、review 状态、依赖上下文 +``` + +--- + +## 2. 核心设计原则 + +### 2.1 所有概率都必须条件化 + +不推荐: + +```text +P(H) = 0.8 +``` + +推荐: + +```text +P(H | I0) = 0.8 +P(H | I0 + D) = 0.93 +``` + +系统可以提供默认 context,但内部语义必须总是 context-indexed。 + +### 2.2 概率作用于命题 + +概率的基本对象不是随机变量表,而是命题: + +```text +H := Drug_A reduces blood pressure by at least 5 mmHg +P(H | I) +``` + +随机变量和分布只是命题族的简写: + +```text +P(θ ∈ [a,b] | I) +``` + +### 2.3 信息状态是一等对象 + +`InformationContext` 不应只是隐式参数。它必须可序列化、可 hash、可 diff、可复现。 + +```text +context_id = hash(IR + priors + evidence + review state + dependencies + inference config) +``` + +### 2.4 Evidence 通过 likelihood 更新 hypothesis + +科学证据不应表达为: + +```text +support_prior = 0.8 +``` + +而应表达为: + +```text +P(E | H, I) +P(E | ¬H, I) +``` + +当 `E` 被观察到时: + +```text +posterior_odds(H | E,I) = prior_odds(H | I) × P(E|H,I) / P(E|¬H,I) +``` + +### 2.5 Review gating 不是概率 + +Review 状态决定某个 action/factor 是否进入信息状态 `I`,但不应变成 numeric prior。 + +```text +accepted -> factor active +unreviewed -> factor inactive by default +rejected -> factor inactive +``` + +不允许: + +```text +accepted -> P=0.99 +rejected -> P=0.01 +``` + +### 2.6 0 和 1 只留给逻辑必然或不可能 + +Jaynes 风格下,不应随便设置: + +```text +P(H | I) = 0 +P(H | I) = 1 +``` + +除非: + +```text +I entails H +I entails not H +``` + +否则应使用 Cromwell epsilon 进行截断,防止未来证据无法更新。 + +--- + +## 3. 范围与非目标 + +### 3.1 范围 + +本文档覆盖: + +```text +Proposition semantics +InformationContext +Bayes update +Likelihood evidence +MaxEnt priors +Model comparison +Prediction +Measurement likelihood +Inference IR +BeliefState +Diagnostics and audit +Gaia mapping +``` + +### 3.2 非目标 + +本文档不要求一次性实现: + +```text +完整连续概率编程语言 +完整因果推理系统 +自动先验生成器 +全自动模型发现 +所有统计检验到 Bayes factor 的自动转换 +完整 Markov Logic / PSL / PPL 兼容层 +``` + +MVP 应聚焦: + +```text +binary claim graph +likelihood evidence +review-gated information context +context-indexed belief output +``` + +--- + +## 4. 核心对象模型 + +### 4.1 Proposition + +命题是可以为真或假的表达式。 + +```text +Proposition := + Atom(predicate, terms) + Equality(term, term) + Inequality(term, term) + Not(Proposition) + And(Proposition, Proposition) + Or(Proposition, Proposition) + Implies(Proposition, Proposition) + Forall(variable, type, Proposition) + Exists(variable, type, Proposition) +``` + +在 Gaia MVP 中,命题主要通过 `Claim` 表示。 + +### 4.2 Claim + +`Claim` 是具有身份、prior、metadata 和 review 依赖的 proposition wrapper。 + +```text +Claim: + id + proposition + prior optional + label optional + grounding optional + parameters optional + provenance optional +``` + +在概率后端中: + +```text +Claim -> binary belief variable +``` + +### 4.3 InformationContext + +```text +InformationContext: + id + claims + accepted_actions + observations + evidence_factors + priors + models + assumptions + review_state + dependency_contexts + inference_config +``` + +上下文可以递增更新: + +```text +I1 = I0 + Observation_1 +I2 = I1 + EvidenceFactor_2 +``` + +### 4.4 ProbabilityExpression + +```text +ProbabilityExpression: + proposition + context + result_type: probability | density | distribution | evidence | expectation +``` + +例子: + +```text +P(H | I) +P(θ ∈ [a,b] | I) +E[X | I] +P(D | M, I) +BayesFactor(M1, M2 | D, I) +``` + +### 4.5 EvidenceFactor + +```text +EvidenceFactor: + hypothesis + evidence + P(E | H) + P(E | not H) + observed_status + source_id + independence_group + assumptions + model_id +``` + +MVP 对应 Gaia `InferAction`。 + +### 4.6 BeliefState + +```text +BeliefState: + context_id + beliefs + diagnostics + inference_method + exactness + generated_at +``` + +每个 belief 应理解为: + +```text +P(claim | context_id) +``` + +--- + +## 5. Jaynes / Cox 概率演算内核 + +### 5.1 加法规则 + +```text +P(A | I) + P(not A | I) = 1 +``` + +一般情况: + +```text +P(A or B | I) = P(A | I) + P(B | I) - P(A and B | I) +``` + +互斥时: + +```text +P(A or B | I) = P(A | I) + P(B | I) +``` + +### 5.2 乘法规则 + +```text +P(A and B | I) = P(A | B,I) P(B | I) +``` + +也可写成: + +```text +P(A,B | I) = P(A | B,I) P(B | I) +``` + +### 5.3 Bayes 更新 + +```text +P(H | D,I) = P(D | H,I) P(H | I) / P(D | I) +``` + +其中: + +```text +P(D | I) = P(D | H,I)P(H|I) + P(D | not H,I)P(not H|I) +``` + +### 5.4 Odds 形式 + +```text +PosteriorOdds(H | D,I) += PriorOdds(H | I) × LikelihoodRatio(D | H, not H, I) +``` + +其中: + +```text +LikelihoodRatio = P(D | H,I) / P(D | not H,I) +``` + +这是 evidence contract 的核心。 + +--- + +## 6. 信息状态设计 + +### 6.1 Context contents + +`InformationContext` 应包含: + +```text +definitions +accepted assumptions +claim priors +accepted observation actions +accepted likelihood evidence factors +accepted logical operators +dependency belief states +prior resolution policy +inference config +``` + +### 6.2 Context hash + +`context_id` 必须由 canonical JSON hash 得出,至少包含: + +```text +IR hash +review manifest hash +accepted target IDs +prior source hash +dependency context IDs +active evidence IDs +observed claim IDs +inference config +``` + +### 6.3 Context diff + +系统应支持: + +```text +gaia diff-context ctx1 ctx2 +``` + +至少报告: + +```text +changed priors +changed review status +added/removed evidence +changed dependencies +changed inference method +``` + +--- + +## 7. Priors 与 MaxEnt + +### 7.1 Prior 必须有依据 + +不推荐: + +```text +prior θ = uniform +``` + +推荐: + +```text +prior θ: + distribution: Uniform(0, 10) + measure: dθ + justification: symmetry over θ within known support +``` + +### 7.2 MaxEnt 原则 + +在只知道约束时,选择不引入额外信息的分布。 + +离散形式: + +```text +p* = argmax_p -Σ p_i log p_i +subject to constraints +``` + +连续形式应使用相对熵: + +```text +p* = argmax_p -∫ p(x) log(p(x)/q(x)) dx +``` + +其中 `q(x)` 是 reference measure / base distribution。 + +### 7.3 MaxEnt 输出 + +若约束为: + +```text +E[f_k(X)] = c_k +``` + +则通常得到: + +```text +p(x) ∝ q(x) exp(Σ λ_k f_k(x)) +``` + +### 7.4 先验警告 + +系统应警告: + +```text +[ ] uniform prior 缺少 support +[ ] uniform prior 缺少 measure +[ ] continuous prior 缺少 parameterization +[ ] prior = 0/1 但非逻辑必然 +[ ] prior source unknown +``` + +--- + +## 8. Evidence / likelihood 语义 + +### 8.1 Evidence 不等于 claim true + +声明 likelihood: + +```text +P(E | H) = 0.95 +P(E | not H) = 0.10 +``` + +并不表示 `E` 已被观察到。 + +只有当 `E` 被观察并进入 context: + +```text +Observe(E) accepted +``` + +才会更新 `H`。 + +### 8.2 Binary evidence factor + +MVP factor: + +```text +H: binary claim +E: binary evidence claim +φ(E,H) = P(E | H) +``` + +CPT: + +```text +P(E=true | H=false) = p_e_given_not_h +P(E=true | H=true) = p_e_given_h +``` + +### 8.3 Likelihood ratio helper + +如果用户提供 LR: + +```text +LR = P(E|H) / P(E|not H) +``` + +可以编译为兼容 CPT: + +```text +P(E|H) = LR / (1 + LR) +P(E|notH) = 1 / (1 + LR) +``` + +但 metadata 必须保留原始 LR。 + +### 8.4 Bayes factor helper + +对 binary hypothesis,Bayes factor 可视为 likelihood ratio: + +```text +BF(H:notH) = P(D|H) / P(D|notH) +``` + +### 8.5 Independence group + +每个 evidence factor 应可声明: + +```text +independence_group +source_id +data_id +``` + +系统应警告重复计数: + +```text +multiple active evidence factors share independence_group +``` + +--- + +## 9. 测量 likelihood + +### 9.1 测量模型 + +```text +Observed = TrueValue + Error +Error ~ Normal(0, σ) +``` + +若 hypothesis 是: + +```text +H := TrueValue > threshold +``` + +measurement evidence 可以编译成: + +```text +P(observed_value | H) +P(observed_value | not H) +``` + +短期可通过 adapter 输出 LR / BF。 + +### 9.2 Continuous value 注意事项 + +不应写: + +```text +P(θ = 1.0 | I) +``` + +应写: + +```text +P(θ ∈ [0.99,1.01] | I) +density(θ=1.0 | I) +``` + +### 9.3 概率密度单位 + +若 `X: Length`,则: + +```text +p_X(x): 1 / Length +``` + +后端应区分 probability 与 density。 + +--- + +## 10. 模型比较 + +### 10.1 Posterior model probability + +```text +P(M | D,I) = P(D | M,I) P(M | I) / P(D | I) +``` + +### 10.2 Marginal likelihood + +```text +P(D | M,I) = ∫ P(D | θ,M,I) P(θ | M,I) dθ +``` + +### 10.3 Bayes factor + +```text +BF(M1:M2) = P(D | M1,I) / P(D | M2,I) +``` + +### 10.4 Occam factor + +复杂模型只有在提高预测解释能力时才应得到更高 marginal likelihood。Jaynes 后端应避免只用 maximum likelihood 做模型比较。 + +--- + +## 11. 预测分布 + +科学模型的重要用途是预测,而不是只估计参数。 + +```text +P(new_data | old_data, I) += ∫ P(new_data | θ,I) P(θ | old_data,I) dθ +``` + +语言应支持: + +```text +predictive(target | context) +posterior_predictive_check(model, data, context) +``` + +MVP 可先不实现完整 continuous predictive,但应在 IR 中预留对象。 + +--- + +## 12. 与硬逻辑的接口 + +如果逻辑层能推出: + +```text +I entails A +``` + +则: + +```text +P(A | I) = 1 +``` + +如果: + +```text +I entails not A +``` + +则: + +```text +P(A | I) = 0 +``` + +否则系统不应把经验命题设为 0 或 1。 + +--- + +## 13. 条件独立与因子分解 + +### 13.1 显式声明独立性 + +不应自动假设: + +```text +P(D1,D2 | H) = P(D1|H)P(D2|H) +``` + +需要声明: + +```text +D1 independent_of D2 given H under I +``` + +### 13.2 Evidence groups + +短期实践中,用 `independence_group` 防止最明显重复计数。 + +```text +evidence_1.independence_group = trial_001.primary_endpoint +evidence_2.independence_group = trial_001.primary_endpoint +``` + +系统警告这两个 evidence 可能不是独立证据。 + +--- + +## 14. Inference IR + +推荐 IR: + +```text +ProbabilisticIR: + claims + priors + operators + evidence_factors + observations + review_state + contexts + queries +``` + +Evidence factor: + +```json +{ + "type": "infer", + "hypothesis": "claim:H", + "evidence": "claim:E", + "conditional_probabilities": [0.10, 0.95], + "metadata": { + "evidence": { + "schema_version": "gaia.evidence.v1", + "evidence_kind": "raw_likelihood", + "p_e_given_h": 0.95, + "p_e_given_not_h": 0.10, + "source_id": "lab:test_T", + "independence_group": "patient_001.test_T" + } + } +} +``` + +--- + +## 15. 编译与 lowering 流程 + +```text +Gaia Lang DSL + -> runtime objects + -> compile actions to IR + -> normalize EvidenceMetadata + -> generate ReviewManifest + -> select accepted actions + -> build BeliefContext + -> lower to factor graph + -> run inference engine + -> emit BeliefState +``` + +关键规则: + +```text +InferAction -> likelihood factor +ObserveAction accepted -> evidence claim pinned true with Cromwell clamp +Unreviewed action -> excluded from context +Rejected action -> excluded from context +``` + +--- + +## 16. 推理后端 + +后端可以有多种: + +```text +exact enumeration +junction tree +loopy belief propagation +generalized belief propagation +factor graph message passing +external PPL adapter +``` + +语义不应依赖具体算法。算法只影响计算近似和 diagnostics。 + +输出必须包含: + +```text +method_used +is_exact +treewidth optional +elapsed_ms +diagnostics +``` + +--- + +## 17. Diagnostics / Explain / Audit + +### 17.1 Explain + +```text +gaia explain +``` + +应显示: + +```text +P(claim | context_id) +prior +direct evidence factors +P(E|H), P(E|notH), LR +observed status +review status +source_id +inference method +exactness +``` + +### 17.2 Sensitivity + +未来支持: + +```text +prior perturbation +disable evidence group +disable review target +compare exact vs approximate +``` + +### 17.3 Audit + +应检查: + +```text +duplicate independence_group +missing source_id +missing context_id +unobserved evidence incorrectly updating posterior +review status used as probability +``` + +--- + +## 18. Gaia v0.6 映射 + +当前 Gaia 可以按以下方式落地: + +```text +Claim -> binary proposition variable +InferAction -> likelihood factor P(E|H) +ObserveAction -> evidence enters context +ReviewManifest -> qualitative gate +BeliefContext -> information state I +BeliefState -> posterior P(Claim | I) +InferenceEngine -> computational backend +``` + +核心 v0.6 invariant: + +```text +Declaring likelihood does not observe evidence. +Only accepted Observe actions place evidence into the context. +``` + +--- + +## 19. API 草案 + +### 19.1 Raw likelihood + +```python +likelihood( + evidence=test_positive, + hypothesis=disease, + p_e_given_h=0.95, + p_e_given_not_h=0.10, + observed=True, + source_id="lab:test_T", + independence_group="patient_001.test_T", +) +``` + +### 19.2 Likelihood ratio + +```python +likelihood_ratio( + evidence=experiment_result, + hypothesis=mechanism_present, + lr=12.4, + observed=True, +) +``` + +### 19.3 Bayes factor + +```python +bayes_factor( + evidence=data_D, + hypothesis=model_M1_better_than_M0, + bf=8.7, + observed=True, +) +``` + +### 19.4 Query + +```text +query P(disease | context_id) +``` + +--- + +## 20. Acceptance checklist + +基础后端被认为可用时,应满足: + +```text +[ ] 所有 belief 输出都有 context_id +[ ] InferAction 表示 P(E|H),不表示 P(H|E) +[ ] 未观测 evidence 不更新 hypothesis +[ ] accepted Observe 才把 evidence 放入 context +[ ] review status 不被当成概率 +[ ] LR/BF metadata 保留原始值 +[ ] posterior odds 与 LR/BF 计算一致 +[ ] priors 被 Cromwell clamp +[ ] duplicate independence_group 发出警告 +[ ] BeliefState 包含 method/exactness/diagnostics +[ ] context_id 对 IR/review/prior/evidence 变化敏感 +``` + +--- + +## 21. 路线图 + +```text +Phase 1: binary likelihood evidence +Phase 2: evidence adapters: binomial, two-binomial, Gaussian measurement, Bayes factor +Phase 3: context reproducibility and diff +Phase 4: quantity-aware measurement likelihoods +Phase 5: model comparison and posterior predictive checks +Phase 6: hybrid continuous/discrete factor graph +Phase 7: causal do-operator layer +``` + +--- + +## 22. 推荐参考方向 + +这些是理论背景,不是实现依赖: + +```text +E. T. Jaynes, Probability Theory: The Logic of Science +R. T. Cox, The Algebra of Probable Inference +Claude Shannon, A Mathematical Theory of Communication +David MacKay, Information Theory, Inference, and Learning Algorithms +Judea Pearl, Causality +Factor graphs, Bayesian networks, probabilistic programming systems +``` + +--- + +## 23. One-line invariant + +```text +A Jaynesian backend evaluates the plausibility of propositions under explicit information contexts; evidence updates beliefs through likelihoods, and every posterior is P(Claim | Context), never a context-free confidence score. +``` diff --git a/docs/ideas/foundation-specs/scientific-formal-language-foundation-spec.md b/docs/ideas/foundation-specs/scientific-formal-language-foundation-spec.md new file mode 100644 index 000000000..8e5a81c70 --- /dev/null +++ b/docs/ideas/foundation-specs/scientific-formal-language-foundation-spec.md @@ -0,0 +1,1029 @@ +# 科学形式化语言 Foundation Spec + +**文档状态**:Foundation Design Spec +**适用范围**:面向 Gaia / Scientific Formal Language 的长期语言内核、IR、编译器与科学知识包设计 +**建议文件名**:`docs/foundations/scientific-formal-language.md` +**核心不变量**:科学形式化语言不仅要表达公式,还要表达公式在什么条件下成立、由什么证据支持、如何测量、如何近似、如何复现、如何被推翻。 + +--- + +## 1. 文档目标 + +本文档定义一门科学形式化语言的基础设计。它不是某个具体实现版本的 API 文档,而是面向长期演化的 foundation spec,用来约束后续 Gaia Lang、Gaia IR、科学知识包、概率后端、实验后端和审查工作流。 + +这门语言的目标不是替代数学、LaTeX、Python、Lean、R 或实验记录系统,而是把它们连接起来,让科学知识具备以下性质: + +1. **可表达**:能表达对象、属性、关系、过程、模型、实验、数据、假设、适用范围和证据。 +2. **可检查**:能检查类型、量纲、单位、作用域、定义、前提和模型适用条件。 +3. **可计算**:能把部分模型 lower 到数值仿真、符号推导、概率推理或验证工具。 +4. **可审查**:每个 claim、support、measurement、inference 都能追踪来源、状态和依赖。 +5. **可更新**:科学结论不是一次性真理,而是在信息状态变化时可被更新的命题。 + +一句话目标: + +```text +A language for representing scientific claims, models, measurements, evidence, and inference under explicit assumptions and contexts. +``` + +--- + +## 2. 设计原则 + +### 2.1 区分真实对象、模型对象与观测对象 + +科学表达中最常见的错误是把三种东西混在一起: + +```text +RealObject 真实世界中的对象 +ModelObject 模型中的理想化对象 +Observation 某次实验或仪器给出的观测记录 +``` + +例如: + +```text +Earth # 真实对象 +Earth_in_NewtonianModel # 模型对象 +Observation_2026_04_23_mass # 一次观测或估计记录 +``` + +语言必须允许分别表达: + +```text +Mass(Earth) # 真实属性,通常未知 +Mass(Earth_in_NewtonianModel) = 1 unit # 模型内归一化参数 +ObservedMass(obs_1) = 5.972e24 kg # 观测结果 +``` + +### 2.2 每个数量都应有量纲和单位 + +科学语言中不应允许: + +```text +3 kg + 5 seconds +Temperature = red +Force = 10 meters +``` + +最低要求是实现: + +```text +Quantity +Dimension +Unit +UnitSystem +UnitConversion +DimensionalCheck +``` + +### 2.3 每个模型都必须声明假设和适用范围 + +科学模型不是无条件真理。模型应至少包含: + +```text +assumptions +validity_range +scale +boundary_conditions +initial_conditions +failure_conditions +approximation_level +``` + +例如: + +```text +model NewtonianMechanics: + assumptions: + velocity << speed_of_light + gravitational_field is weak + quantum_effects are negligible +``` + +### 2.4 测量不是事实,而是带误差模型的证据 + +测得 `10.4 g` 不等于真实质量就是 `10.4 g`。正确表达应为: + +```text +ObservedMass = TrueMass + MeasurementError +MeasurementError ~ ErrorModel +``` + +语言必须支持: + +```text +instrument +calibration +resolution +random_error +systematic_error +uncertainty +confidence_or_credible_interval +measurement_protocol +``` + +### 2.5 定义、假设、经验命题和定律必须分开 + +以下对象语义不同: + +```text +Definition 约定或定义,如 BMI = mass / height^2 +Assumption 当前上下文中暂时接受的前提 +EmpiricalClaim 基于数据的经验命题 +Law 理论内部或经验归纳的规律 +Model 带参数、方程、条件和误差结构的表示 +Theorem 在形式系统内可证明的命题 +Observation 观测记录 +Evidence 用于更新命题可信度的数据或事实 +``` + +混淆这些类别会导致系统无法解释为什么某个结论成立。 + +### 2.6 概率与不确定性必须条件化到信息状态 + +语言可以支持概率,但不应鼓励裸概率: + +```text +P(H) = 0.8 # 不推荐 +``` + +应写成: + +```text +P(H | Context_I) = 0.8 +``` + +概率层属于独立后端,但基础语言必须为其预留: + +```text +Proposition +InformationContext +Evidence +Likelihood +Prior +Posterior +BeliefState +``` + +--- + +## 3. 范围与非目标 + +### 3.1 范围 + +本文档覆盖: + +```text +Core objects +Type system +Quantity and unit system +Logical propositions +Scientific models +Measurements +Experiments +Evidence and provenance +Uncertainty hooks +IR and compiler layers +Validation and audit +Gaia mapping +``` + +### 3.2 非目标 + +本文档不要求一次性实现: + +```text +完整一阶逻辑证明器 +完整连续概率编程语言 +完整自然语言 parser +全自动科学发现系统 +所有科学领域的本体库 +与所有数据格式的完整互操作 +``` + +MVP 应优先稳定: + +```text +typed claims +actions +contexts +measurements +units +model assumptions +evidence/provenance hooks +``` + +--- + +## 4. 分层架构 + +推荐架构: + +```text +Scientific Formal Language +│ +├── Surface Language Layer +│ ├── Python internal DSL +│ ├── controlled natural language +│ └── domain-specific syntax sugar +│ +├── Core Semantic Layer +│ ├── entity +│ ├── type +│ ├── proposition +│ ├── definition +│ ├── context +│ └── action +│ +├── Mathematical Layer +│ ├── expressions +│ ├── equations +│ ├── functions +│ ├── calculus +│ ├── probability hooks +│ └── optimization hooks +│ +├── Scientific Quantity Layer +│ ├── dimensions +│ ├── units +│ ├── constants +│ ├── uncertainty +│ └── measurement models +│ +├── Model Layer +│ ├── assumptions +│ ├── parameters +│ ├── equations +│ ├── validity ranges +│ ├── residual models +│ └── simulations +│ +├── Empirical Layer +│ ├── observations +│ ├── datasets +│ ├── experiments +│ ├── instruments +│ └── protocols +│ +├── Evidence and Review Layer +│ ├── provenance +│ ├── evidence factors +│ ├── review status +│ ├── conflict tracking +│ └── audit trails +│ +├── IR Layer +│ ├── normalized claims +│ ├── operators +│ ├── actions +│ ├── contexts +│ └── lowering contracts +│ +└── Backend Layer + ├── probabilistic inference + ├── symbolic math + ├── numerical simulation + ├── theorem proving + └── data validation +``` + +核心原则:**表层语言可以友好,核心语义必须严格。** + +--- + +## 5. 核心对象模型 + +### 5.1 Entity + +`Entity` 表示可被谈论的对象。 + +```text +Entity: + id + type + label + metadata +``` + +例子: + +```text +entity Earth : Planet +entity sample_A : SoilSample +entity Trial_001 : Experiment +``` + +### 5.2 Type + +`Type` 表示分类和约束。 + +```text +Type: + name + parent_types + constraints + metadata +``` + +例子: + +```text +Particle +Body +Sample +Dataset +Experiment +Model +Quantity[Mass] +Quantity[Temperature] +``` + +### 5.3 Quantity + +`Quantity` 表示带量纲的物理或科学量。 + +```text +Quantity: + value + unit + dimension + uncertainty optional +``` + +例子: + +```text +5.0 kg +298.15 K +9.8 m/s^2 +``` + +### 5.4 Proposition + +`Proposition` 表示可以为真或假的命题。 + +```text +Proposition := + Predicate(terms) + Equality(term, term) + Inequality(term, term) + Not(Proposition) + And(Proposition, Proposition) + Or(Proposition, Proposition) + Implies(Proposition, Proposition) + Forall(variable, type, Proposition) + Exists(variable, type, Proposition) +``` + +例子: + +```text +Mass(sample_A) > 10 g +Drug_A reduces BloodPressure +ModelAdequate(SIR_Model, Dataset_D) +``` + +### 5.5 Claim + +`Claim` 是带身份、可审查、可进入推理图的命题对象。 + +```text +Claim: + id + proposition + prior optional + grounding optional + parameters optional + provenance optional + review_status optional +``` + +在 Gaia 中,`Claim` 应是 belief variable 的主入口。 + +### 5.6 Definition + +`Definition` 表示约定性等式或构造规则。 + +```text +definition BMI(person): + BMI(person) = Mass(person) / Height(person)^2 +``` + +定义不是经验发现,不应被 evidence 更新。 + +### 5.7 Model + +`Model` 表示科学模型。 + +```text +Model: + parameters + equations + assumptions + initial_conditions + boundary_conditions + validity_conditions + residual_model optional +``` + +### 5.8 Observation + +`Observation` 表示实际观测或测量记录。 + +```text +Observation: + target + observed_value + uncertainty + instrument + protocol + timestamp + provenance +``` + +### 5.9 Experiment + +`Experiment` 表示一组有设计目的的观测。 + +```text +Experiment: + hypothesis + population + intervention + control + randomization + blinding + outcome + protocol + dataset +``` + +### 5.10 InformationContext + +`InformationContext` 表示一组背景信息。 + +```text +InformationContext: + definitions + assumptions + claims + observations + models + priors + evidence + review_state + dependency_contexts +``` + +概率和推理应总是相对于某个 context。 + +--- + +## 6. 类型系统与量纲系统 + +### 6.1 类型系统目标 + +类型系统应至少实现: + +```text +entity type checking +quantity type checking +function signature checking +operator compatibility checking +unit conversion checking +proposition well-formedness checking +``` + +### 6.2 量纲代数 + +基础量纲: + +```text +Length +Mass +Time +Temperature +Amount +Current +Luminosity +Information optional +``` + +派生量纲: + +```text +Velocity = Length / Time +Acceleration = Length / Time^2 +Force = Mass * Length / Time^2 +Energy = Mass * Length^2 / Time^2 +Pressure = Mass / (Length * Time^2) +``` + +### 6.3 单位转换 + +语言应区分: + +```text +unit representation +unit conversion +dimension equivalence +``` + +例如: + +```text +1 N = 1 kg*m/s^2 +1 J = 1 N*m +``` + +### 6.4 密度单位 + +概率无量纲,但概率密度有单位。 + +```text +X: Length +p_X(x): 1 / Length +``` + +因此,概率后端必须能识别: + +```text +P(X in [1m, 2m]) # probability +p_X(1.5m) # density, unit 1/m +``` + +--- + +## 7. 逻辑层设计 + +### 7.1 逻辑连接词 + +支持: + +```text +not +and +or +implies +equivalent +contradicts +exclusive +forall +exists +``` + +### 7.2 Operator 与 Claim 分离 + +关系操作符不应自动等同于事实本身。 + +```text +contradicts(A, B) +``` + +可以表示一个关系声明,也可以表示一个已接受的逻辑约束。推荐方式: + +```text +RelationClaim R: A contradicts B under scope S +Operator: if R is active, enforce contradiction(A, B) +``` + +这样可以处理科学中常见的“表面矛盾”:不同定义、不同尺度、不同实验条件、不同单位或不同模型域。 + +### 7.3 硬逻辑与软证据 + +语言应区分: + +```text +hard entailment 逻辑或定义上必然 +soft support 科学上提供支持,但非必然 +evidence likelihood 数据通过似然更新命题 +``` + +--- + +## 8. 模型语义 + +### 8.1 精确模型 + +精确模型把方程当作约束。 + +```text +model ExactSpring: + F = -k*x +``` + +语义: + +```text +P(F = -k*x | Model, assumptions) = 1 +``` + +仅适用于定义、理想模型或严格理论内部推导。 + +### 8.2 近似模型 + +科学应用中更常见的是近似模型。 + +```text +model SpringApprox: + F = -k*x + ε + ε ~ Normal(0, σ_model) +``` + +语义: + +```text +ObservedForce is likely near -k*x under model assumptions. +``` + +### 8.3 适用范围 + +模型应声明: + +```text +valid_for +invalid_for +scale +precision +boundary_conditions +initial_conditions +``` + +例如: + +```text +model IdealGas: + equation: P*V = n*R*T + validity: + low_pressure + high_temperature + failure_conditions: + near_phase_transition + high_density +``` + +--- + +## 9. 测量语义 + +### 9.1 True quantity 与 observed quantity + +推荐区分: + +```text +TrueMass(sample_A) +ObservedMass(measurement_m1) +``` + +一次测量: + +```text +measurement m1: + target: TrueMass(sample_A) + observed_value: 10.4 g + error_model: Normal(0 g, 0.2 g) +``` + +编译成: + +```text +ObservedMass_m1 = TrueMass(sample_A) + ε +ε ~ Normal(0 g, 0.2 g) +``` + +### 9.2 仪器与校准 + +测量应支持: + +```text +instrument_id +calibration_id +resolution +operator +protocol +environmental_conditions +``` + +### 9.3 系统误差 + +系统误差不应被隐藏在随机误差中: + +```text +Observed = True + Bias + RandomError +Bias unknown or calibrated +``` + +--- + +## 10. 证据与来源 + +### 10.1 Provenance + +每个 claim、model、observation、evidence factor 应能携带: + +```text +source_id +author +created_at +version +method +dataset_id +instrument_id +license +review_status +``` + +### 10.2 Evidence + +证据不是“置信度字段”。证据应说明: + +```text +what was observed +which claim it updates +under which model +with what likelihood +under which assumptions +``` + +示例: + +```text +EvidenceFactor: + target: DiseasePresent(patient) + data: TestPositive(patient) + model: DiagnosticTestModel + P(data | target) = sensitivity + P(data | not target) = false_positive_rate +``` + +### 10.3 冲突证据 + +系统必须允许: + +```text +evidence supports H +evidence contradicts H +evidence is inconclusive about H +models disagree +claims conflict under context +``` + +科学知识库不应假设全局无矛盾。 + +--- + +## 11. 实验与数据协议 + +### 11.1 Experiment object + +```text +experiment Trial_001: + hypothesis: Drug_A reduces SBP + population: AdultsWithHypertension + randomization: true + blind: double + treatment: Drug_A + control: placebo + outcome: SystolicBloodPressure + duration: 12 weeks + dataset: Trial_001_Data +``` + +### 11.2 Dataset object + +```text +Dataset: + schema + rows + variables + units + missingness + inclusion_criteria + exclusion_criteria + preprocessing + provenance +``` + +### 11.3 Analysis object + +```text +Analysis: + dataset + model + assumptions + estimand + method + result + diagnostics +``` + +--- + +## 12. Surface language 与 Core IR + +### 12.1 表层语言 + +表层语言可以采用 Python internal DSL: + +```python +class TemperatureAbove(Claim): + """Temperature of {sample} is above {threshold}.""" + sample: Sample + threshold: Quantity +``` + +### 12.2 核心 IR + +核心 IR 应更严格: + +```json +{ + "type": "Claim", + "predicate": "TemperatureAbove", + "parameters": { + "sample": "sample_A", + "threshold": {"value": 5000, "unit": "K"} + } +} +``` + +### 12.3 编译流程 + +```text +Surface DSL + -> AST / runtime objects + -> type check + -> unit check + -> normalized propositions + -> IR graph + -> review manifest + -> lowering to backend graph + -> inference / validation / simulation + -> BeliefState / Report +``` + +--- + +## 13. 与 Gaia 的映射 + +推荐映射: + +```text +Scientific Claim -> Gaia Claim +Definition -> Derive / Compute / Operator +Observation -> Observe action +Evidence likelihood -> Infer action / EvidenceMetadata +Model assumption -> Claim or Setting +Experiment -> structured metadata / future object +InformationContext -> BeliefContext +Posterior belief -> BeliefState +Review state -> ReviewManifest +``` + +Gaia 的核心方向应是: + +```text +Claim-centered, action-backed, review-gated, context-indexed scientific reasoning. +``` + +--- + +## 14. 最小可行语言内核 + +MVP 应实现: + +```text +1. Claim as belief variable +2. typed parameters +3. Quantity and Unit metadata +4. Observe action +5. Infer / likelihood evidence action +6. Derive / Compute action +7. Equal / Contradict / Exclusive operator +8. ReviewManifest gating +9. BeliefContext +10. BeliefState +11. Provenance metadata +12. unit and parameter validation +``` + +MVP 不必实现完整一阶逻辑,也不必实现完整概率编程。 + +--- + +## 15. 示例:力学模型 + +```text +module Mechanics + +entity Body +quantity Mass(Body): Mass +quantity Position(Body, Time): Length +quantity Velocity(Body, Time): Length / Time +quantity Acceleration(Body, Time): Length / Time^2 +quantity Force(Body, Time): Mass * Length / Time^2 + +definition Velocity(b, t): + Velocity(b, t) = d(Position(b, t)) / dt + +definition Acceleration(b, t): + Acceleration(b, t) = d(Velocity(b, t)) / dt + +model NewtonSecondLaw: + equation: Force(b, t) = Mass(b) * Acceleration(b, t) + assumptions: + velocity(b,t) << speed_of_light + quantum_effects_negligible(b) +``` + +测量: + +```text +measurement m1: + target: Force(cart_1, t0) + observed_value: 9.81 N + error_model: Normal(0 N, 0.05 N) + instrument: force_sensor_01 +``` + +查询: + +```text +P(NewtonSecondLawAdequate(cart_1_experiment) | context_with_m1) +``` + +--- + +## 16. 验证与错误系统 + +语言应至少检查: + +```text +[ ] 未声明单位的数量 +[ ] 量纲不一致的加法/等式 +[ ] 连续变量单点概率误用 +[ ] 裸概率 P(A) 未指定 context +[ ] 模型无适用范围 +[ ] measurement 被当成 true value +[ ] evidence 缺少 source_id +[ ] observed evidence 缺少 review status +[ ] relation operator 未说明 scope +[ ] repeated evidence 可能重复计数 +``` + +--- + +## 17. 路线图 + +```text +Phase 1: Propositional scientific claims +Phase 2: Evidence and context contracts +Phase 3: Quantity / Unit / Measurement layer +Phase 4: Model and experiment objects +Phase 5: Cross-package scientific knowledge composition +Phase 6: Hybrid symbolic/probabilistic/numeric backends +Phase 7: Domain ontology libraries +``` + +--- + +## 18. Acceptance checklist + +本 foundation spec 被实现时,应满足: + +```text +[ ] Claim 可以结构化参数化 +[ ] Quantity 带 unit / dimension +[ ] Observe 不等于 true fact,而是 observation record +[ ] Infer 表示 likelihood,不表示 posterior +[ ] Model 声明 assumptions / validity +[ ] Definition 与 empirical claim 分离 +[ ] 每个 posterior 有 context_id +[ ] 每个 evidence 有 provenance +[ ] review status gate information, not probability +[ ] 输出 BeliefState 可复现 +[ ] IR 与 backend 解耦 +``` + +--- + +## 19. 推荐参考方向 + +这些不是实现依赖,而是理论背景: + +```text +E. T. Jaynes, Probability Theory: The Logic of Science +R. T. Cox, The Algebra of Probable Inference +Claude Shannon, A Mathematical Theory of Communication +Judea Pearl, Causality +David MacKay, Information Theory, Inference, and Learning Algorithms +SBML / OWL / RDF / Lean / Coq / PPL ecosystems +``` + +--- + +## 20. One-line invariant + +```text +A scientific formal language must represent not only what is claimed, but under what assumptions, measurements, models, evidence, units, contexts, and review states the claim is meaningful. +``` diff --git a/docs/ideas/gaia-upgrade-specs/00-index.md b/docs/ideas/gaia-upgrade-specs/00-index.md new file mode 100644 index 000000000..eb3142e86 --- /dev/null +++ b/docs/ideas/gaia-upgrade-specs/00-index.md @@ -0,0 +1,67 @@ +# Gaia Upgrade Specs Index + +本文档集把前面讨论过的 Gaia 升级路线拆成可执行的 Markdown spec。每个版本都按相同结构组织:目标、范围、数据契约、API/CLI 变更、实现任务、测试、验收标准和非目标。 + +Status: idea-stage draft, imported under `docs/ideas/gaia-upgrade-specs/`. + +## 版本列表 + +| 文件 | 版本 | 主题 | 目标摘要 | +|---|---:|---|---| +| `01-v0.5.x-contract-freeze.md` | v0.5.x | Contract Freeze | 以 v0.5 代码事实为准,冻结当前语义、版本矩阵和 golden snapshots。 | +| `02-v0.6-evidence-contract.md` | v0.6 | Evidence Contract | 把 `InferAction` 收敛成正式 likelihood evidence contract,并引入 contexted belief output。 | +| `03-v0.7-evidence-model-adapters.md` | v0.7 | Evidence Model Adapters | 加入常见科学证据模型 adapter:binomial、two-binomial、Gaussian measurement、Bayes factor。 | +| `04-v0.8-context-reproducibility.md` | v0.8 | Context Reproducibility | 把 `InformationContext` / `BeliefState` 做成可重放、可 diff、可锁定的 reproducibility contract。 | +| `05-v0.9-quantity-unit-measurement.md` | v0.9 | Quantity / Unit / Measurement | 给 Gaia 加入最小科学量、单位、误差和测量模型语义。 | +| `06-v0.10-explain-sensitivity-audit.md` | v0.10 | Explain / Sensitivity / Audit | 从“算 posterior”升级为“解释 posterior、诊断脆弱性、审计证据”。 | +| `07-v0.11-cross-package-reasoning.md` | v0.11 | Cross-package Reasoning | 稳定跨 package 推理、dependency contexts、foreign claim mapping 和重复证据控制。 | +| `08-v1.0-stable-kernel.md` | v1.0 | Stable Kernel | 发布稳定的 claim-centered、action-backed、review-gated Jaynesian propositional reasoning kernel。 | +| `09-python-ecosystem-integration-spec.md` | v0.6-v1.0 | Python Ecosystem Integration | 规定成熟 Python 包只能通过 adapter 提供计算、验证、图算法、概率后端、单位和数据互操作,不能定义 Gaia 语义。 | + +## 总体设计不变量 + +以下不变量跨版本保持稳定: + +```text +Claim 是唯一 belief variable。 +Action 是作者声明的推理动作,不是概率变量。 +InferAction / EvidenceFactor 表示 likelihood,不表示 posterior。 +Observe 表示 evidence 是否进入当前信息状态 I。 +ReviewManifest 决定哪些 action/factor 激活,而不是给 action 提供概率。 +BeliefState 记录 P(Claim | Context) 和推理 provenance。 +BP / JT / GBP / exact 只是计算后端,不定义 Gaia 的最高语义。 +``` + +## 当前目录结构 + +当前先作为 idea-stage drafts 放入: + +```text +docs/ideas/gaia-upgrade-specs/ + 00-index.md + 01-v0.5.x-contract-freeze.md + 02-v0.6-evidence-contract.md + 03-v0.7-evidence-model-adapters.md + 04-v0.8-context-reproducibility.md + 05-v0.9-quantity-unit-measurement.md + 06-v0.10-explain-sensitivity-audit.md + 07-v0.11-cross-package-reasoning.md + 08-v1.0-stable-kernel.md + 09-python-ecosystem-integration-spec.md +``` + +Promotion target, once accepted and aligned with implementation, may be +`docs/specs/` or the relevant `docs/foundations/` subtrees. + +## 建议执行顺序 + +```text +P0. v0.5.x: contract freeze + golden snapshots +P1. v0.6: evidence contract + minimal contexted BeliefState +P2. v0.7: evidence adapters +P3. v0.8: reproducible context replay / diff / lock +P4. v0.9: quantity / unit / measurement +P5. v0.10: explain / sensitivity / audit +P6. v0.11: cross-package reasoning +P7. v1.0: stable kernel release +``` diff --git a/docs/ideas/gaia-upgrade-specs/01-v0.5.x-contract-freeze.md b/docs/ideas/gaia-upgrade-specs/01-v0.5.x-contract-freeze.md new file mode 100644 index 000000000..21f2db2a5 --- /dev/null +++ b/docs/ideas/gaia-upgrade-specs/01-v0.5.x-contract-freeze.md @@ -0,0 +1,210 @@ +# Gaia v0.5.x Spec: Contract Freeze and Code-Truth Stabilization + +## 1. Release theme + +```text +Gaia v0.5.x — Contract Freeze Release +``` + +v0.5.x 的目标不是扩功能,而是把 v0.5 branch 的真实代码语义冻结成可测试、可 diff、可维护的 contract。后续 v0.6+ 的语义变更都必须以这些 snapshots 和 specs 为基准。 + +## 2. Release goals + +v0.5.x 必须完成: + +```text +1. 版本矩阵对齐。 +2. 当前 runtime / compiler / IR / BP / CLI 事实状态文档化。 +3. 为核心 Gaia Lang examples 建立 golden snapshots。 +4. 明确哪些文档是 implemented,哪些是 target design,哪些已经 outdated。 +5. 建立 contract tests,防止后续 PR 无意改变语义。 +``` + +## 3. Scope + +### In scope + +```text +Knowledge / Claim / Note / Question runtime contract +Action hierarchy: Support / Derive / Observe / Compute / Relate / Equal / Contradict / Exclusive / Infer +Compiler: Gaia Lang runtime objects -> Gaia IR +ReviewManifest generation and review gating +BP lowering and inference method selection +CLI outputs: IR, manifest, beliefs, inquiry tree +Package version and language/runtime version metadata +Golden snapshot fixtures +``` + +### Out of scope + +```text +新增 evidence adapter +新增 context replay +新增 quantity/unit system +新增 cross-package reasoning semantics +修改 relation/operator semantics +删除 legacy infer compatibility path +``` + +## 4. Version matrix contract + +Add or update: + +```text +docs/status.md +gaia/version.py +pyproject.toml +``` + +`docs/status.md` must include: + +```markdown +# Gaia Status + +## Versions + +| Component | Version | Status | +|---|---:|---| +| Python package | 0.5.x | implemented | +| Gaia Lang runtime | v6-runtime-preview | implemented | +| Gaia IR | v0.5 contract | implemented | +| BP backend | v0.5 | implemented | +| CLI | v0.5 | implemented | + +## Implemented contracts +... + +## Target-design-only docs +... + +## Deprecated docs / APIs +... +``` + +## 5. Code-truth spec files + +Create: + +```text +docs/specs/v0.5-code-truth/ + knowledge-runtime.md + action-runtime.md + compiler-contract.md + review-contract.md + bp-lowering-contract.md + cli-output-contract.md +``` + +Each file should contain: + +```text +1. Current implemented behavior. +2. Public API surface. +3. Internal implementation notes. +4. Known limitations. +5. Tests that protect the contract. +``` + +## 6. Golden snapshot framework + +Add: + +```text +tests/snapshots/v0_5_x/ +``` + +Each fixture should include: + +```text +package/ # source DSL package +expected/ir.json # compiled Gaia IR +expected/review_manifest.json # review targets +expected/factor_graph.json # optional normalized factor graph summary +expected/beliefs.json # current beliefs output +expected/inquiry.txt # rendered inquiry tree if applicable +``` + +Normalize nondeterministic fields: + +```text +generated_at +elapsed_ms +absolute paths +random temp directory names +ordering of dictionary keys +``` + +## 7. Required fixtures + +Minimum fixtures: + +```text +01_basic_claim_prior +02_derive_action +03_observe_action +04_compute_action +05_infer_action_raw_likelihood +06_equal_operator +07_contradict_operator +08_exclusive_operator +09_parameterized_claim +10_review_gated_unreviewed +11_review_gated_accepted +12_cross_package_flat_prior_depth_0 +13_cross_package_joint_depth_1 +``` + +## 8. Contract tests + +Add: + +```text +tests/gaia/test_version_contract.py +tests/gaia/snapshots/test_v0_5_x_snapshots.py +``` + +Example tests: + +```python +def test_package_version_matches_status_doc(): + ... + + +def test_gaia_lang_runtime_version_exposed(): + ... + + +def test_snapshot_basic_claim_prior(): + ... +``` + +## 9. Acceptance checklist + +v0.5.x is done when: + +```text +[ ] pyproject package version no longer contradicts branch/release label. +[ ] docs/status.md exists and distinguishes implemented vs target-design-only. +[ ] Code-truth specs exist for runtime, compiler, review, BP, CLI. +[ ] Golden snapshot runner exists. +[ ] Minimum 13 fixtures pass. +[ ] Existing v0.5 tests pass. +[ ] Snapshot normalization ignores nondeterministic fields. +[ ] CI fails when compiled IR or belief output changes unexpectedly. +``` + +## 10. Non-goals + +```text +Do not introduce new EvidenceMetadata. +Do not change InferAction semantics yet. +Do not introduce BeliefContext yet. +Do not add scientific evidence adapters yet. +Do not redesign Claim identity/hash yet. +``` + +## 11. One-line invariant + +```text +v0.5.x freezes Gaia's current implemented semantics so future releases can intentionally evolve them instead of accidentally drifting. +``` diff --git a/docs/ideas/gaia-upgrade-specs/02-v0.6-evidence-contract.md b/docs/ideas/gaia-upgrade-specs/02-v0.6-evidence-contract.md new file mode 100644 index 000000000..adfaa8846 --- /dev/null +++ b/docs/ideas/gaia-upgrade-specs/02-v0.6-evidence-contract.md @@ -0,0 +1,510 @@ +# Gaia v0.6 Spec: Evidence Contract + Contexted BeliefState + +## 1. Release theme + +```text +Gaia v0.6 — Evidence Contract Release +``` + +v0.6 的目标是把 v0.5 中已经存在的 `InferAction` 收敛成稳定的 likelihood-based scientific evidence contract,并让每个 belief output 明确成为: + +```text +P(Claim | Context) +``` + +而不是裸 posterior。 + +## 2. Normative semantics + +### 2.1 Claim-only belief variables + +```text +Claim -> may enter BP as belief variable +Note -> must not enter BP +Question -> must not enter BP +Action -> must not enter BP as variable +Review -> must not enter BP as probability +``` + +### 2.2 InferAction means likelihood + +For: + +```text +H = hypothesis claim +E = evidence claim +``` + +`InferAction(H, E, p_e_given_h, p_e_given_not_h)` means: + +```text +P(E=true | H=true, I) = p_e_given_h +P(E=true | H=false, I) = p_e_given_not_h +``` + +It does **not** mean: + +```text +P(H | E) +E proves H +H supports E as posterior +``` + +### 2.3 Declaring likelihood does not observe evidence + +Required invariant: + +```text +infer(E, hypothesis=H, ...) does not automatically make E true. +``` + +To observe evidence, user must explicitly call: + +```python +observe(E) +``` + +or use: + +```python +likelihood(E, hypothesis=H, ..., observed=True) +``` + +### 2.4 Review gates information, not probability + +```text +ACCEPTED -> action/factor may enter context I +UNREVIEWED -> excluded from context I by default +REJECTED -> excluded from context I +``` + +Review status must never mean numeric priors. + +## 3. Data contracts + +### 3.1 EvidenceMetadata + +Add: + +```text +gaia/lang/runtime/evidence.py +``` + +```python +class EvidenceMetadata(BaseModel): + schema_version: Literal["gaia.evidence.v1"] = "gaia.evidence.v1" + + evidence_kind: Literal[ + "raw_likelihood", + "likelihood_ratio", + "bayes_factor", + ] + + source_id: str | None = None + data_id: str | None = None + model_id: str | None = None + independence_group: str | None = None + + assumptions: list[str] = Field(default_factory=list) + query: dict[str, Any] | None = None + + p_e_given_h: float | None = None + p_e_given_not_h: float | None = None + likelihood_ratio: float | None = None + bayes_factor: float | None = None + + observed: bool = False + generated_from: str | None = None +``` + +Validation rules: + +```text +raw_likelihood requires p_e_given_h and p_e_given_not_h. +likelihood_ratio requires likelihood_ratio. +bayes_factor requires bayes_factor and likelihood_ratio. +``` + +Store metadata at: + +```python +InferAction.metadata["evidence"] +IrStrategy.metadata["evidence"] +``` + +### 3.2 BeliefContext + +Add: + +```text +gaia/ir/context.py +``` + +```python +class BeliefContext(BaseModel): + schema_version: Literal["gaia.context.v1"] = "gaia.context.v1" + + namespace: str + package_name: str + + ir_hash: str + gaia_lang_version: str + gaia_ir_version: str | None = None + package_version: str | None = None + + review_state: ReviewStateRecord + prior_resolution: PriorResolutionRecord + dependencies: list[DependencyContextRecord] = Field(default_factory=list) + inference: InferenceEngineConfigRecord + + active_evidence_ids: list[str] = Field(default_factory=list) + observed_claim_ids: list[str] = Field(default_factory=list) + + metadata: dict[str, Any] = Field(default_factory=dict) + context_id: str +``` + +`context_id` is stable SHA-256 of canonical JSON excluding `context_id` itself. + +Must change when: + +```text +IR hash changes +review state changes +prior source/policy changes +dependency context changes +observed evidence set changes +inference config changes +``` + +Must not change because of: + +```text +elapsed_ms +output path +display formatting +generated_at timestamp +``` + +### 3.3 BeliefState + +Add: + +```text +gaia/ir/belief_state.py +``` + +```python +class BeliefState(BaseModel): + schema_version: Literal["gaia.belief_state.v1"] = "gaia.belief_state.v1" + context_id: str + context: BeliefContext + beliefs: list[BeliefRecord] + diagnostics: InferenceDiagnosticsRecord + generated_at: str +``` + +Output: + +```text +.gaia/belief_state.json +.gaia/context.json +.gaia/beliefs.json # backwards-compatible, now includes context_id +``` + +## 4. DSL API + +### 4.1 Existing infer remains valid + +```python +infer( + evidence, + hypothesis=h, + p_e_given_h=0.9, + p_e_given_not_h=0.2, + rationale="...", + label="...", +) +``` + +New kwargs: + +```python +source_id: str | None +data_id: str | None +model_id: str | None +independence_group: str | None +assumptions: list[str] | None +observed: bool = False +query: dict | None +``` + +### 4.2 likelihood helper + +```python +def likelihood( + evidence: Claim | str, + *, + hypothesis: Claim, + p_e_given_h: float, + p_e_given_not_h: float, + observed: bool = False, + rationale: str = "", + label: str | None = None, + source_id: str | None = None, + data_id: str | None = None, + model_id: str | None = None, + independence_group: str | None = None, + assumptions: list[str] | None = None, + query: dict | None = None, +) -> Claim: ... +``` + +### 4.3 likelihood_ratio helper + +```python +def likelihood_ratio( + evidence: Claim | str, + *, + hypothesis: Claim, + lr: float, + observed: bool = False, + ... +) -> Claim: ... +``` + +Compile to CPT pair: + +```text +p_e_given_h = lr / (1 + lr) +p_e_given_not_h = 1 / (1 + lr) +``` + +Preserve original LR in metadata. + +### 4.4 bayes_factor helper + +```python +def bayes_factor( + evidence: Claim | str, + *, + hypothesis: Claim, + bf: float, + observed: bool = False, + ... +) -> Claim: ... +``` + +Implementation: + +```python +return likelihood_ratio(..., lr=bf, ...) +``` + +## 5. Compiler requirements + +Modify `_compile_infer_action`: + +```text +1. Preserve existing Strategy(type="infer") behavior. +2. Normalize EvidenceMetadata into strategy.metadata["evidence"]. +3. Keep conditional_probabilities = [P(E|¬H), P(E|H)]. +4. If observed=True, ensure Observe action exists or is generated by DSL. +5. Stable review labels for infer action and observed action. +``` + +Recommended label convention: + +```text +