Skip to content

feat(cli): openfilter emit-schema (FILTER-442)#87

Merged
stwilt merged 1 commit into
mainfrom
feat/filter-442-emit-schema-cli
May 6, 2026
Merged

feat(cli): openfilter emit-schema (FILTER-442)#87
stwilt merged 1 commit into
mainfrom
feat/filter-442-emit-schema-cli

Conversation

@stwilt

@stwilt stwilt commented May 5, 2026

Copy link
Copy Markdown
Contributor

📦 Pull Request

📋 What does this PR do?

Adds the openfilter emit-schema <module>[:Class] CLI subcommand — the build-time producer half of the FC-3 schema-transport contract. The command imports a filter module, locates its FilterConfigBase subclass, and writes the emitted JSON Schema (draft 2020-12) to stdout (default) or -o <path>. Filter Dockerfiles invoke this at build time; the resulting JSON is pushed as a sibling OCI artifact (application/vnd.openfilter.schema+json) and the image manifest is stamped with a com.plainsight.openfilter.schema label carrying the artifact's sha256 digest.

Specifically:

  • New openfilter/cli/cmd_emit_schema.py — argparse-driven subcommand with module / module:Class resolution, automatic candidate detection (errors out cleanly when a module declares 0 or 2+ FilterConfigBase subclasses), --include-managed opt-in to surface platform-managed fields, and -o <path> output redirection.
  • Tweak the openfilter CLI dispatcher (openfilter/cli/__main__.py) to translate cmd-with-hyphens to cmd_with_hyphens so subcommands can use their canonical hyphenated spellings without breaking the underscore-named Python handler convention.
  • 5 unit tests in tests/test_emit_schema_cli.py covering stdout output, -o <path>, --include-managed, single-class auto-pick, and unknown-class failure.

The wiring of the sibling-artifact upload + label-stamping into cloudbuild-cascade.yaml is tracked as a sub-step of FILTER-442; it depends on PLAT-side FC-5 ingest and lands separately.


🔍 Why is this needed?

FILTER-441 landed FilterConfigBase.emit_schema() — the in-process serializer that produces each filter's JSON Schema. This PR is the CLI wrapper Dockerfiles invoke at build time so the artifact can ride alongside the image without a Python-runtime hop in the platform's ingest path.

Once both are in place, the transport contract closes: filter CI emits the schema, pushes it as a sibling OCI artifact, and stamps the image manifest's com.plainsight.openfilter.schema label with the sha256 digest. The platform fetches the artifact by digest from the same registry without pulling the filter image's layers. Integrity is transitively protected by whatever trust we already place in the registry serving the image bytes — no separate key infrastructure needed.

(Cosign-signed attestations were considered for the transport but deferred to a future-driver-only ticket — see
FILTER-448, closed Won't Do — the marginal threat surface didn't justify standing up KMS for the first internal use.)


🧪 How was it tested?

5 unit tests in tests/test_emit_schema_cli.py:

  • test_emit_schema_cli_stdoutmodule:Class spec, stdout output, $schema keyword + managed-field stripping
  • test_emit_schema_cli_writes_file-o <path> redirection
  • test_emit_schema_cli_include_managed_surfaces_overrides--include-managed round-trips FC-2 markers
  • test_emit_schema_cli_auto_picks_single_class — module-only spec finds the lone FilterConfigBase subclass
  • test_emit_schema_cli_fails_on_unknown_class — typo'd class name errors out cleanly
$ uv run --with pytest pytest tests/test_emit_schema_cli.py -q
.....                                                                    [100%]
5 passed in 1.79s

Combined with FILTER-441's config-side suite (already merged in #86, 46 tests), the full FC-1 / FC-1.5 / FC-2 / FC-3-emission contract has 51 tests exercising it end-to-end.


🔗 Related Issues


🖼️ Screenshots or Logs (if applicable)

$ openfilter emit-schema my_filter.filter --include-managed
{
  "title": "MyFilterConfig",
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "properties": {
    "sources": {
      "items": {"format": "video-source", "type": "string"},
      "type": "array",
      "x-openfilter-managed": true,
      "x-openfilter-resolve": "orchestrator-generated"
    },
    "confidence_threshold": {
      "type": "number", "minimum": 0, "maximum": 1, "default": 0.5
    },
    ...
  }
}

