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 +41 -0
- fieldloop/_cli.py +486 -0
- fieldloop/_demo.py +267 -0
- fieldloop/_lerobot.py +177 -0
- fieldloop/_native.pyd +0 -0
- fieldloop/_native.pyi +154 -0
- fieldloop/_report.py +114 -0
- fieldloop/_viz.py +75 -0
- fieldloop/data/demo-skew.mcap +0 -0
- fieldloop/data/demo.map.toml +33 -0
- fieldloop/data/demo.mcap +0 -0
- fieldloop/data/embodiment.sample.toml +42 -0
- fieldloop/py.typed +0 -0
- fieldloop-0.2.0.dist-info/METADATA +12 -0
- fieldloop-0.2.0.dist-info/RECORD +18 -0
- fieldloop-0.2.0.dist-info/WHEEL +4 -0
- fieldloop-0.2.0.dist-info/entry_points.txt +2 -0
- fieldloop-0.2.0.dist-info/sboms/fieldloop-py.cyclonedx.json +3087 -0
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())
|