noodlelab 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- noodlelab/__init__.py +64 -0
- noodlelab/__main__.py +3 -0
- noodlelab/agent/__init__.py +138 -0
- noodlelab/agent/guide.md +129 -0
- noodlelab/agent/mcp.py +219 -0
- noodlelab/agent/tools.py +613 -0
- noodlelab/cli.py +934 -0
- noodlelab/config.py +221 -0
- noodlelab/core/__init__.py +1 -0
- noodlelab/core/about.py +212 -0
- noodlelab/core/baseline.py +363 -0
- noodlelab/core/bundle.py +148 -0
- noodlelab/core/cache.py +66 -0
- noodlelab/core/checkpoint.py +230 -0
- noodlelab/core/checks.py +92 -0
- noodlelab/core/codecs.py +426 -0
- noodlelab/core/context.py +113 -0
- noodlelab/core/convert.py +224 -0
- noodlelab/core/events.py +185 -0
- noodlelab/core/executor.py +1693 -0
- noodlelab/core/export.py +405 -0
- noodlelab/core/figures.py +110 -0
- noodlelab/core/files.py +577 -0
- noodlelab/core/fold.py +121 -0
- noodlelab/core/graph.py +510 -0
- noodlelab/core/health.py +462 -0
- noodlelab/core/meta.py +164 -0
- noodlelab/core/monitor.py +142 -0
- noodlelab/core/montecarlo.py +306 -0
- noodlelab/core/node.py +795 -0
- noodlelab/core/ports.py +461 -0
- noodlelab/core/preview.py +201 -0
- noodlelab/core/probe.py +989 -0
- noodlelab/core/provenance.py +477 -0
- noodlelab/core/registry.py +278 -0
- noodlelab/core/reqlog.py +138 -0
- noodlelab/core/requirements.py +430 -0
- noodlelab/core/rundiff.py +168 -0
- noodlelab/core/sample.py +144 -0
- noodlelab/core/studies.py +309 -0
- noodlelab/core/suggest.py +144 -0
- noodlelab/core/tracked.py +161 -0
- noodlelab/core/types.py +107 -0
- noodlelab/core/typesys.py +706 -0
- noodlelab/core/uncertainty.py +421 -0
- noodlelab/core/units.py +230 -0
- noodlelab/examples/01-getting-started.graph.json +71 -0
- noodlelab/examples/02-units.graph.json +113 -0
- noodlelab/examples/03-plot-a-function.graph.json +77 -0
- noodlelab/examples/04-noise-and-smoothing.graph.json +156 -0
- noodlelab/examples/05-first-csv.graph.json +87 -0
- noodlelab/examples/06-straight-line-fit.graph.json +195 -0
- noodlelab/examples/07-filter-and-group.graph.json +133 -0
- noodlelab/examples/08-calibration.graph.json +276 -0
- noodlelab/examples/09-uncertainty.graph.json +211 -0
- noodlelab/examples/10-curve-fit-subgraph.graph.json +559 -0
- noodlelab/examples/11-time-series.graph.json +215 -0
- noodlelab/examples/12-signals.graph.json +196 -0
- noodlelab/examples/13-symbolic.graph.json +242 -0
- noodlelab/examples/14-first-report.graph.json +409 -0
- noodlelab/examples/15-repeat-zone.graph.json +264 -0
- noodlelab/examples/16-until-converged.graph.json +370 -0
- noodlelab/examples/17-sweep-and-optimize.graph.json +430 -0
- noodlelab/examples/18-gps-track.graph.json +208 -0
- noodlelab/examples/19-earthquakes.graph.json +173 -0
- noodlelab/examples/20-groundwater-nitrate.graph.json +2197 -0
- noodlelab/examples/21-fixed-beam.graph.json +2866 -0
- noodlelab/examples/22-watershed.graph.json +3279 -0
- noodlelab/examples/23-satellite-link.graph.json +2059 -0
- noodlelab/examples/24-cantilever-bracket.graph.json +591 -0
- noodlelab/examples/__init__.py +0 -0
- noodlelab/examples/data/05-first-csv/README.md +16 -0
- noodlelab/examples/data/05-first-csv/pendulum.csv +56 -0
- noodlelab/examples/data/06-straight-line-fit/README.md +16 -0
- noodlelab/examples/data/06-straight-line-fit/pendulum.csv +56 -0
- noodlelab/examples/data/07-filter-and-group/README.md +20 -0
- noodlelab/examples/data/07-filter-and-group/field_trial.csv +73 -0
- noodlelab/examples/data/08-calibration/README.md +11 -0
- noodlelab/examples/data/08-calibration/samples.csv +13 -0
- noodlelab/examples/data/08-calibration/standards.csv +25 -0
- noodlelab/examples/data/10-curve-fit-subgraph/README.md +17 -0
- noodlelab/examples/data/10-curve-fit-subgraph/kinetics.csv +49 -0
- noodlelab/examples/data/11-time-series/README.md +16 -0
- noodlelab/examples/data/11-time-series/station_daily.csv +5480 -0
- noodlelab/examples/data/12-signals/README.md +17 -0
- noodlelab/examples/data/12-signals/accelerometer.csv +10001 -0
- noodlelab/examples/data/14-first-report/README.md +16 -0
- noodlelab/examples/data/14-first-report/pendulum.csv +56 -0
- noodlelab/examples/data/18-gps-track/README.md +19 -0
- noodlelab/examples/data/18-gps-track/hike.csv +3526 -0
- noodlelab/examples/data/18-gps-track/waypoints.csv +6 -0
- noodlelab/examples/data/19-earthquakes/README.md +20 -0
- noodlelab/examples/data/19-earthquakes/catalog.csv +2012 -0
- noodlelab/examples/data/19-earthquakes/regions.geojson +1 -0
- noodlelab/examples/data/20-groundwater-nitrate/README.md +22 -0
- noodlelab/examples/data/20-groundwater-nitrate/districts.geojson +1 -0
- noodlelab/examples/data/20-groundwater-nitrate/river.geojson +1 -0
- noodlelab/examples/data/20-groundwater-nitrate/wells.csv +161 -0
- noodlelab/examples/data/21-fixed-beam/README.md +18 -0
- noodlelab/examples/data/21-fixed-beam/strain_gauges.csv +12 -0
- noodlelab/examples/data/22-watershed/README.md +20 -0
- noodlelab/examples/data/22-watershed/catchments.geojson +1 -0
- noodlelab/examples/data/22-watershed/dem.asc +156 -0
- noodlelab/examples/data/22-watershed/dem.prj +1 -0
- noodlelab/examples/data/22-watershed/nir.asc +156 -0
- noodlelab/examples/data/22-watershed/nir.prj +1 -0
- noodlelab/examples/data/22-watershed/rain_gauges.csv +13 -0
- noodlelab/examples/data/22-watershed/red.asc +156 -0
- noodlelab/examples/data/22-watershed/red.prj +1 -0
- noodlelab/examples/data/23-satellite-link/README.md +19 -0
- noodlelab/examples/data/23-satellite-link/radios.csv +9 -0
- noodlelab/nodes/__init__.py +1 -0
- noodlelab/nodes/checks.py +197 -0
- noodlelab/nodes/core.py +500 -0
- noodlelab/nodes/engineering/__init__.py +33 -0
- noodlelab/nodes/engineering/controls.py +181 -0
- noodlelab/nodes/engineering/decibels.py +85 -0
- noodlelab/nodes/engineering/fluids.py +83 -0
- noodlelab/nodes/engineering/materials.py +126 -0
- noodlelab/nodes/engineering/sections.py +120 -0
- noodlelab/nodes/engineering/structures.py +217 -0
- noodlelab/nodes/engineering/thermal.py +80 -0
- noodlelab/nodes/engineering/trade.py +88 -0
- noodlelab/nodes/geo/__init__.py +31 -0
- noodlelab/nodes/geo/plot.py +353 -0
- noodlelab/nodes/geo/raster.py +837 -0
- noodlelab/nodes/geo/seismology.py +128 -0
- noodlelab/nodes/geo/track.py +151 -0
- noodlelab/nodes/geo/types.py +378 -0
- noodlelab/nodes/geo/vector.py +440 -0
- noodlelab/nodes/maths/__init__.py +26 -0
- noodlelab/nodes/maths/_common.py +72 -0
- noodlelab/nodes/maths/arrays.py +100 -0
- noodlelab/nodes/maths/calculus.py +60 -0
- noodlelab/nodes/maths/fitting.py +484 -0
- noodlelab/nodes/maths/plot.py +169 -0
- noodlelab/nodes/report.py +531 -0
- noodlelab/nodes/requirements.py +171 -0
- noodlelab/nodes/science/__init__.py +27 -0
- noodlelab/nodes/science/plot.py +218 -0
- noodlelab/nodes/science/signal.py +247 -0
- noodlelab/nodes/science/stats.py +495 -0
- noodlelab/nodes/science/studies.py +267 -0
- noodlelab/nodes/science/tables.py +619 -0
- noodlelab/nodes/science/timeseries.py +164 -0
- noodlelab/nodes/symbolic/__init__.py +23 -0
- noodlelab/nodes/symbolic/nodes.py +634 -0
- noodlelab/nodes/symbolic/parse.py +264 -0
- noodlelab/nodes/symbolic/types.py +200 -0
- noodlelab/nodes/symbolic/typst.py +280 -0
- noodlelab/nodes/uncertainty.py +275 -0
- noodlelab/nodes/units.py +138 -0
- noodlelab/py.typed +0 -0
- noodlelab/reports/__init__.py +6 -0
- noodlelab/reports/document.py +274 -0
- noodlelab/reports/math.py +81 -0
- noodlelab/reports/render.py +46 -0
- noodlelab/reports/templates/default.typ +160 -0
- noodlelab/server/__init__.py +16 -0
- noodlelab/server/admin.py +410 -0
- noodlelab/server/agents.py +368 -0
- noodlelab/server/api.py +879 -0
- noodlelab/server/app.py +83 -0
- noodlelab/server/auth.py +87 -0
- noodlelab/server/cluster.py +406 -0
- noodlelab/server/hub.py +117 -0
- noodlelab/server/permissions.py +72 -0
- noodlelab/server/runs.py +306 -0
- noodlelab/server/sharing.py +117 -0
- noodlelab/server/state.py +94 -0
- noodlelab/server/store.py +328 -0
- noodlelab/server/workspaces.py +207 -0
- noodlelab/server/ws.py +643 -0
- noodlelab/static/admin.html +31 -0
- noodlelab/static/assets/admin-CdIecA1j.js +8 -0
- noodlelab/static/assets/agent-6GBZ9nXN.css +32 -0
- noodlelab/static/assets/agent-BSxoem_1.js +41 -0
- noodlelab/static/assets/editor-DWWQ0JL2.js +25 -0
- noodlelab/static/assets/sharing-Bnota0Ch.js +1 -0
- noodlelab/static/assets/sharing-DgCDYlte.css +1 -0
- noodlelab/static/index.html +108 -0
- noodlelab/static/vendor/litegraph/litegraph.css +680 -0
- noodlelab/static/vendor/litegraph/litegraph.js +14427 -0
- noodlelab/tiers.py +201 -0
- noodlelab/verify.py +917 -0
- noodlelab-0.1.0.dist-info/METADATA +335 -0
- noodlelab-0.1.0.dist-info/RECORD +190 -0
- noodlelab-0.1.0.dist-info/WHEEL +4 -0
- noodlelab-0.1.0.dist-info/entry_points.txt +15 -0
- noodlelab-0.1.0.dist-info/licenses/LICENSE +21 -0
noodlelab/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""noodlelab: verifiable science for people and AI agents.
|
|
2
|
+
|
|
3
|
+
For calculations in plain Python (units, uncertainty, requirements, checks and
|
|
4
|
+
a provenance record of every run), use :mod:`noodlelab.verify`::
|
|
5
|
+
|
|
6
|
+
import noodlelab.verify as nv
|
|
7
|
+
|
|
8
|
+
with nv.record("pendulum") as rec:
|
|
9
|
+
L = rec.input("L", "1.000 ± 0.002 m", source="tape measure")
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
The public API for node-pack authors lives here::
|
|
13
|
+
|
|
14
|
+
from noodlelab import Param, Probe, ProbeContext, RunContext, node, register_type
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from importlib import metadata as _metadata
|
|
18
|
+
|
|
19
|
+
from .core.codecs import register_codec
|
|
20
|
+
from .core.context import RunContext
|
|
21
|
+
from .core.files import FileRef, as_file
|
|
22
|
+
from .core.health import Problem, error, warning
|
|
23
|
+
from .core.meta import register_meta
|
|
24
|
+
from .core.node import Param, node
|
|
25
|
+
from .core.ports import register_fields
|
|
26
|
+
from .core.preview import Preview, register_preview
|
|
27
|
+
from .core.probe import Probe, ProbeContext, downsample, image_preview, table_preview
|
|
28
|
+
from .core.sample import register_sampler
|
|
29
|
+
from .core.types import register_type
|
|
30
|
+
from .core.uncertainty import Uncertain
|
|
31
|
+
from .core.units import Quantity, register_unit, unit_type
|
|
32
|
+
|
|
33
|
+
try: # set from the git tag at build time (hatch-vcs)
|
|
34
|
+
__version__ = _metadata.version("noodlelab")
|
|
35
|
+
except _metadata.PackageNotFoundError: # running from a source tree that is not installed
|
|
36
|
+
__version__ = "0.0.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"FileRef",
|
|
40
|
+
"Param",
|
|
41
|
+
"Preview",
|
|
42
|
+
"Probe",
|
|
43
|
+
"ProbeContext",
|
|
44
|
+
"Problem",
|
|
45
|
+
"Quantity",
|
|
46
|
+
"RunContext",
|
|
47
|
+
"Uncertain",
|
|
48
|
+
"as_file",
|
|
49
|
+
"downsample",
|
|
50
|
+
"error",
|
|
51
|
+
"image_preview",
|
|
52
|
+
"node",
|
|
53
|
+
"register_codec",
|
|
54
|
+
"register_fields",
|
|
55
|
+
"register_meta",
|
|
56
|
+
"register_preview",
|
|
57
|
+
"register_sampler",
|
|
58
|
+
"register_type",
|
|
59
|
+
"register_unit",
|
|
60
|
+
"table_preview",
|
|
61
|
+
"unit_type",
|
|
62
|
+
"warning",
|
|
63
|
+
"__version__",
|
|
64
|
+
]
|
noodlelab/__main__.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""noodlelab for AI coding agents (Claude Code, Codex CLI and others).
|
|
2
|
+
|
|
3
|
+
- :mod:`noodlelab.agent.mcp`: ``noodlelab mcp``, a Model Context Protocol server
|
|
4
|
+
whose tools build, check, run and verify graphs and reports
|
|
5
|
+
(:mod:`noodlelab.agent.tools`).
|
|
6
|
+
- :func:`init_project`: ``noodlelab init-agent``, which sets up a project so that
|
|
7
|
+
agents working in it find noodlelab. It writes the MCP server into
|
|
8
|
+
``.mcp.json``, a section of rules into ``AGENTS.md`` (imported from
|
|
9
|
+
``CLAUDE.md``), and a skill in ``.claude/skills/noodlelab/``.
|
|
10
|
+
- ``guide.md``: the rules and workflow, served by the ``guide`` tool and copied
|
|
11
|
+
into the skill.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from importlib import resources
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
START, END = "<!-- noodlelab:start -->", "<!-- noodlelab:end -->"
|
|
24
|
+
|
|
25
|
+
SKILL_HEADER = """\
|
|
26
|
+
---
|
|
27
|
+
name: noodlelab
|
|
28
|
+
description: Verifiable scientific and engineering calculations with noodlelab - units and
|
|
29
|
+
uncertainty on every number, requirements checked with margins, checks, and a provenance
|
|
30
|
+
record of every run. Use whenever code computes physical quantities, measurement results,
|
|
31
|
+
design margins or other results people rely on, or when asked to build or change a
|
|
32
|
+
noodlelab graph, report or requirement.
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
AGENTS_SECTION = """\
|
|
38
|
+
## Verifiable calculations (noodlelab)
|
|
39
|
+
|
|
40
|
+
This project uses [noodlelab](https://github.com/nkalis/noodlelab) so that its
|
|
41
|
+
calculations can be checked, not just trusted. When code computes a physical
|
|
42
|
+
quantity, a measurement result or a design margin:
|
|
43
|
+
|
|
44
|
+
- Use quantities with units (`nv.q("9.81 m/s^2")`), and measured values with an
|
|
45
|
+
uncertainty and a source (`rec.input("g", "9.81 ± 0.02 m/s^2", source=...)`).
|
|
46
|
+
- Write requirements down (`COM-001 link_margin >= 3 dB [Analysis]`) and verify
|
|
47
|
+
each one; report margins, not just pass/fail.
|
|
48
|
+
- Wrap each calculation in `with noodlelab.verify.record(...)` (or build it as a
|
|
49
|
+
noodlelab graph), so every run leaves a `provenance.json`.
|
|
50
|
+
- Before saying the work is done, run `noodlelab verify <script.py|graph.json>
|
|
51
|
+
--json` and fix what it reports.
|
|
52
|
+
|
|
53
|
+
The `noodlelab` MCP server (in `.mcp.json`) can build, run and verify graphs and PDF
|
|
54
|
+
reports. Call its `guide` tool first. The full guide is in
|
|
55
|
+
`.claude/skills/noodlelab/SKILL.md`.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Setup:
|
|
61
|
+
"""What :func:`init_project` did."""
|
|
62
|
+
|
|
63
|
+
written: list[str] = field(default_factory=list)
|
|
64
|
+
unchanged: list[str] = field(default_factory=list)
|
|
65
|
+
hints: list[str] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def guide() -> str:
|
|
69
|
+
return (resources.files("noodlelab.agent") / "guide.md").read_text("utf-8")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def mcp_command() -> tuple[str, list[str]]:
|
|
73
|
+
"""How an agent should start the server: the installed ``noodlelab`` when
|
|
74
|
+
there is one, else uvx (which fetches it)."""
|
|
75
|
+
if shutil.which("noodlelab"):
|
|
76
|
+
return "noodlelab", ["mcp"]
|
|
77
|
+
return "uvx", ["noodlelab", "mcp"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def init_project(
|
|
81
|
+
project: str | Path,
|
|
82
|
+
*,
|
|
83
|
+
command: tuple[str, list[str]] | None = None,
|
|
84
|
+
claude: bool = True,
|
|
85
|
+
mcp: bool = True,
|
|
86
|
+
) -> Setup:
|
|
87
|
+
"""Set up ``project`` for AI agents (idempotent: run it again to update)."""
|
|
88
|
+
root = Path(project).resolve()
|
|
89
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
done = Setup()
|
|
91
|
+
cmd, args = command or mcp_command()
|
|
92
|
+
|
|
93
|
+
def write(rel: str, text: str) -> None:
|
|
94
|
+
path = root / rel
|
|
95
|
+
old = path.read_text("utf-8") if path.is_file() else None
|
|
96
|
+
if old == text:
|
|
97
|
+
done.unchanged.append(rel)
|
|
98
|
+
return
|
|
99
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
path.write_text(text, "utf-8")
|
|
101
|
+
done.written.append(rel)
|
|
102
|
+
|
|
103
|
+
# AGENTS.md: read by Codex, Cursor, Copilot, Gemini CLI and many others
|
|
104
|
+
agents = root / "AGENTS.md"
|
|
105
|
+
old = agents.read_text("utf-8") if agents.is_file() else "# Notes for AI agents\n"
|
|
106
|
+
write("AGENTS.md", _with_section(old, AGENTS_SECTION))
|
|
107
|
+
|
|
108
|
+
if claude:
|
|
109
|
+
# Claude Code reads CLAUDE.md, which can import AGENTS.md
|
|
110
|
+
claude_md = root / "CLAUDE.md"
|
|
111
|
+
text = claude_md.read_text("utf-8") if claude_md.is_file() else ""
|
|
112
|
+
if "@AGENTS.md" not in text:
|
|
113
|
+
text = (text.rstrip() + "\n\n" if text.strip() else "") + "@AGENTS.md\n"
|
|
114
|
+
write("CLAUDE.md", text)
|
|
115
|
+
write(".claude/skills/noodlelab/SKILL.md", SKILL_HEADER + guide())
|
|
116
|
+
|
|
117
|
+
if mcp:
|
|
118
|
+
path = root / ".mcp.json"
|
|
119
|
+
config = {}
|
|
120
|
+
if path.is_file():
|
|
121
|
+
try:
|
|
122
|
+
config = json.loads(path.read_text("utf-8"))
|
|
123
|
+
except ValueError:
|
|
124
|
+
done.hints.append(".mcp.json is not valid JSON: left as it is")
|
|
125
|
+
config = None
|
|
126
|
+
if config is not None:
|
|
127
|
+
config.setdefault("mcpServers", {})["noodlelab"] = {"command": cmd, "args": args}
|
|
128
|
+
write(".mcp.json", json.dumps(config, indent=2) + "\n")
|
|
129
|
+
codex = " ".join(["codex mcp add noodlelab --", cmd, *args])
|
|
130
|
+
done.hints.append(f"Codex CLI: {codex}")
|
|
131
|
+
return done
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _with_section(text: str, section: str) -> str:
|
|
135
|
+
block = f"{START}\n{section.rstrip()}\n{END}"
|
|
136
|
+
if START in text and END in text:
|
|
137
|
+
return re.sub(re.escape(START) + r".*?" + re.escape(END), lambda _: block, text, flags=re.S)
|
|
138
|
+
return text.rstrip() + "\n\n" + block + "\n"
|
noodlelab/agent/guide.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# noodlelab for AI agents
|
|
2
|
+
|
|
3
|
+
noodlelab makes scientific and engineering calculations verifiable. Use it
|
|
4
|
+
whenever code computes a physical quantity, a measurement result, a design
|
|
5
|
+
margin or anything a person will rely on. The point is that a reviewer (or
|
|
6
|
+
another agent) can check what you did, not just trust it.
|
|
7
|
+
|
|
8
|
+
## The rules
|
|
9
|
+
|
|
10
|
+
1. **Units on every number.** Use quantities (`q("9.81 m/s^2")`), never bare
|
|
11
|
+
floats with the unit in a variable name or a comment. Mixing dimensions then
|
|
12
|
+
raises instead of silently giving nonsense. Dimensionless results say so
|
|
13
|
+
(`unit="1"`).
|
|
14
|
+
2. **Uncertainty on every measured input, and a source.** Write
|
|
15
|
+
`"9.81 ± 0.02 m/s^2"` and say where it came from (an instrument, datasheet,
|
|
16
|
+
paper or dataset). Uncertainty propagates by itself (GUM, first order, with
|
|
17
|
+
correlations kept). `budget()` shows which input dominates. For strongly
|
|
18
|
+
non-linear models, check with `monte_carlo()`.
|
|
19
|
+
3. **Write the requirements down before checking them**, one per line:
|
|
20
|
+
`COM-001 link_margin >= 3 dB [Analysis] # The link shall close with 3 dB to spare`.
|
|
21
|
+
Verify each one: a check reports its margin, not just pass or fail.
|
|
22
|
+
4. **Leave a record.** Wrap the calculation in `with nv.record(...)`, or build
|
|
23
|
+
it as a graph. Every run then writes a `provenance.json` holding inputs,
|
|
24
|
+
results, checks, the code (hash, git commit), files (SHA-256) and the
|
|
25
|
+
environment.
|
|
26
|
+
5. **Verify before you say you are done.** Run `noodlelab verify <script.py |
|
|
27
|
+
graph.json> --json` and fix what it reports. Exit code 0 means every check
|
|
28
|
+
passed and every requirement was verified. Report the margins and any
|
|
29
|
+
warnings to the user; don't hide failures.
|
|
30
|
+
|
|
31
|
+
Never invent a measured value or its uncertainty. When a number is assumed,
|
|
32
|
+
say so with `rec.note(...)` and `source="assumed: ..."`.
|
|
33
|
+
|
|
34
|
+
## Python: `noodlelab.verify`
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import noodlelab.verify as nv
|
|
38
|
+
|
|
39
|
+
with nv.record("link budget") as rec: # writes runs/<time>-link-budget-<id>/provenance.json
|
|
40
|
+
p_tx = rec.input("p_tx", "10.0 ± 0.3 W", source="PA datasheet rev C")
|
|
41
|
+
d = rec.input("d", nv.q("1200 km"), source="orbit design")
|
|
42
|
+
...
|
|
43
|
+
margin = rec.result("link_margin", computed_margin) # a quantity, e.g. in dB
|
|
44
|
+
rec.require("COM-001 link_margin >= 3 dB [Analysis] # The link shall close")
|
|
45
|
+
rec.verify("COM-001", margin) # Check(passed, margin=+1.2 dB, ...)
|
|
46
|
+
rec.expect(0 < efficiency.m < 1, "efficiency is a fraction")
|
|
47
|
+
rec.close_to("vs. textbook", result, nv.q("2.006 s"), rtol=0.01)
|
|
48
|
+
print(rec.summary())
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- `nv.q(text_or_number, unit)` returns an exact quantity. `nv.measure("x ± u unit")`
|
|
52
|
+
or `nv.measure(x, u, unit, name=...)` returns an uncertain one.
|
|
53
|
+
- `nv.requirements(text)` and `nv.verify(req, value)` work outside a record too.
|
|
54
|
+
- `@nv.traced` records every call of a function (arguments, result, source hash)
|
|
55
|
+
inside a record.
|
|
56
|
+
- `nv.monte_carlo(model, trials)`, where `model` makes its inputs with
|
|
57
|
+
`measure()` and returns the result, checks whether the GUM result can be
|
|
58
|
+
trusted (JCGM 101).
|
|
59
|
+
- `nv.audit(record)` lists findings NL001–NL010: failed checks, unverified
|
|
60
|
+
requirements, results without units or uncertainty, inputs without a source,
|
|
61
|
+
and code that was dirty or has changed since.
|
|
62
|
+
|
|
63
|
+
## Graphs: node pipelines the user can open in the editor
|
|
64
|
+
|
|
65
|
+
A graph is a JSON file `<name>.graph.json` in the workspace. Nodes are typed
|
|
66
|
+
Python functions (`list_nodes`, `describe_node`). Every input is either a
|
|
67
|
+
value or a link to another node's output:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{"nodes": [
|
|
71
|
+
{"id": "1", "type": "core.number", "inputs": {"value": {"value": 3}}},
|
|
72
|
+
{"id": "2", "type": "core.math",
|
|
73
|
+
"inputs": {"operation": {"value": "power"},
|
|
74
|
+
"a": {"link": {"node": "1", "output": "result"}},
|
|
75
|
+
"b": {"value": 2}}}
|
|
76
|
+
],
|
|
77
|
+
"report": [
|
|
78
|
+
{"id": "1", "type": "report.new_report", "inputs": {"title": {"value": "Results"}}},
|
|
79
|
+
{"id": "2", "type": "report.add_value",
|
|
80
|
+
"inputs": {"report": {"link": {"node": "1", "output": "result"}},
|
|
81
|
+
"label": {"value": "3 squared"},
|
|
82
|
+
"value": {"link": {"node": "2", "output": "result", "tab": "processing"}}}},
|
|
83
|
+
{"id": "3", "type": "report.render_report",
|
|
84
|
+
"inputs": {"report": {"link": {"node": "2", "output": "result"}}}}
|
|
85
|
+
]}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- `nodes` is the processing canvas and `report` is the Reporting canvas. A
|
|
89
|
+
report node reads a processing output with `"tab": "processing"` in its
|
|
90
|
+
link. Data only flows from processing into the report.
|
|
91
|
+
- The usual shape for a verified analysis:
|
|
92
|
+
- **Requirements** (`requirements.requirements`, one per line), then the
|
|
93
|
+
inputs, with units and uncertainty (`units.*`, `uncertainty.*`), then the
|
|
94
|
+
model.
|
|
95
|
+
- **Verify Requirement** for each requirement, chaining its `log` output
|
|
96
|
+
from one to the next.
|
|
97
|
+
- A report: **New Report**, Add Heading / Text / Value / Figure /
|
|
98
|
+
Requirements, then Add Compliance Matrix (from the last `log`), Run
|
|
99
|
+
Details (provenance), and Render Report (the PDF).
|
|
100
|
+
- Start from a similar example (`list_examples`, `get_example`). Example 23
|
|
101
|
+
(satellite link budget) and example 24 (cantilever bracket) are complete
|
|
102
|
+
verified studies with reports.
|
|
103
|
+
|
|
104
|
+
## MCP tools (server `noodlelab mcp`)
|
|
105
|
+
|
|
106
|
+
| Tool | Use |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `guide` | this text |
|
|
109
|
+
| `list_nodes`, `describe_node` | find nodes and their inputs, outputs, units and options |
|
|
110
|
+
| `list_examples`, `get_example` | complete graphs to copy from |
|
|
111
|
+
| `list_graphs`, `get_graph` | the workspace's graphs |
|
|
112
|
+
| `save_graph` | write a whole graph (validated; the open editor reloads it) |
|
|
113
|
+
| `edit_graph` | add, set, remove, retitle or track nodes without resending the graph |
|
|
114
|
+
| `check_graph` | problems before running: types, units, missing inputs |
|
|
115
|
+
| `run_graph` | run it; returns each node's result, checks, requirements, files and provenance |
|
|
116
|
+
| `requirements` | each requirement's latest verdict, margin and history |
|
|
117
|
+
| `verify` | audit a script, graph or records, as `noodlelab verify --json` does |
|
|
118
|
+
|
|
119
|
+
When you are started from the editor's Agent panel, the user is watching the
|
|
120
|
+
canvas: every graph you save opens there, laid out automatically.
|
|
121
|
+
`NOODLELAB_GRAPH` names the graph they had open.
|
|
122
|
+
|
|
123
|
+
The workflow:
|
|
124
|
+
1. `guide`, then `list_examples` / `list_nodes`.
|
|
125
|
+
2. `save_graph`, then `check_graph` until there are no errors.
|
|
126
|
+
3. `run_graph`, then read the checks and margins.
|
|
127
|
+
4. Fix and iterate. Finish with `verify`.
|
|
128
|
+
5. Tell the user what passed, the margins, what was assumed, and where the
|
|
129
|
+
report PDF and provenance are.
|
noodlelab/agent/mcp.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""``noodlelab mcp``: the workspace's tools for AI agents over the Model Context
|
|
2
|
+
Protocol (stdio).
|
|
3
|
+
|
|
4
|
+
Add it to an agent once and it can build, check, run and verify graphs and
|
|
5
|
+
reports, and audit scripts::
|
|
6
|
+
|
|
7
|
+
claude mcp add noodlelab -- uvx noodlelab mcp # Claude Code
|
|
8
|
+
codex mcp add noodlelab -- uvx noodlelab mcp # Codex CLI
|
|
9
|
+
|
|
10
|
+
or write ``.mcp.json`` with ``noodlelab init-agent``. The protocol is small
|
|
11
|
+
enough to speak directly (JSON-RPC 2.0, one message per line on stdin and
|
|
12
|
+
stdout), so the server needs nothing beyond the library itself.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import inspect
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
import traceback
|
|
21
|
+
import typing
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import IO, Any
|
|
25
|
+
|
|
26
|
+
from .. import __version__
|
|
27
|
+
from .tools import Remote, ToolError, Workspace
|
|
28
|
+
|
|
29
|
+
PROTOCOL_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")
|
|
30
|
+
TOOLS = (
|
|
31
|
+
"guide",
|
|
32
|
+
"list_nodes",
|
|
33
|
+
"describe_node",
|
|
34
|
+
"list_examples",
|
|
35
|
+
"get_example",
|
|
36
|
+
"copy_example",
|
|
37
|
+
"list_graphs",
|
|
38
|
+
"get_graph",
|
|
39
|
+
"save_graph",
|
|
40
|
+
"edit_graph",
|
|
41
|
+
"check_graph",
|
|
42
|
+
"run_graph",
|
|
43
|
+
"requirements",
|
|
44
|
+
"verify",
|
|
45
|
+
)
|
|
46
|
+
READ_ONLY = {
|
|
47
|
+
"guide",
|
|
48
|
+
"list_nodes",
|
|
49
|
+
"describe_node",
|
|
50
|
+
"list_examples",
|
|
51
|
+
"get_example",
|
|
52
|
+
"list_graphs",
|
|
53
|
+
"get_graph",
|
|
54
|
+
"check_graph",
|
|
55
|
+
"requirements",
|
|
56
|
+
}
|
|
57
|
+
INSTRUCTIONS = (
|
|
58
|
+
"noodlelab makes calculations verifiable: units and uncertainty on every number, "
|
|
59
|
+
"requirements checked with margins, a provenance record of every run. Call `guide` "
|
|
60
|
+
"first. Build analyses as graphs (save_graph/edit_graph, check_graph, run_graph) or as "
|
|
61
|
+
"Python with noodlelab.verify, and finish with `verify`."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _schema(fn: Callable[..., Any]) -> dict[str, Any]:
|
|
66
|
+
"""A JSON schema of a tool's arguments, from its signature."""
|
|
67
|
+
hints = typing.get_type_hints(fn)
|
|
68
|
+
props: dict[str, Any] = {}
|
|
69
|
+
required = []
|
|
70
|
+
for name, p in inspect.signature(fn).parameters.items():
|
|
71
|
+
if name == "self":
|
|
72
|
+
continue
|
|
73
|
+
props[name] = _json_type(hints.get(name, Any))
|
|
74
|
+
if p.default is inspect.Parameter.empty:
|
|
75
|
+
required.append(name)
|
|
76
|
+
else:
|
|
77
|
+
props[name]["default"] = p.default
|
|
78
|
+
return {"type": "object", "properties": props, "required": required}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _json_type(hint: Any) -> dict[str, Any]:
|
|
82
|
+
origin = typing.get_origin(hint)
|
|
83
|
+
if origin in (typing.Union, getattr(__import__("types"), "UnionType", None)):
|
|
84
|
+
args = [a for a in typing.get_args(hint) if a is not type(None)]
|
|
85
|
+
return _json_type(args[0]) if len(args) == 1 else {}
|
|
86
|
+
if hint is str:
|
|
87
|
+
return {"type": "string"}
|
|
88
|
+
if hint is bool:
|
|
89
|
+
return {"type": "boolean"}
|
|
90
|
+
if hint is int:
|
|
91
|
+
return {"type": "integer"}
|
|
92
|
+
if hint is float:
|
|
93
|
+
return {"type": "number"}
|
|
94
|
+
if origin is list or hint is list:
|
|
95
|
+
(item,) = typing.get_args(hint) or (Any,)
|
|
96
|
+
return {"type": "array", "items": _json_type(item)}
|
|
97
|
+
if origin is dict or hint is dict:
|
|
98
|
+
return {"type": "object"}
|
|
99
|
+
return {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Server:
|
|
103
|
+
"""Answers MCP requests with a :class:`Workspace`'s tools."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, workspace: Workspace) -> None:
|
|
106
|
+
self.ws = workspace
|
|
107
|
+
self.tools = {name: getattr(workspace, name) for name in TOOLS}
|
|
108
|
+
|
|
109
|
+
def tool_list(self) -> list[dict[str, Any]]:
|
|
110
|
+
out = []
|
|
111
|
+
for name, fn in self.tools.items():
|
|
112
|
+
doc = inspect.getdoc(fn) or ""
|
|
113
|
+
out.append(
|
|
114
|
+
{
|
|
115
|
+
"name": name,
|
|
116
|
+
"description": doc,
|
|
117
|
+
"inputSchema": _schema(fn),
|
|
118
|
+
"annotations": {"readOnlyHint": name in READ_ONLY},
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
def handle(self, msg: dict[str, Any]) -> dict[str, Any] | None:
|
|
124
|
+
"""The response to one JSON-RPC message (None for a notification)."""
|
|
125
|
+
method, mid = msg.get("method"), msg.get("id")
|
|
126
|
+
if mid is None: # notifications/initialized, cancelled...
|
|
127
|
+
return None
|
|
128
|
+
try:
|
|
129
|
+
result = self._dispatch(method, msg.get("params") or {})
|
|
130
|
+
except _RpcError as exc:
|
|
131
|
+
return {"jsonrpc": "2.0", "id": mid, "error": {"code": exc.code, "message": str(exc)}}
|
|
132
|
+
return {"jsonrpc": "2.0", "id": mid, "result": result}
|
|
133
|
+
|
|
134
|
+
def _dispatch(self, method: str | None, params: dict[str, Any]) -> Any:
|
|
135
|
+
if method == "initialize":
|
|
136
|
+
asked = params.get("protocolVersion")
|
|
137
|
+
return {
|
|
138
|
+
"protocolVersion": asked if asked in PROTOCOL_VERSIONS else PROTOCOL_VERSIONS[0],
|
|
139
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
140
|
+
"serverInfo": {"name": "noodlelab", "version": __version__},
|
|
141
|
+
"instructions": INSTRUCTIONS,
|
|
142
|
+
}
|
|
143
|
+
if method == "ping":
|
|
144
|
+
return {}
|
|
145
|
+
if method == "tools/list":
|
|
146
|
+
return {"tools": self.tool_list()}
|
|
147
|
+
if method == "tools/call":
|
|
148
|
+
return self.call(params.get("name", ""), params.get("arguments") or {})
|
|
149
|
+
if method in ("resources/list", "prompts/list"):
|
|
150
|
+
return {method.split("/")[0]: []}
|
|
151
|
+
raise _RpcError(-32601, f"Method not found: {method}")
|
|
152
|
+
|
|
153
|
+
def call(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
154
|
+
fn = self.tools.get(name)
|
|
155
|
+
if fn is None:
|
|
156
|
+
raise _RpcError(-32602, f"Unknown tool: {name}")
|
|
157
|
+
try:
|
|
158
|
+
value = fn(**arguments)
|
|
159
|
+
except ToolError as exc:
|
|
160
|
+
return _content(str(exc), error=True)
|
|
161
|
+
except TypeError as exc: # arguments that do not fit the signature
|
|
162
|
+
return _content(f"Bad arguments for {name}: {exc}", error=True)
|
|
163
|
+
except Exception as exc: # report the failure to the agent, keep serving
|
|
164
|
+
detail = "".join(traceback.format_exception_only(type(exc), exc)).strip()
|
|
165
|
+
return _content(f"{name} failed: {detail}", error=True)
|
|
166
|
+
return _content(value)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _RpcError(Exception):
|
|
170
|
+
def __init__(self, code: int, message: str) -> None:
|
|
171
|
+
super().__init__(message)
|
|
172
|
+
self.code = code
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _content(value: Any, *, error: bool = False) -> dict[str, Any]:
|
|
176
|
+
text = value if isinstance(value, str) else json.dumps(value, indent=1, default=str)
|
|
177
|
+
out: dict[str, Any] = {"content": [{"type": "text", "text": text}], "isError": error}
|
|
178
|
+
if isinstance(value, dict) and not error:
|
|
179
|
+
out["structuredContent"] = json.loads(json.dumps(value, default=str))
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def serve(
|
|
184
|
+
workspace: Workspace, stdin: IO[str] | None = None, stdout: IO[str] | None = None
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Answer requests on stdin until it closes. Anything a node prints goes to
|
|
187
|
+
stderr, so stdout carries only protocol messages."""
|
|
188
|
+
stdin = stdin or sys.stdin
|
|
189
|
+
out = stdout or sys.stdout
|
|
190
|
+
server = Server(workspace)
|
|
191
|
+
real_stdout = sys.stdout
|
|
192
|
+
sys.stdout = sys.stderr # prints from nodes and scripts must not corrupt the protocol
|
|
193
|
+
try:
|
|
194
|
+
for line in stdin:
|
|
195
|
+
if not line.strip():
|
|
196
|
+
continue
|
|
197
|
+
try:
|
|
198
|
+
msg = json.loads(line)
|
|
199
|
+
except ValueError:
|
|
200
|
+
reply: Any = {
|
|
201
|
+
"jsonrpc": "2.0",
|
|
202
|
+
"id": None,
|
|
203
|
+
"error": {"code": -32700, "message": "Parse error"},
|
|
204
|
+
}
|
|
205
|
+
else:
|
|
206
|
+
if isinstance(msg, list): # a batch
|
|
207
|
+
reply = [r for m in msg if (r := server.handle(m)) is not None] or None
|
|
208
|
+
else:
|
|
209
|
+
reply = server.handle(msg)
|
|
210
|
+
if reply is not None:
|
|
211
|
+
out.write(json.dumps(reply, default=str) + "\n")
|
|
212
|
+
out.flush()
|
|
213
|
+
finally:
|
|
214
|
+
sys.stdout = real_stdout
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def main(root: Path, home: Path | None = None) -> int:
|
|
218
|
+
serve(Workspace(root, remote=Remote.from_env(), home=home))
|
|
219
|
+
return 0
|