✅ Checklist

# 📦 Pull Request

## 📋 What does this PR do?

Adds the `openfilter emit-schema <module>[:Class]` CLI subcommand —
the build-time producer half of the FC-3 schema-transport contract.
The command imports a filter module, locates its `FilterConfigBase`
subclass, and writes the emitted JSON Schema (draft 2020-12) to
stdout (default) or `-o <path>`. Filter Dockerfiles invoke this at
build time; the resulting JSON is pushed as a sibling OCI artifact
(`application/vnd.openfilter.schema+json`) and the image manifest is
stamped with a `com.plainsight.openfilter.schema` label carrying the
artifact's sha256 digest.

Specifically:

- New `openfilter/cli/cmd_emit_schema.py` — argparse-driven
  subcommand with `module` / `module:Class` resolution, automatic
  candidate detection (errors out cleanly when a module declares 0
  or 2+ `FilterConfigBase` subclasses), `--include-managed` opt-in
  to surface platform-managed fields, and `-o <path>` output
  redirection.
- Tweak the `openfilter` CLI dispatcher
  (`openfilter/cli/__main__.py`) to translate `cmd-with-hyphens` to
  `cmd_with_hyphens` so subcommands can use their canonical
  hyphenated spellings without breaking the underscore-named Python
  handler convention.
- 5 unit tests in `tests/test_emit_schema_cli.py` covering stdout
  output, `-o <path>`, `--include-managed`, single-class auto-pick,
  and unknown-class failure.

The wiring of the sibling-artifact upload + label-stamping into
`cloudbuild-cascade.yaml` is tracked as a sub-step of
[FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442);
it depends on PLAT-side FC-5 ingest and lands separately.

---

## 🔍 Why is this needed?

[FILTER-441](https://plainsight-ai.atlassian.net/browse/FILTER-441)
landed `FilterConfigBase.emit_schema()` — the in-process serializer
that produces each filter's JSON Schema. This PR is the CLI wrapper
Dockerfiles invoke at build time so the artifact can ride alongside
the image without a Python-runtime hop in the platform's ingest
path.

Once both are in place, the transport contract closes: filter CI
emits the schema, pushes it as a sibling OCI artifact, and stamps
the image manifest's `com.plainsight.openfilter.schema` label with
the sha256 digest. The platform fetches the artifact by digest from
the same registry without pulling the filter image's layers.
Integrity is transitively protected by whatever trust we already
place in the registry serving the image bytes — no separate key
infrastructure needed.

(Cosign-signed attestations were considered for the transport but
deferred to a future-driver-only ticket — see
[FILTER-448](https://plainsight-ai.atlassian.net/browse/FILTER-448),
closed Won't Do — the marginal threat surface didn't justify
standing up KMS for the first internal use.)

---

## 🧪 How was it tested?

5 unit tests in `tests/test_emit_schema_cli.py`:

- `test_emit_schema_cli_stdout` — `module:Class` spec, stdout
  output, `$schema` keyword + managed-field stripping
- `test_emit_schema_cli_writes_file` — `-o <path>` redirection
- `test_emit_schema_cli_include_managed_surfaces_overrides` —
  `--include-managed` round-trips FC-2 markers
- `test_emit_schema_cli_auto_picks_single_class` — module-only spec
  finds the lone `FilterConfigBase` subclass
- `test_emit_schema_cli_fails_on_unknown_class` — typo'd class name
  errors out cleanly

```
$ uv run --with pytest pytest tests/test_emit_schema_cli.py -q
.....                                                                    [100%]
5 passed in 1.79s
```

Combined with [FILTER-441](https://plainsight-ai.atlassian.net/browse/FILTER-441)'s
config-side suite (already merged in #86, 46 tests), the full
FC-1 / FC-1.5 / FC-2 / FC-3-emission contract has 51 tests
exercising it end-to-end.

---

## 🔗 Related Issues

- [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) — this ticket
- [FILTER-441](https://plainsight-ai.atlassian.net/browse/FILTER-441) — `FilterConfigBase.emit_schema()` (merged in #86)
- [FILTER-440](https://plainsight-ai.atlassian.net/browse/FILTER-440) — Declarative Configuration Contract (parent epic)
- [FILTER-448](https://plainsight-ai.atlassian.net/browse/FILTER-448) — cosign / KMS signing (closed Won't Do; revisit on concrete compliance/multi-tenancy driver)

---

## 🖼️ Screenshots or Logs (if applicable)

```
$ openfilter emit-schema my_filter.filter --include-managed
{
  "title": "MyFilterConfig",
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "properties": {
    "sources": {
      "items": {"format": "video-source", "type": "string"},
      "type": "array",
      "x-openfilter-managed": true,
      "x-openfilter-resolve": "orchestrator-generated"
    },
    "confidence_threshold": {
      "type": "number", "minimum": 0, "maximum": 1, "default": 0.5
    },
    ...
  }
}
```

---

## ✅ Checklist

- [x] I have read and agreed to the terms of the [LICENSE](../LICENSE)
- [x] I have read the [CONTRIBUTING](../CONTRIBUTING.md) guide
- [x] I have followed the [coding style](../CONTRIBUTING.md#coding-style)
- [x] I have signed all commits in compliance with the DCO (`git commit -s`)
- [x] I have added or updated **tests** as needed
- [x] I have added or updated **documentation** as needed
  (`docs/declarative-config.md`'s "Schema emission" section, landed
  with FILTER-441 in #86, already describes this CLI's surface)

Signed-off-by: stwilt <swilt@plainsight.ai>
@stwilt stwilt requested review from lucasmundim and shingonoide May 5, 2026 08:28

@lucasmundim lucasmundim left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Clean dispatcher tweak, correct use of the FilterConfigBase.emit_schema(include_managed=...) API from #86, and a reasonable test set. A few non-blocking notes for follow-up:

  • Non-atomic write to -o <path>: cmd_emit_schema.py writes directly with open(path, "w"). If this ever feeds OCI artifact upload at build time, a tempfile.NamedTemporaryFile on the same filesystem + os.replace() would avoid leaving a partial file on interruption.
  • Unwrapped importlib.import_module in _resolve_config_class: every other failure branch raises a clean SystemExit(...), but a bad module path falls through as a raw ModuleNotFoundError traceback. Minor consistency nit.
  • Dead setdefault calls for title and $schema in the CLI: both keys are already injected by pydantic and FilterConfigBase.emit_schema() respectively, so the CLI's setdefault calls are no-ops and can be removed.
  • Auto-detect edge cases worth keeping in mind: module-level class aliases (Alias = MyConfig) would be yielded twice by inspect.getmembers, and package re-exports (__init__.py re-exporting a class defined in a submodule) would fail the __module__ check. Neither pattern is used in the repo today — flagging in case third-party filters hit them.

@shingonoide shingonoide left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Echoing Lucas's APPROVE — adding one finding worth picking up alongside his follow-ups, plus a test-coverage note.

LOG_LEVEL typo crashes the entire openfilter CLI at import time (cmd_emit_schema.py:25)

logger.setLevel(int(getattr(logging, (os.getenv("LOG_LEVEL") or "INFO").upper())))

getattr(logging, "VERBOSE") (or any other typo / unsupported level) raises AttributeError at module import. Because openfilter/cli/__main__.py imports cmd_emit_schema unconditionally, a bad LOG_LEVEL in the build environment doesn't just break emit-schema — it breaks openfilter run, openfilter info, and any future subcommand the dispatcher tries to load. Suggested fix:

logger.setLevel(getattr(logging, (os.getenv("LOG_LEVEL") or "INFO").upper(), logging.INFO))

(The int(...) cast is also unneeded — logging.INFO is already an int.)

Test coverage gaps tied to the import paths

The 5 tests cover happy paths well; two negative-path gaps worth filling alongside Lucas's import_module wrap:

  • No test for ModuleNotFoundError (e.g. ["emit-schema", "no.such.module"]) — would lock in his suggested wrap.
  • test_emit_schema_cli_fails_on_unknown_class exercises getattr(...) is None, but not the inspect.isclass(cls) and not issubclass(cls, FilterConfigBase) branch (cmd_emit_schema.py:48-52). Passing a non-Filter attribute (module:dict) would close that hole.

Comment thread openfilter/cli/cmd_emit_schema.py
@stwilt stwilt merged commit 722ba73 into main May 6, 2026
10 checks passed
@stwilt stwilt deleted the feat/filter-442-emit-schema-cli branch May 6, 2026 08:49
stwilt added a commit that referenced this pull request May 6, 2026
…FILTER-444)

## What does this PR do?

Adds the SDK affordance for filters to declare their `frame.data` output as a build-time JSON Schema (draft 2020-12), parallel to `FilterConfigBase` from [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442). Ships:

- `FilterOutputSchema(BaseModel)` in `openfilter.filter_runtime.output` with `__schema_id__` (`$id`) and `__frame_data_key__` (dotted path on `frame.data`, surfaced as the `x-openfilter-frame-data-key` extension). No managed/resolve filtering — output schemas describe data, not config.
- `openfilter.filter_runtime.shapes` catalog: `BoundingBox`, `Polygon`, `Mask`, `Keypoint`, `Detection`, `DetectionSet`, `Track`, `TrackSet`, `Pose`, `PoseSet`, `KeypointSet`, `OCRSpan`, `OCRSpanSet`, `ClassificationResult`. Each with stable `$id` at `https://schemas.plainsight.ai/shapes/<kebab>/v1` and per-shape coordinate conventions documented inline (pixel xyxy for boxes / polygons / masks / OCR quads; normalized [0,1] for keypoints).
- `openfilter emit-schema --kind {config,output}` flag, defaulting to `config` to preserve the [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) contract. Auto-detection respects the selected base class; `--include-managed` is rejected with `--kind output`.

## Why is this needed?

Consumers of `frame.data` (a shared visualizer, webvis SSE clients, GT label visualization, downstream filters) currently negotiate shapes out-of-band — every consumer hard-codes its own dialect against every producer. Declaring output as a schema lets all consumers bind against one interop type.

The catalog is **normative, not descriptive** — it defines what filters *should* emit, with per-filter migrations as separate follow-ups (catalog shapes were seeded from the superset of what `filter-sam3-detector`, `filter-protege-model`, `filter-optical-character-recognition`, and `filter-pose-estimation` currently emit on `frame.data`). Filters that don't want to use the catalog can ship their own bespoke `FilterOutputSchema` with a filter-owned `$id`; catalog `$ref`s are an opt-in convenience, never a gate.

## How was it tested?

- 26 new unit tests in `tests/test_output_schema.py` covering base-class semantics (`$id` / frame-data-key emission, draft 2020-12 conformance, catalog `$ref` resolution into `$defs`, bespoke schema with no catalog dep), every catalog shape's `$id`, and shape-specific validation (BoundingBox pixel xyxy, Keypoint normalized range, Detection open-set label-only, Track inheritance, OCRSpan quad arity, Pose skeleton convention, ClassificationResult multilabel toggle).
- 5 new CLI tests in `tests/test_emit_schema_cli.py` covering `--kind output` happy path, auto-detection of a single output class, cross-base rejection (config kind on output class and vice versa), and `--include-managed` incompatibility with `--kind output`.
- Full suite: 559 passing (36 new + 523 unchanged); 42 pre-existing failures in `test_filter_video_out.py` / `test_filter_rest.py` / `test_filter_webvis.py` are gated on `av` / `fastapi` extras absent from `--extra dev` and unrelated to this change.

## Related Issues

- Implements [FILTER-444](https://plainsight-ai.atlassian.net/browse/FILTER-444) — SDK half.
- Workflow plumbing for emit + OCI sibling-artifact attach (config + output) is split into [FILTER-450](https://plainsight-ai.atlassian.net/browse/FILTER-450).
- Sibling to [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) (config schema CLI, shipped in #87).

## Screenshots or Logs

N/A — pure SDK module; no UI surface.

## Checklist

- [ ] I have read and agreed to the terms of the [LICENSE](../LICENSE)
- [ ] I have read the [CONTRIBUTING](../CONTRIBUTING.md) guide
- [ ] I have followed the [coding style](../CONTRIBUTING.md#coding-style)
- [x] I have signed all commits in compliance with the DCO (`git commit -s`)
- [x] I have added or updated **tests** as needed
- [ ] I have added or updated **documentation** as needed (module-level docstrings only; no separate docs-site change)

Signed-off-by: stwilt <swilt@plainsight.ai>
stwilt added a commit that referenced this pull request May 6, 2026
…FILTER-444)

## What does this PR do?

Adds the SDK affordance for filters to declare their `frame.data` output as a build-time JSON Schema (draft 2020-12), parallel to `FilterConfigBase` from [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442). Ships:

- `FilterOutputSchema(BaseModel)` in `openfilter.filter_runtime.output` with `__schema_id__` (`$id`) and `__frame_data_key__` (dotted path on `frame.data`, surfaced as the `x-openfilter-frame-data-key` extension). No managed/resolve filtering — output schemas describe data, not config.
- `openfilter.filter_runtime.shapes` catalog: `BoundingBox`, `Polygon`, `Mask`, `Keypoint`, `Detection`, `DetectionSet`, `Track`, `TrackSet`, `Pose`, `PoseSet`, `KeypointSet`, `OCRSpan`, `OCRSpanSet`, `ClassificationResult`. Each with stable `$id` at `https://schemas.plainsight.ai/shapes/<kebab>/v1` and per-shape coordinate conventions documented inline (pixel xyxy for boxes / polygons / masks / OCR quads; normalized [0,1] for keypoints).
- `openfilter emit-schema --kind {config,output}` flag, defaulting to `config` to preserve the [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) contract. Auto-detection respects the selected base class; `--include-managed` is rejected with `--kind output`.

## Why is this needed?

Consumers of `frame.data` (a shared visualizer, webvis SSE clients, GT label visualization, downstream filters) currently negotiate shapes out-of-band — every consumer hard-codes its own dialect against every producer. Declaring output as a schema lets all consumers bind against one interop type.

The catalog is **normative, not descriptive** — it defines what filters *should* emit, with per-filter migrations as separate follow-ups (catalog shapes were seeded from the superset of what `filter-sam3-detector`, `filter-protege-model`, `filter-optical-character-recognition`, and `filter-pose-estimation` currently emit on `frame.data`). Filters that don't want to use the catalog can ship their own bespoke `FilterOutputSchema` with a filter-owned `$id`; catalog `$ref`s are an opt-in convenience, never a gate.

## How was it tested?

- 26 new unit tests in `tests/test_output_schema.py` covering base-class semantics (`$id` / frame-data-key emission, draft 2020-12 conformance, catalog `$ref` resolution into `$defs`, bespoke schema with no catalog dep), every catalog shape's `$id`, and shape-specific validation (BoundingBox pixel xyxy, Keypoint normalized range, Detection open-set label-only, Track inheritance, OCRSpan quad arity, Pose skeleton convention, ClassificationResult multilabel toggle).
- 5 new CLI tests in `tests/test_emit_schema_cli.py` covering `--kind output` happy path, auto-detection of a single output class, cross-base rejection (config kind on output class and vice versa), and `--include-managed` incompatibility with `--kind output`.
- Full suite: 559 passing (36 new + 523 unchanged); 42 pre-existing failures in `test_filter_video_out.py` / `test_filter_rest.py` / `test_filter_webvis.py` are gated on `av` / `fastapi` extras absent from `--extra dev` and unrelated to this change.

## Related Issues

- Implements [FILTER-444](https://plainsight-ai.atlassian.net/browse/FILTER-444) — SDK half.
- Workflow plumbing for emit + OCI sibling-artifact attach (config + output) is split into [FILTER-450](https://plainsight-ai.atlassian.net/browse/FILTER-450).
- Sibling to [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) (config schema CLI, shipped in #87).

## Screenshots or Logs

N/A — pure SDK module; no UI surface.

## Checklist

- [x] I have read and agreed to the terms of the [LICENSE](../LICENSE)
- [x] I have read the [CONTRIBUTING](../CONTRIBUTING.md) guide
- [x] I have followed the [coding style](../CONTRIBUTING.md#coding-style)
- [x] I have signed all commits in compliance with the DCO (`git commit -s`)
- [x] I have added or updated **tests** as needed
- [x] I have added or updated **documentation** as needed

Signed-off-by: stwilt <swilt@plainsight.ai>
stwilt added a commit that referenced this pull request May 10, 2026
…FILTER-444)

## What does this PR do?

Adds the SDK affordance for filters to declare their `frame.data` output as a build-time JSON Schema (draft 2020-12), parallel to `FilterConfigBase` from [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442). Ships:

- `FilterOutputSchema(BaseModel)` in `openfilter.filter_runtime.output` with `__schema_id__` (`$id`) and `__frame_data_key__` (dotted path on `frame.data`, surfaced as the `x-openfilter-frame-data-key` extension). No managed/resolve filtering — output schemas describe data, not config.
- `openfilter.filter_runtime.shapes` catalog: `BoundingBox`, `Polygon`, `Mask`, `Keypoint`, `Detection`, `DetectionSet`, `Track`, `TrackSet`, `Pose`, `PoseSet`, `KeypointSet`, `OCRSpan`, `OCRSpanSet`, `ClassificationResult`. Each with stable `$id` at `https://schemas.plainsight.ai/shapes/<kebab>/v1` and per-shape coordinate conventions documented inline (pixel xyxy for boxes / polygons / masks / OCR quads; normalized [0,1] for keypoints).
- `openfilter emit-schema --kind {config,output}` flag, defaulting to `config` to preserve the [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) contract. Auto-detection respects the selected base class; `--include-managed` is rejected with `--kind output`.

## Why is this needed?

Consumers of `frame.data` (a shared visualizer, webvis SSE clients, GT label visualization, downstream filters) currently negotiate shapes out-of-band — every consumer hard-codes its own dialect against every producer. Declaring output as a schema lets all consumers bind against one interop type.

The catalog is **normative, not descriptive** — it defines what filters *should* emit, with per-filter migrations as separate follow-ups (catalog shapes were seeded from the superset of what `filter-sam3-detector`, `filter-protege-model`, `filter-optical-character-recognition`, and `filter-pose-estimation` currently emit on `frame.data`). Filters that don't want to use the catalog can ship their own bespoke `FilterOutputSchema` with a filter-owned `$id`; catalog `$ref`s are an opt-in convenience, never a gate.

## How was it tested?

- 26 new unit tests in `tests/test_output_schema.py` covering base-class semantics (`$id` / frame-data-key emission, draft 2020-12 conformance, catalog `$ref` resolution into `$defs`, bespoke schema with no catalog dep), every catalog shape's `$id`, and shape-specific validation (BoundingBox pixel xyxy, Keypoint normalized range, Detection open-set label-only, Track inheritance, OCRSpan quad arity, Pose skeleton convention, ClassificationResult multilabel toggle).
- 5 new CLI tests in `tests/test_emit_schema_cli.py` covering `--kind output` happy path, auto-detection of a single output class, cross-base rejection (config kind on output class and vice versa), and `--include-managed` incompatibility with `--kind output`.
- Full suite: 559 passing (36 new + 523 unchanged); 42 pre-existing failures in `test_filter_video_out.py` / `test_filter_rest.py` / `test_filter_webvis.py` are gated on `av` / `fastapi` extras absent from `--extra dev` and unrelated to this change.

## Related Issues

- Implements [FILTER-444](https://plainsight-ai.atlassian.net/browse/FILTER-444) — SDK half.
- Workflow plumbing for emit + OCI sibling-artifact attach (config + output) is split into [FILTER-450](https://plainsight-ai.atlassian.net/browse/FILTER-450).
- Sibling to [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) (config schema CLI, shipped in #87).

## Screenshots or Logs

N/A — pure SDK module; no UI surface.

## Checklist

- [x] I have read and agreed to the terms of the [LICENSE](../LICENSE)
- [x] I have read the [CONTRIBUTING](../CONTRIBUTING.md) guide
- [x] I have followed the [coding style](../CONTRIBUTING.md#coding-style)
- [x] I have signed all commits in compliance with the DCO (`git commit -s`)
- [x] I have added or updated **tests** as needed
- [x] I have added or updated **documentation** as needed

Signed-off-by: stwilt <swilt@plainsight.ai>
stwilt added a commit that referenced this pull request May 10, 2026
…FILTER-444) (#88)

…FILTER-444)

## What does this PR do?

Adds the SDK affordance for filters to declare their `frame.data` output
as a build-time JSON Schema (draft 2020-12), parallel to
`FilterConfigBase` from
[FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442).
Ships:

- `FilterOutputSchema(BaseModel)` in `openfilter.filter_runtime.output`
with `__schema_id__` (`$id`) and `__frame_data_key__` (dotted path on
`frame.data`, surfaced as the `x-openfilter-frame-data-key` extension).
No managed/resolve filtering — output schemas describe data, not config.
- `openfilter.filter_runtime.shapes` catalog: `BoundingBox`, `Polygon`,
`Mask`, `Keypoint`, `Detection`, `DetectionSet`, `Track`, `TrackSet`,
`Pose`, `PoseSet`, `KeypointSet`, `OCRSpan`, `OCRSpanSet`,
`ClassificationResult`. Each with stable `$id` at
`https://schemas.plainsight.ai/shapes/<kebab>/v1` and per-shape
coordinate conventions documented inline (pixel xyxy for boxes /
polygons / masks / OCR quads; normalized [0,1] for keypoints).
- `openfilter emit-schema --kind {config,output}` flag, defaulting to
`config` to preserve the
[FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442)
contract. Auto-detection respects the selected base class;
`--include-managed` is rejected with `--kind output`.

## Why is this needed?

Consumers of `frame.data` (a shared visualizer, webvis SSE clients, GT
label visualization, downstream filters) currently negotiate shapes
out-of-band — every consumer hard-codes its own dialect against every
producer. Declaring output as a schema lets all consumers bind against
one interop type.

The catalog is **normative, not descriptive** — it defines what filters
*should* emit, with per-filter migrations as separate follow-ups
(catalog shapes were seeded from the superset of what
`filter-sam3-detector`, `filter-protege-model`,
`filter-optical-character-recognition`, and `filter-pose-estimation`
currently emit on `frame.data`). Filters that don't want to use the
catalog can ship their own bespoke `FilterOutputSchema` with a
filter-owned `$id`; catalog `$ref`s are an opt-in convenience, never a
gate.

## How was it tested?

- 26 new unit tests in `tests/test_output_schema.py` covering base-class
semantics (`$id` / frame-data-key emission, draft 2020-12 conformance,
catalog `$ref` resolution into `$defs`, bespoke schema with no catalog
dep), every catalog shape's `$id`, and shape-specific validation
(BoundingBox pixel xyxy, Keypoint normalized range, Detection open-set
label-only, Track inheritance, OCRSpan quad arity, Pose skeleton
convention, ClassificationResult multilabel toggle).
- 5 new CLI tests in `tests/test_emit_schema_cli.py` covering `--kind
output` happy path, auto-detection of a single output class, cross-base
rejection (config kind on output class and vice versa), and
`--include-managed` incompatibility with `--kind output`.
- Full suite: 559 passing (36 new + 523 unchanged); 42 pre-existing
failures in `test_filter_video_out.py` / `test_filter_rest.py` /
`test_filter_webvis.py` are gated on `av` / `fastapi` extras absent from
`--extra dev` and unrelated to this change.

## Related Issues

- Implements
[FILTER-444](https://plainsight-ai.atlassian.net/browse/FILTER-444) —
SDK half.
- Workflow plumbing for emit + OCI sibling-artifact attach (config +
output) is split into
[FILTER-450](https://plainsight-ai.atlassian.net/browse/FILTER-450).
- Sibling to
[FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442)
(config schema CLI, shipped in #87).

## Screenshots or Logs

N/A — pure SDK module; no UI surface.

## Checklist

- [x] I have read and agreed to the terms of the [LICENSE](../LICENSE)
- [x] I have read the [CONTRIBUTING](../CONTRIBUTING.md) guide
- [x] I have followed the [coding
style](../CONTRIBUTING.md#coding-style)
- [x] I have signed all commits in compliance with the DCO (`git commit
-s`)
- [x] I have added or updated **tests** as needed
- [x] I have added or updated **documentation** as needed


[FILTER-442]:
https://plainsight-ai.atlassian.net/browse/FILTER-442?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
[FILTER-442]:
https://plainsight-ai.atlassian.net/browse/FILTER-442?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ

---------

Signed-off-by: stwilt <swilt@plainsight.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants