From 398059f24ee3307e5cb6d174f5813445ff48b101 Mon Sep 17 00:00:00 2001 From: stwilt Date: Thu, 11 Jun 2026 11:09:04 -0400 Subject: [PATCH 1/2] feat(shapes): emit JSON Schema if/then for Pose coco-17 arity (FILTER-454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐Ÿ“‹ What does this PR do? Makes the `Pose` output shape emit standard JSON Schema (draft 2020-12) `if`/`then` for the coco-17 keypoint-arity invariant: when `skeleton == "coco-17"`, `keypoints` must contain exactly 17 entries (`minItems`/`maxItems`: 17). The constraint is injected via `Pose.model_config.json_schema_extra`, so it flows through `model_json_schema()` โ†’ `emit_schema()` with no change to the base `FilterOutputSchema.emit_schema()`. The base `__init_subclass__` merges `$id` into the same `json_schema_extra` dict, so identity and constraint co-exist, and the `if`/`then` rides into `$defs[Pose]` wherever Pose is `$ref`'d (e.g. `PoseSet`). ## ๐Ÿ” Why is this needed? `Pose._validate_skeleton_arity` enforces the coco-17 arity for Pydantic producers, but consumers validating raw JSON against `emit_schema()` โ€” Go (santhosh-tekuri), TS (Ajv) โ€” get nothing, so they silently accept malformed poses. This is the producer/consumer asymmetry FILTER-454 exists to close. The coco-17 case is the one invariant expressible in *stock* JSON Schema, so it lands now as "slice A" with zero custom-keyword infrastructure. The two genuinely-custom keywords (`x-openfilter-xyxy-ordering`, `x-openfilter-parallel-arrays`) are slice B, blocked on the public validator-home decision. ## ๐Ÿงช How was it tested? - `pytest tests/test_output_schema.py tests/test_emit_schema_cli.py` โ€” 54 passed. - New tests: `test_pose_emits_coco17_arity_if_then` (asserts the `if`/`then` at the schema top level with `$id` intact) and `test_pose_arity_if_then_travels_into_defs` (asserts it rides into `$defs[Pose]` when referenced from `PoseSet`). - `ruff check` clean on the changed files; `black` clean on the additions. ## ๐Ÿ”— Related Issues Implements slice A of [FILTER-454](https://plainsight-ai.atlassian.net/browse/FILTER-454). ## โœ… Checklist - [x] I have read and agreed to the terms of the LICENSE - [x] I have read the CONTRIBUTING guide - [x] I have followed the coding style (ruff clean; additions black-clean; no unrelated reformatting โ€” repo has no black/ruff-format CI or line-length override) - [x] I have signed all commits in compliance with the DCO (`git commit -s`) - [x] I have added or updated tests as needed - [x] Documentation: the `Pose` docstring already states the enforced coco-17 arity; vocabulary-level docs are slice B Signed-off-by: stwilt --- openfilter/filter_runtime/shapes.py | 18 +++++++++++++++++- tests/test_output_schema.py | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/openfilter/filter_runtime/shapes.py b/openfilter/filter_runtime/shapes.py index 4641be8..11ce59a 100644 --- a/openfilter/filter_runtime/shapes.py +++ b/openfilter/filter_runtime/shapes.py @@ -28,7 +28,7 @@ from typing import ClassVar, Literal -from pydantic import Field, model_validator +from pydantic import ConfigDict, Field, model_validator from .output import FilterOutputSchema @@ -274,6 +274,22 @@ class Pose(FilterOutputSchema): description="Keypoint-order convention; consumers infer edges from this.", ) + # Standard JSON Schema (draft 2020-12) mirror of `_validate_skeleton_arity` + # so stock validators (Ajv, santhosh-tekuri) enforce the coco-17 arity from + # `emit_schema()` alone โ€” no openfilter custom-keyword vocabulary needed + # (FILTER-454 slice A). `__init_subclass__` merges `$id` into this dict. + model_config = ConfigDict( + json_schema_extra={ + "if": { + "properties": {"skeleton": {"const": "coco-17"}}, + "required": ["skeleton"], + }, + "then": { + "properties": {"keypoints": {"minItems": 17, "maxItems": 17}}, + }, + } + ) + @model_validator(mode="after") def _validate_skeleton_arity(self) -> "Pose": if self.skeleton == "coco-17" and len(self.keypoints) != 17: diff --git a/tests/test_output_schema.py b/tests/test_output_schema.py index e3b64fb..f5cd50e 100644 --- a/tests/test_output_schema.py +++ b/tests/test_output_schema.py @@ -360,6 +360,30 @@ def test_pose_coco17_rejects_wrong_arity() -> None: ) +def test_pose_emits_coco17_arity_if_then() -> None: + """Slice A (FILTER-454): the coco-17 keypoint-arity invariant โ€” Pydantic-only + via `_validate_skeleton_arity` โ€” is mirrored into standard JSON Schema + `if`/`then` so stock validators (Ajv, santhosh-tekuri) enforce it from + `emit_schema()` alone, without the openfilter custom-keyword vocabulary.""" + schema = Pose.emit_schema() + assert schema["if"] == { + "properties": {"skeleton": {"const": "coco-17"}}, + "required": ["skeleton"], + } + assert schema["then"] == { + "properties": {"keypoints": {"minItems": 17, "maxItems": 17}}, + } + # $id still merges in alongside the if/then (json_schema_extra co-exists). + assert schema["$id"] == f"{SHAPE_ID_BASE}/pose/v1" + + +def test_pose_arity_if_then_travels_into_defs() -> None: + """The constraint rides with the Pose `$defs` entry wherever Pose is + `$ref`'d, so consumers validating a PoseSet get it too.""" + pose_def = PoseSet.emit_schema()["$defs"]["Pose"] + assert "if" in pose_def and "then" in pose_def + + def test_classification_result_supports_multilabel() -> None: single = ClassificationResult(classes=["dog"], confidences=[0.9]) assert single.multilabel is False From 1eb3f0749b22aaf1aefe387731b1870482624914 Mon Sep 17 00:00:00 2001 From: stwilt Date: Mon, 15 Jun 2026 06:56:45 -0400 Subject: [PATCH 2/2] Address coco-17 schema review fixes Signed-off-by: stwilt --- RELEASE.md | 2 +- openfilter/filter_runtime/shapes.py | 24 +++++++++++++++++------- tests/test_output_schema.py | 4 +++- uv.lock | 12 ------------ 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 36a48db..9f13364 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -15,7 +15,7 @@ OpenFilter Library release notes ### Added - **OpenTelemetry MQ hop spans + per-frame trace propagation ([PLAT-866](https://plainsight-ai.atlassian.net/browse/PLAT-866), #99)**: Every filter-to-filter hop now produces `mq.send` / `mq.recv` outer hop spans (attributes: `topic`, `payload_bytes`, `frame.id`, `frame.format`, `mq.id`), with nested `frame.serialize` / `frame.deserialize` CPU sub-spans and `zmq.send_multipart` / `zmq.recv_multipart` kernel-syscall sub-spans. `frame.encode_jpg` / `frame.decode_jpg` codec sub-spans fire on the jpg transport path only. Per-frame W3C trace context (`traceparent` / `tracestate`) now travels inside the ZMQ envelope via the standard `opentelemetry.propagate.inject` / `extract` (`TraceContextTextMapPropagator`); the consumer-side `{FilterClass}.process` span (PLAT-848) parents on the per-frame context extracted from the envelope, so filter A's frame-17 spans and filter B's frame-17 spans share a single trace ID and nest correctly. The `TRACEPARENT` env-var fallback (PLAT-848 behavior) is retained for source filters that never call `mq.recv` (e.g. `VideoIn`). When tracing is disabled (the default, `TELEMETRY_EXPORTER_TYPE=silent`), the hot path short-circuits cleanly: codec / kernel sub-spans bail on a single `is_recording()` check before any tracer lookup or `Span` allocation, and the per-recv `time_ns()` brackets + per-part byte tally in `ZMQReceiver.recv_once` / `ZMQSender.send_maybe` are gated on the module-level hop tracer being set (so they don't run either). No new environment variables. - +- **Emit JSON Schema if/then for Pose coco-17 arity ([FILTER-454](https://plainsight-ai.atlassian.net/browse/FILTER-454))**: The `Pose` output shape now emits standard JSON Schema (draft 2020-12) `if`/`then` constraints for the coco-17 keypoint-arity invariant. When `skeleton == "coco-17"`, `keypoints` must contain exactly 17 entries. This closes the validation asymmetry between Pydantic producers and raw JSON consumers. - **Windowed async batch dispatch for `process_batch()`** ([FILTER-457](https://plainsight-ai.atlassian.net/browse/FILTER-457), [FILTER-458](https://plainsight-ai.atlassian.net/browse/FILTER-458), [FILTER-459](https://plainsight-ai.atlassian.net/browse/FILTER-459), [FILTER-460](https://plainsight-ai.atlassian.net/browse/FILTER-460), #95): four composable, opt-in additions to the runtime batch path. `process_batch()` result slots may now be deferred `Callable`s that `mq.send` resolves lazily, so a filter can hand batch work to a worker thread instead of blocking the loop thread on the batch's wall-clock duration. A new `accumulate_window` config decouples the runtime ring buffer from `batch_size`, with a public `Filter.select_batch()` hook to pick which frames are dispatched. `Filter.flush_batch()` plus `batch_trigger: "auto" | "manual"` give filters explicit control over when a batch drains. A `batch_workers` pool runs `process_batch()` on a `ThreadPoolExecutor` with semaphore backpressure, so the loop accumulates the next batch while previous batches are still in flight. All four preserve existing behavior when unset (`batch_workers=1`, `batch_trigger="auto"`, `accumulate_window` unset). ## v1.0.0 - 2026-05-18 diff --git a/openfilter/filter_runtime/shapes.py b/openfilter/filter_runtime/shapes.py index 11ce59a..0efd6a9 100644 --- a/openfilter/filter_runtime/shapes.py +++ b/openfilter/filter_runtime/shapes.py @@ -47,12 +47,15 @@ "OCRSpan", "OCRSpanSet", "ClassificationResult", + "SKELETON_ARITY", "SHAPE_ID_BASE", ] SHAPE_ID_BASE = "https://schemas.plainsight.ai/shapes" +SKELETON_ARITY = {"coco-17": 17} + def _shape_id(slug: str) -> str: return f"{SHAPE_ID_BASE}/{slug}/v1" @@ -281,22 +284,29 @@ class Pose(FilterOutputSchema): model_config = ConfigDict( json_schema_extra={ "if": { - "properties": {"skeleton": {"const": "coco-17"}}, + "properties": {"skeleton": {"const": next(iter(SKELETON_ARITY))}}, "required": ["skeleton"], }, "then": { - "properties": {"keypoints": {"minItems": 17, "maxItems": 17}}, + "properties": { + "keypoints": { + "minItems": next(iter(SKELETON_ARITY.values())), + "maxItems": next(iter(SKELETON_ARITY.values())), + } + }, }, } ) @model_validator(mode="after") def _validate_skeleton_arity(self) -> "Pose": - if self.skeleton == "coco-17" and len(self.keypoints) != 17: - raise ValueError( - f"Pose.skeleton='coco-17' requires 17 keypoints, " - f"got {len(self.keypoints)}" - ) + if self.skeleton in SKELETON_ARITY: + expected = SKELETON_ARITY[self.skeleton] + if len(self.keypoints) != expected: + raise ValueError( + f"Pose.skeleton={self.skeleton!r} requires {expected} keypoints, " + f"got {len(self.keypoints)}" + ) return self diff --git a/tests/test_output_schema.py b/tests/test_output_schema.py index f5cd50e..a872651 100644 --- a/tests/test_output_schema.py +++ b/tests/test_output_schema.py @@ -380,8 +380,10 @@ def test_pose_emits_coco17_arity_if_then() -> None: def test_pose_arity_if_then_travels_into_defs() -> None: """The constraint rides with the Pose `$defs` entry wherever Pose is `$ref`'d, so consumers validating a PoseSet get it too.""" + schema = Pose.emit_schema() pose_def = PoseSet.emit_schema()["$defs"]["Pose"] - assert "if" in pose_def and "then" in pose_def + assert pose_def["if"] == schema["if"] + assert pose_def["then"] == schema["then"] def test_classification_result_supports_multilabel() -> None: diff --git a/uv.lock b/uv.lock index 81272e2..f5b9e19 100644 --- a/uv.lock +++ b/uv.lock @@ -1089,12 +1089,6 @@ webvis = [ { name = "uvicorn" }, ] -[package.dev-dependencies] -dev = [ - { name = "pytest" }, - { name = "pytest-benchmark" }, -] - [package.metadata] requires-dist = [ { name = "av", marker = "extra == 'all'", specifier = "~=16.1.0" }, @@ -1148,12 +1142,6 @@ requires-dist = [ ] provides-extras = ["dev", "mqtt-out", "recorder", "image-in", "image-out", "rest", "util", "video", "video-in", "video-out", "webvis", "all"] -[package.metadata.requires-dev] -dev = [ - { name = "pytest", specifier = ">=9.0.2" }, - { name = "pytest-benchmark", specifier = ">=5.1.0" }, -] - [[package]] name = "openlineage-python" version = "1.42.1"