Share the generic parts of a node - #94
Merged
Merged
Conversation
The team-wide part of CONTRIBUTING.md is a copy of templates/CONTRIBUTING.md in the data_team repository, kept in sync by the sync-style-guide skill. Four repos held byte-identical copies and classification_node had already drifted, so the copies were re-synced from one corrected template and the marker heading convention the skill expects is now documented in the vault. AGENTS.md gives an agent the project structure, the commands that actually work here and the traps specific to this repository. It points at CONTRIBUTING.md for the coding standards instead of repeating them a third time. CLAUDE.md imports it via @AGENTS.md, matching nicegui, zoe-app and data_team. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
learning_loop_node is public, but AGENTS.md named dfine_node and classification_node, which are not — neither appears anywhere else in the public tree. The dependency rule they illustrated holds without naming them, and "grep ../dfine_node" is advice no external contributor can follow anyway. Also drop the claim that the shared Linting section contradicts this repo: that section now covers repositories without a .pre-commit-config.yaml explicitly. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every detector node does the same three things with a model's raw output: drop low-confidence predictions, suppress overlapping boxes, and turn what survives into the loop's dataclasses. None of it depends on the model, yet each node repository had grown its own copy, and the copies had drifted: clip_point was byte-identical in three places, while clip_box existed in the same three plus a fourth that used a centre-based convention under the same name. Move that code here as detector/postprocess.py and detector/geometry.py, and give the centre-based variant its own name (clip_box_centered) so the two conventions can no longer be confused. The library also carried two containers for the same detections: a detector reports ImageMetadata, a trainer's auto-detection pass reports Detections. to_image_metadata and to_detections now build both from one routine, so the two paths clip and filter identically instead of diverging. The scalar arguments of non_max_suppression and post_process are keyword-only: transposing origin_h and origin_w was too easy to do. Add tests/unit, the first suite that runs without a Learning Loop, and gate the credential-dependent jobs on it in CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every main.py in every node repository repeats the same twenty lines: read UVICORN_RELOAD, build the settings, construct the node, call uvicorn.run. Eight copies across three repositories, and they had drifted - dfine parses settings with configargparse so each one is both a flag and an environment variable, while the others hand-roll os.getenv plus asserts and hardcode the host and port. node_parser and run_node put that in one place. A setting is named after its flag: --conf-threshold reads CONF_THRESHOLD. That is what yolov5_node and classification_node already use, so adopting this renames nothing in the repositories that are actually deployed. --host and --port are the exception, reading NODE_HOST and NODE_PORT. The bare HOST already means the loop's address - every deployment sets it - and a node adopting that would hand it to uvicorn and fail to bind. dfine_node required a DFINE_TRAINER_ / DFINE_DETECTOR_ prefix, so it passes legacy_env_prefix and its prefixed names keep working with a warning naming the replacement. The logging.basicConfig call the trainers carry is not reproduced here - the library configures the root logger on import, so it has been a no-op. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things every trainer needs, none of which depend on the training framework, all of which existed only inside dfine_node or in worse form elsewhere: iterator_cpu_bound runs a training generator in a spawned process. A trainer that trains in-process needs this - the event loop must stay responsive and CUDA state must stay out of the node - and it is pure stdlib. find_batch_size probes for the largest power-of-two batch that fits, around a fits predicate the trainer supplies. The search is pure arithmetic; only the predicate needs a framework, so nothing here imports torch. Three repositories had three different implementations of this: a real probe, an estimate parsed out of torchinfo's summary text, and a doubling loop catching bare RuntimeError. is_out_of_memory exists because of that last one - catching RuntimeError treats every crash as a full card. macro_f1 scores the confusion matrix the loop stores, which was implemented three times over two different data shapes. _get_executor_error_from_log gets a default implementation, because yolov5_node and classification_node had byte-identical copies of it. The PEP 695 type parameters the generator used are rewritten as TypeVar and ParamSpec: this library supports Python 3.10 and dfine_node targets 3.12. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 21, 2026
klangenk
force-pushed
the
share-generic-node-code
branch
from
August 25, 2026 07:20
e66f05a to
f3b3ca9
Compare
klangenk
force-pushed
the
share-generic-node-code
branch
from
August 25, 2026 08:36
9be17ae to
f3b3ca9
Compare
read_from_env accepts an ordered list of names - ['LOOP_HOST', 'HOST'] and so on - and returned None when two of them were set to different values. The caller then fell back to its default, and host()'s default is 'learning-loop.ai': a .env carrying HOST and LOOP_HOST with different values pointed the node at production, with only a warning in the log to say so. Since values is built in the order of possible_names and then filtered, the first surviving value is already the one the list prefers - so the fix is simply to warn and fall through to the existing return instead of bailing out. ignore_errors=False still raises, as before. This is the right place for the fix. The node repositories' docker.sh scripts had been working around it by forwarding exactly one spelling, which meant every sub-project's list had to agree on which one; with the container receiving the whole .env, only the library can decide. Adds tests/unit/test_environment_reader.py: the prefixed name wins, either name alone is read, a disagreement resolves to the preferred name rather than the default, and ignore_errors=False still raises. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
klangenk
force-pushed
the
share-generic-node-code
branch
from
August 25, 2026 10:03
f40ab58 to
13c7793
Compare
The environment variable table already lists the alias for each loop setting but said nothing about both being set. That is the case the previous commit changed: the prefixed name wins, and the variable is never treated as unset, which would have let LOOP_HOST fall back to learning-loop.ai. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
klangenk
force-pushed
the
share-generic-node-code
branch
3 times, most recently
from
August 25, 2026 14:09
08f1e66 to
8e402d6
Compare
category_by_index and category_by_name resolve a model's metadata; they do not post-process anything. It showed at the one call site outside this library: classification_node's detector, which produces no boxes at all, imported category_by_name from a module called postprocess. They move to detector/categories.py, which leaves postprocess to the job its name describes - suppression, detection building and conversion to the loop's dataclasses. Both functions are unchanged, and their tests move with them. Worth doing now rather than later: these are public API the moment the library is released, and renaming a module afterwards is a breaking change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This repository is public and the example named a node that is not. The prefix a private node once required is not what the parameter is about, and the tests need any prefix at all, not that one. 203 unit tests pass, ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CONTRIBUTING rule 6 wants the main functions at the top and every helper below its callers, so a reader meets the caller before the callee. Both modules came from node repositories the other way round: `subprocess.py` had the public `iterator_cpu_bound` last, below both helpers it calls, and `postprocess.py` had `_append_detections` above `to_image_metadata` and `to_detections`, which are its only two callers, while the NMS chain read callee-first throughout. Pure moves, no line changed - `git show --color-moved` shows it as one. The reorder was applied by script and asserted to leave the set of lines identical. 203 unit tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CONTRIBUTING rule 10 asks for a module-level logger. The three modules this branch adds or changes were logging through the root logger, inherited from the node code they came from: `postprocess.py` twice, `entrypoint.py` twice and `environment_reader.py` twice, including the warning this branch added. `getLogger(__name__)` rather than a hand-written name, matching `batch_size.py` and the newer library modules. Also modernise `read_from_env`'s annotations while the file is open, which is what rule 7 asks for and clears three pre-existing ruff findings (`List`, `Optional`). 203 unit tests pass, ruff clean on all four files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A node that used to require an environment prefix had it applied to the *flag*, so `--host` was read from `<PREFIX>HOST`. `_adopt_legacy_env_vars` derived the legacy name from the current `env_var` instead, so for `--host` it looked for `<PREFIX>NODE_HOST` -- a name no deployment has ever set, because the rename to `NODE_HOST` is what this migration introduces. The effect was worst for exactly the settings the migration renamed: `<PREFIX>HOST` and `<PREFIX>PORT` were dropped silently, with no value adopted and no deprecation warning, and the node fell back to binding 0.0.0.0:80. Every setting whose environment variable is simply its flag in upper case was unaffected, which is why it went unnoticed. Both spellings are now tried, the prefixed current name first, so a node whose prefixed spelling already matched keeps working too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The node repositories improved these two files after this branch had already moved them out, so merging main there resolves towards the deletion. The improvements themselves still belong here, or the move would quietly undo them: - `Detection` becomes a typed `NamedTuple`. As a bare `namedtuple` its six fields had no types at all, which is what dfine_node fixed in #39. - The failing child process logs through the logger instead of `print(traceback.format_exc())`, so a training failure lands in the node's log rather than only on stdout. The module logger, not the root one, since that is what the rest of this library does. Assisted-by: Claude:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
klangenk
marked this pull request as ready for review
September 3, 2026 08:30
The docstrings written while moving this code explain why each implementation was chosen, which CONTRIBUTING rule 4 puts in the pull request rather than in code that goes stale after the merge; what the code cannot show stays, and the NOTE block in read_from_env is reduced to its one real point, that possible_names is ordered by preference. find_batch_size now raises InsufficientMemoryError instead of the bare RuntimeError that is_out_of_memory exists to disambiguate, Detection's fields are named at the call site (rule 9), and the docstrings in trainer_logic.py and exceptions.py use double quotes (rule 1). subprocess.py also drops the claim that the process is "spawned" - without an explicit context multiprocessing takes the platform default, fork on Linux.
…ose of the module
jfrieli
requested changes
Sep 4, 2026
jfrieli
left a comment
Contributor
There was a problem hiding this comment.
Nice PR in general, really looking forward to merge this :)
I still found some points that could be worth to discuss/implement from my side.
It came from Yolov5TrainerLogic.clip_box, whose only caller was the trainer's label-parsing pass. That caller now converts to the top-left convention before handing predictions to to_detections, which clips with clip_box -- so the centre-based variant would ship as public API with no consumer, for a convention the library has decided not to use internally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--vram-limit-gb is how a deployment shares one card between processes, and every trainer that probes for a batch size needs the same two halves of it: the budget the probe measures against, and the cap that holds the process to it. dfine had both; a second node would have copied them. Capping an allocator is a torch operation with no NVML equivalent, so this module imports torch. The package does not declare it: only a trainer imports the module, and a trainer brings torch already, so the library keeps installing on machines that train nothing. The unit test installs a stand-in under the name torch, which covers the arithmetic and the guards -- whether the cap holds is torch's own business. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README linked docs/writing-a-node.md, which this branch never added. The guide is worth writing, but not as a passenger in a refactoring change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Without a context multiprocessing takes the platform default, which is fork on Linux -- and forking a process that has already initialised CUDA is what this helper exists to avoid. dfine enforces spawn globally today; now the helper does it wherever it runs, and picklability of the callable is a stated requirement rather than an accident of the platform. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
detector_node carried its own find_category_id_by_name, the second name lookup in the detector. category_by_name raises where this one returned '', so the caller catches: this path runs on metadata a client uploaded over socket.io, where an unknown name is the client's error and should cost that detection its id rather than the whole upload. The warning now names every unknown category once per upload instead of once per detection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
requires-python promises >=3.10, but 3.11 and 3.12 were tested nowhere -- and 3.12 is what every node but the Jetson detector runs. The suite needs no loop and no secrets, so covering all four costs only wall-clock on the job that gates the rest. --locked replaces --frozen so a lockfile that no longer matches pyproject.toml fails here rather than later, --python names the interpreter instead of taking whatever PATH offers first, and --no-sync keeps uv run from re-resolving into something other than what uv sync installed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Detection read like the base of BoxDetection, PointDetection and ClassificationDetection, and like the singular of Detections; it is neither. It is one surviving prediction in the model's own vocabulary -- a class index plus box geometry -- before category.type decides what it becomes. Prediction says that, category_index says which of the two identities it carries, and width/height/ confidence match the names the loop's dataclasses already use. It becomes a frozen keyword-only dataclass: four consecutive numbers built positionally make a swapped width/height type-correct and silent, and the detector hot path in yolov5 built it positionally. post_process now ends in predictions_from_xyxy instead of repeating the conversion, so there is one pipeline rather than two that had already drifted. Its int() went with the duplication: truncating here and rounding again in clip_box is strictly worse than rounding once, and the geometry stays float until the BoxDetection is built. Point detections gain real precision, since their centre no longer comes from truncated ints. Confidence is no longer rounded to two decimals on the way out -- that was never a contract, and the model's own score is what the loop should see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two jobs split the suites between versions rather than the versions between suites: general, detector and mock_detector only ever ran on 3.10, annotator, trainer and mock_trainer only on 3.13. Neither version was actually tested, only half of each. One matrix job runs all six on 3.10 and 3.13 -- the ends of what requires-python promises, with the unit job covering 3.11 and 3.12 as well. max-parallel keeps the entries serialised: they share one Learning Loop, and test_general creates and deletes zauberzeug/pytest_nodelib_general by a fixed name, so two entries at once would delete each other's project. That serialisation used to come from chaining the two jobs with needs. Every suite collects on both versions locally; whether they pass needs the loop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
find_batch_size arrived here without the measuring around it, so a node adopting it still had to write the part that is easy to get wrong. dfine_node had written it twice over -- once for training, once for the detection pass -- and what those two share is not model-specific at all: hold back a safety margin, reset the peak counters, run the step, and decide whether a failure means "too big" or "broken". probe_batch_size is the whole probe for a node whose measurement is a single call. It resolves the limit == 0 sentinel, falls back without a card, holds the margin for the length of the search and logs the size it settled on. measured_fits is that skeleton on its own, for a probe that has to build a throwaway model before it can measure: the margin has to be claimed before the model is built, so such a probe composes reserve_margin, measured_fits and find_batch_size itself rather than handing over a closure. on_out_of_memory is what lets it drop an optimizer's gradients after a failed trial without also owning the guard. The guard is the reason this belongs in the library rather than in each node. isinstance(exc, torch.cuda.OutOfMemoryError) or is_out_of_memory(exc) is one line, and a node that writes `except RuntimeError: return False` instead reports the smallest batch size as the card's fault whenever a shape is wrong. Both repositories that had a probe had their own private copy of that predicate. MAX_BATCH_SIZE moves in beside NO_GPU_BATCH_SIZE and MIN_TRAIN_STEPS_PER_EPOCH: it is search policy, not a torch concern, and a node resolving its own sentinel wants it from the framework-free module. SAFETY_MARGIN stays here, since claiming the margin is an allocation. 14 tests against the same stand-in for torch, covering what the module decides: the doubling and the power-of-two rounding, the margin as a share of the budget rather than of the card, the fallback that runs nothing without a GPU, MemoryError and the bare RuntimeErrors that cuDNN and cuBLAS raise -- and that a failure which is not about memory propagates rather than reading as a full card. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pass over this branch's own code against CONTRIBUTING.md, after reading it back rule by rule. Ordering: cuda.py read bottom-up. probe_batch_size -- which its own docstring calls the whole probe -- sat fourth, below three functions it calls, and usable_memory_bytes sat above its only caller. The order is now the call graph, so a reader meets probe_batch_size first and every callee below all of its callers. Same rule in the unit tests, where _FakeTorch, the load fixture and the small predicate helpers sat above the tests they serve. Defaults: usable_memory_bytes and limit_cuda_memory took device=0, which no caller ever varied. That is a constant hiding in a signature, and the constant was wrong for a trainer that pins itself with torch.cuda.set_device(1) -- it would budget and cap card 0 while training on card 1. Both now let torch resolve the current device. dfine_node passes neither argument, so nothing there needs a companion change. Prose: rules 4 and 7 ask for as few and as short as possible, and no motivation. cuda.py's module docstring goes from 26 lines to 8, keeping the two facts the code cannot state and AGENTS.md does not carry: the cap is a share of the card's total memory rather than of what is free, and torch is imported but undeclared, so only a trainer may import the module. The reasoning about fragmentation, the NVML argument and how the pieces compose already live in AGENTS.md. free_cuda_memory loses its docstring to its own name; is_out_of_memory keeps the one sentence that justifies matching on message text. Eight test docstrings and six comments that restated the test name in other words are gone; the four that state a fact stay -- the bare RuntimeErrors cuDNN and cuBLAS raise, the test whose passing is the absence of a hang, where the confusion-matrix numbers come from, and why <PREFIX>HOST looks like the wrong variable name. The Lightning attribution in batch_size.py is untouched: that one is a license, not a style choice. Along the way: test_cuda.py imports relatively like every other test here, its fake torch records capped fractions instead of (-1.0, -1) sentinels, and the new pytest.ini loses the trailing whitespace, the "debbuging" typo and the missing final newline it had inherited from its siblings. 229 unit tests pass. ruff is unchanged at 61 findings across the touched files, all of them in detector_node.py and trainer_logic.py and all predating this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The matrix tested the two ends of requires-python, 3.10 and 3.13, and so tested neither version a node runs: dfine_node's and yolov5_node's trainers are both python:3.12.11-slim-bookworm, and the dfine detector runs on ubuntu24.04, whose system python is 3.12. The suites that talk to a real loop are exactly the ones where that gap matters, so 3.13 gives way to 3.12 there. 3.13 keeps its coverage in the unit job, which runs the whole 3.10 through 3.13 range and gates this one -- and no node runs it in production yet. The floor stays, since requires-python promises it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…h used When find_batch_size raised, the margin tensor stayed referenced by the traceback until the caller's except block ended, so the reserved share was still held while a fallback ran. The release now sits in a finally. The limit is rounded down to a power of two once, before the search; the log line printed the raw value, so a limit of 12 read as if 12 had been tried.
jfrieli
approved these changes
Sep 8, 2026
The ruleset requires pytest_3_10 and pytest_3_13, which were job ids until the two jobs became one matrix job. A matrix job's check run is named "<job> (<entry>)", so both contexts stopped existing -- and an unreported required check is not a failure but "expected", which waits forever. Every PR carrying the refactor is stuck on a status that nothing can report, this one included. all-green needs both jobs and passes only when both succeeded, so the ruleset can name it instead and stop tracking the matrix. always() plus an explicit test of each result is what makes it a gate: a job that is skipped because its dependency failed reports no status at all, and a required check that never reports blocks rather than fails. Swapping the two contexts for all-green in the ruleset needs a repo admin; until then this changes nothing about what CI runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…are-generic-node-code
klangenk
added a commit
to zauberzeug/yolov5_node
that referenced
this pull request
Sep 16, 2026
## Motivation Both detectors and the trainer carried their own copies of code that has nothing to do with YOLOv5. `clip_point` was byte-identical in three places; `clip_box` in two, plus a fourth copy in the trainer under the same name but with a centre-based convention. `bbox_iou` was duplicated between the two detectors, and the detection-building loop existed twice. All of it now comes from `learning_loop_node.detector`, leaving only what is genuinely YOLOv5: the packed `[cx,cy,w,h,conf,probs...]` layout, the letterbox correction in `xywh2xyxy`, and the tensorrtx engine build. **285 lines removed for 33 added, plus the `docker.sh` consolidation.** The commits carry the reasoning in full; this is the summary. **Depends on zauberzeug/learning_loop_node#94**, which is merged and released: all three sub-projects now pin `learning_loop_node==0.23.0`, so the imports resolve. ## Implementation - `detector/yolov5.py`: remove the local `Detection` namedtuple and `bbox_iou`, including its `x1y1x2y2=False` branch, dead at every call site. - `detector/yolov5_detector.py`: remove `clip_box`, `clip_point`, `MIN_BOX_SIZE` and `_collect_detections` — the last was exactly the library's `to_image_metadata`. - `detector/yolov5_detector.py`: also fix `batch_evaluate`, which clipped every result in a batch against `images[0].shape`. `infer_batch` letterboxes each image separately and already undoes that with the right per-image size, so a batch mixing resolutions clipped the later images against the first one's bounds. Predates this branch; the line moved here, which is how it turned up. - `detector_cpu/yolov5_detector.py`: same removals; `evaluate` builds detections with `predictions_from_xyxy` + `to_image_metadata` instead of a 40-line inline loop, and the detection cap becomes a named `MAX_DETECTIONS`. - `trainer/app_code/yolov5_trainer.py`: `_parse_file` returns `Prediction` values with the label file's centre-based coordinates converted to the top-left corner `to_detections` expects, so the local centre-based `clip_box` and `clip_point` go away and the clipping happens in the library. Note that this also subjects the auto-detections to the shared `MIN_BOX_SIZE` filter, which the trainer never had -- see *Behaviour changes*. Also inherit `_get_executor_error_from_log`, which was byte-identical to another node's. - The three `main.py` use `node_parser` / `run_node`: every setting is a flag *and* an environment variable, `--help` lists them, and argparse `choices` replaces the hand-written asserts. - Replace the three `docker.sh` scripts with a five-line wrapper over `scripts/node-docker.sh` plus a `docker.conf` per sub-project. `./docker.sh` from inside a sub-project is unchanged. ## No environment variable is renamed `WEIGHT_TYPE`, `IOU_THRESHOLD`, `CONF_THRESHOLD` and `BUILDER_OPTIMIZATION_LEVEL` keep the names your `.env` files and `face_detection/app_code/docker-compose.detectors.yml` already set. **This is the widely deployed node, so it does not move**; the other nodes migrate instead. `--host`/`--port` are new, reading `NODE_HOST`/`NODE_PORT` — the bind address was hardcoded before. ## Behaviour changes Two in `detector_cpu`, both bringing it in line with the GPU detector: - Boxes of 2 px or less are dropped; `detector_cpu` was the only detector not filtering them. - A category index outside the model's categories raises instead of logging and skipping. A model/metadata mismatch makes every detection on that image suspect. And one in the trainer, from going through the same converter: - Auto-detections of 2 px or less are dropped, where `_parse_file` used to upload them. Surviving boxes also move by up to 1 px, because the corner and size are now rounded rather than truncated. Plus one fix in `docker.sh`: `grep -oP` is a GNU extension, so on any host where BSD grep comes first every image was tagged `:-nlv`. Now `sed`. `HOST_PORT` makes ports overridable (defaults unchanged), `CONTAINER_NAME` replaces `DETECTOR_NAME`/`TRAINER_NAME` with the old spelling still accepted, and every name declared in `.env` is forwarded to the container as `--env NAME`, so it arrives with the value the shell evaluated. (`--env-file .env` was the first attempt; docker's env-file reader keeps quotes and expands nothing, so `PASSWORD='secret'` would have reached the container quoted and an int-typed setting would not have parsed at all — caught in review.) ## Verified Each removed implementation was checked against its replacement — `_collect_detections` vs `to_image_metadata`, `bbox_iou` over 50 random boxes, both clip functions, and `detector_cpu`'s inline building — all identical output. `docker.sh` was swept across 12 subcommands × 4 `.env` variants (documented, quoted, awkward, absent) per sub-project, diffing both the emitted `docker` command and the environment the container ends up with against the previous script. `ruff` findings go **down**: 12 → 7 in `detector_cpu`, 9 → 7 in `trainer`, 2 → 1 in `detector`; the rest is pre-existing. The environments cannot be installed on macOS, so linting used `uvx ruff` and the equivalence checks ran against the library checkout. Committed with `--no-verify`.⚠️ **The Jetson path has never been executed.** `detector/docker.conf`'s `select_image` — base image, tag suffix, `LINKLL` path and the L4T version parse — was reasoned through on a machine with no `/etc/nv_tegra_release`. The L4T parse was checked against the R32, R35 and R36 formats, but **one build on real hardware is wanted before merge.** ## Deliberately not included `app_code/batch_size_calculation.py` still estimates the batch size by string-parsing `torchinfo.summary`'s output, where the shared `find_batch_size` would do a real probe. Changing how a batch size is chosen — unverifiable here, in a repository whose trainer tests need a GPU and a live loop — is how a training silently degrades weeks later. It wants someone who can run one. --- ⬛ claude-opus-5[1m] · 710k tokens · $17.10 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: jan <jan@zauberzeug.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Three node repositories build on this library, and a surprising amount of what they contain is not about their models at all. It had been copied between them and had drifted:
clip_pointwas byte-identical in five places.clip_boxexisted in those five plus a sixth that used a centre-based convention under the same name, so the function meant two different things depending on the file.main.pyrepeated the same twenty lines of parser-and-uvicorn scaffolding — eight copies.torchinfo's summary text, and a doubling loop catching bareRuntimeError._get_executor_error_from_logwas byte-identical in two repositories.A fourth node would have copied all of it again. This adds the shared home for it, in three commits that are worth reading in order.
What is added
detector/postprocess.py,detector/geometry.py,detector/categories.py—Detection,bbox_iou,non_max_suppression,post_process,detections_from_xyxy; one clipping implementation per convention (clip_box,clip_box_centered,clip_point); andcategory_by_index/category_by_name, which resolve a model's metadata rather than post-processing anything and so live apart from it.The two containers for the same detections are unified:
to_image_metadata(what a detector reports) andto_detections(what a trainer's auto-detection pass reports) share one routine, so the two paths can no longer clip and filter differently — which they did.helpers/entrypoint.py—node_parserandrun_node. A setting is named after its flag:--conf-thresholdreadsCONF_THRESHOLD, and--helplists it.legacy_env_prefixlets a node that once required a prefix keep reading the old names with a deprecation warning.--host/--portare the deliberate exception, readingNODE_HOST/NODE_PORT. The bareHOSTalready means the loop's address — every deployment sets it — so a node deriving it would hand that to uvicorn and fail to bind. There is a test for exactly this.trainer/cuda.py—usable_memory_bytes,limit_cuda_memoryandfree_cuda_memory, the two halves of a--vram-limit-gbsetting: the budget a batch-size probe measures against, and the cap that holds the process to it. This is the one module that imports torch, and the package deliberately does not declare it — only a trainer imports it, and a trainer brings torch already. Its test installs a stand-in under that name, which covers the arithmetic and the guards; that the cap holds can only be seen on a card.trainer/subprocess.py,trainer/batch_size.py,trainer/metrics.py—iterator_cpu_bound, the power-of-two batch-size search, andmacro_f1. The batch-size search imports no framework: it is pure arithmetic around afitspredicate the trainer supplies, which is what lets any node use it.is_out_of_memoryis part of that: allocation failures do not all arrive as a dedicated error type — cuDNN and cuBLAS raise plainRuntimeError— and one repository was catching bareRuntimeError, so every crash looked like a full card.TrainerLogic._get_executor_error_from_loggains a default implementation and stops being abstract; existing overrides keep working.Testing
Adds
learning_loop_node/tests/unit/, the first suite that runs without a Learning Loop — no credentials, no network, 214 tests in about 1.5 seconds, on every Python versionrequires-pythonpromises. CI gates the credential-dependent jobs on it.Those jobs are restructured while here. They used to split the suites between versions — general, detector and mock_detector on 3.10, annotator, trainer and mock_trainer on 3.13 — so neither version was tested, only half of each. One matrix job now runs all six on 3.10 and 3.13, serialised: they share one Learning Loop, and
test_generalcreates and deleteszauberzeug/pytest_nodelib_generalby a fixed name.That gap is why the node repositories have so few tests:
learning_loop_node/tests/is excluded from the wheel, so nothing in it is importable downstream. Making the helpers shippable is a follow-up phase; this establishes the suite.A disagreeing environment variable no longer points at production
read_from_envtakes an ordered list of names —['LOOP_HOST', 'HOST']and so on — and returnedNonewhen two were set to different values. The caller then fell back to its default, andhost()'s default is'learning-loop.ai'. So a.envcarrying both spellings with different values pointed the node at production, with only a log warning.Since
valuesis built inpossible_namesorder and then filtered, the first surviving value is already the preferred one — so the fix is to warn and fall through to the existingreturn values[0].ignore_errors=Falsestill raises.This is the right layer for it: the node repositories'
docker.shscripts had been working around it by forwarding exactly one spelling, which forced every sub-project to agree on which. Now that the container receives the whole.env, only the library can decide. Six tests added intests/unit/test_environment_reader.py.Notes for the reviewer
clip_box. The first version of this branch keptpost_process'sint()so detector output stayed bit-identical; review pointed out that truncating and then rounding again is strictly worse than rounding once. A box moves by at most a pixel, confidences are no longer clipped to two decimals, and point detections gain real precision — their centre used to come from truncated ints. No node consumes this yet.def f[T]) in the moved generator were rewritten asTypeVar/ParamSpec: this library supports Python 3.10 while the node repository they came from targets 3.12. The unit suite runs on 3.10 through 3.13 in CI, and every suite now runs on both ends of that range.logging.basicConfig(...)every trainer carries is not reproduced.helpers/log_conf.pyconfigures the root logger at import, sobasicConfighas been a no-op — checked, not assumed.yolov5_nodepicks its batch size by string-parsingtorchinfo.summary()instead of probing, and replacing that changes which batch size a training gets — it needs a GPU box and has its own card.is_out_of_memoryfrom the same module already has two consumers.0.21.0until this is released.Deliberately not included
SubprocessTrainerLogic, a third trainer base class. With a single node as its only consumer, its progress protocol would be invented from one example, andyolov5_node's executor-and-log-scraping shape may not fit it.⬛ claude-opus-5[1m] · 700k tokens · $16.90