loopmath 0.2.3__py3-none-any.whl → 0.2.4__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 (81) hide show
  1. loopmath/__init__.py +1 -1
  2. loopmath/adapters/atrium.py +1 -1
  3. loopmath/adapters/bb.py +1 -1
  4. loopmath/adapters/opencode.py +1 -1
  5. loopmath/adapters/orca.py +1 -1
  6. loopmath/adapters/otel_genai.py +1 -1
  7. loopmath/adapters/paseo.py +1 -1
  8. loopmath/belief/check_pymc.py +7 -3
  9. loopmath/belief/commands.py +41 -1
  10. loopmath/belief/fit.py +1 -1
  11. loopmath/builder/context.py +2 -1
  12. loopmath/builder/predict.py +8 -1
  13. loopmath/cli.py +73 -30
  14. loopmath/cli_graph.py +28 -19
  15. loopmath/cli_registry.py +25 -8
  16. loopmath/cli_support.py +117 -4
  17. loopmath/e0/verb.py +16 -8
  18. loopmath/fit_bayes.py +57 -0
  19. loopmath/graph/dataset_build.py +2 -2
  20. loopmath/graph/html_common.py +56 -25
  21. loopmath/graph/html_css.py +5 -2
  22. loopmath/graph/html_data.py +35 -12
  23. loopmath/graph/html_force.py +9 -1
  24. loopmath/graph/html_render.py +1 -1
  25. loopmath/graph/html_swimlanes.py +28 -6
  26. loopmath/logmatch/clip.py +16 -3
  27. loopmath/logmatch/match.py +94 -7
  28. loopmath/logmatch/settle.py +7 -6
  29. loopmath/onboard/commands.py +19 -6
  30. loopmath/onboard/history.py +48 -6
  31. loopmath/onboard/usual.py +4 -2
  32. loopmath/output.py +47 -0
  33. loopmath/priors/commands.py +5 -3
  34. loopmath/recommend/commands.py +65 -18
  35. loopmath/recommend/message.py +4 -3
  36. loopmath/recommend/storeread.py +23 -10
  37. loopmath/report/terminal.py +28 -3
  38. loopmath/share/commands.py +45 -5
  39. loopmath/share/export.py +12 -3
  40. loopmath/skill/commands.py +34 -7
  41. loopmath/skill/doctor.py +9 -2
  42. loopmath/skill/install.py +38 -21
  43. loopmath/skill/skills/loopmath-record-run/SKILL.md +2 -2
  44. loopmath/skill/skills/reference.md +10 -1
  45. loopmath/store/budget.py +79 -12
  46. loopmath/store/commands.py +166 -47
  47. loopmath/store/config.py +43 -0
  48. loopmath/store/finish.py +2 -2
  49. loopmath/store/fitjob.py +56 -11
  50. loopmath/store/home.py +68 -11
  51. loopmath/store/ids.py +28 -4
  52. loopmath/store/record.py +192 -31
  53. loopmath/store/report.py +18 -14
  54. loopmath/store/run_names.py +45 -0
  55. loopmath/store/runs.py +14 -0
  56. loopmath/store/status.py +42 -10
  57. loopmath/store/verify.py +106 -0
  58. loopmath/surface_presentation.py +28 -2
  59. loopmath/views/assets/builder.js +29 -6
  60. loopmath/views/assets/common.js +14 -4
  61. loopmath/views/assets/estimates.js +10 -5
  62. loopmath/views/assets/graph.js +71 -10
  63. loopmath/views/assets/plans.js +15 -4
  64. loopmath/views/assets/results.js +49 -19
  65. loopmath/views/assets/runs.js +1 -1
  66. loopmath/views/assets/viz.css +3 -1
  67. loopmath/views/assets/viz.js +19 -3
  68. loopmath/views/common.py +25 -1
  69. loopmath/views/posterior.py +126 -7
  70. loopmath/views/runs.py +37 -11
  71. loopmath/workflows/commands.py +14 -4
  72. loopmath/workflows/shapes.py +15 -0
  73. {loopmath-0.2.3.dist-info → loopmath-0.2.4.dist-info}/METADATA +37 -24
  74. {loopmath-0.2.3.dist-info → loopmath-0.2.4.dist-info}/RECORD +81 -79
  75. {loopmath-0.2.3.data → loopmath-0.2.4.data}/data/spec/ocp-v0.2.schema.json +0 -0
  76. {loopmath-0.2.3.data → loopmath-0.2.4.data}/data/spec/ocp-v0.schema.json +0 -0
  77. {loopmath-0.2.3.data → loopmath-0.2.4.data}/data/spec/ocp_conformance.py +0 -0
  78. {loopmath-0.2.3.dist-info → loopmath-0.2.4.dist-info}/WHEEL +0 -0
  79. {loopmath-0.2.3.dist-info → loopmath-0.2.4.dist-info}/entry_points.txt +0 -0
  80. {loopmath-0.2.3.dist-info → loopmath-0.2.4.dist-info}/licenses/LICENSE +0 -0
  81. {loopmath-0.2.3.dist-info → loopmath-0.2.4.dist-info}/top_level.txt +0 -0
loopmath/__init__.py CHANGED
@@ -5,4 +5,4 @@ The command line (`loopmath`, or its alias `loop`) is the interface; see the
5
5
  README and `loopmath --help`.
6
6
  """
7
7
 
8
- __version__ = "0.2.3"
8
+ __version__ = "0.2.4"
@@ -470,7 +470,7 @@ class AtriumAdapter(Adapter):
470
470
  return {
471
471
  "ocp": "0.2",
472
472
  "producer": {
473
- "name": "dagr-adapter-atrium",
473
+ "name": "loopmath/adapter-atrium",
474
474
  "framework": "atrium",
475
475
  "source_contract": "atrium/timeline+task-run-segments",
476
476
  "capabilities": {
loopmath/adapters/bb.py CHANGED
@@ -408,7 +408,7 @@ class BbAdapter(Adapter):
408
408
  )
409
409
  ignored_snapshots = sum(thread["ignored_token_snapshots"] for thread in selected)
410
410
  producer: dict[str, Any] = {
411
- "name": "dagr-adapter-bb",
411
+ "name": "loopmath/adapter-bb",
412
412
  "framework": "bb",
413
413
  "source_contract": "bb/sqlite",
414
414
  "capabilities": {
@@ -386,7 +386,7 @@ def _emit_document(
386
386
  else:
387
387
  source_contract = "opencode export"
388
388
  producer: dict[str, Any] = {
389
- "name": "dagr-adapter-opencode",
389
+ "name": "loopmath/adapter-opencode",
390
390
  "framework": "opencode",
391
391
  "source_contract": source_contract,
392
392
  "capabilities": {
loopmath/adapters/orca.py CHANGED
@@ -458,7 +458,7 @@ class OrcaAdapter(Adapter):
458
458
  return {
459
459
  "ocp": "0.2",
460
460
  "producer": {
461
- "name": "dagr-adapter-orca",
461
+ "name": "loopmath/adapter-orca",
462
462
  "framework": "orca",
463
463
  "source_contract": "orca/orchestration.db",
464
464
  "capabilities": {
@@ -383,7 +383,7 @@ class OtelGenAIAdapter(Adapter):
383
383
  return {
384
384
  "ocp": "0.2",
385
385
  "producer": {
386
- "name": "dagr-adapter-otel-genai",
386
+ "name": "loopmath/adapter-otel-genai",
387
387
  "framework": "+".join(harnesses) if harnesses else "unknown",
388
388
  "source_contract": "OTLP/JSON file exporter",
389
389
  "capabilities": {
@@ -418,7 +418,7 @@ class PaseoAdapter(Adapter):
418
418
  return {
419
419
  "ocp": "0.2",
420
420
  "producer": {
421
- "name": "dagr-adapter-paseo",
421
+ "name": "loopmath/adapter-paseo",
422
422
  "framework": "paseo",
423
423
  "source_contract": "paseo/agent-json",
424
424
  "capabilities": {
@@ -21,9 +21,13 @@ from __future__ import annotations
21
21
  import time
22
22
 
23
23
  import numpy as np
24
- import pymc as pm
25
- import pytensor
26
- import pytensor.tensor as pt
24
+
25
+ from ..fit_bayes import use_store_compiledir
26
+
27
+ use_store_compiledir() # before pytensor is imported: its compile folder goes in the store's cache
28
+ import pymc as pm # noqa: E402
29
+ import pytensor # noqa: E402
30
+ import pytensor.tensor as pt # noqa: E402
27
31
 
28
32
  from .gaussian import HYPER_SD, SIGMA_PRIOR_SD, GaussianHead
29
33
 
@@ -121,6 +121,36 @@ def _print_summary(s: dict) -> None:
121
121
  f"(table in pymc_check.json)")
122
122
 
123
123
 
124
+ def _in_pymc_check(exc: BaseException) -> bool:
125
+ """Whether `exc` was raised inside the `--full` PyMC check (check_pymc, pymc or pytensor)."""
126
+ tb = exc.__traceback__
127
+ while tb is not None:
128
+ if tb.tb_frame.f_globals.get("__name__") == "loopmath.belief.check_pymc":
129
+ return True
130
+ tb = tb.tb_next
131
+ return False
132
+
133
+
134
+ def _pymc_check_failed(home: Path, exc: BaseException) -> int:
135
+ """`fit --full` whose PyMC check failed (a compiler error, say): remove the unfinished fit
136
+ folder and say what happened in one line, without a traceback."""
137
+ from .fit import FitBusy, fit_lock, remove_partials
138
+
139
+ try:
140
+ with fit_lock(home, LOCK_WAIT_S): # under the lock every .partial is from a fit that stopped
141
+ remove_partials(home)
142
+ except FitBusy:
143
+ pass # the next fit removes it
144
+ lines = [ln.strip() for ln in str(exc).splitlines() if ln.strip()]
145
+ said = ([ln for ln in lines if ln.startswith("ld:")] # the linker's reason, then the first compiler error
146
+ or [ln for ln in lines if ln.startswith("error:") or " error: " in ln])
147
+ detail = (said[0] if said else (lines or [type(exc).__name__])[-1])[:200]
148
+ kind = "could not compile its model on this machine" if type(exc).__name__ == "CompileError" else "stopped"
149
+ return output.fail(f"fit --full: the PyMC check {kind} ({type(exc).__name__}: {detail}). No fit was "
150
+ "written and fits/latest did not move; loopmath fit without --full fits as usual.",
151
+ output.EXIT_USER)
152
+
153
+
124
154
  def fit(args: argparse.Namespace) -> int:
125
155
  home = output.home(getattr(args, "home", None))
126
156
  without = tuple(getattr(args, "without", None) or ())
@@ -140,10 +170,16 @@ def fit(args: argparse.Namespace) -> int:
140
170
  else:
141
171
  print(f"fit queued: {started.get('reason', 'a fit is running')}")
142
172
  return output.EXIT_OK
173
+ import contextlib
174
+ import sys
175
+
143
176
  from .fit import FitBusy, NothingToFit, UnknownSource, fit as run_fit
144
177
 
178
+ # --full: a failed compile makes pytensor print where it left the C code on stdout, which is for the result
179
+ quiet = contextlib.redirect_stdout(sys.stderr) if full else contextlib.nullcontext()
145
180
  try:
146
- path = run_fit(home, no_prior=no_prior, without=without, full=full, wait_s=LOCK_WAIT_S)
181
+ with quiet:
182
+ path = run_fit(home, no_prior=no_prior, without=without, full=full, wait_s=LOCK_WAIT_S)
147
183
  except FitBusy:
148
184
  return output.fail(f"another fit has held {home / 'fits'} for more than {int(LOCK_WAIT_S)} s; "
149
185
  "try again later or use --background", output.EXIT_LOCKED)
@@ -151,6 +187,10 @@ def fit(args: argparse.Namespace) -> int:
151
187
  return output.fail(str(exc), output.EXIT_NOT_FOUND)
152
188
  except NothingToFit as exc:
153
189
  return output.fail(str(exc), output.EXIT_USER)
190
+ except Exception as exc:
191
+ if not (full and _in_pymc_check(exc)):
192
+ raise
193
+ return _pymc_check_failed(home, exc)
154
194
  summary = _summary(path)
155
195
  if as_json:
156
196
  output.emit_json(SCHEMA, summary)
loopmath/belief/fit.py CHANGED
@@ -72,7 +72,7 @@ REASON_NOTES = {
72
72
  "attempt without usable cost": "crashed or no usage reported",
73
73
  "attempt outside the workflow": "a model attempt at no piece of the run's workflow",
74
74
  "duplicate run id": "the same run found twice",
75
- SHIPPED_COPY: "your store has the same run id; your copy is used",
75
+ SHIPPED_COPY: "this run ships with loopmath and is also in your store, so it counts once, as yours",
76
76
  "no configuration": "the run records no workflow and settings",
77
77
  "workflow ref not resolved": "a workflow this version does not know",
78
78
  "workflow has no pieces": "an empty workflow",
@@ -21,6 +21,7 @@ from typing import Any
21
21
  from ..output import EXIT_NOT_FOUND, EXIT_OK, fail, home as home_dir
22
22
  from ..recommend import commands as C
23
23
  from ..recommend import engine, storeread
24
+ from ..store import run_names
24
25
  from ..recommend.curve import accepted_within
25
26
  from ..recommend.engine import Recommendation, r6
26
27
  from ..recommend.storeread import Conf
@@ -221,7 +222,7 @@ def recorded_runs(home: Any) -> list[tuple[Configuration, int]]:
221
222
  run_of[str(cfg)] = str(row["run"])
222
223
  out = []
223
224
  for cfg_id, n in counts.items():
224
- doc = storeread.read_json(home / "runs" / f"{run_of[cfg_id]}.ocp.json") if cfg_id in run_of else None
225
+ doc = storeread.read_json(home / "runs" / run_names.file_name(run_of[cfg_id])) if cfg_id in run_of else None
225
226
  conf = doc.get("run", {}).get("configuration") if isinstance(doc, dict) else None
226
227
  cfg = storeread.config_from_any(conf) if conf else None
227
228
  if cfg is not None:
@@ -136,6 +136,12 @@ def clean(obj: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
136
136
  return {**obj, "id": None, "settings": settings}, notes
137
137
 
138
138
 
139
+ # P3-21 (0.2.4): a review gate that names a repairer at a round limit of 1 is one of loopmath's own shapes (reviewed
140
+ # once; the search builds it and may recommend it). The workflow file check warns about it; the builder page instead
141
+ # says on the gate what 1 round means, so that warning is not shown here.
142
+ REVIEW_ONCE = "budget_rounds is 1"
143
+
144
+
139
145
  def parse_config(obj: Any, models: dict[str, int]
140
146
  ) -> tuple[Configuration | None, list[dict[str, Any]], list[dict[str, Any]]]:
141
147
  """(configuration, errors, warnings); the configuration is None when it does not read at all."""
@@ -158,7 +164,8 @@ def parse_config(obj: Any, models: dict[str, int]
158
164
  errors.append(err(p.id, f"piece {p.id!r}: model {s.model!r} is a retired or unknown model; "
159
165
  f"pick one of {known}"))
160
166
  if not errors:
161
- warnings += notes_of(workflow_warnings(cfg.workflow) + settings_warnings(cfg.settings), cfg)
167
+ shown = [w for w in workflow_warnings(cfg.workflow) if REVIEW_ONCE not in w]
168
+ warnings += notes_of(shown + settings_warnings(cfg.settings), cfg)
162
169
  return cfg, errors, warnings
163
170
 
164
171
 
loopmath/cli.py CHANGED
@@ -5,27 +5,26 @@ Verbs:
5
5
  scan explain where implicit scanning occurs
6
6
  adapt emit OCP v0.2 from a registered ADE adapter
7
7
  validate-prices check a price table's schema, staleness and todo flags
8
- analyze-e0 the frozen E0 corpus walkdown
9
8
  prices show the active price table
10
9
  graph the workflow graph of one or more workspaces (ocp, json, dot, run, html: self-contained interactive HTML viewer)
11
- research E1 v2 cost model: research fit, research transfer-test (needs the [bayes] extra)
12
- verify-receipts verify an E2 spend ledger and frozen preregistration
10
+ research research data folders: research fit, research transfer-test (the bayes extra), research analyze-e0 (the e0 extra)
11
+ verify-receipts check that the receipts in your store are whole and scored right (or give a research ledger file)
13
12
 
14
13
  0.1.0 verbs (design/0.1/02-commands.md, registered in cli_registry.py):
15
- plan: task-types workflows recommend plan
16
- record: run start|attempt|artifact|finish|import outcome budget config
14
+ plan: task-types workflows recommend builder plan
15
+ record: run start|record|import|attempt|artifact|finish|sessions outcome budget config
17
16
  learn: fit onboard share
18
17
  view: runs posterior status report doctor skill ocp
19
18
 
20
- No verb in this CLI makes a network call. Verbs write only to the store
21
- ($LOOPMATH_HOME, default ~/.loopmath, which also holds the parse cache) and to
22
- paths the user names.
19
+ No verb in this CLI connects to another machine; builder serves on 127.0.0.1 only.
20
+ Verbs write only to the store ($LOOPMATH_HOME, default ~/.loopmath, which also
21
+ holds the parse cache and the fit --full compile folder), to the skill folders
22
+ skill install names, and to paths the user names.
23
23
  """
