fieldloop 0.2.0__cp312-abi3-win_amd64.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.
fieldloop/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """FieldLoop — capture robot decisions, attribute delayed outcomes, curate evidence.
2
+
3
+ The public surface re-exported here is the whole API; the compiled Rust core lives in
4
+ the internal `fieldloop._native` module and callers should never import it directly.
5
+ Re-exporting from `__init__` keeps `import fieldloop` stable while the package layers
6
+ pure-Python tooling (the `fieldloop` CLI, the bundled demo) around the Rust core.
7
+
8
+ - `Capture` — on-robot, non-blocking decision capture for the control-loop hot path.
9
+ - `attribute(config_toml, rollouts, outcomes)` — bind delayed outcomes back to the
10
+ decisions that caused them, with an explicit method and confidence per binding.
11
+ - `attribute_mcap(config_toml, mcap_bytes, mapping_toml)` — the same attribution run
12
+ directly on an MCAP recording plus a topic-mapping TOML (the file-import path).
13
+ - `doctor(mcap_bytes, mapping_toml)` — check a mapping against an MCAP file's real
14
+ topics and flag cross-clock skew, before attribution is ever run.
15
+ - `curate(spec, rollouts, feedbacks)` — compile trusted bindings into a training
16
+ slice; weak evidence is held for review, never silently included.
17
+ - `select_uploads(rollouts, outcomes, max_requests=...)` — choose which raw payloads
18
+ are worth pulling, under a budget that only safety triggers may bypass.
19
+ """
20
+
21
+ from fieldloop._native import (
22
+ Calibrator,
23
+ Capture,
24
+ attribute,
25
+ attribute_mcap,
26
+ curate,
27
+ doctor,
28
+ fit_calibrator,
29
+ select_uploads,
30
+ )
31
+
32
+ __all__ = [
33
+ "Calibrator",
34
+ "Capture",
35
+ "attribute",
36
+ "attribute_mcap",
37
+ "curate",
38
+ "doctor",
39
+ "fit_calibrator",
40
+ "select_uploads",
41
+ ]
fieldloop/_cli.py ADDED
@@ -0,0 +1,486 @@
1
+ """The `fieldloop` command-line front door.
2
+
3
+ Four subcommands, mirroring the loop's stages on files instead of in-process dicts:
4
+
5
+ fieldloop demo # the bundled toy-data loop, end to end
6
+ fieldloop init [--out my_robot.toml] # scaffold an embodiment config to edit
7
+ fieldloop attribute run.mcap --map mapping.toml --config embodiment.toml # MCAP in
8
+ fieldloop attribute --config ... --rollouts ... --outcomes ... # JSONL in
9
+ fieldloop curate --rollouts ... --feedbacks ... # gate evidence
10
+
11
+ `attribute` takes either an MCAP recording (a positional file plus a `--map`
12
+ topic-mapping TOML) and writes an incidents JSONL + a Markdown report, or the plain
13
+ JSON Lines inputs (one dict per line for rollouts/outcomes/feedbacks — the exact dict
14
+ shapes `fieldloop.attribute`/`curate` take) so any logging stack can produce them with a
15
+ few lines of glue and no SDK.
16
+
17
+ Exit codes are scriptable: 0 success, 1 the engine rejected the inputs (e.g. invalid
18
+ config), 2 the files themselves were unreadable or malformed.
19
+ """
20
+
21
+ import argparse
22
+ import json
23
+ import sys
24
+ from importlib import metadata, resources
25
+ from pathlib import Path
26
+
27
+ # The engine rejected semantically-bad input (bad config, bad shapes): the caller's
28
+ # data is wrong. Distinct from EXIT_BAD_FILE so scripts can tell "fix my data" from
29
+ # "fix my paths/encoding".
30
+ EXIT_ENGINE_ERROR = 1
31
+ # The input files were missing or not parseable at all.
32
+ EXIT_BAD_FILE = 2
33
+ # `doctor` ran fine but found problems (a declared topic missing from the file, or a
34
+ # topic on a divergent clock). A distinct meaning from EXIT_ENGINE_ERROR despite the
35
+ # shared value: nonzero so a CI step or onboarding script halts on a bad mapping/file.
36
+ EXIT_DOCTOR_PROBLEMS = 1
37
+
38
+
39
+ def _fail(code, message):
40
+ print(f"fieldloop: error: {message}", file=sys.stderr)
41
+ return code
42
+
43
+
44
+ def _read_jsonl(path):
45
+ """Read a JSON Lines file into a list of dicts, naming the exact offending line on
46
+ failure — 'line 37: expecting value' beats a bare traceback when the file came out
47
+ of someone's log-conversion script."""
48
+ rows = []
49
+ text = Path(path).read_text(encoding="utf-8")
50
+ for lineno, line in enumerate(text.splitlines(), start=1):
51
+ if not line.strip():
52
+ continue
53
+ try:
54
+ row = json.loads(line)
55
+ except json.JSONDecodeError as e:
56
+ raise ValueError(f"{path}: line {lineno}: {e.msg}") from e
57
+ if not isinstance(row, dict):
58
+ raise ValueError(f"{path}: line {lineno}: expected a JSON object")
59
+ rows.append(row)
60
+ return rows
61
+
62
+
63
+ def _write_jsonl(path, rows):
64
+ with Path(path).open("w", encoding="utf-8") as f:
65
+ for row in rows:
66
+ f.write(json.dumps(row, sort_keys=True))
67
+ f.write("\n")
68
+
69
+
70
+ def _cmd_demo(args):
71
+ from fieldloop import _demo
72
+
73
+ return _demo.run(json_output=args.json, viz=args.viz)
74
+
75
+
76
+ def _cmd_init(args):
77
+ """Write the packaged embodiment sample config for the user to edit. Refuses to
78
+ overwrite without --force: the file is meant to be hand-edited, so clobbering it
79
+ silently could destroy someone's tuned attribution windows."""
80
+ dest = Path(args.out)
81
+ if dest.exists() and not args.force:
82
+ return _fail(EXIT_BAD_FILE, f"{dest} already exists (use --force to overwrite)")
83
+ sample = (
84
+ resources.files("fieldloop")
85
+ .joinpath("data/embodiment.sample.toml")
86
+ .read_text(encoding="utf-8")
87
+ )
88
+ try:
89
+ dest.write_text(sample, encoding="utf-8")
90
+ except OSError as e:
91
+ return _fail(EXIT_BAD_FILE, str(e))
92
+ print(f"wrote {dest}")
93
+ print("edit the embodiment name, action_space, and per-kind window_ms, then:")
94
+ print(f" fieldloop attribute --config {dest} --rollouts r.jsonl --outcomes o.jsonl")
95
+ return 0
96
+
97
+
98
+ def _cmd_attribute(args):
99
+ """Two input modes share one subcommand. An MCAP file (positional `source`, with
100
+ `--map`) is the file-import path; `--rollouts`/`--outcomes` is the JSONL path. The
101
+ config is needed by both, so it is read once here before the mode split."""
102
+ if args.source is not None and (args.rollouts or args.outcomes):
103
+ return _fail(EXIT_BAD_FILE, "pass an MCAP source or --rollouts/--outcomes, not both")
104
+ try:
105
+ config_toml = Path(args.config).read_text(encoding="utf-8")
106
+ except (OSError, UnicodeDecodeError) as e:
107
+ return _fail(EXIT_BAD_FILE, str(e))
108
+
109
+ if args.source is not None:
110
+ return _attribute_mcap(args, config_toml)
111
+ if args.rollouts and args.outcomes:
112
+ return _attribute_jsonl(args, config_toml)
113
+ return _fail(
114
+ EXIT_BAD_FILE,
115
+ "provide an MCAP file with --map, or both --rollouts and --outcomes",
116
+ )
117
+
118
+
119
+ def _attribute_jsonl(args, config_toml):
120
+ """The dict-in path: rollout/outcome JSONL through `fieldloop.attribute`, writing
121
+ feedbacks to --out."""
122
+ try:
123
+ rollouts = _read_jsonl(args.rollouts)
124
+ outcomes = _read_jsonl(args.outcomes)
125
+ except (OSError, ValueError) as e:
126
+ return _fail(EXIT_BAD_FILE, str(e))
127
+
128
+ import fieldloop
129
+
130
+ try:
131
+ result = fieldloop.attribute(config_toml, rollouts, outcomes)
132
+ except ValueError as e:
133
+ return _fail(EXIT_ENGINE_ERROR, str(e))
134
+
135
+ feedbacks = result["feedbacks"]
136
+ skipped = result["skipped"]
137
+ print(f"attributed {len(feedbacks)} outcomes ({len(skipped)} skipped):")
138
+ for fb in feedbacks:
139
+ print(
140
+ f" - {fb['join_method']:<18} confidence={fb['join_confidence']:.2f}"
141
+ f" -> {fb['target_id']}"
142
+ )
143
+ for skip in skipped:
144
+ print(f" skipped: {skip.get('reason', skip)}")
145
+
146
+ # The engine already printed its result above, so an --out write failure here
147
+ # loses nothing — but it must still exit non-zero (and cleanly), or a script
148
+ # piping `--out` into a later stage would march on past a file that was never
149
+ # written.
150
+ if args.out:
151
+ try:
152
+ _write_jsonl(args.out, feedbacks)
153
+ except OSError as e:
154
+ return _fail(EXIT_BAD_FILE, str(e))
155
+ print(f"wrote {len(feedbacks)} feedbacks to {args.out}")
156
+ return 0
157
+
158
+
159
+ def _attribute_mcap(args, config_toml):
160
+ """The file-import path: an MCAP recording + a topic-mapping TOML through
161
+ `fieldloop.attribute_mcap`, writing an incidents JSONL (--out) and/or a Markdown
162
+ report (--report)."""
163
+ if not args.mapping:
164
+ return _fail(
165
+ EXIT_BAD_FILE,
166
+ "an MCAP source needs --map pointing at a topic-mapping TOML",
167
+ )
168
+ try:
169
+ mapping_toml = Path(args.mapping).read_text(encoding="utf-8")
170
+ mcap_bytes = Path(args.source).read_bytes()
171
+ except (OSError, UnicodeDecodeError) as e:
172
+ return _fail(EXIT_BAD_FILE, str(e))
173
+
174
+ import fieldloop
175
+ from fieldloop import _report
176
+
177
+ try:
178
+ result = fieldloop.attribute_mcap(config_toml, mcap_bytes, mapping_toml)
179
+ except ValueError as e:
180
+ return _fail(EXIT_ENGINE_ERROR, str(e))
181
+
182
+ feedbacks = result["feedbacks"]
183
+ skipped = result["skipped"]
184
+ incidents = _report.build_incidents(feedbacks, skipped)
185
+ print(
186
+ f"imported {args.source}: {len(feedbacks)} attributed, {len(skipped)} unattributed"
187
+ )
188
+ for inc in incidents:
189
+ if inc["state"] == "attributed":
190
+ conf = inc["confidence"]
191
+ conf_str = f"{conf:.2f}" if isinstance(conf, (int, float)) else "n/a"
192
+ print(
193
+ f" - {inc['join_method']:<18} confidence={conf_str}"
194
+ f" outcome {inc['outcome_id']} -> {inc['bound_target_id']}"
195
+ )
196
+ else:
197
+ print(f" unattributed: {inc.get('reason')} ({inc.get('outcome_kind')})")
198
+
199
+ # As in the JSONL path, the result already printed, so a write failure must still
200
+ # exit cleanly and non-zero rather than crash a downstream script.
201
+ if args.out:
202
+ try:
203
+ _write_jsonl(args.out, incidents)
204
+ except OSError as e:
205
+ return _fail(EXIT_BAD_FILE, str(e))
206
+ print(f"wrote {len(incidents)} incidents to {args.out}")
207
+ if args.report:
208
+ try:
209
+ Path(args.report).write_text(
210
+ _report.build_report_md(incidents), encoding="utf-8"
211
+ )
212
+ except OSError as e:
213
+ return _fail(EXIT_BAD_FILE, str(e))
214
+ print(f"wrote report to {args.report}")
215
+ return 0
216
+
217
+
218
+ def _cmd_doctor(args):
219
+ """Check an MCAP file against a topic-mapping before attribution: report each topic's
220
+ role + clock skew, list declared-but-absent topics and divergent-clock topics, and
221
+ exit nonzero if any problem was found so a script can halt on a bad mapping/file."""
222
+ if args.clock_skew_threshold_ns is not None and args.clock_skew_threshold_ns < 0:
223
+ return _fail(EXIT_BAD_FILE, "clock-skew threshold must be >= 0")
224
+ try:
225
+ mapping_toml = Path(args.mapping).read_text(encoding="utf-8")
226
+ mcap_bytes = Path(args.source).read_bytes()
227
+ except (OSError, UnicodeDecodeError) as e:
228
+ return _fail(EXIT_BAD_FILE, str(e))
229
+
230
+ import fieldloop
231
+
232
+ try:
233
+ report = fieldloop.doctor(
234
+ mcap_bytes,
235
+ mapping_toml,
236
+ clock_skew_threshold_ns=args.clock_skew_threshold_ns,
237
+ )
238
+ except ValueError as e:
239
+ return _fail(EXIT_ENGINE_ERROR, str(e))
240
+
241
+ print(f"doctor: {args.source}")
242
+ for topic in report["topics"]:
243
+ skew_s = topic["max_clock_skew_ns"] / 1e9
244
+ print(
245
+ f" {topic['role']:<9} {topic['topic']} "
246
+ f"({topic['message_count']} msgs, max clock skew {skew_s:.3f}s)"
247
+ )
248
+ if report["missing_topics"]:
249
+ print(" MISSING — declared in the mapping but absent from the file:")
250
+ for topic in report["missing_topics"]:
251
+ print(f" - {topic}")
252
+ if report["skewed_topics"]:
253
+ threshold_s = report["clock_skew_threshold_ns"] / 1e9
254
+ print(
255
+ f" CLOCK SKEW > {threshold_s:.3f}s — source clock diverges from the recorder "
256
+ "clock (a sensor on a different time base):"
257
+ )
258
+ for topic in report["skewed_topics"]:
259
+ print(f" - {topic}")
260
+
261
+ if report["ok"]:
262
+ print("ok: every declared topic is present and all clocks agree")
263
+ return 0
264
+ return _fail(EXIT_DOCTOR_PROBLEMS, "doctor found problems (see above)")
265
+
266
+
267
+ def _cmd_curate(args):
268
+ try:
269
+ rollouts = _read_jsonl(args.rollouts)
270
+ feedbacks = _read_jsonl(args.feedbacks)
271
+ except (OSError, ValueError) as e:
272
+ return _fail(EXIT_BAD_FILE, str(e))
273
+
274
+ import fieldloop
275
+
276
+ spec = {"grain": args.grain, "min_confidence": args.min_confidence}
277
+ try:
278
+ result = fieldloop.curate(spec, rollouts, feedbacks)
279
+ except ValueError as e:
280
+ return _fail(EXIT_ENGINE_ERROR, str(e))
281
+
282
+ items = result["items"]
283
+ review = result["needs_review"]
284
+ print(f"accepted {len(items)} · needs_review {len(review)}")
285
+ for held in review:
286
+ print(f" needs_review: {held.get('reason', '')} target={held.get('target_id', '')}")
287
+
288
+ if args.out:
289
+ try:
290
+ Path(args.out).write_text(
291
+ json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8"
292
+ )
293
+ except OSError as e:
294
+ return _fail(EXIT_BAD_FILE, str(e))
295
+ print(f"wrote slice to {args.out}")
296
+ return 0
297
+
298
+
299
+ def _cmd_view(args):
300
+ """Render an incidents JSONL on a Rerun timeline. Needs the optional `viz` extra; if
301
+ Rerun is absent, the import raises and we report the install line as a clean exit, not
302
+ a traceback."""
303
+ try:
304
+ incidents = _read_jsonl(args.incidents)
305
+ except (OSError, ValueError) as e:
306
+ return _fail(EXIT_BAD_FILE, str(e))
307
+
308
+ from fieldloop import _viz
309
+
310
+ try:
311
+ # No --save: open the live viewer. With --save: write a .rrd (the CI/screenshot path).
312
+ _viz.log_incidents(incidents, save_path=args.save, spawn=args.save is None)
313
+ except ImportError as e:
314
+ return _fail(EXIT_ENGINE_ERROR, str(e))
315
+
316
+ if args.save:
317
+ print(f"wrote Rerun recording to {args.save}")
318
+ else:
319
+ print("opened Rerun viewer")
320
+ return 0
321
+
322
+
323
+ def _cmd_import_lerobot(args):
324
+ """Load a LeRobot dataset and write its frames as rollouts + episode-terminal outcomes,
325
+ ready for `fieldloop attribute`. Needs the optional 'lerobot' extra; a missing extra is
326
+ reported cleanly, not as a traceback."""
327
+ from fieldloop import _lerobot
328
+
329
+ try:
330
+ dataset = _lerobot.load_dataset(args.repo_id, args.root)
331
+ result = _lerobot.import_dataset(
332
+ dataset,
333
+ tenant_id=args.tenant_id,
334
+ robot_id=args.robot_id,
335
+ policy_version=args.policy_version,
336
+ embodiment=args.embodiment,
337
+ terminal_outcome=args.terminal_outcome,
338
+ task_id=args.task_id,
339
+ )
340
+ except ImportError as e:
341
+ return _fail(EXIT_ENGINE_ERROR, str(e))
342
+ except Exception as e:
343
+ return _fail(EXIT_BAD_FILE, f"could not import dataset: {e}")
344
+ rollouts, outcomes = result["rollouts"], result["outcomes"]
345
+ print(
346
+ f"imported {len(rollouts)} rollouts and {len(outcomes)} outcomes from {args.root}"
347
+ )
348
+ try:
349
+ _write_jsonl(args.out, rollouts)
350
+ _write_jsonl(args.outcomes_out, outcomes)
351
+ except OSError as e:
352
+ return _fail(EXIT_BAD_FILE, str(e))
353
+ print(f"wrote {args.out} and {args.outcomes_out}")
354
+ print(
355
+ f" next: fieldloop attribute --config <embodiment.toml> "
356
+ f"--rollouts {args.out} --outcomes {args.outcomes_out}"
357
+ )
358
+ return 0
359
+
360
+
361
+ def _build_parser():
362
+ parser = argparse.ArgumentParser(
363
+ prog="fieldloop",
364
+ description=(
365
+ "Open incident workbench for robot field failures: attribute delayed "
366
+ "outcomes back to the decisions that caused them, with confidence, and "
367
+ "gate weak evidence out of training data."
368
+ ),
369
+ )
370
+ parser.add_argument(
371
+ "--version", action="version", version=f"fieldloop {metadata.version('fieldloop')}"
372
+ )
373
+ sub = parser.add_subparsers(dest="command", required=True)
374
+
375
+ p_demo = sub.add_parser("demo", help="run the bundled toy-data loop end to end")
376
+ p_demo.add_argument("--json", action="store_true", help="emit stable summary counts")
377
+ p_demo.add_argument(
378
+ "--viz",
379
+ action="store_true",
380
+ help="open the MCAP-demo incidents in Rerun (needs the optional 'viz' extra)",
381
+ )
382
+ p_demo.set_defaults(func=_cmd_demo)
383
+
384
+ p_init = sub.add_parser("init", help="scaffold an embodiment config to edit")
385
+ p_init.add_argument("--out", default="my_robot.toml", help="where to write the config")
386
+ p_init.add_argument("--force", action="store_true", help="overwrite an existing file")
387
+ p_init.set_defaults(func=_cmd_init)
388
+
389
+ p_attr = sub.add_parser(
390
+ "attribute",
391
+ help="bind delayed outcomes to decisions, from an MCAP file or JSONL",
392
+ )
393
+ p_attr.add_argument(
394
+ "source",
395
+ nargs="?",
396
+ help="an MCAP recording to import (use with --map); omit for the JSONL inputs",
397
+ )
398
+ p_attr.add_argument("--config", required=True, help="embodiment TOML (see: fieldloop init)")
399
+ p_attr.add_argument(
400
+ "--map",
401
+ dest="mapping",
402
+ help="topic-mapping TOML (required when a source MCAP is given)",
403
+ )
404
+ p_attr.add_argument("--rollouts", help="rollout dicts, one JSON per line (JSONL mode)")
405
+ p_attr.add_argument("--outcomes", help="outcome dicts, one JSON per line (JSONL mode)")
406
+ p_attr.add_argument(
407
+ "--out",
408
+ help="write output JSONL here (incidents in MCAP mode, feedbacks in JSONL mode)",
409
+ )
410
+ p_attr.add_argument("--report", help="write a Markdown incident report here (MCAP mode)")
411
+ p_attr.set_defaults(func=_cmd_attribute)
412
+
413
+ p_doc = sub.add_parser(
414
+ "doctor",
415
+ help="check a mapping against an MCAP file's topics + clocks (exit 1 on problems)",
416
+ )
417
+ p_doc.add_argument("source", help="the MCAP file to check")
418
+ p_doc.add_argument("--map", dest="mapping", required=True, help="topic-mapping TOML")
419
+ p_doc.add_argument(
420
+ "--clock-skew-threshold-ns",
421
+ dest="clock_skew_threshold_ns",
422
+ type=int,
423
+ help="flag a topic whose source/recorder clock gap exceeds this (default 1s)",
424
+ )
425
+ p_doc.set_defaults(func=_cmd_doctor)
426
+
427
+ p_cur = sub.add_parser(
428
+ "curate", help="gate attributed feedbacks into a training slice (fail-closed)"
429
+ )
430
+ p_cur.add_argument("--rollouts", required=True, help="rollout dicts, one JSON per line")
431
+ p_cur.add_argument("--feedbacks", required=True, help="feedbacks from `fieldloop attribute`")
432
+ p_cur.add_argument("--grain", default="rollout", help="slice grain (default: rollout)")
433
+ p_cur.add_argument(
434
+ "--min-confidence",
435
+ type=float,
436
+ default=0.70,
437
+ help="confidence floor; weaker bindings go to needs_review (default: 0.70)",
438
+ )
439
+ p_cur.add_argument("--out", help="write the full slice result here as JSON")
440
+ p_cur.set_defaults(func=_cmd_curate)
441
+
442
+ p_view = sub.add_parser(
443
+ "view",
444
+ help="visualize incidents on a Rerun timeline (needs the optional 'viz' extra)",
445
+ )
446
+ p_view.add_argument("incidents", help="incidents JSONL from `fieldloop attribute --out`")
447
+ p_view.add_argument(
448
+ "--save", help="write a .rrd recording to this path instead of opening the viewer"
449
+ )
450
+ p_view.set_defaults(func=_cmd_view)
451
+
452
+ p_lr = sub.add_parser(
453
+ "import-lerobot",
454
+ help="import a LeRobot dataset into rollouts + outcomes (needs the 'lerobot' extra)",
455
+ )
456
+ p_lr.add_argument("root", help="local LeRobot dataset root directory")
457
+ p_lr.add_argument(
458
+ "--repo-id", dest="repo_id", required=True, help="the dataset's repo id (see its meta/info.json)"
459
+ )
460
+ p_lr.add_argument("--tenant-id", dest="tenant_id", required=True)
461
+ p_lr.add_argument("--robot-id", dest="robot_id", required=True)
462
+ p_lr.add_argument("--policy-version", dest="policy_version", required=True)
463
+ p_lr.add_argument("--embodiment", required=True)
464
+ p_lr.add_argument(
465
+ "--terminal-outcome",
466
+ dest="terminal_outcome",
467
+ required=True,
468
+ help="outcome kind stamped on each episode's final frame (e.g. task_success)",
469
+ )
470
+ p_lr.add_argument("--task-id", dest="task_id", help="override the per-frame task label")
471
+ p_lr.add_argument("--out", required=True, help="write rollouts JSONL here")
472
+ p_lr.add_argument(
473
+ "--outcomes-out", dest="outcomes_out", required=True, help="write outcomes JSONL here"
474
+ )
475
+ p_lr.set_defaults(func=_cmd_import_lerobot)
476
+
477
+ return parser
478
+
479
+
480
+ def main(argv=None):
481
+ args = _build_parser().parse_args(argv)
482
+ return args.func(args)
483
+
484
+
485
+ if __name__ == "__main__":
486
+ sys.exit(main())