argus-proof 0.2.0__tar.gz → 0.2.2__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 (86) hide show
  1. {argus_proof-0.2.0 → argus_proof-0.2.2}/.env.example +7 -0
  2. {argus_proof-0.2.0 → argus_proof-0.2.2}/.github/workflows/release.yml +46 -1
  3. argus_proof-0.2.2/CHANGELOG.md +94 -0
  4. argus_proof-0.2.2/CLAUDE.md +53 -0
  5. argus_proof-0.2.2/Dockerfile +39 -0
  6. {argus_proof-0.2.0 → argus_proof-0.2.2}/PKG-INFO +9 -6
  7. {argus_proof-0.2.0 → argus_proof-0.2.2}/README.md +3 -1
  8. {argus_proof-0.2.0 → argus_proof-0.2.2}/pyproject.toml +6 -4
  9. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/_version.py +2 -2
  10. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/scorers/safety.py +69 -28
  11. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_scorers_safety.py +76 -6
  12. argus_proof-0.2.0/Dockerfile +0 -27
  13. {argus_proof-0.2.0 → argus_proof-0.2.2}/.copier-answers.yml +0 -0
  14. {argus_proof-0.2.0 → argus_proof-0.2.2}/.github/workflows/ci.yml +0 -0
  15. {argus_proof-0.2.0 → argus_proof-0.2.2}/.gitignore +0 -0
  16. {argus_proof-0.2.0 → argus_proof-0.2.2}/LICENSE +0 -0
  17. {argus_proof-0.2.0 → argus_proof-0.2.2}/Makefile +0 -0
  18. {argus_proof-0.2.0 → argus_proof-0.2.2}/schema/proof-wire.schema.json +0 -0
  19. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/__init__.py +0 -0
  20. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/acceptance.py +0 -0
  21. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/archive.py +0 -0
  22. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/backends/__init__.py +0 -0
  23. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/backends/a1111.py +0 -0
  24. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/backends/base.py +0 -0
  25. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/backends/comfyui.py +0 -0
  26. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/backends/diffusers.py +0 -0
  27. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/backends/http.py +0 -0
  28. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/backends/pnginfo.py +0 -0
  29. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/backends/remote.py +0 -0
  30. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/backends/workflow.py +0 -0
  31. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/cli.py +0 -0
  32. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/crossrun.py +0 -0
  33. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/evaluate.py +0 -0
  34. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/experiment.py +0 -0
  35. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/explore.py +0 -0
  36. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/grid.py +0 -0
  37. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/hashing.py +0 -0
  38. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/models.py +0 -0
  39. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/moderation.py +0 -0
  40. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/py.typed +0 -0
  41. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/recommend.py +0 -0
  42. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/refinement.py +0 -0
  43. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/reports.py +0 -0
  44. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/__init__.py +0 -0
  45. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/base.py +0 -0
  46. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/gate.py +0 -0
  47. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/orchestrator.py +0 -0
  48. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/scorers/__init__.py +0 -0
  49. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/scorers/_util.py +0 -0
  50. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/scorers/identity.py +0 -0
  51. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/scorers/phash.py +0 -0
  52. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/scorers/quality.py +0 -0
  53. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/scoring/summary.py +0 -0
  54. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/server/__init__.py +0 -0
  55. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/server/app.py +0 -0
  56. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/stats.py +0 -0
  57. {argus_proof-0.2.0 → argus_proof-0.2.2}/src/argus_proof/templates/comfyui_sdxl_lora.json +0 -0
  58. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/fakebackend.py +0 -0
  59. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/pnghelpers.py +0 -0
  60. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_a1111_backend.py +0 -0
  61. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_acceptance.py +0 -0
  62. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_archive.py +0 -0
  63. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_backends.py +0 -0
  64. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_cli.py +0 -0
  65. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_crossrun.py +0 -0
  66. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_diffusers_backend.py +0 -0
  67. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_evaluate.py +0 -0
  68. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_experiment.py +0 -0
  69. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_explore.py +0 -0
  70. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_grid.py +0 -0
  71. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_models.py +0 -0
  72. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_moderation.py +0 -0
  73. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_pnginfo.py +0 -0
  74. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_recommend.py +0 -0
  75. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_refinement.py +0 -0
  76. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_remote_backend.py +0 -0
  77. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_reports.py +0 -0
  78. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_scorer_util.py +0 -0
  79. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_scorers_identity.py +0 -0
  80. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_scorers_phash.py +0 -0
  81. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_scorers_quality.py +0 -0
  82. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_scoring.py +0 -0
  83. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_server.py +0 -0
  84. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_smoke.py +0 -0
  85. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_stats.py +0 -0
  86. {argus_proof-0.2.0 → argus_proof-0.2.2}/tests/test_workflow.py +0 -0
@@ -41,6 +41,13 @@ PROOF_MODELS_DIR=~/models
41
41
  COMFYUI_BASE_URL=http://127.0.0.1:8188
42
42
  A1111_BASE_URL=http://127.0.0.1:7860
43
43
 
44
+ # ── Safety scoring ───────────────────────────────────────────────────
45
+ # NudeNet server for the safety scorer (its origin; /infer is appended), e.g.
46
+ # ghcr.io/notai-tech/nudenet or any server speaking its /infer API. Unset = the
47
+ # safety scorer is skipped. Proof never imports NudeNet (AGPL-3.0).
48
+ #PROOF_NUDENET_URL=http://nudenet:8080
49
+ #PROOF_NUDENET_TIMEOUT=30
50
+
44
51
  # remote (cloud) backend — the hosted generation endpoint + its bearer token.
45
52
  PROOF_REMOTE_URL=https://your-proof-gen.example.com
46
53
  PROOF_REMOTE_API_KEY=
@@ -62,7 +62,15 @@ jobs:
62
62
 
63
63
  publish-pypi:
64
64
  runs-on: ubuntu-latest
65
- needs: publish-testpypi
65
+ # Ordered *after* the TestPyPI rehearsal so its outcome is visible first, but
66
+ # deliberately not gated on it. A TestPyPI-only fault — a missing trusted
67
+ # publisher, an index outage — must not silently withhold the real release:
68
+ # that failure mode shipped argus-proof images for two tags with no wheel on
69
+ # PyPI, and nothing surfaced it because publish-image is independent.
70
+ # Only the build + install gate is required; a TestPyPI failure still marks
71
+ # the whole run failed, so the problem stays loud.
72
+ needs: [test-install, publish-testpypi]
73
+ if: "!cancelled() && needs.test-install.result == 'success'"
66
74
  environment: pypi
67
75
  steps:
68
76
  - uses: actions/download-artifact@v4
@@ -104,3 +112,40 @@ jobs:
104
112
  labels: ${{ steps.meta.outputs.labels }}
105
113
  cache-from: type=gha
106
114
  cache-to: type=gha,mode=max
115
+
116
+ github-release:
117
+ # The GitHub Release is the last step, so it only appears once the version is
118
+ # actually on PyPI. Its notes are this version's CHANGELOG.md section.
119
+ runs-on: ubuntu-latest
120
+ needs: publish-pypi
121
+ permissions:
122
+ contents: write
123
+ steps:
124
+ - uses: actions/checkout@v4
125
+ - uses: actions/download-artifact@v4
126
+ with:
127
+ name: dist
128
+ path: dist/
129
+ - name: Extract release notes from CHANGELOG.md
130
+ run: |
131
+ version="${GITHUB_REF_NAME#v}"
132
+ awk -v head="## [${version}]" 'index($0, head) == 1 {found = 1; next} found && /^## \[/ {exit} found' \
133
+ CHANGELOG.md > notes.md
134
+ if ! grep -q '[^[:space:]]' notes.md; then
135
+ echo "::error::CHANGELOG.md has no section for ${version}"
136
+ exit 1
137
+ fi
138
+ - name: Create or update the GitHub Release
139
+ env:
140
+ GH_TOKEN: ${{ github.token }}
141
+ run: |
142
+ tag="${GITHUB_REF_NAME}"
143
+ prerelease=""
144
+ case "$tag" in *-*|*rc*|*a[0-9]*|*b[0-9]*) prerelease="--prerelease" ;; esac
145
+ if gh release view "$tag" >/dev/null 2>&1; then
146
+ # Re-run of the workflow: refresh notes and assets rather than failing.
147
+ gh release edit "$tag" --notes-file notes.md
148
+ gh release upload "$tag" dist/* --clobber
149
+ else
150
+ gh release create "$tag" dist/* --verify-tag --title "$tag" --notes-file notes.md $prerelease
151
+ fi
@@ -0,0 +1,94 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.2] - 2026-10-26
11
+
12
+ ### Added
13
+ - `CHANGELOG.md`, with sections for every published version (#52).
14
+ - `release.yml` creates a **GitHub Release** for each `v*` tag once the version is on PyPI (#52).
15
+ Release notes come from that version's section in this file, and a missing section fails the job.
16
+ The built wheel and sdist are attached. Re-running the job updates the existing Release instead
17
+ of failing. Tags containing `-`, `rc`, `a<n>` or `b<n>` become pre-releases.
18
+
19
+ ### Security
20
+ - The Docker image no longer trusts **emSign ECC Root CA - G3** after the eMudhra/emSign
21
+ certificate incident
22
+ ([details](https://discuss.python.org/t/information-for-python-users-regarding-emudhra-emsign-ca-certificate-security-incident/109576)).
23
+ uv is pinned to 0.13.5 instead of `:latest`, because a cached `:latest` can still be a
24
+ pre-0.13.4 digest. The system CA bundle, which urllib callers such as the ComfyUI backend use,
25
+ disables emSign ECC Root CA - G3 and the Comodo AAA cross-sign root. The image installs pip 26.2.2
26
+ (hash-pinned) and `certifi>=2026.10.19`.
27
+ - `certifi>=2026.10.19` is pinned in the `dev` extra.
28
+
29
+ ## [0.2.1] - 2026-07-22
30
+
31
+ ### Fixed
32
+ - Release workflow: a TestPyPI failure no longer blocks the real PyPI publish. `publish-pypi` still
33
+ runs after the TestPyPI rehearsal but only requires the build and install-test jobs, and a
34
+ TestPyPI failure still fails the run. Before this, v0.1.0 and v0.2.0 shipped container images
35
+ with no wheel on PyPI.
36
+ - `httpx2` added to the `dev` extra. Starlette's `TestClient` prefers it and raises when neither
37
+ `httpx2` nor `httpx` is installed.
38
+
39
+ ## [0.2.0] - 2026-07-21
40
+
41
+ ### Added
42
+ - **Replay / read-only mode** (#45). `ARGUS_PROOF_READ_ONLY=1` (or `serve --read-only`) serves
43
+ stored reports but refuses live GPU evaluation and other writes with 403.
44
+ `/health` reports `read_only`. Off by default.
45
+ - `GET /scorers` lists the scorers and whether each one is available.
46
+ `GET /report/{run_id}/image_at/{index}` serves a run's image by position, a seed-free URL for
47
+ blind review.
48
+ - **Policy-taxonomy moderation** (#41). `argus_proof.moderation` scores images and input prompts
49
+ against a category taxonomy (sexual, violence, hate, self-harm, weapons, shocking, illegal) with
50
+ per-category tail stats. A Llama Guard adapter is available behind the new `[moderation]` extra.
51
+ - `RejectReason.category` attributes a HITL reject to a policy category. This bumps
52
+ `PROOF_VERSION` to 1.3 (additive).
53
+ - Cross-run stats can slice by `step_config` and by experiment labels (`label:<key>`) (#36).
54
+
55
+ ### Changed
56
+ - `GridConfig` and `ExperimentMatrix` share a common `GridAxes` base instead of duplicating its
57
+ fields (#37).
58
+ - The server uses the shared `argus_cortex.server` write guard (#47) instead of a local
59
+ `BaseHTTPMiddleware`, which buffered the `/run/stream` NDJSON stream. Requires
60
+ `argus-cortex>=0.2.0`.
61
+
62
+ ### Security
63
+ - `image_at` validates the image id it gets from a stored report, which closes a path traversal
64
+ that allowed arbitrary image reads. A 404 no longer echoes the image id.
65
+
66
+ ## [0.1.0] - 2026-07-17
67
+
68
+ First release: the MVP evaluation pipeline for trained LoRAs.
69
+
70
+ ### Added
71
+ - Versioned wire contract (`PROOF_VERSION` 1.2) with a generated JSON Schema
72
+ (`schema/proof-wire.schema.json`).
73
+ - Generation backends behind one protocol: ComfyUI, in-process diffusers, AUTOMATIC1111, and a
74
+ remote endpoint that speaks the proof wire. Every run records a model manifest with hashes.
75
+ - Prompt-grid builder that reads curator export captions.
76
+ - Scoring spine (composite gate with auto-pass, auto-fail and HITL bands, plus near-duplicate
77
+ collapse) and the scorers: phash dedup and diversity, ArcFace identity against a held-out
78
+ reference set, CLIPScore / pyiqa / ImageReward quality, and NudeNet safety.
79
+ - HITL review and an optional refinement re-rank of the passing subset.
80
+ - Reject archival.
81
+ - CI acceptance gate with Wilson intervals (`argus-proof gate`, exit 0/1/2).
82
+ - Cross-run stats store (parquet) with pooled pass-rate intervals per slice and Krippendorff's alpha.
83
+ - Routed recommendations (`argus-proof recommend`).
84
+ - A/B experiment matrix with a GPU-hour budget guardrail and optional Optuna search.
85
+ - FiftyOne exploration (`argus-proof explore`) behind the `[fiftyone]` extra.
86
+ - CLI verbs `run`, `score`, `report`, `inspect`, plus a FastAPI server (`argus-proof serve`) with
87
+ `/run/stream`, report/HITL/refine endpoints, and model and export listings.
88
+ - Container image `ghcr.io/smk762/argus-proof`.
89
+
90
+ [Unreleased]: https://github.com/smk762/argus-proof/compare/v0.2.2...HEAD
91
+ [0.2.2]: https://github.com/smk762/argus-proof/compare/v0.2.1...v0.2.2
92
+ [0.2.1]: https://github.com/smk762/argus-proof/compare/v0.2.0...v0.2.1
93
+ [0.2.0]: https://github.com/smk762/argus-proof/compare/v0.1.0...v0.2.0
94
+ [0.1.0]: https://github.com/smk762/argus-proof/releases/tag/v0.1.0
@@ -0,0 +1,53 @@
1
+ # CLAUDE.md — argus-proof
2
+
3
+ Guidance for AI agents working in this repo. Human-facing usage lives in [README.md](README.md); this file is the orientation an agent needs to change code safely.
4
+
5
+ ## What this is
6
+
7
+ The **post-training evaluation/optimisation** stage — it closes the suite loop: generate a sample grid from a trained LoRA, score it against the curated dataset it was trained from, and emit a pass/fail verdict that feeds back to curation, captioning, and training.
8
+
9
+ ```
10
+ argus-quarry -> argus-curator -> argus-lens -> argus-forge -> your trainer -> argus-proof
11
+ acquire curate/export caption configs LoRA evaluate + optimise
12
+ ```
13
+
14
+ **The forge→proof handoff is a soft join, not an embed.** A `RunManifest` links back to the curator export via `source_manifest` (the `manifest.jsonl`) and, when the LoRA came through the suite, to forge's `training_run_id` (== forge `RunEvent.run_id`). Both are optional — a LoRA can be supplied ad hoc. Proof owns its own `run_id` (the eval run), distinct from the forge training run. README.md is the source of truth for behaviour; it is unusually detailed.
15
+
16
+ ## Layout
17
+
18
+ `src/argus_proof/`:
19
+
20
+ - `models.py` — Pydantic v2 wire types, the three versioned top-level shapes (`RunManifest`, `EvalReport`, `RejectArchive`) plus `RunSpec`/grid/gate/acceptance models. **This is the API contract**; `wire_schema()`/`render_wire_schema()` back `argus-proof schema` and the committed `schema/proof-wire.schema.json`.
21
+ - `grid.py` / `experiment.py` — the prompt-grid builder (axes × prompts × seeds → `RunSpec`s) and the A/B `ExperimentMatrix` that varies base-checkpoint × step-config as outer factors.
22
+ - `backends/` — pluggable generation engines over `base.GenBackend`: `comfyui` (shipped), `diffusers`, `a1111`, `remote`; `workflow.py`/`pnginfo.py`/`http.py` are shared helpers. `get_backend(name, ...)` selects by config (`_BACKENDS` registry).
23
+ - `scoring/` — `base.py` (the `ImageScorer`/`Deduper`/`DiversityScorer` protocols + `METRIC_FIELDS`), `orchestrator.py` (`score_run`), `gate.py`, `summary.py`; concrete scorers under `scoring/scorers/` (phash, identity, quality behind the `[score]` extra; safety posts to a NudeNet server at `PROOF_NUDENET_URL` through argus-cortex's `NudeNetClient` — **never import `nudenet` in-process: it is AGPL-3.0**).
24
+ - `reports.py` (`ReportStore`, `apply_hitl`), `refinement.py`, `acceptance.py`, `recommend.py`, `crossrun.py`, `archive.py`, `stats.py`, `moderation.py`, `explore.py` — the report store + the optimisation layers on top of a scored run.
25
+ - `cli.py` — Typer app (`inspect`, `run`, `score`, `report`, `gate`, `recommend`, `experiment`, `explore`, `schema`, `serve`).
26
+ - `server/app.py` — FastAPI micro-server on **:8104** (peer to lens :8100, curator :8101, quarry :8102, forge :8103); backs the argus-studio `/proof` view. Optional `[server]` extra.
27
+ - `templates/comfyui_sdxl_lora.json` — the shipped parametric ComfyUI workflow (`$placeholder` graph).
28
+
29
+ ## Commands
30
+
31
+ ```bash
32
+ make install # uv venv + editable install with [dev,server,cli]
33
+ make test # uv run --no-sync pytest --tb=short -q
34
+ make lint # ruff 0.15.16 check + format --check
35
+ make format # ruff format + check --fix
36
+ ```
37
+
38
+ Run a single test: `uv run --no-sync pytest tests/test_scoring.py::test_name -q`.
39
+
40
+ ## Conventions & gotchas
41
+
42
+ - **The wire schema is checked in CI** (`argus-proof schema --check` runs post-test). If you touch `WIRE_MODELS`/types in `models.py`, regenerate: `argus-proof schema > schema/proof-wire.schema.json`. Bump `PROOF_VERSION` (minor = additive field/metric, major = breaking) and mind `SUPPORTED_PROOF_MAJORS`.
43
+ - **Pass-rate is computed over near-duplicate *groups*, not raw frames** (`n_passed / n_groups`), so a Monte-Carlo cluster counts once. Never revert this to per-image — it's the core anti-inflation invariant. `n_groups == n_images` when no deduper ran.
44
+ - **Scorers return normalised `[0,1]` (higher = better) or `None`** — never a fabricated `0.0` for an axis that didn't run. At most one scorer per metric (`METRIC_FIELDS`); the orchestrator raises if two target the same one. The `[0,1]` quality-scorer normalisation `lo`/`hi` defaults are **placeholders** — calibrate against real generations.
45
+ - **Every model file is pinned by SHA256** in the `RunManifest`, not by filename — a run must reconstruct exactly. Backends resolve a requested name to a file and hash it; don't record a name without its digest. `RejectArchive`/`RejectRecord` are **image-free by construction** (keyed by `(run_id, seed)` — seed + manifest reconstructs the pixels); never add a path/thumbnail field there.
46
+ - **Refinement is a separate layer.** `ImageScores.refinement` never overwrites the first-pass `hitl_rating`/`reject_reasons` or the verdict; refining an image not in the passing subset is refused.
47
+ - **Backend swap is a config change, not a code change** — scoring/report code is engine-agnostic (`PROOF_BACKEND` selects). Keep new engines behind `GenBackend` + the `_BACKENDS` registry; heavy adapters stay lazy-imported and `is_available()`-guarded (same for optional scorers/moderation/explore).
48
+ - **Server safety — don't loosen either boundary:**
49
+ - Images are addressed by `(run_id, image_id)`, both validated against a strict charset (`_SAFE_ID`) — **no client-supplied filesystem path is ever resolved**. Keep it that way; there is no traversal surface by design.
50
+ - Read-only/replay mode (`ARGUS_PROOF_READ_ONLY`, `serve --read-only`): every mutating request — `POST /run/stream` (live GPU eval) above all — is refused with `403` via the shared `argus_cortex.server.WriteGuard`. `/health` echoes the flag. It fails *safe*.
51
+ - Concurrent report writes are serialised by a per-run `flock` sidecar (`reports.py`), so a streaming run's write and a reviewer's HITL save can't drop updates. On non-POSIX it degrades to a no-op — don't rely on it for multi-host.
52
+ - Shared suite code (taxonomy, wire-schema tooling, the server write-guard/env-flag helpers, `RemoteBackend`) lives in **argus-cortex** (`argus-cortex[server]`) — reuse it, don't re-implement.
53
+ - Versioning is git-tag-derived (`hatch-vcs`); `_version.py` is generated (gitignored). `structlog` logging, Pydantic v2, async server (`asyncio_mode = auto`), ruff line-length 120. Scaffolded from `argus-pkg-template` — `copier update` pulls tooling changes.
@@ -0,0 +1,39 @@
1
+ # syntax=docker/dockerfile:1
2
+ FROM python:3.11-slim
3
+
4
+ # Pin past the eMudhra/emSign incident. A floating :latest tag can stay cached
5
+ # on a pre-0.13.4 digest, which still trusts emSign ECC Root CA - G3.
6
+ # https://discuss.python.org/t/information-for-python-users-regarding-emudhra-emsign-ca-certificate-security-incident/109576
7
+ COPY --from=ghcr.io/astral-sh/uv:0.13.5 /uv /uvx /bin/
8
+
9
+ WORKDIR /app
10
+
11
+ # hatch-vcs derives the version from git history, so the build context must
12
+ # include .git (keep it out of .dockerignore for this image) and the image
13
+ # needs the git binary for setuptools-scm to read it (slim ships without it).
14
+ # ComfyUI and the other urllib callers trust this bundle, not certifi.
15
+ # Distrust emSign ECC Root CA - G3 and the Comodo AAA cross-sign root.
16
+ RUN apt-get update \
17
+ && apt-get install -y --no-install-recommends ca-certificates git \
18
+ && sed -i \
19
+ -e 's|^mozilla/emSign_ECC_Root_CA_-_G3|!mozilla/emSign_ECC_Root_CA_-_G3|' \
20
+ -e 's|^mozilla/Comodo_AAA|!mozilla/Comodo_AAA|' \
21
+ /etc/ca-certificates.conf \
22
+ && update-ca-certificates --fresh >/dev/null \
23
+ && rm -rf /var/lib/apt/lists/*
24
+
25
+ COPY . .
26
+
27
+ # Extras baked into the image. Default keeps it light; build with
28
+ # EXTRAS="server,cli,score" for the full scorer stack (torch, insightface, …).
29
+ ARG EXTRAS="server,cli"
30
+
31
+ # pillow + imagehash ride along regardless: they power phash near-dup collapse
32
+ # and diversity (honest group pass-rates) at negligible image cost.
33
+ RUN python -m pip install --no-cache-dir \
34
+ "pip @ https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl#sha256=fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70" \
35
+ && uv pip install --system --no-cache "certifi>=2026.10.19" ".[${EXTRAS}]" pillow imagehash
36
+
37
+ EXPOSE 8104
38
+ CMD ["argus-proof", "serve", "--port", "8104", "--cors"]
39
+
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: argus-proof
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Post-training LoRA evaluation and optimisation: generate samples from a trained LoRA and score them against the curated dataset it was trained from
5
5
  Project-URL: Repository, https://github.com/smk762/argus-proof
6
6
  Author: smk762
@@ -14,13 +14,15 @@ Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Typing :: Typed
16
16
  Requires-Python: >=3.11
17
- Requires-Dist: argus-cortex>=0.2.0
17
+ Requires-Dist: argus-cortex<0.5,>=0.4
18
18
  Requires-Dist: pydantic>=2.7
19
19
  Requires-Dist: structlog>=24.0
20
20
  Provides-Extra: cli
21
21
  Requires-Dist: rich>=13.0; extra == 'cli'
22
22
  Requires-Dist: typer>=0.12; extra == 'cli'
23
23
  Provides-Extra: dev
24
+ Requires-Dist: certifi>=2026.10.19; extra == 'dev'
25
+ Requires-Dist: httpx2>=2.7; extra == 'dev'
24
26
  Requires-Dist: httpx>=0.27; extra == 'dev'
25
27
  Requires-Dist: imagehash>=4.3; extra == 'dev'
26
28
  Requires-Dist: krippendorff>=0.6; extra == 'dev'
@@ -51,14 +53,13 @@ Provides-Extra: score
51
53
  Requires-Dist: image-reward>=1.5; extra == 'score'
52
54
  Requires-Dist: imagehash>=4.3; extra == 'score'
53
55
  Requires-Dist: insightface>=0.7; extra == 'score'
54
- Requires-Dist: nudenet>=3.0; extra == 'score'
55
56
  Requires-Dist: onnxruntime>=1.16; extra == 'score'
56
57
  Requires-Dist: pillow>=10.0; extra == 'score'
57
58
  Requires-Dist: pyiqa>=0.1.10; extra == 'score'
58
59
  Requires-Dist: torch>=2.0; extra == 'score'
59
60
  Requires-Dist: torchmetrics>=1.0; extra == 'score'
60
61
  Provides-Extra: server
61
- Requires-Dist: argus-cortex[server]>=0.2.0; extra == 'server'
62
+ Requires-Dist: argus-cortex[server]<0.5,>=0.4; extra == 'server'
62
63
  Requires-Dist: fastapi>=0.110; extra == 'server'
63
64
  Requires-Dist: python-multipart>=0.0.7; extra == 'server'
64
65
  Requires-Dist: uvicorn>=0.27; extra == 'server'
@@ -219,7 +220,7 @@ skips it when the extra is absent). Shipped:
219
220
  - **dedup + diversity** — `PhashDeduper`, `PhashDiversityScorer` (perceptual hash, CPU-only)
220
221
  - **identity** — `IdentityScorer` (InsightFace ArcFace cosine vs a held-out reference set)
221
222
  - **quality / adherence** — `clip_score_scorer()` (CLIPScore), `pyiqa_scorer()` (CLIP-IQA), `image_reward_scorer()` (ImageReward), each normalising its raw score to `[0,1]`
222
- - **safety** — `SafetyScorer` (NudeNet ensemble, `1 - unsafe`); set a `safety` hard gate to auto-fail unsafe images. `safety_tail_aggregate()` surfaces the any-hit/min/percentile tail
223
+ - **safety** — `SafetyScorer` (NudeNet ensemble, `1 - unsafe`); set a `safety` hard gate to auto-fail unsafe images. `safety_tail_aggregate()` surfaces the any-hit/min/percentile tail. NudeNet runs as a **server over HTTP** (`PROOF_NUDENET_URL`, via argus-cortex's `NudeNetClient`), never in-process (it is AGPL-3.0); without a URL the scorer is skipped. "Unsafe" is the suite's exposed-nudity set (exposed buttocks, breasts, genitalia, anus), the same as curator's
223
224
 
224
225
  ```python
225
226
  from argus_proof.scoring import score_run
@@ -450,6 +451,8 @@ make lint
450
451
 
451
452
  - **CI** runs via the shared [`argus-ci`](https://github.com/smk762/argus-ci) reusable workflow.
452
453
  - **Release** publishes to PyPI (OIDC trusted publishing) and GHCR on `v*` tags.
454
+ - Once PyPI has the version, a **GitHub Release** is created with that version's
455
+ `CHANGELOG.md` section as its notes. Add the section before you tag; the job fails without it.
453
456
  - Versioning is derived from git tags via `hatch-vcs` — tag `vX.Y.Z` to cut a release.
454
457
 
455
458
  This repo was scaffolded from [`argus-pkg-template`](https://github.com/smk762/argus-pkg-template).
@@ -150,7 +150,7 @@ skips it when the extra is absent). Shipped:
150
150
  - **dedup + diversity** — `PhashDeduper`, `PhashDiversityScorer` (perceptual hash, CPU-only)
151
151
  - **identity** — `IdentityScorer` (InsightFace ArcFace cosine vs a held-out reference set)
152
152
  - **quality / adherence** — `clip_score_scorer()` (CLIPScore), `pyiqa_scorer()` (CLIP-IQA), `image_reward_scorer()` (ImageReward), each normalising its raw score to `[0,1]`
153
- - **safety** — `SafetyScorer` (NudeNet ensemble, `1 - unsafe`); set a `safety` hard gate to auto-fail unsafe images. `safety_tail_aggregate()` surfaces the any-hit/min/percentile tail
153
+ - **safety** — `SafetyScorer` (NudeNet ensemble, `1 - unsafe`); set a `safety` hard gate to auto-fail unsafe images. `safety_tail_aggregate()` surfaces the any-hit/min/percentile tail. NudeNet runs as a **server over HTTP** (`PROOF_NUDENET_URL`, via argus-cortex's `NudeNetClient`), never in-process (it is AGPL-3.0); without a URL the scorer is skipped. "Unsafe" is the suite's exposed-nudity set (exposed buttocks, breasts, genitalia, anus), the same as curator's
154
154
 
155
155
  ```python
156
156
  from argus_proof.scoring import score_run
@@ -381,6 +381,8 @@ make lint
381
381
 
382
382
  - **CI** runs via the shared [`argus-ci`](https://github.com/smk762/argus-ci) reusable workflow.
383
383
  - **Release** publishes to PyPI (OIDC trusted publishing) and GHCR on `v*` tags.
384
+ - Once PyPI has the version, a **GitHub Release** is created with that version's
385
+ `CHANGELOG.md` section as its notes. Add the section before you tag; the job fails without it.
384
386
  - Versioning is derived from git tags via `hatch-vcs` — tag `vX.Y.Z` to cut a release.
385
387
 
386
388
  This repo was scaffolded from [`argus-pkg-template`](https://github.com/smk762/argus-pkg-template).
@@ -22,7 +22,10 @@ classifiers = [
22
22
  dependencies = [
23
23
  # Shared suite foundation (taxonomy, wire-schema tooling, AI-backend contract,
24
24
  # and the server write-guard scaffolding used by server/app.py — 0.2.0+).
25
- "argus-cortex>=0.2.0",
25
+ # 0.4+: argus_cortex.moderation (the NudeNet client the safety scorer uses).
26
+ # Capped below the next minor: argus-cortex is pre-1.0, where a minor bump
27
+ # carries no compatibility guarantee.
28
+ "argus-cortex>=0.4,<0.5",
26
29
  "pydantic>=2.7",
27
30
  "structlog>=24.0",
28
31
  ]
@@ -30,7 +33,7 @@ dependencies = [
30
33
  [project.optional-dependencies]
31
34
  # argus-cortex[server] pulls starlette for the shared write-guard (argus_cortex.server);
32
35
  # fastapi provides it too, but the extra declares the dependency honestly.
33
- server = ["fastapi>=0.110", "uvicorn>=0.27", "python-multipart>=0.0.7", "argus-cortex[server]>=0.2.0"]
36
+ server = ["fastapi>=0.110", "uvicorn>=0.27", "python-multipart>=0.0.7", "argus-cortex[server]>=0.4,<0.5"]
34
37
  cli = ["typer>=0.12", "rich>=13.0"]
35
38
  # Concrete scorers (implement argus_proof.scoring protocols): dedup/diversity
36
39
  # (phash) + identity (InsightFace ArcFace) + quality (CLIPScore/pyiqa/ImageReward).
@@ -45,7 +48,6 @@ score = [
45
48
  "torchmetrics>=1.0",
46
49
  "pyiqa>=0.1.10",
47
50
  "image-reward>=1.5",
48
- "nudenet>=3.0",
49
51
  ]
50
52
  # Cross-run stats store (pass-rate CIs use the dependency-free argus_proof.stats;
51
53
  # the parquet store needs polars, inter-rater reliability the krippendorff pkg).
@@ -67,7 +69,7 @@ diffusers = ["diffusers>=0.27", "torch>=2.0", "transformers>=4.38", "accelerate>
67
69
  # outputs + input prompts. LLM-weight, so opt-in; lazy-imported by
68
70
  # argus_proof.moderation and is_available()-guarded (absent -> a clear hint).
69
71
  moderation = ["transformers>=4.43", "torch>=2.0", "accelerate>=0.27", "pillow>=10.0"]
70
- dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "ruff==0.15.16", "httpx>=0.27", "pillow>=10.0", "imagehash>=4.3", "polars>=1.0", "krippendorff>=0.6", "optuna>=3.0"]
72
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "ruff==0.15.16", "httpx>=0.27", "certifi>=2026.10.19", "pillow>=10.0", "imagehash>=4.3", "polars>=1.0", "krippendorff>=0.6", "optuna>=3.0", "httpx2>=2.7"]
71
73
 
72
74
  [project.scripts]
73
75
  argus-proof = "argus_proof.cli:app"
@@ -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.2.0'
22
- __version_tuple__ = version_tuple = (0, 2, 0)
21
+ __version__ = version = '0.2.2'
22
+ __version_tuple__ = version_tuple = (0, 2, 2)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -7,7 +7,8 @@ set a ``safety`` hard gate to auto-fail (and flag ``unsafe``) anything too risky
7
7
 
8
8
  An **ensemble** of detectors is combined conservatively (worst/most-unsafe wins).
9
9
  The detector is injectable, so the scoring logic is CI-tested with a fake; the
10
- real :class:`NudeNetDetector` is lazy and behind the ``[score]`` extra.
10
+ real :class:`NudeNetDetector` calls a NudeNet server (``PROOF_NUDENET_URL``)
11
+ and is unavailable without one.
11
12
 
12
13
  :func:`safety_tail_aggregate` gives the run-level tail view (any-hit / min-safety
13
14
  / low percentile) that matters for safety, where the mean hides a single bad frame.
@@ -15,12 +16,21 @@ real :class:`NudeNetDetector` is lazy and behind the ``[score]`` extra.
15
16
 
16
17
  from __future__ import annotations
17
18
 
19
+ import os
18
20
  from collections.abc import Sequence
19
21
  from pathlib import Path
20
22
  from typing import TYPE_CHECKING, Protocol, runtime_checkable
21
23
 
24
+ from argus_cortex.moderation import (
25
+ DEFAULT_EXPOSED_LABELS,
26
+ CheckFailed,
27
+ ModerationConfigError,
28
+ NudeNetClient,
29
+ exposed_score,
30
+ )
31
+
22
32
  from argus_proof.models import ScorerProvenance
23
- from argus_proof.scoring.scorers._util import clamp01, module_available
33
+ from argus_proof.scoring.scorers._util import clamp01
24
34
  from argus_proof.scoring.scorers._util import percentile as _percentile
25
35
 
26
36
  if TYPE_CHECKING:
@@ -28,6 +38,12 @@ if TYPE_CHECKING:
28
38
  from argus_proof.scoring.base import ScoreContext
29
39
 
30
40
 
41
+ # The NudeNet server (its origin) and per-image timeout, kept in lock-step with .env.example.
42
+ ENV_NUDENET_URL = "PROOF_NUDENET_URL"
43
+ ENV_NUDENET_TIMEOUT = "PROOF_NUDENET_TIMEOUT"
44
+ DEFAULT_NUDENET_TIMEOUT = 30.0
45
+
46
+
31
47
  @runtime_checkable
32
48
  class SafetyDetector(Protocol):
33
49
  """Returns an image's unsafe probability in ``[0, 1]`` (or ``None`` if unknown)."""
@@ -81,45 +97,70 @@ class SafetyScorer:
81
97
 
82
98
 
83
99
  class NudeNetDetector:
84
- """Unsafe-probability via NudeNet 3.x ``NudeDetector`` (max exposed-class score).
85
-
86
- NudeNet 3.x replaced the old ``NudeClassifier`` with ``NudeDetector().detect()``,
87
- which returns a list of ``{"class", "score", "box"}`` detections; the unsafe
88
- score is the max confidence over the *exposed* classes. Heavy (onnxruntime +
89
- model); lazy, behind the ``[score]`` extra. A detector error on one image
90
- returns ``None`` (skip it) rather than crashing the whole scoring run.
100
+ """Unsafe-probability from a NudeNet server over HTTP (max exposed-class score).
101
+
102
+ Proof never imports NudeNet (AGPL-3.0): it posts each image to a NudeNet
103
+ server through argus-cortex's :class:`~argus_cortex.moderation.NudeNetClient`.
104
+ The server is ``PROOF_NUDENET_URL`` (its origin, e.g. ``http://nudenet:8080``)
105
+ unless *base_url* is given; without one the detector is unavailable and the
106
+ orchestrator skips it.
107
+
108
+ The unsafe score is the highest score among the suite's exposed-nudity
109
+ classes (:data:`~argus_cortex.moderation.DEFAULT_EXPOSED_LABELS`: exposed
110
+ buttocks, breasts, genitalia, anus), shared with curator. Faces, covered
111
+ parts, and bare feet, belly, armpits or male chest do not count. A failed
112
+ check on one image returns ``None`` (skip it) rather than crashing the run.
91
113
  """
92
114
 
93
115
  name = "nudenet"
94
116
 
95
- def __init__(self, unsafe_classes: frozenset[str] | None = None) -> None:
96
- # Default: any explicit "*_EXPOSED" class (genitalia, breast, buttocks,
97
- # anus). Override to widen/narrow what counts as unsafe.
98
- self._unsafe_classes = unsafe_classes
99
- self._detector = None
117
+ def __init__(
118
+ self,
119
+ unsafe_classes: frozenset[str] | None = None,
120
+ base_url: str | None = None,
121
+ timeout: float | None = None,
122
+ ) -> None:
123
+ # Override to widen/narrow what counts as unsafe (NudeNet class names).
124
+ self._unsafe_classes = unsafe_classes if unsafe_classes is not None else frozenset(DEFAULT_EXPOSED_LABELS)
125
+ self._base_url = base_url
126
+ self._timeout = timeout
127
+ self._client: NudeNetClient | None = None
128
+
129
+ def _url(self) -> str:
130
+ return (self._base_url if self._base_url is not None else os.environ.get(ENV_NUDENET_URL, "")).strip()
100
131
 
101
132
  def is_available(self) -> bool:
102
- return module_available("nudenet")
103
-
104
- def _load(self): # noqa: ANN202 - nudenet types aren't importable without the extra
105
- if self._detector is None:
106
- from nudenet import NudeDetector
133
+ if not self._url():
134
+ return False
135
+ try:
136
+ self._load()
137
+ except ModerationConfigError:
138
+ return False
139
+ return True
107
140
 
108
- self._detector = NudeDetector()
109
- return self._detector
141
+ def _load(self) -> NudeNetClient:
142
+ if self._client is None:
143
+ timeout = self._timeout or _float_env(ENV_NUDENET_TIMEOUT, DEFAULT_NUDENET_TIMEOUT)
144
+ self._client = NudeNetClient(self._url(), timeout=timeout)
145
+ return self._client
110
146
 
111
147
  def _is_unsafe(self, label: str) -> bool:
112
- if self._unsafe_classes is not None:
113
- return label in self._unsafe_classes
114
- return label.endswith("_EXPOSED")
148
+ return label in self._unsafe_classes
115
149
 
116
150
  def unsafe_probability(self, image_path: Path) -> float | None:
117
151
  try:
118
- detections = self._load().detect(str(image_path))
119
- except Exception: # noqa: BLE001 - one bad image must not abort the run
152
+ result = self._load().check(Path(image_path).read_bytes(), Path(image_path).name)
153
+ except (CheckFailed, ModerationConfigError, OSError): # one bad image must not abort the run
120
154
  return None
121
- scores = [float(d["score"]) for d in detections if self._is_unsafe(str(d.get("class", "")))]
122
- return max(scores) if scores else 0.0 # no exposed detection -> safe
155
+ return exposed_score(result.labels, sorted(self._unsafe_classes)) # no exposed detection -> 0.0 (safe)
156
+
157
+
158
+ def _float_env(name: str, default: float) -> float:
159
+ try:
160
+ value = float(os.environ.get(name, ""))
161
+ except ValueError:
162
+ return default
163
+ return value if value > 0 else default
123
164
 
124
165
 
125
166
  def safety_tail_aggregate(report: EvalReport, *, unsafe_below: float = 0.5) -> dict[str, float]:
@@ -1,5 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
4
+ import threading
5
+ from collections.abc import Iterator
6
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
3
7
  from pathlib import Path
4
8
 
5
9
  import pytest
@@ -121,26 +125,92 @@ def test_tail_aggregate_no_safety_scores() -> None:
121
125
 
122
126
 
123
127
  # --------------------------------------------------------------------------
124
- # NudeNetDetector (3.x API) — pure logic + graceful degradation
128
+ # NudeNetDetector — a NudeNet server over HTTP (argus-cortex's client)
125
129
  # --------------------------------------------------------------------------
126
130
 
127
131
 
128
- def test_nudenet_unsafe_class_heuristic() -> None:
132
+ class _NudeNet(BaseHTTPRequestHandler):
133
+ """Speaks NudeNet's /infer API; answers by the uploaded file's name."""
134
+
135
+ answers = {
136
+ "topless.png": [
137
+ {"class": "FEMALE_BREAST_EXPOSED", "score": 0.81, "box": [0, 0, 5, 5]},
138
+ {"class": "FACE_FEMALE", "score": 0.93, "box": [0, 0, 5, 5]},
139
+ ],
140
+ "beach.png": [
141
+ {"class": "FEET_EXPOSED", "score": 0.9, "box": [0, 0, 5, 5]},
142
+ {"class": "MALE_BREAST_EXPOSED", "score": 0.7, "box": [0, 0, 5, 5]},
143
+ ],
144
+ }
145
+
146
+ def log_message(self, *args) -> None: # keep test output quiet
147
+ pass
148
+
149
+ def do_POST(self) -> None:
150
+ body = self.rfile.read(int(self.headers["Content-Length"]))
151
+ name = next(n for n in self.answers if f'filename="{n}"'.encode() in body) if b"broken" not in body else None
152
+ if name is None:
153
+ self.send_response(500)
154
+ self.end_headers()
155
+ return
156
+ out = json.dumps({"prediction": [self.answers[name]], "success": True}).encode()
157
+ self.send_response(200)
158
+ self.send_header("Content-Type", "application/json")
159
+ self.send_header("Content-Length", str(len(out)))
160
+ self.end_headers()
161
+ self.wfile.write(out)
162
+
163
+
164
+ @pytest.fixture
165
+ def nudenet_url() -> Iterator[str]:
166
+ server = ThreadingHTTPServer(("127.0.0.1", 0), _NudeNet)
167
+ threading.Thread(target=server.serve_forever, daemon=True).start()
168
+ try:
169
+ yield f"http://127.0.0.1:{server.server_port}"
170
+ finally:
171
+ server.shutdown()
172
+ server.server_close()
173
+
174
+
175
+ def test_nudenet_counts_only_the_suite_exposed_classes() -> None:
129
176
  d = NudeNetDetector()
130
177
  assert d._is_unsafe("FEMALE_BREAST_EXPOSED") is True
131
178
  assert d._is_unsafe("BUTTOCKS_EXPOSED") is True
132
179
  assert d._is_unsafe("FACE_FEMALE") is False
133
180
  assert d._is_unsafe("FEMALE_BREAST_COVERED") is False
181
+ assert d._is_unsafe("FEET_EXPOSED") is False # bare feet / chest are not nudity
182
+ assert d._is_unsafe("MALE_BREAST_EXPOSED") is False
134
183
 
135
184
 
136
185
  def test_nudenet_unsafe_class_override() -> None:
137
186
  d = NudeNetDetector(unsafe_classes=frozenset({"CUSTOM"}))
138
187
  assert d._is_unsafe("CUSTOM") is True
139
- assert d._is_unsafe("FEMALE_BREAST_EXPOSED") is False # override replaces the heuristic
188
+ assert d._is_unsafe("FEMALE_BREAST_EXPOSED") is False # override replaces the default set
140
189
 
141
190
 
142
- def test_nudenet_unavailable_without_extra_and_degrades() -> None:
191
+ def test_nudenet_scores_through_the_server(nudenet_url: str, tmp_path: Path) -> None:
192
+ for name in ("topless.png", "beach.png", "broken.png"):
193
+ (tmp_path / name).write_bytes(b"\x89PNG fake")
194
+ d = NudeNetDetector(base_url=nudenet_url, timeout=5)
195
+ assert d.is_available()
196
+ assert d.unsafe_probability(tmp_path / "topless.png") == 0.81 # the face (0.93) does not count
197
+ assert d.unsafe_probability(tmp_path / "beach.png") == 0.0
198
+ assert d.unsafe_probability(tmp_path / "broken.png") is None # failed check: skipped, not safe
199
+ assert d.unsafe_probability(tmp_path / "missing.png") is None
200
+ wide = NudeNetDetector(unsafe_classes=frozenset({"FEET_EXPOSED"}), base_url=nudenet_url)
201
+ assert wide.unsafe_probability(tmp_path / "beach.png") == 0.9
202
+
203
+
204
+ def test_nudenet_url_from_env(nudenet_url: str, monkeypatch: pytest.MonkeyPatch) -> None:
205
+ monkeypatch.setenv("PROOF_NUDENET_URL", nudenet_url)
206
+ assert NudeNetDetector().is_available()
207
+ monkeypatch.setenv("PROOF_NUDENET_URL", "ftp://nudenet")
208
+ assert NudeNetDetector().is_available() is False
209
+
210
+
211
+ def test_nudenet_unavailable_without_a_server_and_degrades(monkeypatch: pytest.MonkeyPatch) -> None:
212
+ monkeypatch.delenv("PROOF_NUDENET_URL", raising=False)
143
213
  d = NudeNetDetector()
144
- assert d.is_available() is False # nudenet not in the CI env
145
- # calling anyway must not crash — the load error is caught -> None
214
+ assert d.is_available() is False # no NudeNet server configured
215
+ # calling anyway must not crash — the config error is caught -> None
146
216
  assert d.unsafe_probability(Path("nope.png")) is None
@@ -1,27 +0,0 @@
1
- # syntax=docker/dockerfile:1
2
- FROM python:3.11-slim
3
-
4
- COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
5
-
6
- WORKDIR /app
7
-
8
- # hatch-vcs derives the version from git history, so the build context must
9
- # include .git (keep it out of .dockerignore for this image) and the image
10
- # needs the git binary for setuptools-scm to read it (slim ships without it).
11
- RUN apt-get update \
12
- && apt-get install -y --no-install-recommends git \
13
- && rm -rf /var/lib/apt/lists/*
14
-
15
- COPY . .
16
-
17
- # Extras baked into the image. Default keeps it light; build with
18
- # EXTRAS="server,cli,score" for the full scorer stack (torch, insightface, …).
19
- ARG EXTRAS="server,cli"
20
-
21
- # pillow + imagehash ride along regardless: they power phash near-dup collapse
22
- # and diversity (honest group pass-rates) at negligible image cost.
23
- RUN uv pip install --system --no-cache ".[${EXTRAS}]" pillow imagehash
24
-
25
- EXPOSE 8104
26
- CMD ["argus-proof", "serve", "--port", "8104", "--cors"]
27
-
File without changes
File without changes
File without changes