24
24
 
25
25
  from __future__ import annotations
26
26
 
27
27
  import argparse
28
- import importlib.util as _importlib_util
29
28
  import sys
30
29
  import time
31
30
  from types import ModuleType
@@ -79,6 +78,7 @@ class _CliModule(ModuleType):
79
78
  sys.modules[__name__].__class__ = _CliModule
80
79
 
81
80
  DEFAULT_SINCE_DAYS = 14.0
81
+ _ANALYZE_SCHEMA = "loopmath.analyze/1"
82
82
 
83
83
 
84
84
  def _counted_noun(count: int, singular: str, plural: str | None = None) -> str:
@@ -185,6 +185,18 @@ def analyze(args: argparse.Namespace) -> int:
185
185
  ci=args.ci,
186
186
  )
187
187
  result["ratio_pair"] = _extremes_ratio(df, result, price_mod)
188
+ result["min_n"] = args.min_n
189
+
190
+ if getattr(args, "json", False):
191
+ from .output import emit_json
192
+
193
+ grading = list(grade_mod.grading_report(records, coverage)) if args.grading else None
194
+ emit_json(_ANALYZE_SCHEMA, _cli_support.analyze_payload(
195
+ result, diag=diag, coverage=coverage, price_lines=price_lines, grading=grading,
196
+ walkdown=surface_mod.walkdown(result)))
197
+ if not args.quiet:
198
+ print(f"ran in {time.time() - t0:.1f} s", file=sys.stderr)
199
+ return 0
188
200
 
189
201
  print(
190
202
  terminal.render(
@@ -246,7 +258,7 @@ def _add_graph(sub) -> None:
246
258
  p.add_argument("--workspace", action="append", default=[], help="native workspace name to include (repeatable; required unless --ocp is used)")
247
259
  p.add_argument("--ocp", action="append", default=[], metavar="FILE", help="read an OCP document (v0.2 or later) as a graph source (repeatable)")
248
260
  p.add_argument("--format", type=_cli_support._graph_format, choices=GRAPH_FORMATS, default="ocp", help="ocp (OCP v0.3, default), json (internal dagr_graph), dot (Graphviz), run (herdr-dagr contract v3 run file), html (self-contained interactive HTML viewer)")
249
- p.add_argument("--out", default=None, help="write here instead of stdout (must be inside the git worktree of the current directory; parent directories are created)")
261
+ p.add_argument("--out", default=None, help="write here instead of stdout (any path; missing folders are created; an existing file is replaced)")
250
262
  p.add_argument("--privacy", choices=("metadata_only", "full"), default="metadata_only", help="OCP privacy profile (default metadata_only)")
251
263
  p.add_argument("--logs", default=None, help="parse this path instead of the default log roots")
252
264
  p.add_argument(
@@ -259,7 +271,8 @@ def _add_graph(sub) -> None:
259
271
  p.add_argument("--limit", type=int, default=None, help="stop after N files per harness")
260
272
  p.add_argument("--no-cache", action="store_true", help="reparse everything, ignoring the cache")
261
273
  p.add_argument("--prices", default=None, help="price table to use instead of the packaged one")
262
- p.add_argument("--quiet", action="store_true", help="suppress progress output on stderr; exclusion counters always print")
274
+ p.add_argument("--quiet", action="store_true", help="suppress progress output on stderr")
275
+ p.add_argument("--verbose", action="store_true", help="also print every count of what was skipped, excluded or could not be placed")
263
276
  p.set_defaults(func=graph_verb)
264
277
 
265
278
 
@@ -279,7 +292,7 @@ def adapt_verb(args: argparse.Namespace) -> int:
279
292
 
280
293
  out_path = None
281
294
  if args.out:
282
- out_path, err = _resolve_out(args.out)
295
+ out_path, err = _resolve_out(args.out, "out.ocp.json")
283
296
  if err:
284
297
  print(err, file=sys.stderr)
285
298
  return 2
@@ -302,9 +315,18 @@ def adapt_verb(args: argparse.Namespace) -> int:
302
315
 
303
316
  adapter = adapter_type()
304
317
  adapter.configure_services(default_adapter_services())
305
- doc = adapter.emit(selection)
318
+ try:
319
+ doc = adapter.emit(selection)
320
+ except (OSError, ValueError) as exc: # a store path it cannot read: say so, no traceback
321
+ print(f"error: adapt {args.name}: {exc}", file=sys.stderr)
322
+ return 1
306
323
  if not isinstance(doc, dict):
307
324
  raise TypeError(f"adapter {args.name!r} emit() must return a dict")
325
+ if not doc.get("nodes") and not doc.get("attempts"):
326
+ where = ", ".join(str(p) for p in selection.stores) if selection.stores else "its default location"
327
+ print(f"error: adapt {args.name} found no sessions in {where}; nothing written. Point --store at the "
328
+ f"{args.name} data, or widen --since, --until, --session and --workspace", file=sys.stderr)
329
+ return 1
308
330
  from .ocp import version_at_least
309
331
 
310
332
  if not version_at_least(doc, "0.2"):
@@ -323,7 +345,7 @@ def adapt_verb(args: argparse.Namespace) -> int:
323
345
  def _add_adapt(sub) -> None:
324
346
  p = sub.add_parser("adapt", help="emit OCP v0.2 from an ADE store (loopmath ocp migrate converts it to v0.3)")
325
347
  p.add_argument("name", metavar="NAME", help="registered adapter name")
326
- p.add_argument("--out", default=None, help="write here instead of stdout (must be inside the git worktree of the current directory; parent directories are created)")
348
+ p.add_argument("--out", default=None, help="write here instead of stdout (any path; missing folders are created; an existing file is replaced)")
327
349
  p.add_argument("--store", action="append", default=[], metavar="PATH", help="use this store path instead of discovery (repeatable)")
328
350
  p.add_argument("--session", action="append", default=[], metavar="ID", help="include this session id (repeatable; default: all)")
329
351
  p.add_argument("--workspace", action="append", default=[], metavar="WORKSPACE", help="include this workspace (repeatable; default: all)")
@@ -371,6 +393,9 @@ def _add_analyze(sub) -> None:
371
393
  p.add_argument("--tokens", action="store_true", help="rank by output tokens instead of dollars")
372
394
  p.add_argument("--grading", action="store_true", help="print which rule graded what")
373
395
  p.add_argument("--quiet", action="store_true", help="suppress progress and timing on stderr")
396
+ p.add_argument("--home", default=None, metavar="PATH", help="store root whose cache/ holds the log parse cache "
397
+ "(default: $LOOPMATH_HOME or ~/.loopmath; LOOPMATH_CACHE_DIR wins); analyze writes nothing else")
398
+ p.add_argument("--json", action="store_true", help="print one JSON object on stdout")
374
399
  p.set_defaults(func=analyze)
375
400
 
376
401
 
@@ -385,11 +410,18 @@ def _add_validate_prices(sub) -> None:
385
410
  def _add_fit(sub) -> None:
386
411
  from .research_defaults import E1A_OBSERVE, SEED
387
412
 
388
- research = sub.add_parser("research", help="E1 research verbs: fit, transfer-test (needs the [bayes] extra)")
413
+ research = sub.add_parser(
414
+ "research",
415
+ help="verbs for research data folders that are not part of the package (loopmath research --help)",
416
+ description="Verbs for research data folders that are not part of the package: sweep run records "
417
+ "(research fit, research transfer-test; they need the bayes extra) and a session corpus "
418
+ "(research analyze-e0; it needs the e0 extra). Name a folder with --sweep-dir or --corpus, "
419
+ "LOOPMATH_SWEEP_DIR or LOOPMATH_E0_CORPUS, or config research.sweep_dir or research.e0_corpus.",
420
+ )
389
421
  rsub = research.add_subparsers(dest="research_command", required=True)
390
422
  for name, fn, helptext in (
391
- ("fit", fit_verb, "fit the E1 cost model on the sweep data"),
392
- ("transfer-test", transfer_test_verb, "score held-out cells from a fit"),
423
+ ("fit", fit_verb, "fit the research cost model on sweep run records"),
424
+ ("transfer-test", transfer_test_verb, "fit on some cells of the sweep and score the held-out ones"),
393
425
  ):
394
426
  p = rsub.add_parser(name, help=helptext)
395
427
  p.add_argument("--sweep-dir", default=None, help="sweep run records directory (default: LOOPMATH_SWEEP_DIR, then research.sweep_dir in config)")
@@ -405,10 +437,20 @@ def _add_fit(sub) -> None:
405
437
  else:
406
438
  p.add_argument("--ci", type=float, default=0.80, help="central interval for held-out coverage (default 0.80)")
407
439
  p.set_defaults(func=fn)
440
+ from .e0 import verb as _e0_verb
441
+
442
+ _e0_verb.register(rsub)
408
443
 
409
444
 
410
445
  def verify_receipts_verb(args: argparse.Namespace) -> int:
411
- """Verify an E2 receipt ledger and print one machine-friendly result."""
446
+ """No ledger: check the store's receipts. A ledger path: verify that research receipt ledger."""
447
+ if args.ledger is None:
448
+ from .store.home import Store
449
+ from .store.verify import verify_lines, verify_store
450
+
451
+ res = verify_store(Store(args.home))
452
+ print("\n".join(verify_lines(res)))
453
+ return 1 if res["failed"] else 0
412
454
  from .receipts import ReceiptViolation, verify_receipts
413
455
 
414
456
  try:
@@ -421,18 +463,20 @@ def verify_receipts_verb(args: argparse.Namespace) -> int:
421
463
 
422
464
 
423
465
  def _add_verify_receipts(sub) -> None:
424
- p = sub.add_parser("verify-receipts", help="verify an E2 receipt ledger")
466
+ p = sub.add_parser("verify-receipts", help="check that the receipts in your store are whole and scored right "
467
+ "(or give a research ledger file)")
425
468
  p.add_argument(
426
469
  "ledger",
427
470
  nargs="?",
428
- default="e2-receipts.jsonl",
429
- help="receipt ledger (default: e2-receipts.jsonl)",
471
+ default=None,
472
+ help="a research receipt ledger to verify instead (e.g. e2-receipts.jsonl)",
430
473
  )
431
474
  p.add_argument(
432
475
  "--frozen",
433
476
  default=None,
434
- help="checksum file (default: FROZEN.txt beside the ledger)",
477
+ help="with a ledger: its checksum file (default: FROZEN.txt beside the ledger)",
435
478
  )
479
+ p.add_argument("--home", default=None, metavar="PATH", help="the store (default: $LOOPMATH_HOME or ~/.loopmath)")
436
480
  p.set_defaults(func=verify_receipts_verb)
437
481
 
438
482
 
@@ -455,15 +499,14 @@ def main(argv: list[str] | None = None) -> int:
455
499
 
456
500
  _registry.register(sub)
457
501
 
458
- if _importlib_util.find_spec("matplotlib") is not None:
459
- # The E0 walkdown verb draws figures, so running it needs matplotlib.
460
- # SPEC section 2's light-core rule says `analyze` runs on numpy and
461
- # pandas, so matplotlib lives in the `e0` extra and this verb is simply
462
- # absent without it. It registers from e0/verb.py without importing the
463
- # walkdown, so no other command pays for pandas and matplotlib.
464
- from .e0 import verb as _e0_verb
502
+ # The E0 walkdown draws figures, so running it needs matplotlib (the `e0`
503
+ # extra; SPEC section 2's light core is numpy and pandas). It is listed under
504
+ # `loopmath research`; the old top-level name stays callable, off the list.
505
+ # It registers from e0/verb.py without importing the walkdown, so no other
506
+ # command pays for pandas and matplotlib.
507
+ from .e0 import verb as _e0_verb
465
508
 
466
- _e0_verb.register(sub)
509
+ _e0_verb.register(sub, listed=False)
467
510
 
468
511
  try:
469
512
  _add_fit(sub)
loopmath/cli_graph.py CHANGED
@@ -10,6 +10,8 @@ from .cli_support import _pipeline_counters, _scan_snapshot_id
10
10
 
11
11
 
12
12
  GRAPH_FORMATS = ("ocp", "json", "dot", "run", "html")
13
+ # The file name a refused directory `--out` suggests, by format.
14
+ _OUT_EXAMPLES = {"ocp": "out.ocp.json", "json": "out.json", "dot": "out.dot", "run": "out.run.json", "html": "out.html"}
13
15
  # Graph.meta keys that are totals, not exclusions: printed in the headline, not the list.
14
16
  _GRAPH_META_TOTALS = (
15
17
  "n_nodes",
@@ -47,21 +49,21 @@ def _worktree_root():
47
49
  return Path(r.stdout.strip()).resolve()
48
50
 
49
51
 
50
- def _resolve_out(out: str):
51
- """`--out` resolved against the worktree root (the git top level of the
52
- current directory); anything outside it is refused, and so is any `--out`
53
- when the current directory is not inside a git worktree. Returns (path, None)
54
- or (None, error message); nothing is written on refusal."""
52
+ def _resolve_out(out: str, example: str = "out.json"):
53
+ """`--out` as the user named it: any path, relative to the current directory,
54
+ with `~` expanded. A path that is an existing directory is refused (writing
55
+ would replace it), and so is one under an existing file, which cannot be a
56
+ folder. Returns (path, None) or (None, error message); nothing is written on
57
+ refusal. Missing parent folders are created when the file is written.
58
+ `example` is the file name the directory refusal suggests."""
55
59
  from pathlib import Path
56
60
 
57
- root = _worktree_root()
58
61
  target = Path(out).expanduser().resolve()
59
- if root is None:
60
- return None, f"error: --out {out} refused: {Path.cwd()} is not inside a git worktree (git rev-parse --show-toplevel failed), so there is no worktree to write into; run from inside one or drop --out"
61
- try:
62
- target.relative_to(root)
63
- except ValueError:
64
- return None, f"error: --out {out} resolves to {target}, outside the worktree {root}; the extractor writes only inside it"
62
+ if target.is_dir():
63
+ return None, f"error: --out {out} is a directory; name a file, for example {Path(out) / example}"
64
+ blocker = next((p for p in target.parents if p.exists()), None)
65
+ if blocker is not None and not blocker.is_dir():
66
+ return None, f"error: --out {out} cannot be written: {blocker} is a file, not a folder"
65
67
  return target, None
66
68
 
67
69
 
@@ -106,9 +108,9 @@ def graph_verb(args: argparse.Namespace) -> int:
106
108
  self-contained interactive visualizer. Everything the
107
109
  pipeline excluded (files skipped, cut by `--limit` or the time window,
108
110
  records ungraded or unpriced) and everything the extractor could not place
109
- is counted in the graph's meta, which every format carries, and printed on
110
- stderr. `--quiet` silences progress
111
- only; the counters always print.
111
+ is counted in the graph's meta, which every format carries; `--verbose`
112
+ prints every counter on stderr, and by default only a summary prints.
113
+ `--quiet` silences progress only.
112
114
  """
113
115
  import json
114
116
 
@@ -125,7 +127,7 @@ def graph_verb(args: argparse.Namespace) -> int:
125
127
 
126
128
  out_path = None
127
129
  if args.out:
128
- out_path, err = _resolve_out(args.out)
130
+ out_path, err = _resolve_out(args.out, _OUT_EXAMPLES.get(args.format, "out.json"))
129
131
  if err:
130
132
  print(err, file=sys.stderr)
131
133
  return 2
@@ -317,24 +319,31 @@ def graph_verb(args: argparse.Namespace) -> int:
317
319
  f" imported {len(args.ocp)} OCP document(s); "
318
320
  "costs and evidence tiers kept as supplied"
319
321
  )
322
+ counters: list[str] = []
320
323
  for key in sorted(m):
321
324
  value = m[key]
322
325
  if key.endswith("_reason"):
323
326
  continue
324
327
  if value is None and f"{key}_reason" in m:
325
- say(f" {key}: unknown ({m[f'{key}_reason']})")
328
+ counters.append(f" {key}: unknown ({m[f'{key}_reason']})")
326
329
  elif (
327
330
  isinstance(value, (int, float))
328
331
  and not isinstance(value, bool)
329
332
  and value
330
333
  and key not in _GRAPH_META_TOTALS
331
334
  ):
332
- say(f" {key}: {value}")
335
+ counters.append(f" {key}: {value}")
333
336
  for key in sorted(emitter):
334
337
  # The run exporter is a full honesty ledger: print every one of its
335
338
  # counters, including zero, so a collision class is never silent.
336
339
  if emitter[key] or (args.format == "run" and key.startswith("run.")):
337
- say(f" emitter.{key}: {emitter[key]}")
340
+ counters.append(f" emitter.{key}: {emitter[key]}")
341
+ # The counters are for whoever debugs the extractor; every format carries them in its meta.
342
+ if getattr(args, "verbose", False):
343
+ for line in counters:
344
+ say(line)
345
+ elif counters:
346
+ say(f" {len(counters)} more counts of what was skipped or could not be placed: add --verbose")
338
347
  if out_path is not None:
339
348
  say(f"wrote {out_path} ({args.format}) in {time.time() - t0:.1f} s")
340
349
  return 0
loopmath/cli_registry.py CHANGED
@@ -146,7 +146,7 @@ TIER_CHOICES = ["verified", "reported", "heuristic", "asserted"]
146
146
 
147
147
 
148
148
  def _add_recording(sub) -> None:
149
- p = sub.add_parser("run", help="record a run: start, record, import, attempt, artifact, finish")
149
+ p = sub.add_parser("run", help="record a run: start, record, import, attempt, artifact, finish, sessions")
150
150
  rs = p.add_subparsers(dest="run_command", required=True)
151
151
 
152
152
  q = rs.add_parser("start", help="open a run (OCP v0.3 skeleton in the store)")
@@ -178,7 +178,7 @@ def _add_recording(sub) -> None:
178
178
  q.add_argument("--model", default=None, metavar="M", help="model name")
179
179
  q.add_argument("--effort", default=None, metavar="E", help="reasoning effort")
180
180
  q.add_argument("--cwd", default=None, metavar="PATH", help="the folder the agent works in; with no --session its log is matched by this folder, a session that started in the attempt's window, and the model")
181
- q.add_argument("--session", default=None, metavar="ID", help="session id, or self for the orchestrator's own Claude Code session, counted only inside this attempt's started-at to ended-at window")
181
+ q.add_argument("--session", default=None, metavar="ID", help="session id, or self for the orchestrator's own Claude Code session, counted only inside this attempt's started-at to ended-at window (outside Claude Code: the one Claude Code session in this folder active in the last 10 minutes; run sessions lists the ids)")
182
182
  q.add_argument("--round", type=int, default=1, metavar="K", help="round of this piece, 1 for the first try")
183
183
  q.add_argument("--cause", default="initial", choices=["initial", "sent_back", "gate_failed", "followup", "other"],
184
184
  help="why this attempt was started")
@@ -225,10 +225,13 @@ def _add_recording(sub) -> None:
225
225
  "it replaces the rule the recommendation was made for")
226
226
  q.add_argument("--session", action="append", default=[], metavar="[PIECE=]ID", help="a session that worked on the run: a "
227
227
  "Claude Code session id, a Codex thread id, or self for this Claude Code session (repeatable); "
228
- "PIECE= names its piece")
228
+ "PIECE= names its piece; run sessions lists the ids. Only the part of a session inside the window "
229
+ "counts (a Codex session counts whole)")
229
230
  q.add_argument("--cwd", action="append", default=[], metavar="[PIECE=]PATH", help="a folder an agent with no session id "
230
231
  "worked in; its log is matched at finish by folder and time (repeatable)")
231
- q.add_argument("--since", default=None, metavar="TS", help="when the work began, without --run (e.g. 2h or an ISO time)")
232
+ q.add_argument("--since", default=None, metavar="TS", help="when the work began (e.g. 2h or a local ISO time); with "
233
+ "--run the default is the run's start")
234
+ q.add_argument("--until", default=None, metavar="TS", help="when the work ended (e.g. 1h or a local ISO time; default now)")
232
235
  q.add_argument("--verified", action="append", default=[], metavar="NAME=VALUE", help="a verdict you observed yourself "
233
236
  "(pass, fail, accept, reject, error)")
234
237
  q.add_argument("--reported", action="append", default=[], metavar="NAME=VALUE", help="a verdict someone reported")
@@ -240,6 +243,13 @@ def _add_recording(sub) -> None:
240
243
  _common(q)
241
244
  q.set_defaults(slate=None, new_slate=False, func=_lazy("loopmath.store.record:run_record"))
242
245
 
246
+ q = rs.add_parser("sessions", help="list the recent Claude Code and Codex sessions in this folder, with their ids")
247
+ q.add_argument("--cwd", default=None, metavar="PATH", help="the folder the sessions ran in (default: this one)")
248
+ q.add_argument("--days", type=float, default=7, metavar="N", help="how far back to look (default 7)")
249
+ q.add_argument("--limit", type=int, default=10, metavar="N", help="at most this many, newest first (default 10)")
250
+ _common(q)
251
+ q.set_defaults(func=_lazy("loopmath.store.commands:run_sessions"))
252
+
243
253
  p = sub.add_parser("outcome", help="record a verdict, score, late event or pair preference")
244
254
  target = p.add_mutually_exclusive_group(required=True)
245
255
  target.add_argument("--run", default=None, metavar="RUN", help="the run the signal is about")
@@ -329,16 +339,16 @@ def _add_learning(sub) -> None:
329
339
  p = sub.add_parser("share", help="write the shareable part of your store to a file",
330
340
  description="Keeps task types, features, workflows, settings, tokens, dollars and outcomes; "
331
341
  "repos, tasks and runs only as salted hashes; no titles, paths, commands, session ids or commits.")
332
- p.add_argument("--out", default=None, metavar="FILE", help="the share file to write (gzip JSON; check it first with --preview)")
342
+ p.add_argument("--out", default=None, metavar="FILE", help="the share file to write: plain JSON for FILE.json, gzip JSON for FILE.json.gz or any other name (check it first with --preview)")
333
343
  p.add_argument("--since", default=None, metavar="D", help="only runs since then: 90d, 4w, 12h or a date")
334
344
  p.add_argument("--preview", action="store_true", help="print exactly what would leave; writes no share file")
335
345
  _common(p)
336
346
  p.set_defaults(func=_lazy("loopmath.share.commands:share"))
337
347
 
338
- p = sub.add_parser("prior", help="the shipped prior bundle: show, build, import-shared")
348
+ p = sub.add_parser("prior", help="the shipped prior bundle: show, build, import-shared, remove-shared")
339
349
  ps = p.add_subparsers(dest="prior_command", required=True)
340
350
  q = ps.add_parser("build", help="rebuild the packaged prior bundle from the sweep, E0 and RQ1 inputs")
341
- q.add_argument("--out", default=None, metavar="DIR", help="where to write the bundle (default: the packaged bundle folder, which it replaces)")
351
+ q.add_argument("--out", default=None, metavar="DIR", help="where to write the bundle (required)")
342
352
  q.add_argument("--sweep-dir", action="append", default=None, metavar="PATH", help="sweep results, one folder per batch; repeatable (default: LOOPMATH_SWEEP_DIR, one folder)")
343
353
  q.add_argument("--e0-corpus", default=None, metavar="PATH", help="E0 corpus (default: LOOPMATH_E0_CORPUS)")
344
354
  q.add_argument("--rq1-dir", default=None, metavar="PATH", help="RQ1 OCP documents (default: LOOPMATH_PRIOR_RQ1)")
@@ -352,6 +362,10 @@ def _add_learning(sub) -> None:
352
362
  q.add_argument("file", metavar="FILE", help="a loopmath.share/1 file written by loopmath share (gzip or plain JSON)")
353
363
  _common(q)
354
364
  q.set_defaults(func=_lazy("loopmath.share.commands:import_shared"))
365
+ q = ps.add_parser("remove-shared", help="take an imported organization's runs out of your priors")
366
+ q.add_argument("org", metavar="ORG", help="the organization import-shared named, as shared:HASH or HASH")
367
+ _common(q)
368
+ q.set_defaults(func=_lazy("loopmath.share.commands:remove_shared"))
355
369
 
356
370
 
357
371
  # ---------------------------------------------------------------- viewing
@@ -408,6 +422,9 @@ def _add_viewing(sub) -> None:
408
422
  help="for you in every folder (user), or for this repository only (project)")
409
423
  q.add_argument("--dir", default=None, metavar="PATH", help="the project folder for --scope project "
410
424
  "(default: this folder)")
425
+ if verb == "install":
426
+ q.add_argument("--dry-run", action="store_true", help="print every file install would write or "
427
+ "change, and write nothing")
411
428
  _common(q)
412
429
  q.set_defaults(func=_lazy(f"loopmath.skill.commands:{verb}"))
413
430
  q = ss.add_parser("show", help="print one skill (default loopmath, the start-here skill) or the shared reference")
@@ -429,7 +446,7 @@ def _add_viewing(sub) -> None:
429
446
  q.set_defaults(func=_lazy("loopmath.ocp.commands:migrate"))
430
447
 
431
448
 
432
- HIDDEN = frozenset({"plan"}) # callable, but not listed by `loopmath --help`
449
+ HIDDEN = frozenset({"plan", "analyze-e0"}) # callable, but not listed by `loopmath --help`
433
450
 
434
451
 
435
452
  def command_metavar(sub) -> str: