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.
Files changed (190) hide show
  1. noodlelab/__init__.py +64 -0
  2. noodlelab/__main__.py +3 -0
  3. noodlelab/agent/__init__.py +138 -0
  4. noodlelab/agent/guide.md +129 -0
  5. noodlelab/agent/mcp.py +219 -0
  6. noodlelab/agent/tools.py +613 -0
  7. noodlelab/cli.py +934 -0
  8. noodlelab/config.py +221 -0
  9. noodlelab/core/__init__.py +1 -0
  10. noodlelab/core/about.py +212 -0
  11. noodlelab/core/baseline.py +363 -0
  12. noodlelab/core/bundle.py +148 -0
  13. noodlelab/core/cache.py +66 -0
  14. noodlelab/core/checkpoint.py +230 -0
  15. noodlelab/core/checks.py +92 -0
  16. noodlelab/core/codecs.py +426 -0
  17. noodlelab/core/context.py +113 -0
  18. noodlelab/core/convert.py +224 -0
  19. noodlelab/core/events.py +185 -0
  20. noodlelab/core/executor.py +1693 -0
  21. noodlelab/core/export.py +405 -0
  22. noodlelab/core/figures.py +110 -0
  23. noodlelab/core/files.py +577 -0
  24. noodlelab/core/fold.py +121 -0
  25. noodlelab/core/graph.py +510 -0
  26. noodlelab/core/health.py +462 -0
  27. noodlelab/core/meta.py +164 -0
  28. noodlelab/core/monitor.py +142 -0
  29. noodlelab/core/montecarlo.py +306 -0
  30. noodlelab/core/node.py +795 -0
  31. noodlelab/core/ports.py +461 -0
  32. noodlelab/core/preview.py +201 -0
  33. noodlelab/core/probe.py +989 -0
  34. noodlelab/core/provenance.py +477 -0
  35. noodlelab/core/registry.py +278 -0
  36. noodlelab/core/reqlog.py +138 -0
  37. noodlelab/core/requirements.py +430 -0
  38. noodlelab/core/rundiff.py +168 -0
  39. noodlelab/core/sample.py +144 -0
  40. noodlelab/core/studies.py +309 -0
  41. noodlelab/core/suggest.py +144 -0
  42. noodlelab/core/tracked.py +161 -0
  43. noodlelab/core/types.py +107 -0
  44. noodlelab/core/typesys.py +706 -0
  45. noodlelab/core/uncertainty.py +421 -0
  46. noodlelab/core/units.py +230 -0
  47. noodlelab/examples/01-getting-started.graph.json +71 -0
  48. noodlelab/examples/02-units.graph.json +113 -0
  49. noodlelab/examples/03-plot-a-function.graph.json +77 -0
  50. noodlelab/examples/04-noise-and-smoothing.graph.json +156 -0
  51. noodlelab/examples/05-first-csv.graph.json +87 -0
  52. noodlelab/examples/06-straight-line-fit.graph.json +195 -0
  53. noodlelab/examples/07-filter-and-group.graph.json +133 -0
  54. noodlelab/examples/08-calibration.graph.json +276 -0
  55. noodlelab/examples/09-uncertainty.graph.json +211 -0
  56. noodlelab/examples/10-curve-fit-subgraph.graph.json +559 -0
  57. noodlelab/examples/11-time-series.graph.json +215 -0
  58. noodlelab/examples/12-signals.graph.json +196 -0
  59. noodlelab/examples/13-symbolic.graph.json +242 -0
  60. noodlelab/examples/14-first-report.graph.json +409 -0
  61. noodlelab/examples/15-repeat-zone.graph.json +264 -0
  62. noodlelab/examples/16-until-converged.graph.json +370 -0
  63. noodlelab/examples/17-sweep-and-optimize.graph.json +430 -0
  64. noodlelab/examples/18-gps-track.graph.json +208 -0
  65. noodlelab/examples/19-earthquakes.graph.json +173 -0
  66. noodlelab/examples/20-groundwater-nitrate.graph.json +2197 -0
  67. noodlelab/examples/21-fixed-beam.graph.json +2866 -0
  68. noodlelab/examples/22-watershed.graph.json +3279 -0
  69. noodlelab/examples/23-satellite-link.graph.json +2059 -0
  70. noodlelab/examples/24-cantilever-bracket.graph.json +591 -0
  71. noodlelab/examples/__init__.py +0 -0
  72. noodlelab/examples/data/05-first-csv/README.md +16 -0
  73. noodlelab/examples/data/05-first-csv/pendulum.csv +56 -0
  74. noodlelab/examples/data/06-straight-line-fit/README.md +16 -0
  75. noodlelab/examples/data/06-straight-line-fit/pendulum.csv +56 -0
  76. noodlelab/examples/data/07-filter-and-group/README.md +20 -0
  77. noodlelab/examples/data/07-filter-and-group/field_trial.csv +73 -0
  78. noodlelab/examples/data/08-calibration/README.md +11 -0
  79. noodlelab/examples/data/08-calibration/samples.csv +13 -0
  80. noodlelab/examples/data/08-calibration/standards.csv +25 -0
  81. noodlelab/examples/data/10-curve-fit-subgraph/README.md +17 -0
  82. noodlelab/examples/data/10-curve-fit-subgraph/kinetics.csv +49 -0
  83. noodlelab/examples/data/11-time-series/README.md +16 -0
  84. noodlelab/examples/data/11-time-series/station_daily.csv +5480 -0
  85. noodlelab/examples/data/12-signals/README.md +17 -0
  86. noodlelab/examples/data/12-signals/accelerometer.csv +10001 -0
  87. noodlelab/examples/data/14-first-report/README.md +16 -0
  88. noodlelab/examples/data/14-first-report/pendulum.csv +56 -0
  89. noodlelab/examples/data/18-gps-track/README.md +19 -0
  90. noodlelab/examples/data/18-gps-track/hike.csv +3526 -0
  91. noodlelab/examples/data/18-gps-track/waypoints.csv +6 -0
  92. noodlelab/examples/data/19-earthquakes/README.md +20 -0
  93. noodlelab/examples/data/19-earthquakes/catalog.csv +2012 -0
  94. noodlelab/examples/data/19-earthquakes/regions.geojson +1 -0
  95. noodlelab/examples/data/20-groundwater-nitrate/README.md +22 -0
  96. noodlelab/examples/data/20-groundwater-nitrate/districts.geojson +1 -0
  97. noodlelab/examples/data/20-groundwater-nitrate/river.geojson +1 -0
  98. noodlelab/examples/data/20-groundwater-nitrate/wells.csv +161 -0
  99. noodlelab/examples/data/21-fixed-beam/README.md +18 -0
  100. noodlelab/examples/data/21-fixed-beam/strain_gauges.csv +12 -0
  101. noodlelab/examples/data/22-watershed/README.md +20 -0
  102. noodlelab/examples/data/22-watershed/catchments.geojson +1 -0
  103. noodlelab/examples/data/22-watershed/dem.asc +156 -0
  104. noodlelab/examples/data/22-watershed/dem.prj +1 -0
  105. noodlelab/examples/data/22-watershed/nir.asc +156 -0
  106. noodlelab/examples/data/22-watershed/nir.prj +1 -0
  107. noodlelab/examples/data/22-watershed/rain_gauges.csv +13 -0
  108. noodlelab/examples/data/22-watershed/red.asc +156 -0
  109. noodlelab/examples/data/22-watershed/red.prj +1 -0
  110. noodlelab/examples/data/23-satellite-link/README.md +19 -0
  111. noodlelab/examples/data/23-satellite-link/radios.csv +9 -0
  112. noodlelab/nodes/__init__.py +1 -0
  113. noodlelab/nodes/checks.py +197 -0
  114. noodlelab/nodes/core.py +500 -0
  115. noodlelab/nodes/engineering/__init__.py +33 -0
  116. noodlelab/nodes/engineering/controls.py +181 -0
  117. noodlelab/nodes/engineering/decibels.py +85 -0
  118. noodlelab/nodes/engineering/fluids.py +83 -0
  119. noodlelab/nodes/engineering/materials.py +126 -0
  120. noodlelab/nodes/engineering/sections.py +120 -0
  121. noodlelab/nodes/engineering/structures.py +217 -0
  122. noodlelab/nodes/engineering/thermal.py +80 -0
  123. noodlelab/nodes/engineering/trade.py +88 -0
  124. noodlelab/nodes/geo/__init__.py +31 -0
  125. noodlelab/nodes/geo/plot.py +353 -0
  126. noodlelab/nodes/geo/raster.py +837 -0
  127. noodlelab/nodes/geo/seismology.py +128 -0
  128. noodlelab/nodes/geo/track.py +151 -0
  129. noodlelab/nodes/geo/types.py +378 -0
  130. noodlelab/nodes/geo/vector.py +440 -0
  131. noodlelab/nodes/maths/__init__.py +26 -0
  132. noodlelab/nodes/maths/_common.py +72 -0
  133. noodlelab/nodes/maths/arrays.py +100 -0
  134. noodlelab/nodes/maths/calculus.py +60 -0
  135. noodlelab/nodes/maths/fitting.py +484 -0
  136. noodlelab/nodes/maths/plot.py +169 -0
  137. noodlelab/nodes/report.py +531 -0
  138. noodlelab/nodes/requirements.py +171 -0
  139. noodlelab/nodes/science/__init__.py +27 -0
  140. noodlelab/nodes/science/plot.py +218 -0
  141. noodlelab/nodes/science/signal.py +247 -0
  142. noodlelab/nodes/science/stats.py +495 -0
  143. noodlelab/nodes/science/studies.py +267 -0
  144. noodlelab/nodes/science/tables.py +619 -0
  145. noodlelab/nodes/science/timeseries.py +164 -0
  146. noodlelab/nodes/symbolic/__init__.py +23 -0
  147. noodlelab/nodes/symbolic/nodes.py +634 -0
  148. noodlelab/nodes/symbolic/parse.py +264 -0
  149. noodlelab/nodes/symbolic/types.py +200 -0
  150. noodlelab/nodes/symbolic/typst.py +280 -0
  151. noodlelab/nodes/uncertainty.py +275 -0
  152. noodlelab/nodes/units.py +138 -0
  153. noodlelab/py.typed +0 -0
  154. noodlelab/reports/__init__.py +6 -0
  155. noodlelab/reports/document.py +274 -0
  156. noodlelab/reports/math.py +81 -0
  157. noodlelab/reports/render.py +46 -0
  158. noodlelab/reports/templates/default.typ +160 -0
  159. noodlelab/server/__init__.py +16 -0
  160. noodlelab/server/admin.py +410 -0
  161. noodlelab/server/agents.py +368 -0
  162. noodlelab/server/api.py +879 -0
  163. noodlelab/server/app.py +83 -0
  164. noodlelab/server/auth.py +87 -0
  165. noodlelab/server/cluster.py +406 -0
  166. noodlelab/server/hub.py +117 -0
  167. noodlelab/server/permissions.py +72 -0
  168. noodlelab/server/runs.py +306 -0
  169. noodlelab/server/sharing.py +117 -0
  170. noodlelab/server/state.py +94 -0
  171. noodlelab/server/store.py +328 -0
  172. noodlelab/server/workspaces.py +207 -0
  173. noodlelab/server/ws.py +643 -0
  174. noodlelab/static/admin.html +31 -0
  175. noodlelab/static/assets/admin-CdIecA1j.js +8 -0
  176. noodlelab/static/assets/agent-6GBZ9nXN.css +32 -0
  177. noodlelab/static/assets/agent-BSxoem_1.js +41 -0
  178. noodlelab/static/assets/editor-DWWQ0JL2.js +25 -0
  179. noodlelab/static/assets/sharing-Bnota0Ch.js +1 -0
  180. noodlelab/static/assets/sharing-DgCDYlte.css +1 -0
  181. noodlelab/static/index.html +108 -0
  182. noodlelab/static/vendor/litegraph/litegraph.css +680 -0
  183. noodlelab/static/vendor/litegraph/litegraph.js +14427 -0
  184. noodlelab/tiers.py +201 -0
  185. noodlelab/verify.py +917 -0
  186. noodlelab-0.1.0.dist-info/METADATA +335 -0
  187. noodlelab-0.1.0.dist-info/RECORD +190 -0
  188. noodlelab-0.1.0.dist-info/WHEEL +4 -0
  189. noodlelab-0.1.0.dist-info/entry_points.txt +15 -0
  190. 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,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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"
@@ -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