argus-cortex 0.4.1__tar.gz → 0.4.3__tar.gz

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.
Files changed (47) hide show
  1. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/CHANGELOG.md +51 -1
  2. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/CLAUDE.md +2 -1
  3. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/PKG-INFO +28 -1
  4. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/README.md +24 -0
  5. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/pyproject.toml +4 -0
  6. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/_version.py +2 -2
  7. argus_cortex-0.4.3/src/argus_cortex/gpu.py +242 -0
  8. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/moderation/__init__.py +2 -0
  9. argus_cortex-0.4.3/src/argus_cortex/moderation/policies.py +186 -0
  10. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/moderation/singguard.py +21 -6
  11. argus_cortex-0.4.3/tests/test_gpu.py +227 -0
  12. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/tests/test_moderation.py +25 -3
  13. argus_cortex-0.4.3/tests/test_policies.py +54 -0
  14. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/.copier-answers.yml +0 -0
  15. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/.env.example +0 -0
  16. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/.github/workflows/ci.yml +0 -0
  17. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/.github/workflows/release.yml +0 -0
  18. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/.gitignore +0 -0
  19. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/LICENSE +0 -0
  20. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/Makefile +0 -0
  21. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/__init__.py +0 -0
  22. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/backends.py +0 -0
  23. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/moderation/_http.py +0 -0
  24. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/moderation/nudenet.py +0 -0
  25. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/py.typed +0 -0
  26. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/server.py +0 -0
  27. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/store/__init__.py +0 -0
  28. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/store/blob.py +0 -0
  29. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/store/config.py +0 -0
  30. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/store/errors.py +0 -0
  31. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/store/lineage.py +0 -0
  32. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/store/models.py +0 -0
  33. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/store/schema.py +0 -0
  34. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/store/vector.py +0 -0
  35. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/taxonomy.py +0 -0
  36. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/wire.py +0 -0
  37. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/src/argus_cortex/xmp.py +0 -0
  38. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/tests/test_backends.py +0 -0
  39. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/tests/test_blob.py +0 -0
  40. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/tests/test_server.py +0 -0
  41. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/tests/test_smoke.py +0 -0
  42. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/tests/test_store.py +0 -0
  43. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/tests/test_store_pg_integration.py +0 -0
  44. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/tests/test_taxonomy.py +0 -0
  45. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/tests/test_vector.py +0 -0
  46. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/tests/test_wire.py +0 -0
  47. {argus_cortex-0.4.1 → argus_cortex-0.4.3}/tests/test_xmp.py +0 -0
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.3] - 2026-10-30
11
+
12
+ ### Added
13
+ - **`argus_cortex.gpu`**: GPU tenancy shared across the stages, generalised
14
+ from argus-lens's `gpu` coordinator.
15
+ - `GpuCoordinator`: the protocol. `lease(caller=, min_vram_mb=)` is a
16
+ context manager that holds a GPU slot for its body. `GpuLeaseTimeout` is a
17
+ `TimeoutError`.
18
+ - `NullCoordinator`: no gating, and the default.
19
+ - `LeaseCoordinator`: an exclusive `flock` that every argus process on the
20
+ host takes, then, while still holding it, a wait until `free_vram_mb()`
21
+ reports `min_vram_mb`. Unlike lens's lease, `min_vram_mb` is honoured. NVML
22
+ counts every process on the card, so ComfyUI and Ollama are accounted for
23
+ without taking part. One deadline covers both waits. A probe that cannot
24
+ run (no driver, no `[gpu]` extra, a CPU host) lets the lease through.
25
+ - `free_vram_mb(device)`: NVML free memory in MB, or `None`.
26
+ - `build_coordinator()` / `coordinator_from_env()`: `ARGUS_GPU_COORDINATOR`
27
+ (`none` / `lease`), `ARGUS_GPU_LEASE_PATH`, `ARGUS_GPU_LEASE_TIMEOUT_S`
28
+ (300 s) and `ARGUS_GPU_DEVICE`. These are the names lens already reads. An
29
+ unusable number logs a warning and falls back to the default.
30
+ - The default lock is `<tempdir>/argus-gpu.lock`. Containers have their own
31
+ `/tmp`, so a shared lock needs `ARGUS_GPU_LEASE_PATH` on a volume every
32
+ GPU service mounts. The lock file is `chmod 0o666` after create so a
33
+ restrictive umask does not block other container UIDs from opening it.
34
+ - `[gpu]` extra: `nvidia-ml-py`, imported only when probed.
35
+
36
+ ## [0.4.2] - 2026-10-29
37
+
38
+ ### Added
39
+ - `SingGuardClient.check_text(text, role="user")`: a text-only check, with no
40
+ image, for judging a prompt before it is run (or, with `role="assistant"`, a
41
+ model's reply). It sends `input_role` and keeps the client's policy and
42
+ thinking mode. It needs a server that takes a check without `image_base64`:
43
+ agent-composer's guard router does, while argus-cull's `singguard` service
44
+ answers HTTP 422, raised as `GuardCheckFailed`. `INPUT_ROLES` lists the roles.
45
+ - **`argus_cortex.moderation.policies`**: the named SingGuard policies the
46
+ stages share, moved from argus-curator's `guard.py` with their texts
47
+ unchanged. `POLICIES` / `PolicyName` (`default`, `adult`, `adult-granular`,
48
+ `adult-cautious`) and `policy_text(name)` (`None` for SingGuard's own rules).
49
+ - `illegal_rules(name)` / `is_illegal(name, rule)`: the rule letters whose
50
+ firing means illegal content, per policy. `adult-granular` is A, H and I;
51
+ `adult` and `adult-cautious` are A. `default` has none, because its A
52
+ covers consensual adult nudity too. B (crime) and G (animal abuse) are
53
+ never illegal.
54
+ - `mode(name)`: `sfw` for `default` and any `sfw-*` name (the prefix is
55
+ reserved for SFW policies added later), `nsfw` for every other policy.
56
+ - An unknown name raises `ModerationConfigError`.
57
+
10
58
  ## [0.4.1] - 2026-10-27
11
59
 
12
60
  ### Added
@@ -116,7 +164,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
116
164
  (`LocalBackend`, `RemoteBackend`, `RemoteProvider`, `with_retries`
117
165
  retrying only transient errors, `resolve_device`).
118
166
 
119
- [Unreleased]: https://github.com/smk762/argus-cortex/compare/v0.4.1...HEAD
167
+ [Unreleased]: https://github.com/smk762/argus-cortex/compare/v0.4.3...HEAD
168
+ [0.4.3]: https://github.com/smk762/argus-cortex/compare/v0.4.2...v0.4.3
169
+ [0.4.2]: https://github.com/smk762/argus-cortex/compare/v0.4.1...v0.4.2
120
170
  [0.4.1]: https://github.com/smk762/argus-cortex/compare/v0.4.0...v0.4.1
121
171
  [0.4.0]: https://github.com/smk762/argus-cortex/compare/v0.3.0...v0.4.0
122
172
  [0.3.0]: https://github.com/smk762/argus-cortex/compare/v0.2.0...v0.3.0
@@ -18,8 +18,9 @@ Because everything here is imported by the downstream stages, **a change ripples
18
18
  - `wire.py` — the versioned wire-schema toolkit consumers build their contracts on: `make_versioned_base(field_name, current, majors)` (a Pydantic base that stamps + version-checks a field like `proof_version`/`manifest_version`), `check_version()` (the major-compatibility gate), `render_schema()`/`wire_schema()` (the canonical committed-schema string behind each consumer's `schema --check` CLI), and `VersionError`.
19
19
  - `backends.py` — the AI-backend contract: `Backend`/`LocalBackend`/`RemoteBackend` (point at a hosted service via `from_host(host, port)` or `from_provider(RemoteProvider.…)`), plus `with_retries()` and `resolve_device()`. httpx is lazy, behind `[remote]`.
20
20
  - `server.py` — the shared FastAPI/ASGI scaffolding other repos' micro-servers mount: `WriteGuard` (pure-ASGI method gate), the `constant_refuse()` (read-only/replay) and `cross_site_refuse()` (cross-origin write) predicates, `env_flag()`, and the `UNSAFE_METHODS`/`TRUTHY`/`FALSY` constants. Starlette is behind `[server]`.
21
+ - `gpu.py` — GPU tenancy (from argus-lens): the `GpuCoordinator` protocol (`lease(caller=, min_vram_mb=)`), `NullCoordinator`, `LeaseCoordinator` (host-wide `flock` **then** a wait for NVML free VRAM, so tenants outside the lock like ComfyUI and Ollama are accounted for), `free_vram_mb()`, `coordinator_from_env()` (`ARGUS_GPU_*` — host-wide names on purpose: every process must agree). NVML lazily, behind `[gpu]`; a probe that cannot run grants the lease. **The lock file must be on a volume every GPU container mounts** — per-container `/tmp` coordinates nothing.
21
22
  - `xmp.py` — the suite's XMP sidecar read/merge/write (`write_xmp`/`read_xmp`/`merge_xmp`/`parse_xmp`, `XmpData`/`XmpRegions`, `XmpError`), used by lens (keywords/caption) and curator (faces, content rating). Stdlib `xml.etree` only. **Writes merge — latest wins per property, foreign properties preserved; keep that.** `None` = leave untouched, empty = remove. A DTD is refused and an unparseable sidecar raises rather than being overwritten. The `argus` namespace URI is a wire contract — don't change it.
22
- - `moderation/` — content-moderation clients over HTTP: `nudenet.py` (`NudeNetClient`, `NsfwResult`, `DEFAULT_EXPOSED_LABELS` — **the suite's one definition of nudity; don't widen it casually**, every stage's NSFW numbers move with it), `singguard.py` (`SingGuardClient`, `GuardResult`, `rule_matches`), `_http.py` (shared POST, proxy-free opener, `CheckFailed`/`ModerationConfigError`). Stdlib + pydantic; Pillow lazily, behind `[images]`. Never import NudeNet (AGPL) — HTTP only. URLs are passed in; stages own their env config.
23
+ - `moderation/` — content-moderation clients over HTTP: `nudenet.py` (`NudeNetClient`, `NsfwResult`, `DEFAULT_EXPOSED_LABELS` — **the suite's one definition of nudity; don't widen it casually**, every stage's NSFW numbers move with it), `singguard.py` (`SingGuardClient` incl. text-only `check_text()`, `GuardResult`, `rule_matches`), `policies.py` (the named policy texts, the per-policy illegal-rule table and `mode()`: `default`/`sfw-*` are SFW — **a policy's text and its illegal letters change together**), `_http.py` (shared POST, proxy-free opener, `CheckFailed`/`ModerationConfigError`). Stdlib + pydantic; Pillow lazily, behind `[images]`. Never import NudeNet (AGPL) — HTTP only. URLs are passed in; stages own their env config.
23
24
  - `store/` — the optional stateful-services layer, opt-in per `CORTEX_*` env var: `lineage.py` (Phase 1 Postgres DAG, `open_lineage_store()`), `vector.py` (Phase 2 Qdrant, `open_vector_store()`), `blob.py` (Phase 3 MinIO/S3, `open_blob_store()`), over `config.py`/`models.py`/`errors.py`. Each `open_*()` returns a `Null*` no-op when its URL is unset. Drivers are behind `[postgres]`/`[qdrant]`/`[s3]`.
24
25
 
25
26
  There is **no CLI and no committed schema in this repo** — the `schema`/`schema --check` pattern is something `wire.py` *enables* for downstream repos, not something cortex runs on itself.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: argus-cortex
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Shared foundation for the Argus suite: taxonomy, versioned wire-schema tooling, and the local/remote AI backend contract
5
5
  Project-URL: Repository, https://github.com/smk762/argus-cortex
6
6
  Author: smk762
@@ -20,6 +20,7 @@ Requires-Dist: certifi>=2026.10.19; extra == 'dev'
20
20
  Requires-Dist: httpx2>=2.7; extra == 'dev'
21
21
  Requires-Dist: httpx>=0.27; extra == 'dev'
22
22
  Requires-Dist: minio>=7.2; extra == 'dev'
23
+ Requires-Dist: nvidia-ml-py>=12.535; extra == 'dev'
23
24
  Requires-Dist: pillow>=10.0; extra == 'dev'
24
25
  Requires-Dist: psycopg[binary,pool]>=3.1; extra == 'dev'
25
26
  Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
@@ -27,6 +28,8 @@ Requires-Dist: pytest>=8.0; extra == 'dev'
27
28
  Requires-Dist: qdrant-client>=1.10; extra == 'dev'
28
29
  Requires-Dist: ruff==0.15.16; extra == 'dev'
29
30
  Requires-Dist: starlette>=0.37; extra == 'dev'
31
+ Provides-Extra: gpu
32
+ Requires-Dist: nvidia-ml-py>=12.535; extra == 'gpu'
30
33
  Provides-Extra: images
31
34
  Requires-Dist: pillow>=10.0; extra == 'images'
32
35
  Provides-Extra: postgres
@@ -58,6 +61,7 @@ Part of the [Argus suite](https://github.com/smk762?tab=repositories&q=argus) (q
58
61
  - **`argus_cortex.server`** — shared write-guard + env-flag scaffolding for the suite's FastAPI micro-servers: `WriteGuard` (a pure-ASGI method-gate that never buffers streaming responses), the `constant_refuse()` / `cross_site_refuse()` predicates (read-only/replay mode and cross-site-write protection are two configs of the one guard), the `UNSAFE_METHODS` / `TRUTHY` / `FALSY` constants, and `env_flag()` (warns on a mistyped protection flag rather than silently disabling it). Starlette lives behind the `[server]` extra.
59
62
  - **`argus_cortex.xmp`** — the suite's one XMP sidecar implementation (hoisted from argus-lens): `write_xmp()` / `read_xmp()` / `merge_xmp()` / `parse_xmp()` over an `XmpData` model covering `dc` (keywords, description), `xmp` (Rating, Label), `lr` (hierarchical keywords), `Iptc4xmpExt` (PersonInImage), `mwg-rs` face/pet regions, and `argus:ContentRating`. Writes **merge**: each property you set replaces that property (latest write wins), everything else in the sidecar — Lightroom develop settings, GPS, another stage's fields — is preserved. `None` leaves a property alone, an empty value removes it. Writes are atomic; an unparseable existing sidecar raises `XmpError` instead of being clobbered. Stdlib only.
60
63
  - **`argus_cortex.moderation`** — the suite's content-moderation clients, over HTTP to servers the deployer runs (nothing here imports a detector): `NudeNetClient` (a NudeNet server's `/infer` → `NsfwResult`: detections, per-class maxima, and an **exposed score**) and `SingGuardClient` (a SingGuard server's `/api/guard/check` → `GuardResult`: verdict, the policy rules that fired, explanation; optional custom policy text). `DEFAULT_EXPOSED_LABELS` is the suite's **one definition of nudity**: the five exposed buttocks/breast/genitalia/anus classes — faces, covered parts, and bare feet, belly, armpits or male chest never count. Stdlib + pydantic; Pillow (for SingGuard's downscaling of large images, on by default) is behind `[images]`: without it `SingGuardClient(...)` raises `ModerationConfigError` at construction unless given `downscale=False`. A `user:password@` in a URL is sent as HTTP Basic auth. URLs are passed in, never read from the environment here: each stage owns its config. NudeNet itself is AGPL-3.0, which is why it stays behind an HTTP boundary.
64
+ - **`argus_cortex.gpu`**: GPU tenancy, generalised from argus-lens. `coordinator_from_env()` returns a coordinator whose `lease(caller=, min_vram_mb=)` holds a GPU slot for a block of work. `none` (the default) grants every lease at once. `lease` takes an exclusive `flock` that every argus process on the host shares (one lock for the host, not per GPU), then waits until `free_vram_mb()` (NVML, behind `[gpu]`) reports `min_vram_mb` free across *every* process on the card, ComfyUI and Ollama included, which never take the lock. It raises `GpuLeaseTimeout` after `ARGUS_GPU_LEASE_TIMEOUT_S` (300 s). The VRAM check is a snapshot, not a reservation, and a probe that cannot run lets the lease through. Containers each have their own `/tmp`, so set `ARGUS_GPU_LEASE_PATH` to a file on a volume every GPU service mounts, or the lock coordinates nothing.
61
65
  - **`argus_cortex.store`** — the optional **stateful services layer** (the suite's persistent "memory"). External and opt-in — every store is configured by `CORTEX_*` env vars and no-ops when its URL is unset.
62
66
  - **Phase 1 — Postgres lineage store.** Persists the `source_asset → caption → human_edit → dataset_membership → training_run` DAG for LoRA reproducibility, edit capture, and the feedback loop. Backed by a psycopg connection **pool** (concurrent `asyncio.to_thread` calls are safe; a dropped connection is replaced), with a `transaction()` boundary for atomic multi-step writes. `open_lineage_store()` returns a no-op store when `CORTEX_PG_URL` is unset; psycopg lives behind the `[postgres]` extra.
63
67
  - **Phase 2 — Qdrant vector store.** Stores image + tag-set embeddings for retrieval-augmented few-shot and near-duplicate curation; put a `caption_id`/`asset_id` in a point's payload and a search hit joins straight back to the lineage. `open_vector_store()` no-ops when `CORTEX_QDRANT_URL` is unset; qdrant-client lives behind the `[qdrant]` extra. cortex stores vectors it's *handed* — computing embeddings is the caller's job.
@@ -103,10 +107,33 @@ guard = SingGuardClient("http://guard:9150", policy=None) # None = SingGuard's
103
107
  # with no fast verdict ahead of the reasoning; "cascade" is fast, then slow only for what fast
104
108
  # flagged (the last two: argus-cull's singguard service only).
105
109
  verdict = guard.check(image_bytes, text="the prompt, if any")
110
+ preflight = guard.check_text("the prompt") # no image: judge a prompt before it is run
106
111
  verdict.safe, verdict.rules # False, ["B. Real-World Crimes & Public Safety"]
107
112
  any(rule_matches(r, "B") for r in verdict.rules) # match a rule by letter or full name
108
113
  ```
109
114
 
115
+ ```python
116
+ from argus_cortex.moderation import SingGuardClient, policies
117
+
118
+ guard = SingGuardClient("http://guard:9150", policy=policies.policy_text("adult-granular"))
119
+ verdict = guard.check(image_bytes)
120
+ illegal = [r for r in verdict.rules if policies.is_illegal("adult-granular", r)] # A, H or I
121
+ policies.mode("adult-granular"), policies.mode("default") # "nsfw", "sfw"
122
+ ```
123
+
124
+ Stages ask for a policy by name; `policies` holds each one's text, the rules that mean illegal content under it, and its SFW/NSFW mode (`default` and `sfw-*` are SFW).
125
+
126
+ ```python
127
+ from argus_cortex.gpu import GpuLeaseTimeout, coordinator_from_env
128
+
129
+ gpu = coordinator_from_env() # ARGUS_GPU_COORDINATOR=lease, ARGUS_GPU_LEASE_PATH=/shared/argus-gpu.lock
130
+ try:
131
+ with gpu.lease(caller="faces", min_vram_mb=1500): # one argus job at a time, and 1.5 GB free
132
+ run_the_model()
133
+ except GpuLeaseTimeout:
134
+ ... # no slot in time: back off, or answer 503
135
+ ```
136
+
110
137
  Each `check()` raises `CheckFailed` (`NudeNetCheckFailed` / `GuardCheckFailed`) for a network error or an unusable answer, so a caller checking many images can record "unchecked" against the one image — and should treat unchecked as *not* safe. Bad configuration raises `ModerationConfigError`, whose message never echoes the URL.
111
138
 
112
139
  ```python
@@ -12,6 +12,7 @@ Part of the [Argus suite](https://github.com/smk762?tab=repositories&q=argus) (q
12
12
  - **`argus_cortex.server`** — shared write-guard + env-flag scaffolding for the suite's FastAPI micro-servers: `WriteGuard` (a pure-ASGI method-gate that never buffers streaming responses), the `constant_refuse()` / `cross_site_refuse()` predicates (read-only/replay mode and cross-site-write protection are two configs of the one guard), the `UNSAFE_METHODS` / `TRUTHY` / `FALSY` constants, and `env_flag()` (warns on a mistyped protection flag rather than silently disabling it). Starlette lives behind the `[server]` extra.
13
13
  - **`argus_cortex.xmp`** — the suite's one XMP sidecar implementation (hoisted from argus-lens): `write_xmp()` / `read_xmp()` / `merge_xmp()` / `parse_xmp()` over an `XmpData` model covering `dc` (keywords, description), `xmp` (Rating, Label), `lr` (hierarchical keywords), `Iptc4xmpExt` (PersonInImage), `mwg-rs` face/pet regions, and `argus:ContentRating`. Writes **merge**: each property you set replaces that property (latest write wins), everything else in the sidecar — Lightroom develop settings, GPS, another stage's fields — is preserved. `None` leaves a property alone, an empty value removes it. Writes are atomic; an unparseable existing sidecar raises `XmpError` instead of being clobbered. Stdlib only.
14
14
  - **`argus_cortex.moderation`** — the suite's content-moderation clients, over HTTP to servers the deployer runs (nothing here imports a detector): `NudeNetClient` (a NudeNet server's `/infer` → `NsfwResult`: detections, per-class maxima, and an **exposed score**) and `SingGuardClient` (a SingGuard server's `/api/guard/check` → `GuardResult`: verdict, the policy rules that fired, explanation; optional custom policy text). `DEFAULT_EXPOSED_LABELS` is the suite's **one definition of nudity**: the five exposed buttocks/breast/genitalia/anus classes — faces, covered parts, and bare feet, belly, armpits or male chest never count. Stdlib + pydantic; Pillow (for SingGuard's downscaling of large images, on by default) is behind `[images]`: without it `SingGuardClient(...)` raises `ModerationConfigError` at construction unless given `downscale=False`. A `user:password@` in a URL is sent as HTTP Basic auth. URLs are passed in, never read from the environment here: each stage owns its config. NudeNet itself is AGPL-3.0, which is why it stays behind an HTTP boundary.
15
+ - **`argus_cortex.gpu`**: GPU tenancy, generalised from argus-lens. `coordinator_from_env()` returns a coordinator whose `lease(caller=, min_vram_mb=)` holds a GPU slot for a block of work. `none` (the default) grants every lease at once. `lease` takes an exclusive `flock` that every argus process on the host shares (one lock for the host, not per GPU), then waits until `free_vram_mb()` (NVML, behind `[gpu]`) reports `min_vram_mb` free across *every* process on the card, ComfyUI and Ollama included, which never take the lock. It raises `GpuLeaseTimeout` after `ARGUS_GPU_LEASE_TIMEOUT_S` (300 s). The VRAM check is a snapshot, not a reservation, and a probe that cannot run lets the lease through. Containers each have their own `/tmp`, so set `ARGUS_GPU_LEASE_PATH` to a file on a volume every GPU service mounts, or the lock coordinates nothing.
15
16
  - **`argus_cortex.store`** — the optional **stateful services layer** (the suite's persistent "memory"). External and opt-in — every store is configured by `CORTEX_*` env vars and no-ops when its URL is unset.
16
17
  - **Phase 1 — Postgres lineage store.** Persists the `source_asset → caption → human_edit → dataset_membership → training_run` DAG for LoRA reproducibility, edit capture, and the feedback loop. Backed by a psycopg connection **pool** (concurrent `asyncio.to_thread` calls are safe; a dropped connection is replaced), with a `transaction()` boundary for atomic multi-step writes. `open_lineage_store()` returns a no-op store when `CORTEX_PG_URL` is unset; psycopg lives behind the `[postgres]` extra.
17
18
  - **Phase 2 — Qdrant vector store.** Stores image + tag-set embeddings for retrieval-augmented few-shot and near-duplicate curation; put a `caption_id`/`asset_id` in a point's payload and a search hit joins straight back to the lineage. `open_vector_store()` no-ops when `CORTEX_QDRANT_URL` is unset; qdrant-client lives behind the `[qdrant]` extra. cortex stores vectors it's *handed* — computing embeddings is the caller's job.
@@ -57,10 +58,33 @@ guard = SingGuardClient("http://guard:9150", policy=None) # None = SingGuard's
57
58
  # with no fast verdict ahead of the reasoning; "cascade" is fast, then slow only for what fast
58
59
  # flagged (the last two: argus-cull's singguard service only).
59
60
  verdict = guard.check(image_bytes, text="the prompt, if any")
61
+ preflight = guard.check_text("the prompt") # no image: judge a prompt before it is run
60
62
  verdict.safe, verdict.rules # False, ["B. Real-World Crimes & Public Safety"]
61
63
  any(rule_matches(r, "B") for r in verdict.rules) # match a rule by letter or full name
62
64
  ```
63
65
 
66
+ ```python
67
+ from argus_cortex.moderation import SingGuardClient, policies
68
+
69
+ guard = SingGuardClient("http://guard:9150", policy=policies.policy_text("adult-granular"))
70
+ verdict = guard.check(image_bytes)
71
+ illegal = [r for r in verdict.rules if policies.is_illegal("adult-granular", r)] # A, H or I
72
+ policies.mode("adult-granular"), policies.mode("default") # "nsfw", "sfw"
73
+ ```
74
+
75
+ Stages ask for a policy by name; `policies` holds each one's text, the rules that mean illegal content under it, and its SFW/NSFW mode (`default` and `sfw-*` are SFW).
76
+
77
+ ```python
78
+ from argus_cortex.gpu import GpuLeaseTimeout, coordinator_from_env
79
+
80
+ gpu = coordinator_from_env() # ARGUS_GPU_COORDINATOR=lease, ARGUS_GPU_LEASE_PATH=/shared/argus-gpu.lock
81
+ try:
82
+ with gpu.lease(caller="faces", min_vram_mb=1500): # one argus job at a time, and 1.5 GB free
83
+ run_the_model()
84
+ except GpuLeaseTimeout:
85
+ ... # no slot in time: back off, or answer 503
86
+ ```
87
+
64
88
  Each `check()` raises `CheckFailed` (`NudeNetCheckFailed` / `GuardCheckFailed`) for a network error or an unusable answer, so a caller checking many images can record "unchecked" against the one image — and should treat unchecked as *not* safe. Bad configuration raises `ModerationConfigError`, whose message never echoes the URL.
65
89
 
66
90
  ```python
@@ -35,6 +35,9 @@ server = ["starlette>=0.37"]
35
35
  # Pillow, for SingGuardClient's downscaling of large images
36
36
  # (argus_cortex.moderation.singguard), imported only when it downscales.
37
37
  images = ["pillow>=10.0"]
38
+ # NVML (nvidia-ml-py, imported as pynvml), for argus_cortex.gpu's free-VRAM probe;
39
+ # imported only when probed. Without it the lease skips the VRAM wait.
40
+ gpu = ["nvidia-ml-py>=12.535"]
38
41
  # The store drivers are in dev so each store's real client path (pool/enum/client
39
42
  # construction, error wrapping) is exercised in CI, not just via fakes.
40
43
  # starlette + httpx power the pure-ASGI guard tests (Starlette TestClient).
@@ -49,6 +52,7 @@ dev = [
49
52
  "httpx>=0.27",
50
53
  "httpx2>=2.7",
51
54
  "pillow>=10.0",
55
+ "nvidia-ml-py>=12.535",
52
56
  "certifi>=2026.10.19",
53
57
  ]
54
58
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.4.1'
22
- __version_tuple__ = version_tuple = (0, 4, 1)
21
+ __version__ = version = '0.4.3'
22
+ __version_tuple__ = version_tuple = (0, 4, 3)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,242 @@
1
+ """GPU tenancy shared across the suite: one heavy argus job on a GPU at a time,
2
+ and only once the card has room for it.
3
+
4
+ Generalised from argus-lens's ``gpu`` coordinator so every stage that runs a
5
+ model on a GPU it shares (lens captioning, curator's faces, alongside ComfyUI
6
+ and an Ollama-backed SingGuard) takes the same lease.
7
+
8
+ * :class:`GpuCoordinator`: the protocol: ``lease(caller=, min_vram_mb=)`` is a
9
+ context manager that holds a GPU slot for its body.
10
+ * :class:`NullCoordinator`: no gating, the default.
11
+ * :class:`LeaseCoordinator`: an advisory file lock (POSIX ``flock``) taken by
12
+ every argus process on the host, then a wait until :func:`free_vram_mb` reports
13
+ ``min_vram_mb`` free. The lock orders argus jobs among themselves; the VRAM wait
14
+ is what accounts for tenants that never take the lock (ComfyUI, Ollama), since
15
+ NVML reports free memory across every process on the card.
16
+ * :func:`coordinator_from_env`: ``ARGUS_GPU_COORDINATOR`` (``none``/``lease``),
17
+ ``ARGUS_GPU_LEASE_PATH``, ``ARGUS_GPU_LEASE_TIMEOUT_S``, ``ARGUS_GPU_DEVICE``.
18
+ The names are host-wide, not per stage: every process must agree on them.
19
+ One lock serialises every argus GPU job on the host; ``ARGUS_GPU_DEVICE``
20
+ only selects which card NVML probes.
21
+
22
+ The VRAM check is a snapshot, not a reservation: a tenant outside the lock can
23
+ still allocate between the check and the job. Each stage owns its footprint
24
+ estimates (``min_vram_mb``); a probe that cannot run (no driver, no NVML, a CPU
25
+ host) lets the lease through rather than blocking forever.
26
+
27
+ Containers have their own ``/tmp``: the lock only coordinates processes that see
28
+ the same file, so point ``ARGUS_GPU_LEASE_PATH`` at a volume every GPU service
29
+ mounts. ``flock`` works across containers on one host kernel. NVML is behind the
30
+ ``[gpu]`` extra (``nvidia-ml-py``) and imported only when probed.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import contextlib
36
+ import logging
37
+ import math
38
+ import os
39
+ import tempfile
40
+ import threading
41
+ import time
42
+ from collections.abc import Callable, Iterator
43
+ from typing import Protocol, runtime_checkable
44
+
45
+ _log = logging.getLogger(__name__)
46
+
47
+ DEFAULT_TIMEOUT_S = 300.0
48
+ DEFAULT_POLL_S = 0.5
49
+ COORDINATORS = ("none", "lease")
50
+
51
+ _nvml_lock = threading.Lock()
52
+ _nvml_ready: bool | None = None # None: not tried yet; False: NVML cannot run here
53
+
54
+
55
+ class GpuLeaseTimeout(TimeoutError):
56
+ """No GPU slot within the timeout: the lock stayed held, or the card never had room.
57
+
58
+ A ``TimeoutError``, so a caller can catch either, and tell "no GPU slot" from
59
+ a failed job and back off.
60
+ """
61
+
62
+
63
+ @runtime_checkable
64
+ class GpuCoordinator(Protocol):
65
+ """Gates a block of GPU work behind a lease."""
66
+
67
+ name: str
68
+
69
+ def lease(self, *, caller: str, min_vram_mb: int) -> contextlib.AbstractContextManager[None]:
70
+ """A context manager that holds a GPU slot for its body."""
71
+ ...
72
+
73
+
74
+ class NullCoordinator:
75
+ """No gating: every lease is granted at once. The default."""
76
+
77
+ name = "none"
78
+
79
+ @contextlib.contextmanager
80
+ def lease(self, *, caller: str, min_vram_mb: int) -> Iterator[None]:
81
+ yield
82
+
83
+
84
+ def free_vram_mb(device: int = 0) -> int | None:
85
+ """Free VRAM on NVML device *device*, in MB, across every process on it.
86
+
87
+ ``None`` when it cannot be read: no ``[gpu]`` extra, no driver, no such
88
+ device. NVML numbers devices as ``nvidia-smi`` does, which inside a container
89
+ means the GPUs the container was given.
90
+ """
91
+ global _nvml_ready
92
+ try:
93
+ import pynvml # noqa: PLC0415 - optional, behind the [gpu] extra
94
+ except ImportError:
95
+ return None
96
+ with _nvml_lock:
97
+ if _nvml_ready is None:
98
+ try:
99
+ pynvml.nvmlInit()
100
+ _nvml_ready = True
101
+ except Exception: # noqa: BLE001 - any NVML failure means "cannot probe"
102
+ _nvml_ready = False
103
+ if not _nvml_ready:
104
+ return None
105
+ try:
106
+ info = pynvml.nvmlDeviceGetMemoryInfo(pynvml.nvmlDeviceGetHandleByIndex(device))
107
+ except Exception: # noqa: BLE001
108
+ return None
109
+ return max(0, int(info.free) // (1024 * 1024))
110
+
111
+
112
+ def default_lock_path() -> str:
113
+ """``<tempdir>/argus-gpu.lock``: shared only by processes that share that directory."""
114
+ return os.path.join(tempfile.gettempdir(), "argus-gpu.lock")
115
+
116
+
117
+ class LeaseCoordinator:
118
+ """One argus GPU job at a time on this host, started only when the card has room.
119
+
120
+ Takes an exclusive ``flock`` on *lock_path*, then (when *min_vram_mb* > 0)
121
+ waits until *probe* reports that much free, polling every *poll_s*. The lock
122
+ is held through the wait, so the next argus job cannot take the room first.
123
+ Both waits share one *timeout_s* deadline; past it :class:`GpuLeaseTimeout`
124
+ is raised and the lock released. A probe answering ``None`` grants the lease.
125
+ """
126
+
127
+ name = "lease"
128
+
129
+ def __init__(
130
+ self,
131
+ lock_path: str | None = None,
132
+ timeout_s: float = DEFAULT_TIMEOUT_S,
133
+ poll_s: float = DEFAULT_POLL_S,
134
+ device: int = 0,
135
+ probe: Callable[[int], int | None] = free_vram_mb,
136
+ ) -> None:
137
+ if not math.isfinite(timeout_s):
138
+ raise ValueError("timeout_s must be a finite number")
139
+ self.lock_path = lock_path or default_lock_path()
140
+ self.timeout_s = max(0.0, timeout_s)
141
+ self.poll_s = poll_s
142
+ self.device = device
143
+ self.probe = probe
144
+
145
+ @contextlib.contextmanager
146
+ def lease(self, *, caller: str, min_vram_mb: int) -> Iterator[None]:
147
+ try:
148
+ import fcntl # noqa: PLC0415 - POSIX only
149
+ except ImportError as exc: # pragma: no cover - non-POSIX host
150
+ raise RuntimeError("the 'lease' GPU coordinator needs POSIX fcntl; use 'none'") from exc
151
+
152
+ deadline = time.monotonic() + self.timeout_s
153
+ directory = os.path.dirname(self.lock_path)
154
+ if directory:
155
+ os.makedirs(directory, exist_ok=True)
156
+ # 0o666: services in different containers may run as different users.
157
+ # os.open's mode is masked by umask, so chmod the fd or the file is
158
+ # 0644/0600 and the next UID cannot open it.
159
+ fd = os.open(self.lock_path, os.O_CREAT | os.O_RDWR, 0o666)
160
+ try:
161
+ with contextlib.suppress(OSError):
162
+ os.fchmod(fd, 0o666)
163
+ while True:
164
+ try:
165
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
166
+ break
167
+ except BlockingIOError:
168
+ if time.monotonic() >= deadline:
169
+ raise GpuLeaseTimeout(
170
+ f"GPU lease not acquired within {self.timeout_s:g}s ({self.lock_path})"
171
+ ) from None
172
+ time.sleep(self.poll_s)
173
+ try:
174
+ self._wait_for_room(caller, min_vram_mb, deadline)
175
+ _log.debug("gpu lease acquired by %s (min %d MB, %s)", caller, min_vram_mb, self.lock_path)
176
+ yield
177
+ finally:
178
+ with contextlib.suppress(OSError):
179
+ fcntl.flock(fd, fcntl.LOCK_UN)
180
+ finally:
181
+ os.close(fd)
182
+
183
+ def _wait_for_room(self, caller: str, min_vram_mb: int, deadline: float) -> None:
184
+ if min_vram_mb <= 0:
185
+ return
186
+ while True:
187
+ free = self.probe(self.device)
188
+ if free is None or free >= min_vram_mb:
189
+ return
190
+ if time.monotonic() >= deadline:
191
+ raise GpuLeaseTimeout(
192
+ f"GPU {self.device} had {free} MB free, {caller} needs {min_vram_mb} MB, after {self.timeout_s:g}s"
193
+ )
194
+ time.sleep(self.poll_s)
195
+
196
+
197
+ def build_coordinator(
198
+ name: str,
199
+ *,
200
+ lock_path: str | None = None,
201
+ timeout_s: float | None = None,
202
+ device: int = 0,
203
+ ) -> GpuCoordinator:
204
+ """A coordinator by name, one of :data:`COORDINATORS` (empty is ``none``)."""
205
+ key = (name or "none").strip().lower()
206
+ if key == "none":
207
+ return NullCoordinator()
208
+ if key == "lease":
209
+ return LeaseCoordinator(
210
+ lock_path=lock_path,
211
+ timeout_s=DEFAULT_TIMEOUT_S if timeout_s is None else timeout_s,
212
+ device=device,
213
+ )
214
+ raise ValueError(f"unknown GPU coordinator {name!r}; choose from: {', '.join(COORDINATORS)}")
215
+
216
+
217
+ def _env_number(name: str, kind: type[float] | type[int]) -> float | int | None:
218
+ raw = os.environ.get(name, "").strip()
219
+ if not raw:
220
+ return None
221
+ try:
222
+ value = kind(raw)
223
+ except (ValueError, OverflowError):
224
+ value = -1
225
+ # inf/nan parse as float and never compare < 0, so the deadline never fires.
226
+ if isinstance(value, float) and not math.isfinite(value):
227
+ value = -1
228
+ if value < 0:
229
+ _log.warning("%s set to an unusable value %r; using the default", name, raw)
230
+ return None
231
+ return value
232
+
233
+
234
+ def coordinator_from_env() -> GpuCoordinator:
235
+ """The coordinator the environment names; ``none`` when ``ARGUS_GPU_COORDINATOR`` is unset."""
236
+ device = _env_number("ARGUS_GPU_DEVICE", int)
237
+ return build_coordinator(
238
+ os.environ.get("ARGUS_GPU_COORDINATOR", "none"),
239
+ lock_path=os.environ.get("ARGUS_GPU_LEASE_PATH") or None,
240
+ timeout_s=_env_number("ARGUS_GPU_LEASE_TIMEOUT_S", float),
241
+ device=0 if device is None else int(device),
242
+ )
@@ -20,6 +20,7 @@ from argus_cortex.moderation.nudenet import (
20
20
  exposed_score,
21
21
  )
22
22
  from argus_cortex.moderation.singguard import (
23
+ INPUT_ROLES,
23
24
  THINKING_MODES,
24
25
  GuardCheckFailed,
25
26
  GuardResult,
@@ -31,6 +32,7 @@ from argus_cortex.moderation.singguard import (
31
32
 
32
33
  __all__ = [
33
34
  "DEFAULT_EXPOSED_LABELS",
35
+ "INPUT_ROLES",
34
36
  "NUDENET_LABELS",
35
37
  "THINKING_MODES",
36
38
  "CheckFailed",
@@ -0,0 +1,186 @@
1
+ """The named SingGuard policies the stages share, and what each one's rules mean.
2
+
3
+ A stage asks for a policy by name and sends its text with :class:`SingGuardClient`
4
+ (``policy=policy_text(name)``); requests never carry policy text of their own.
5
+ Beside each text sit the two facts a stage acts on:
6
+
7
+ * :func:`illegal_rules`: the rules whose firing means illegal content, the
8
+ ones a stage deletes for rather than flags for review. Letters mean
9
+ different things under each policy (A under SingGuard's ``default`` is all
10
+ sexual content, adult nudity included), so the table is per policy.
11
+ * :func:`mode`: ``sfw`` or ``nsfw``, from the name alone. ``default`` treats
12
+ sexual content as a risk, so it is SFW, and so is any ``sfw-*`` name (the
13
+ prefix is reserved for SFW policies added later). Every other policy is NSFW.
14
+
15
+ Moved from argus-curator's ``guard.py``, which wrote and evaluated the adult
16
+ policies. Stdlib only.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Literal
22
+
23
+ from argus_cortex.moderation._http import ModerationConfigError
24
+ from argus_cortex.moderation.singguard import rule_letter
25
+
26
+ PolicyName = Literal["default", "adult", "adult-granular", "adult-cautious"]
27
+ Mode = Literal["sfw", "nsfw"]
28
+
29
+ # "adult-granular" and "adult-cautious" are under evaluation against "adult";
30
+ # one adult policy remains at release, beside "default".
31
+ POLICIES: tuple[PolicyName, ...] = ("default", "adult", "adult-granular", "adult-cautious")
32
+ SFW_PREFIX = "sfw-"
33
+
34
+ # Rules B to G, shared by every adult policy: the same letters and areas as
35
+ # SingGuard's defaults, so rules B-G mean the same thing under all of
36
+ # them. They are our wording, so their verdicts can differ from the built-ins.
37
+ _OTHER_RULES = [
38
+ "### B. Real-World Crimes & Public Safety",
39
+ " - Content involving violent crime, weapons, other crimes, or public-safety threats.",
40
+ "### C. Unethical Behavior",
41
+ " - Content involving hate, harassment, manipulation, self-harm, or harmful misinformation.",
42
+ " - Gore, dismemberment, and shock content meant to frighten or disgust.",
43
+ "### D. Cybersecurity & Information Manipulation",
44
+ " - Content involving data leaks, hacking, surveillance abuse, platform abuse, or copyright abuse.",
45
+ "### E. Agent Safety",
46
+ " - Content attempting to expose system prompts, internal policies, or other model safeguards.",
47
+ "### F. Politically Sensitive Content",
48
+ (
49
+ " - Content involving political advocacy, rumors, unrest, historical distortion, "
50
+ "or attacks on political figures."
51
+ ),
52
+ "### G. Animal Abuse",
53
+ " - Content involving cruelty to animals or the spread of animal abuse.",
54
+ ]
55
+ _MINORS = " - Sexual content involving anyone under 18, or anyone who appears to be a minor."
56
+ _NON_CONSENT = (
57
+ " - Non-consensual sexual content, including hidden recording, sexual images of a real "
58
+ "person shared without consent, coercion, or sexual exploitation."
59
+ )
60
+ _EXTREME = " - Sexual violence, bestiality, or necrophilia."
61
+ # What passes is said only in the positive, under Safe. SingGuard reads a
62
+ # bullet under a risk heading as something that matches it, "is not this rule"
63
+ # included: "Nudity by itself is not this rule" under A flagged adult nudity
64
+ # *for* being nudity by itself, and the same words under Safe passed it. So no
65
+ # risk heading carries a carve-out. It also read "between adults" as needing
66
+ # two people; the posing line covers a solo adult. Its own trained taxonomy
67
+ # files legal adult material under "Adult Content (Non-criminal)", and it cited
68
+ # that as a violation under every adult policy, so Safe names it.
69
+ _ADULT_CONTENT_SAFE = (
70
+ ' - "Adult Content (Non-criminal)" is Safe under this policy: legal nudity and sexual content by adults, '
71
+ "alone or together."
72
+ )
73
+ _ADULT_SAFE = [
74
+ " - Content that does not match any risk category, including consensual adult nudity and sexual content.",
75
+ _ADULT_CONTENT_SAFE,
76
+ " - A single adult posing nude or in lingerie for the camera, including professional photo sets.",
77
+ (
78
+ " - Consent cannot be read from an image. A posed, camera-aware adult counts as consenting unless "
79
+ "hidden recording, restraint, coercion, or an unconscious person is shown."
80
+ ),
81
+ ]
82
+
83
+ # Sent in full: SingGuard reads a bare "nsfw" as rule text. One broad rule A
84
+ # holds every hard sexual case.
85
+ ADULT_POLICY = "\n".join(
86
+ [
87
+ "## Risk Categories",
88
+ "### A. Illegal or Extreme Sexual Content",
89
+ _MINORS,
90
+ _NON_CONSENT,
91
+ _EXTREME,
92
+ *_OTHER_RULES,
93
+ "### Safe",
94
+ *_ADULT_SAFE,
95
+ ]
96
+ )
97
+
98
+ # The same coverage with a letter per hard case (A minors, H non-consent,
99
+ # I sexual violence and extreme content), so the verdict itself says which
100
+ # hard rule fired instead of only the write-up. Narrower rules may also fire
101
+ # less often on doubtful cases; that is what the evaluation measures.
102
+ ADULT_GRANULAR_POLICY = "\n".join(
103
+ [
104
+ "## Risk Categories",
105
+ "### A. Minors",
106
+ _MINORS,
107
+ *_OTHER_RULES,
108
+ "### H. Non-Consensual Sexual Content",
109
+ _NON_CONSENT,
110
+ " - Sexual content showing someone asleep, unconscious, or otherwise unable to consent.",
111
+ "### I. Sexual Violence & Extreme Content",
112
+ _EXTREME,
113
+ "### Safe",
114
+ *_ADULT_SAFE,
115
+ ]
116
+ )
117
+
118
+ # ADULT_POLICY with doubt resolved towards rule A: an age or consent the image
119
+ # does not make clear counts as a hit. Trades false passes for false flags.
120
+ ADULT_CAUTIOUS_POLICY = "\n".join(
121
+ [
122
+ "## Risk Categories",
123
+ "### A. Illegal or Extreme Sexual Content",
124
+ _MINORS,
125
+ " - Nude or sexual content where any person's age is unclear and they could be under 18.",
126
+ _NON_CONSENT,
127
+ (
128
+ " - Nude or sexual content where consent is unclear, for example someone who seems "
129
+ "unaware of the camera, asleep, intoxicated, or restrained."
130
+ ),
131
+ _EXTREME,
132
+ *_OTHER_RULES,
133
+ "### Safe",
134
+ (
135
+ " - Content that does not match any risk category, including consensual adult nudity and "
136
+ "sexual content where every person clearly appears to be an adult."
137
+ ),
138
+ (
139
+ ' - "Adult Content (Non-criminal)" is Safe under this policy: legal nudity and sexual content '
140
+ "where every person clearly appears to be an adult."
141
+ ),
142
+ ]
143
+ )
144
+
145
+ _TEXT: dict[str, str | None] = {
146
+ "default": None, # SingGuard's own rules: nothing is sent
147
+ "adult": ADULT_POLICY,
148
+ "adult-granular": ADULT_GRANULAR_POLICY,
149
+ "adult-cautious": ADULT_CAUTIOUS_POLICY,
150
+ }
151
+
152
+ # Rule letters that mean illegal content. "default" has none: its A covers
153
+ # consensual adult nudity too, so it cannot tell illegal from adult. Crime (B)
154
+ # and animal abuse (G) depict, they are not illegal content, under every policy.
155
+ _ILLEGAL: dict[str, frozenset[str]] = {
156
+ "default": frozenset(),
157
+ "adult": frozenset({"A"}),
158
+ "adult-granular": frozenset({"A", "H", "I"}),
159
+ "adult-cautious": frozenset({"A"}),
160
+ }
161
+
162
+
163
+ def _known(name: str) -> str:
164
+ if name not in _TEXT:
165
+ raise ModerationConfigError(f"unknown SingGuard policy {name!r}; known: {', '.join(POLICIES)}")
166
+ return name
167
+
168
+
169
+ def policy_text(name: str) -> str | None:
170
+ """The text to send for policy *name*: ``None`` for ``default`` (SingGuard's own rules)."""
171
+ return _TEXT[_known(name)]
172
+
173
+
174
+ def illegal_rules(name: str) -> frozenset[str]:
175
+ """The rule letters under policy *name* whose firing means illegal content."""
176
+ return _ILLEGAL[_known(name)]
177
+
178
+
179
+ def is_illegal(name: str, rule: str) -> bool:
180
+ """*rule*, as a verdict reports it (``"H. Non-Consensual Sexual Content"`` or ``"H"``), is illegal under *name*."""
181
+ return rule_letter(rule).removesuffix(".").upper() in illegal_rules(name)
182
+
183
+
184
+ def mode(name: str) -> Mode:
185
+ """``sfw`` for ``default`` and any ``sfw-*`` name, else ``nsfw``. By name, so it needs no table entry."""
186
+ return "sfw" if name == "default" or name.startswith(SFW_PREFIX) else "nsfw"
@@ -8,7 +8,8 @@ gore), D. Cybersecurity, E. Agent Safety, F. Politically Sensitive and G.
8
8
  Animal Abuse. A custom policy text replaces them. It gives no scores.
9
9
 
10
10
  API: ``POST /api/guard/check`` with JSON ``{input, image_base64, policy,
11
- thinking}``, answered by ``{safe, verdict, rule, explanation, categories:
11
+ thinking}`` (or ``{input, input_role, policy, thinking}`` for a text-only
12
+ check, which not every server takes), answered by ``{safe, verdict, rule, explanation, categories:
12
13
  [{code, label, why}], model, elapsed_ms}`` and, from the argus-cull
13
14
  ``singguard`` service, ``thinking``: the mode the server judged in, which
14
15
  its ``SINGGUARD_FORCE_THINKING`` can make differ from the one asked for.
@@ -38,6 +39,8 @@ from pydantic import BaseModel, Field
38
39
  from argus_cortex.moderation._http import CheckFailed, ModerationConfigError, post_json, service_url
39
40
 
40
41
  THINKING_MODES = ("fast", "fast-slow", "slow", "cascade")
42
+ # Who wrote the text of a text-only check: the person asking, or a model answering.
43
+ INPUT_ROLES = ("user", "assistant")
41
44
  # The guard model sees far fewer pixels than a camera original, and base64
42
45
  # JSON inflates every byte by 4/3.
43
46
  MAX_EDGE = 1536
@@ -205,11 +208,23 @@ class SingGuardClient:
205
208
  Raises :class:`GuardCheckFailed`.
206
209
  """
207
210
  image = downscale(data) if self.downscale else data
208
- body: dict[str, str] = {
209
- "input": text,
210
- "image_base64": base64.b64encode(image).decode("ascii"),
211
- "thinking": self.thinking,
212
- }
211
+ return self._post({"input": text, "image_base64": base64.b64encode(image).decode("ascii")})
212
+
213
+ def check_text(self, text: str, role: str = "user") -> GuardResult:
214
+ """Judge *text* with no image: a prompt before it is run (``role="user"``), or a model's reply
215
+ (``"assistant"``).
216
+
217
+ Needs a server that takes a check without ``image_base64``: agent-composer's guard router
218
+ does; argus-cull's ``singguard`` service answers HTTP 422, raised as :class:`GuardCheckFailed`.
219
+ """
220
+ if role not in INPUT_ROLES:
221
+ raise ValueError(f"role must be one of {', '.join(INPUT_ROLES)}")
222
+ if not text.strip():
223
+ raise ValueError("there is no text to check")
224
+ return self._post({"input": text, "input_role": role})
225
+
226
+ def _post(self, body: dict[str, str]) -> GuardResult:
227
+ body["thinking"] = self.thinking
213
228
  if self.policy is not None:
214
229
  body["policy"] = self.policy
215
230
  payload = post_json(
@@ -0,0 +1,227 @@
1
+ """GPU coordination: the file lease, the VRAM wait, and configuration from the environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ import os
7
+ import threading
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ from argus_cortex import gpu
13
+
14
+
15
+ @pytest.fixture(autouse=True)
16
+ def _reset_nvml_ready(monkeypatch: pytest.MonkeyPatch) -> None:
17
+ monkeypatch.setattr(gpu, "_nvml_ready", None)
18
+
19
+
20
+ def _lease(tmp_path: Path, probe=lambda device: None, timeout_s: float = 5.0) -> gpu.LeaseCoordinator: # noqa: ANN001
21
+ return gpu.LeaseCoordinator(
22
+ lock_path=str(tmp_path / "locks" / "gpu.lock"), timeout_s=timeout_s, poll_s=0.01, probe=probe
23
+ )
24
+
25
+
26
+ def test_null_coordinator_grants_at_once() -> None:
27
+ coordinator = gpu.NullCoordinator()
28
+ assert isinstance(coordinator, gpu.GpuCoordinator)
29
+ with coordinator.lease(caller="t", min_vram_mb=10**9):
30
+ pass
31
+
32
+
33
+ def test_lock_file_is_world_writable_despite_umask(tmp_path: Path) -> None:
34
+ """os.open's mode is umask-masked; without fchmod the next container UID cannot open the lock."""
35
+ path = tmp_path / "locks" / "gpu.lock"
36
+ previous = os.umask(0o077)
37
+ try:
38
+ with gpu.LeaseCoordinator(lock_path=str(path), timeout_s=1, probe=lambda device: None).lease(
39
+ caller="a", min_vram_mb=0
40
+ ):
41
+ mode = path.stat().st_mode & 0o777
42
+ finally:
43
+ os.umask(previous)
44
+ assert mode == 0o666
45
+
46
+
47
+ def test_lease_released_when_the_body_raises(tmp_path: Path) -> None:
48
+ with (
49
+ pytest.raises(RuntimeError, match="boom"),
50
+ _lease(tmp_path).lease(caller="a", min_vram_mb=0),
51
+ ):
52
+ raise RuntimeError("boom")
53
+ with _lease(tmp_path, timeout_s=0.05).lease(caller="b", min_vram_mb=0):
54
+ pass
55
+
56
+
57
+ def test_lease_is_exclusive_and_released(tmp_path: Path) -> None:
58
+ """A second holder waits for the first; a holder that never lets go times the other out."""
59
+ holder, waiter = _lease(tmp_path), _lease(tmp_path, timeout_s=0.1)
60
+ assert isinstance(holder, gpu.GpuCoordinator)
61
+ with (
62
+ holder.lease(caller="a", min_vram_mb=0),
63
+ pytest.raises(gpu.GpuLeaseTimeout, match="not acquired"),
64
+ waiter.lease(caller="b", min_vram_mb=0),
65
+ ):
66
+ pass
67
+ with waiter.lease(caller="b", min_vram_mb=0): # free again once the first exits
68
+ pass
69
+
70
+
71
+ def test_lease_waits_for_the_holder_to_finish(tmp_path: Path) -> None:
72
+ order: list[str] = []
73
+ entered, release = threading.Event(), threading.Event()
74
+
75
+ def first() -> None:
76
+ with _lease(tmp_path).lease(caller="a", min_vram_mb=0):
77
+ entered.set()
78
+ release.wait(5)
79
+ order.append("a done")
80
+
81
+ thread = threading.Thread(target=first)
82
+ thread.start()
83
+ assert entered.wait(5)
84
+ threading.Timer(0.05, release.set).start()
85
+ with _lease(tmp_path).lease(caller="b", min_vram_mb=0):
86
+ order.append("b in")
87
+ thread.join(5)
88
+ assert order == ["a done", "b in"]
89
+
90
+
91
+ def test_lease_waits_for_free_vram_while_holding_the_lock(tmp_path: Path) -> None:
92
+ readings = iter([1000, 2000, 9000])
93
+ seen: list[int] = []
94
+
95
+ def probe(device: int) -> int | None:
96
+ seen.append(device)
97
+ return next(readings)
98
+
99
+ coordinator = _lease(tmp_path, probe=probe)
100
+ # The lock is held during and after the wait: another argus job cannot take the room.
101
+ with (
102
+ coordinator.lease(caller="faces", min_vram_mb=4000),
103
+ pytest.raises(gpu.GpuLeaseTimeout),
104
+ _lease(tmp_path, timeout_s=0.05).lease(caller="lens", min_vram_mb=0),
105
+ ):
106
+ pass
107
+ assert seen == [0, 0, 0]
108
+
109
+
110
+ def test_lease_times_out_when_the_card_never_has_room_and_lets_go(tmp_path: Path) -> None:
111
+ coordinator = _lease(tmp_path, probe=lambda device: 500, timeout_s=0.05)
112
+ with (
113
+ pytest.raises(gpu.GpuLeaseTimeout, match="500 MB free, faces needs 4000 MB"),
114
+ coordinator.lease(caller="faces", min_vram_mb=4000),
115
+ ):
116
+ pass
117
+ with _lease(tmp_path, timeout_s=0.05).lease(caller="next", min_vram_mb=0): # the lock was released
118
+ pass
119
+
120
+
121
+ def test_lease_proceeds_when_vram_cannot_be_read_or_is_not_needed(tmp_path: Path) -> None:
122
+ calls: list[int] = []
123
+ with _lease(tmp_path, probe=lambda device: calls.append(device)).lease(caller="cpu host", min_vram_mb=4000):
124
+ pass
125
+ assert calls == [0] # asked once, got None, went ahead
126
+ with _lease(tmp_path, probe=lambda device: pytest.fail("probed")).lease(caller="tiny", min_vram_mb=0):
127
+ pass
128
+
129
+
130
+ def test_free_vram_reads_bytes_for_the_asked_device(monkeypatch: pytest.MonkeyPatch) -> None:
131
+ import sys
132
+ import types
133
+
134
+ fake = types.ModuleType("pynvml")
135
+ seen: list[int] = []
136
+
137
+ class _Info:
138
+ free = 3 * 1024 * 1024 * 1024
139
+
140
+ def handle(index: int) -> str:
141
+ seen.append(index)
142
+ return f"dev{index}"
143
+
144
+ def memory_info(dev: str) -> _Info:
145
+ assert dev == "dev2"
146
+ return _Info()
147
+
148
+ fake.nvmlInit = lambda: None # type: ignore[attr-defined]
149
+ fake.nvmlDeviceGetHandleByIndex = handle # type: ignore[attr-defined]
150
+ fake.nvmlDeviceGetMemoryInfo = memory_info # type: ignore[attr-defined]
151
+ monkeypatch.setitem(sys.modules, "pynvml", fake)
152
+ assert gpu.free_vram_mb(2) == 3072
153
+ assert seen == [2]
154
+
155
+
156
+ def test_free_vram_is_none_without_nvml(monkeypatch: pytest.MonkeyPatch) -> None:
157
+ real_import = builtins.__import__
158
+
159
+ def no_pynvml(name: str, *args, **kwargs): # noqa: ANN002, ANN003, ANN202
160
+ if name == "pynvml":
161
+ raise ImportError(name)
162
+ return real_import(name, *args, **kwargs)
163
+
164
+ monkeypatch.setattr(builtins, "__import__", no_pynvml)
165
+ assert gpu.free_vram_mb() is None
166
+
167
+
168
+ def test_free_vram_is_none_when_nvml_cannot_start(monkeypatch: pytest.MonkeyPatch) -> None:
169
+ """No driver: NVML init fails once and is not retried on every probe."""
170
+ import sys
171
+ import types
172
+
173
+ attempts: list[int] = []
174
+ fake = types.ModuleType("pynvml")
175
+
176
+ def init() -> None:
177
+ attempts.append(1)
178
+ raise RuntimeError("NVML Shared Library Not Found")
179
+
180
+ fake.nvmlInit = init # type: ignore[attr-defined]
181
+ monkeypatch.setitem(sys.modules, "pynvml", fake)
182
+ monkeypatch.setattr(gpu, "_nvml_ready", None)
183
+ assert gpu.free_vram_mb() is None and gpu.free_vram_mb() is None
184
+ assert attempts == [1]
185
+
186
+
187
+ def test_build_coordinator() -> None:
188
+ assert isinstance(gpu.build_coordinator(""), gpu.NullCoordinator)
189
+ assert isinstance(gpu.build_coordinator(" NONE "), gpu.NullCoordinator)
190
+ lease = gpu.build_coordinator("lease", lock_path="/x/gpu.lock", timeout_s=7, device=1)
191
+ assert isinstance(lease, gpu.LeaseCoordinator)
192
+ assert (lease.lock_path, lease.timeout_s, lease.device) == ("/x/gpu.lock", 7, 1)
193
+ assert gpu.build_coordinator("lease").lock_path == gpu.default_lock_path()
194
+ with pytest.raises(ValueError, match="none, lease"):
195
+ gpu.build_coordinator("gothmog")
196
+ with pytest.raises(ValueError, match="finite"):
197
+ gpu.LeaseCoordinator(timeout_s=float("inf"))
198
+ with pytest.raises(ValueError, match="finite"):
199
+ gpu.LeaseCoordinator(timeout_s=float("nan"))
200
+
201
+
202
+ def test_coordinator_from_env(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None:
203
+ for name in ("ARGUS_GPU_COORDINATOR", "ARGUS_GPU_LEASE_PATH", "ARGUS_GPU_LEASE_TIMEOUT_S", "ARGUS_GPU_DEVICE"):
204
+ monkeypatch.delenv(name, raising=False)
205
+ assert isinstance(gpu.coordinator_from_env(), gpu.NullCoordinator)
206
+ monkeypatch.setenv("ARGUS_GPU_COORDINATOR", "lease")
207
+ monkeypatch.setenv("ARGUS_GPU_LEASE_PATH", "/shared/argus-gpu.lock")
208
+ monkeypatch.setenv("ARGUS_GPU_LEASE_TIMEOUT_S", "30")
209
+ monkeypatch.setenv("ARGUS_GPU_DEVICE", "1")
210
+ lease = gpu.coordinator_from_env()
211
+ assert (lease.lock_path, lease.timeout_s, lease.device) == ("/shared/argus-gpu.lock", 30.0, 1)
212
+ monkeypatch.setenv("ARGUS_GPU_LEASE_TIMEOUT_S", "soon")
213
+ monkeypatch.setenv("ARGUS_GPU_DEVICE", "-1")
214
+ with caplog.at_level("WARNING"):
215
+ lease = gpu.coordinator_from_env()
216
+ assert (lease.timeout_s, lease.device) == (gpu.DEFAULT_TIMEOUT_S, 0)
217
+ assert "ARGUS_GPU_LEASE_TIMEOUT_S" in caplog.text and "ARGUS_GPU_DEVICE" in caplog.text
218
+ caplog.clear()
219
+ monkeypatch.setenv("ARGUS_GPU_LEASE_TIMEOUT_S", "inf")
220
+ with caplog.at_level("WARNING"):
221
+ assert gpu.coordinator_from_env().timeout_s == gpu.DEFAULT_TIMEOUT_S
222
+ assert "ARGUS_GPU_LEASE_TIMEOUT_S" in caplog.text
223
+ caplog.clear()
224
+ monkeypatch.setenv("ARGUS_GPU_LEASE_TIMEOUT_S", "nan")
225
+ with caplog.at_level("WARNING"):
226
+ assert gpu.coordinator_from_env().timeout_s == gpu.DEFAULT_TIMEOUT_S
227
+ assert "ARGUS_GPU_LEASE_TIMEOUT_S" in caplog.text
@@ -106,10 +106,14 @@ class _SingGuard(_Fake):
106
106
 
107
107
  def do_POST(self) -> None:
108
108
  body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
109
- image = Image.open(io.BytesIO(base64.b64decode(body["image_base64"])))
110
- red = image.convert("RGB").getpixel((0, 0))[0]
109
+ if "image_base64" in body:
110
+ image = Image.open(io.BytesIO(base64.b64decode(body["image_base64"])))
111
+ red = image.convert("RGB").getpixel((0, 0))[0]
112
+ size, fmt = image.size, image.format
113
+ else: # a text-only check: the text picks the answer
114
+ red, size, fmt = _TEXT_ANSWERS.get(body["input"], 10), None, None
111
115
  type(self).seen.append(
112
- {"path": self.path, "size": image.size, "format": image.format,
116
+ {"path": self.path, "size": size, "format": fmt,
113
117
  **{k: v for k, v in body.items() if k != "image_base64"}}
114
118
  ) # fmt: skip
115
119
  if red == _FAIL:
@@ -230,6 +234,7 @@ _GUARD_ANSWERS: dict[int, dict] = {
230
234
  30: {"safe": False, "verdict": "unsafe", "rule": _GORE, "explanation": "", "categories": 5}, # malformed
231
235
  40: {"verdict": "safe"}, # no boolean verdict: a failed check, not a pass
232
236
  }
237
+ _TEXT_ANSWERS = {"rob a bank": 20, "fail": _FAIL} # a text-only check's input -> the red value it answers as
233
238
 
234
239
 
235
240
  def test_singguard_config() -> None:
@@ -265,6 +270,23 @@ def test_singguard_check(singguard_server) -> None:
265
270
  assert {s["thinking"] for s in fake.seen} == {"fast-slow"} and {s["policy"] for s in fake.seen} == {"### A. Minors"}
266
271
 
267
272
 
273
+ def test_singguard_check_text(singguard_server) -> None:
274
+ """A text-only check sends no image, says whose text it is, and keeps the client's policy and mode."""
275
+ fake, url = singguard_server
276
+ client = SingGuardClient(url, timeout=5, thinking="cascade", policy="### A. Minors")
277
+ assert client.check_text("a cat on a sofa").safe
278
+ assert client.check_text("rob a bank", role="assistant").rules == [_CRIME, _GORE]
279
+ with pytest.raises(GuardCheckFailed, match="HTTP 500"):
280
+ client.check_text("fail")
281
+ assert all("image_base64" not in s and s["size"] is None for s in fake.seen)
282
+ assert [s["input_role"] for s in fake.seen] == ["user", "assistant", "user"]
283
+ assert {s["thinking"] for s in fake.seen} == {"cascade"} and {s["policy"] for s in fake.seen} == {"### A. Minors"}
284
+ for text, role in (("", "user"), (" \n", "user"), ("hi", "system")):
285
+ with pytest.raises(ValueError):
286
+ client.check_text(text, role=role)
287
+ assert len(fake.seen) == 3 # a bad argument never reaches the server
288
+
289
+
268
290
  def test_singguard_default_policy_is_not_sent(singguard_server) -> None:
269
291
  fake, url = singguard_server
270
292
  SingGuardClient(url).check(_png(10))
@@ -0,0 +1,54 @@
1
+ """The named SingGuard policies: their texts, illegal rules and modes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from argus_cortex.moderation import ModerationConfigError, SingGuardClient, policies, policy_name
8
+
9
+
10
+ def test_every_policy_has_a_text_and_an_illegal_table() -> None:
11
+ assert policies.POLICIES == ("default", "adult", "adult-granular", "adult-cautious")
12
+ assert policies.policy_text("default") is None # SingGuard's own rules: nothing is sent
13
+ for name in policies.POLICIES[1:]:
14
+ text = policies.policy_text(name)
15
+ assert text.startswith("## Risk Categories\n") and "### Safe" in text
16
+ # The client sends it as written, so a recorded policy name is stable.
17
+ assert SingGuardClient("http://g", policy=text, downscale=False).policy_name == policy_name(text)
18
+ assert len({policies.policy_text(n) for n in policies.POLICIES}) == len(policies.POLICIES)
19
+
20
+
21
+ def test_illegal_rules_are_per_policy() -> None:
22
+ assert policies.illegal_rules("default") == frozenset() # its A is all sexual content, adult included
23
+ assert policies.illegal_rules("adult-granular") == {"A", "H", "I"}
24
+ assert policies.illegal_rules("adult") == policies.illegal_rules("adult-cautious") == {"A"}
25
+ # Each illegal letter is a heading in that policy's own text.
26
+ for name in policies.POLICIES[1:]:
27
+ for letter in policies.illegal_rules(name):
28
+ assert f"\n### {letter}. " in policies.policy_text(name)
29
+
30
+
31
+ def test_is_illegal_reads_a_rule_as_verdicts_report_it() -> None:
32
+ assert policies.is_illegal("adult-granular", "H. Non-Consensual Sexual Content")
33
+ assert policies.is_illegal("adult-granular", "I") and policies.is_illegal("adult-granular", "a.")
34
+ assert policies.is_illegal("adult", "A. Illegal or Extreme Sexual Content")
35
+ assert not policies.is_illegal("default", "A. Sexual Content Risk")
36
+ for name in policies.POLICIES: # crime and animal abuse go to review, never deletion
37
+ assert not policies.is_illegal(name, "B. Real-World Crimes & Public Safety")
38
+ assert not policies.is_illegal(name, "G. Animal Abuse")
39
+ assert not policies.is_illegal(name, "Safe")
40
+
41
+
42
+ def test_mode_is_by_name() -> None:
43
+ assert policies.mode("default") == "sfw"
44
+ assert policies.mode("sfw-strict") == "sfw" # a later SFW policy needs no table entry
45
+ assert [policies.mode(n) for n in policies.POLICIES[1:]] == ["nsfw"] * 3
46
+ assert policies.mode("defaults") == "nsfw"
47
+
48
+
49
+ def test_unknown_policy_names_are_refused() -> None:
50
+ for call in (policies.policy_text, policies.illegal_rules):
51
+ with pytest.raises(ModerationConfigError, match="unknown SingGuard policy"):
52
+ call("adult-lax")
53
+ with pytest.raises(ModerationConfigError):
54
+ policies.is_illegal("adult-lax", "A")
File without changes
File without changes
File without changes
File without changes