Compare commits

...
Author SHA1 Message Date
Zohaib Hassnain f83d2a8b12 document evals API 2026-09-03 03:19:09 +05:00
Mohd Kaif bd584b7402 Update features list in README
Removed 'Self-Hostable' and 'Auditable' from the features list.
2026-09-02 22:21:41 +05:30
Mohd Kaif a4500f5b20 Merge pull request #1396 from semantica-agi/readme-enterprise-connectors-update
docs: tighten README audience list, add SAP connector mentions, log Salesforce ingestor
2026-09-02 21:50:51 +05:30
KaifAhmad1 bdd12e8ac6 fix: correct JWT auth requirements in changelog, add missing SAP install extra
- CHANGELOG: JWT Bearer requires username too, not just consumer_key + private key
- README: add pip install semantica[ingest-sap] to the install-extras list, which was missing despite SAP appearing in the supported-sources lists

Addresses Qodo review feedback on #1396.
2026-09-02 21:45:24 +05:30
KaifAhmad1 48204d4e02 docs: tighten README audience list, add SAP to connector mentions, log unreleased Salesforce ingestor
- Trim "Who it's for" bullets in README for concision
- Propagate SAP OData connector mentions across README's integration lists (was only in the What's New section)
- Add missing CHANGELOG entry for the unreleased Salesforce ingestor (#1240)
- Remove sample `semantica doctor` output lines from the quickstart snippet
2026-09-02 21:23:29 +05:30
Zohaib Hassnain 1bc873cbbd Merge pull request #1328 from semantica-agi/feat/pinecone-iter-all
feat(vector_store): add pinecone iter_all
2026-09-02 20:07:54 +05:30
KaifAhmad1 4dd88375e1 fix(vector_store): don't short-circuit pinecone iter_all() on an empty page
if not vector_ids: return fired before the continuation token was ever
checked. Pinecone's actual pagination contract is that a scan is only
exhausted when the response carries no pagination token -- a page can
legitimately list zero ids while pagination.next is still set (sparse
or filtered namespaces, eventual-consistency windows on serverless
indexes). This was flagged in review but the fix commit that followed
only addressed the separate repeated-token stall case, not this one.

Reproduced concretely against the unfixed code: a page with data,
followed by an empty page with a live token, followed by a page with
more data -- the last page was silently dropped with no error raised,
exactly the #1083 failure mode (store migrate reporting success after
copying only part of a collection).

Now the empty-page case skips the pointless fetch() call but still
falls through to the same next_token check every other path already
goes through, so a live token continues the scan and only a genuinely
absent token (or one that's stopped advancing) ends it.

Added test_continues_past_an_empty_page_with_a_live_token, the
"empty page + non-None next token" case the original review asked for
and that wasn't otherwise covered.
2026-09-02 19:54:24 +05:30
Mohd Kaif fc899c6966 Merge pull request #1326 from semantica-agi/feat/milvus-iter-all
feat(vector_store): add milvus iter_all
2026-09-02 19:37:54 +05:30
KaifAhmad1 98bd632585 Merge remote-tracking branch 'origin/main' into feat/milvus-iter-all 2026-09-02 19:20:55 +05:30
KevinandSameer Kadam 30a91a3a78 feat(evals): add per-metric objective support to runner (closes #1091) (#1092)
* chore: ignore .worktrees directory

* feat(evals): add eval metric and result models

* feat(evals): add evaluator registry

* feat(evals): add exact/regex/range/length evaluators

* feat(evals): add keyword/levenshtein/rouge/llm-as-judge evaluators

* feat(evals): add decision_scores composite evaluator

* feat(evals): add evaluation runner

* feat(evals): expose public API and module proxy

* fix(evals): resolve __all__ names and repair usage example

* docs(evals): add usage docs and changelog entry

* style(evals): tidy evaluator metadata and wiring comments

* fix(evals): honor expected arg and classify error metrics

* fix(evals): export get_evaluator and fix shared meta default

* fix(evals): guard provenance check against non-dict metadata

* docs: add objective layer design spec for semantica.evals

* docs: refine objective spec for consistency with AIP Evals semantics

* docs: add implementation plan for evals objective layer

* docs: fix plan tests to use module-level pytest import

* feat(evals): add per-metric objective support to runner

* docs(evals): document per-metric objectives

* docs(evals): fix minimize example threshold to demonstrate pass

* fix(evals): validate objective config shape strictly

* docs(evals): clarify objective examples and Boolean semantics

* fix(evals): honor direction-only minimize, fail fast on objectives, deep-merge case config

- minimize without threshold is now a no-op, matching maximize (issue #1091
  requires thresholds to be optional for both directions)
- objective config is parsed for every case before any target_fn/evaluator
  runs, so an invalid per-case objective rejects the run up front
- per-case evaluator config deep-merges over the global config so a case
  that overrides one setting keeps the run-level objective
- regression tests for all three, plus updated docs/CHANGELOG

Addresses 3 of 4 Qodo findings on #1092 (the 4th, 'result models defined
twice', is a false positive: types live in types.py)

* fix: finalize eval objectives review

---------

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-09-02 18:47:28 +05:30
Mohd Kaif 23126106a3 Merge pull request #1317 from semantica-agi/feat/weaviate-iter-all
feat(vector_store): add weaviate iter_all
2026-09-02 18:39:57 +05:30
Mohd Kaif 4b001b4c9d Merge branch 'main' into feat/weaviate-iter-all 2026-09-02 18:29:14 +05:30
KaifAhmad1 6c9eb2296d fix(vector_store): don't treat an empty weaviate page as end of scan
iter_all() unconditionally returned on any empty fetch_objects() page,
regardless of pagination mode. That's safe for offset/single_page (an
empty page there is a direct, unambiguous statement about live rows),
but not for cursor mode: `after` has no server-issued continuation
value of its own, it's derived client-side from the last object's uuid,
so an empty page gives nothing to advance it with. If Weaviate's cursor
walks internal storage position rather than strict uuid order, a batch
can in principle land entirely on a gap (e.g. tombstoned objects) with
live data past it -- the same risk already confirmed and fixed for
Qdrant's scroll cursor in #1316. Reproduced concretely against the
pre-fix code: a full page followed by an empty page followed by a page
with real data silently dropped that last page with no error raised.

iter_all() now falls back to offset pagination once when a cursor-mode
page comes back empty, rather than assuming that's the end. Offset
addresses live rows directly by position and has no equivalent gap, so
an empty page there (or in single_page mode) is trustworthy and still
ends the scan immediately.

Also updates test_iter_all_empty_collection_yields_nothing and
test_iter_all_requests_vectors, which needed a second empty page now
that a genuinely empty collection takes two calls (cursor, then the
confirming offset check) to report as such.
2026-09-02 17:43:52 +05:30
KaifAhmad1 1ad17beaf6 fix(vector_store): sync qdrant iter_all() with #1316's stall-guard fix
This branch was forked from an earlier commit of feat/vector-store-iter-all
(#1316), before that PR fixed a false-positive/silent-truncation bug in
QdrantStore.iter_all(): an empty scroll page with a still-advancing cursor
(e.g. a window landing entirely on tombstoned points) was treated as the
end of the collection instead of continuing. Syncing qdrant_store.py,
vector_store.py, and their tests to #1316's current tip (fa967983) so this
branch doesn't reintroduce the already-fixed bug once merged. Content-only
sync of the 4 shared files (verified via diff against origin/feat/vector-store-iter-all)
rather than a full branch merge, to avoid pulling in unrelated main drift
that has landed on that branch since this one diverged.
2026-09-02 17:39:42 +05:30
Zohaib Hassnain 110f6deb1e Merge pull request #1316 from semantica-agi/feat/vector-store-iter-all
feat(vector_store): add iter_all enumeration for cursor-based backends
2026-09-02 17:37:11 +05:30
Zohaib Hassnain b8299b1427 chore: clean it 2026-09-02 17:19:19 +05:30
Zohaib Hassnain fa967983e6 fix(vector_store): dedupe qdrant record conversion, don't abort iter_all on a live cursor with an empty page 2026-09-02 17:19:19 +05:30
Zohaib Hassnain bbd423c50a fix(vector_store): raise on stalled pinecone pagination instead of truncating 2026-09-02 17:19:19 +05:30
Zohaib Hassnain fcdad56893 making it clean 2026-09-02 17:19:19 +05:30
Zohaib Hassnain 6b36379f15 feat(vector_store): add pinecone iter_all 2026-09-02 17:19:19 +05:30
Zohaib Hassnain f2e7b9ed75 fix(vector_store): raise instead of truncating when a qdrant scan cannot advance 2026-09-02 17:19:19 +05:30
Zohaib Hassnain 5e80ebd837 fix(vector_store): drop qdrant migrate wiring, keep iter_all only
VectorStore cannot actually migrate to or from qdrant yet. _init_backend_store constructs QdrantStore without connecting or selecting a collection, so reads raise a Collection not initialized error, and the facade store_vectors dispatches only to add/add_vectors while QdrantStore exposes insert_vectors, so writes raise NotImplementedError.

Both are pre-existing facade gaps that nothing had exposed, since migrate previously only allowed faiss/sqlite/pgvector. Adding qdrant to the allowlist claimed support that does not work end to end, so it is removed along with the dimension inference that only fires for backends missing a .dimension attribute. Tracked separately; this PR keeps just the iter_all primitive.
2026-09-02 17:19:19 +05:30
Zohaib Hassnain d175f894a4 feat(vector_store): add iter_all enumeration and wire up qdrant migration 2026-09-02 17:19:19 +05:30
Mohd Kaif 3d32254b07 Merge pull request #1390 from semantica-agi/fix/security-scan-pip-audit-migration
fix(ci): migrate security-scan from Safety to pip-audit
2026-09-02 16:39:01 +05:30
KaifAhmad1 b5199ae6e3 fix(ci): handle pip-audit skipped dependencies, restore manual trigger, fix stale docs
Addresses review feedback on this PR:

- Guard 2 and the PR-comment JS parser both required every dependency
  in pip-audit's report to carry an array-valued `vulns` field. A
  dependency pip-audit can't resolve/audit is reported instead as
  {"name": ..., "skip_reason": ...} with no `vulns` key at all (see
  pip_audit._format.json.JsonFormat._format_dep) - a normal, documented
  shape, not a malformed one. That made a single unauditable package
  hard-fail the whole job and show "Invalid report structure" in the PR
  comment, reintroducing the same class of scan-unrelated CI break this
  migration was meant to fix for Safety. Both now accept skipped
  entries, treat them as zero vulns, and surface them explicitly (job
  log + PR comment) instead of silently dropping or crashing on them.
  Verified the fixed jq queries and JS parse logic against synthetic
  pip-audit report fixtures covering the normal, skipped, and malformed
  shapes.

- Restored a `workflow_dispatch` trigger on security-scan.yml. Deleting
  security.yml (which had it) left no way to manually run a dependency
  audit on demand.

- Updated SECURITY.md, which still described security.yml as a live
  scanning workflow and Safety as an active scanner after this PR
  deletes both.
2026-09-02 16:22:16 +05:30
Mohd Kaif db48f73755 Merge branch 'main' into fix/security-scan-pip-audit-migration 2026-09-02 16:01:17 +05:30
Zohaib Hassnain 07113d2d2d fix(ci): migrate security scan from Safety to pip audit 2026-09-02 15:21:36 +05:00
Shubham SrivastavaandSameer Kadam 909ccf0ded test: install extractor dispatch mocks per test, not at module scope (#1337)
The module assigned MagicMocks into sys.modules at import time and never
removed them. pytest imports every test module during collection before
running anything, so those mocks were live while later modules were
imported and each bound them into its own globals.

132 tests passed alone and failed in a full-suite run as a result. Full
suite goes from 199 failed / 5506 passed to 67 failed / 5638 passed.

A tearDownModule cannot fix this: collection has already finished by the
time it runs. The extractors resolve 'from .methods import
get_entity_method' lazily inside their methods, so the stand-in only has
to be in sys.modules while a test executes - it is now installed per test
via patch.dict in setUp and removed by addCleanup.

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-09-02 15:31:38 +05:30
Guofang.Tang fb69b033be fix(ontology): resolve endpoints in direct property inference (#1229)
The relationship-endpoint fix merged in #1170 covers the main ontology generation pipeline, but the public property-inference path still had the same gap.

`OntologyGenerator.infer_properties()`, and the `PropertyGenerator` it delegates to, fell back to `owl:Thing` for both domain and range when a relationship used entity IDs or aliases instead of explicit `source_type` / `target_type` values. The pipeline resolved those endpoints correctly, but the public API path did not.

This moves the existing alias-building and endpoint-resolution logic out of `OntologyGenerator` and into a shared `relationship_utils` module:

* `build_entity_aliases`
* `get_relationship_endpoint`
* `resolve_relationship_endpoint_type`

`OntologyGenerator` now uses those shared helpers instead of keeping its own copies.

`PropertyGenerator._infer_object_properties()` now also receives the entity list, builds the same alias index, and uses the shared endpoint resolver. This replaces the old fallback:

```python
rel.get("source_type") or self._infer_class_from_entity(...)
```

which could only fall back to `owl:Thing` because `_infer_class_from_entity()` never actually resolved an entity.

There are two small behavior changes from centralizing the logic. `build_entity_aliases()` now converts `entity_type` to `str` before adding it to the alias set, avoiding mixed-type alias values. `resolve_relationship_endpoint_type()` returns `None` rather than `""` when there is no usable explicit type, since an empty string isn't a meaningful endpoint type.

The new `test_public_infer_properties_resolves_id_endpoints` covers the broken public API path directly. It creates entities and ID-based relationships through `infer_classes()` / `infer_properties()` and verifies that the inferred `worksFor` property resolves to `Person` for the domain and `Organization` for the range instead of falling back to `owl:Thing`.

That exercises the same endpoint-resolution behavior already covered by the pipeline tests, but through the public entry point that was still missing it.
2026-09-02 14:26:13 +05:00
Guofang.Tang c10090dc9b fix(ci): fail closed on malformed Safety reports (#1366)
The Security Scan workflow already scans `requirements-ci.txt` directly, but malformed Safety output could still be treated as a clean scan. If the report existed on disk but `vulnerabilities` was missing, `null`, or the wrong type, the workflow could end up counting it as zero findings.

This adds a structural check immediately after the report is written. `vulnerabilities` must be an array; otherwise the step fails closed with a clear error instead of treating a broken report as a successful scan.

There was a related problem in the PR reporting path. The comment step already knew how to render an `Invalid report structure` warning, but that branch was effectively unreachable. In GitHub Actions, a custom `if:` is implicitly gated by `success()` unless it includes a status function such as `always()` or `failure()`. Once the Safety step exited non-zero, the Upload and Comment steps were skipped, so the warning could never be posted.

Fixing that required changing how Safety failures flow through the job rather than just adding another guard. The Safety step now uses `continue-on-error: true`, which lets Bandit and Semgrep continue running and allows the Upload and Comment steps to process the failed or malformed Safety result.

Because `continue-on-error` means the Safety step no longer carries the job's final failure signal itself, the workflow now tracks that state explicitly with `SAFETY_SCAN_STATUS`. It is set to `failed` at the start of the Safety step, before any validation runs, and changes to `passed` only when the report is valid and contains zero vulnerabilities.

That default-failed behavior covers every other exit path: a missing report, malformed `vulnerabilities` field, invalid vulnerability count, Safety failure, or an actual vulnerability finding all leave the status as `failed`.

A final `Enforce Safety Gate` step checks `SAFETY_SCAN_STATUS` and fails the job unless it is exactly `passed`. This keeps the same merge-blocking behavior while still allowing the rest of the security checks and reporting steps to run after a Safety failure.

This is a follow-up to #1356. The overlapping Safety behavior changes and duplicate pip-audit path from that PR were dropped after `main` picked up the canonical fix for the underlying `cuda-toolkit` crash. This change keeps only the report-validation hardening that remains independent of that fix.
2026-09-02 14:12:33 +05:00
Zohaib Hassnain 28c96c2539 fix(ci): update actions/deploy-pages pin to current v5 (v5.0.1) (#1387) 2026-09-02 13:59:42 +05:00
Zohaib Hassnain af829f5f20 fix(vector_store): don't let iterator close() mask the real scan error, dedupe milvus result shaping, split unavailable/uninitialized messages 2026-09-01 23:09:53 +05:00
Zohaib Hassnain 930e7f9b71 docs(vector_store): note the milvus schema assumption 2026-09-01 23:09:53 +05:00
Zohaib Hassnain e335971dcd feat(vector_store): add milvus iter_all 2026-09-01 23:09:53 +05:00
Zohaib Hassnain 78682076d5 fix(vector_store): raise before yielding on a stalled weaviate cursor, extract v4 dict vectors, dedupe fallback ladder 2026-09-01 23:03:07 +05:00
Zohaib Hassnain 1227947be5 fix(vector_store): dedupe qdrant record conversion, don't abort iter_all on a live cursor with an empty page 2026-09-01 22:51:32 +05:00
Zohaib Hassnain b4a14d87f5 making it clean 2026-09-01 22:51:32 +05:00
Zohaib Hassnain 3bf89e523f fix(vector_store): raise instead of truncating when a qdrant scan cannot advance 2026-09-01 22:51:32 +05:00
Zohaib Hassnain 2b5b62bb8d fix(vector_store): drop qdrant migrate wiring, keep iter_all only
VectorStore cannot actually migrate to or from qdrant yet. _init_backend_store constructs QdrantStore without connecting or selecting a collection, so reads raise a Collection not initialized error, and the facade store_vectors dispatches only to add/add_vectors while QdrantStore exposes insert_vectors, so writes raise NotImplementedError.

Both are pre-existing facade gaps that nothing had exposed, since migrate previously only allowed faiss/sqlite/pgvector. Adding qdrant to the allowlist claimed support that does not work end to end, so it is removed along with the dimension inference that only fires for backends missing a .dimension attribute. Tracked separately; this PR keeps just the iter_all primitive.
2026-09-01 22:51:32 +05:00
Zohaib Hassnain 3a0f3f672a feat(vector_store): add iter_all enumeration and wire up qdrant migration 2026-09-01 22:51:32 +05:00
Zohaib Hassnain bd1ba24b24 fix(vector_store): carry weaviate offset fallback across pages, raise on truncation 2026-08-31 13:22:23 +05:00
Zohaib Hassnain e8ff36f088 feat(vector_store): add weaviate iter_all 2026-08-31 03:01:37 +05:00
Zohaib Hassnain ec9e63e16f fix(vector_store): drop qdrant migrate wiring, keep iter_all only
VectorStore cannot actually migrate to or from qdrant yet. _init_backend_store constructs QdrantStore without connecting or selecting a collection, so reads raise a Collection not initialized error, and the facade store_vectors dispatches only to add/add_vectors while QdrantStore exposes insert_vectors, so writes raise NotImplementedError.

Both are pre-existing facade gaps that nothing had exposed, since migrate previously only allowed faiss/sqlite/pgvector. Adding qdrant to the allowlist claimed support that does not work end to end, so it is removed along with the dimension inference that only fires for backends missing a .dimension attribute. Tracked separately; this PR keeps just the iter_all primitive.
2026-08-31 03:00:56 +05:00
Zohaib Hassnain 274d5d1195 feat(vector_store): add iter_all enumeration and wire up qdrant migration 2026-08-31 02:34:26 +05:00
42 changed files with 3883 additions and 645 deletions
+3 -3
View File
@@ -26,7 +26,7 @@ each file's own autogenerated header comment for its exact command).
| File | Used by | Installs |
| --- | --- | --- |
| `bootstrap.txt` | security.yml, security-scan.yml, benchmark.yml | pip, setuptools (upgrade before anything else) |
| `bootstrap.txt` | security-scan.yml, benchmark.yml | pip, setuptools (upgrade before anything else) |
| `pep517-build.txt` | ci.yml, benchmark.yml, Dockerfile | exact `[build-system] requires` from `pyproject.toml` (setuptools, wheel) - installed with `--no-build-isolation` before any `pip install -e .` / `pip install .`, since `--no-deps` alone doesn't stop pip's PEP 517 build isolation from fetching those two *unhashed* |
| `explorer-extra-py311.txt` | ci.yml | semantica's base deps + the `explorer` extra, resolved for python 3.11 |
| `explorer-extra-py313.txt` | Dockerfile | the same, resolved for python 3.13 (the image's actual interpreter) |
@@ -34,8 +34,8 @@ each file's own autogenerated header comment for its exact command).
| `uv-tool.txt` | ci.yml | uv, to verify requirements-ci.txt is current |
| `build-tools.txt` | ci.yml, release.yml | build, wheel |
| `twine.txt` | release.yml | twine |
| `pip-audit.txt` | security.yml | pip-audit |
| `security-scan-tools.txt` | security-scan.yml | safety, bandit, semgrep, jq |
| `pip-audit.txt` | security-scan.yml | pip-audit |
| `security-scan-tools.txt` | security-scan.yml | bandit, semgrep, jq |
| `base-deps.txt` | benchmark.yml | semantica's base deps (no extras) |
| `benchmark-extra.txt` | benchmark.yml | the benchmark-only libs (neo4j, pdfplumber, etc.) |
@@ -1,4 +1,3 @@
safety==3.8.1
bandit==1.9.4
semgrep==1.175.0
jq==1.12.0
+3 -308
View File
@@ -1,9 +1,5 @@
# This file was autogenerated by uv via the following command:
# uv pip compile .github/requirements/security-scan-tools.in --generate-hashes --python-version 3.11 --python-platform linux -o .github/requirements/security-scan-tools.txt
annotated-doc==0.0.5 \
--hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \
--hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb
# via typer
annotated-types==0.8.0 \
--hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
--hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
@@ -24,10 +20,6 @@ attrs==26.1.0 \
# jsonschema
# referencing
# semgrep
authlib==1.8.0 \
--hash=sha256:88aebbd9af6757e14e912d5dc007ae1dc1f3e27e3b2152ce7c552ee2c3b3c121 \
--hash=sha256:f3ecd5f1da737262fb53bf1a4d95c4ea1ad9dd509316587a255c99ab1838a4f0
# via safety
bandit==1.9.4 \
--hash=sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628 \
--hash=sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e
@@ -50,7 +42,6 @@ certifi==2026.7.22 \
# httpcore
# httpx
# requests
# safety
cffi==2.1.1 \
--hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \
--hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \
@@ -332,19 +323,12 @@ click==8.4.2 \
--hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
# via
# click-option-group
# nltk
# safety
# semgrep
# typer
# uvicorn
click-option-group==0.5.9 \
--hash=sha256:ad2599248bd373e2e19bec5407967c3eec1d0d4fc4a5e77b08a0481e75991080 \
--hash=sha256:f94ed2bc4cf69052e0f29592bd1e771a1789bd7bfc482dd0bc482134aff95823
# via semgrep
cloudpickle==3.1.2 \
--hash=sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414 \
--hash=sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a
# via joblib
colorama==0.4.6 \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
@@ -396,20 +380,7 @@ cryptography==50.0.1 \
--hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \
--hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \
--hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef
# via
# authlib
# joserfc
# pyjwt
defusedxml==0.7.1 \
--hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \
--hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
# via nltk
dparse==0.6.4 \
--hash=sha256:90b29c39e3edc36c6284c82c4132648eaf28a01863eb3c231c2512196132201a \
--hash=sha256:fbab4d50d54d0e739fbb4dedfc3d92771003a5b9aa8545ca7a7045e3b174af57
# via
# safety
# safety-schemas
# via pyjwt
exceptiongroup==1.2.2 \
--hash=sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b \
--hash=sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc
@@ -418,10 +389,6 @@ face==26.0.1 \
--hash=sha256:8183d94bc248baaea855a9f8445f97a22a9988908e60abddccc6e251da77c4c6 \
--hash=sha256:ab0a83c37c9789dce658a67a9a80eafaa113c9ec37c5a9d950ff5480542a062d
# via glom
filelock==3.32.4 \
--hash=sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd \
--hash=sha256:2bde2e4cf732e0153406d8a7bc80620ecf5e621fe0d25e41143c4e3b4733ff30
# via safety
glom==25.12.0 \
--hash=sha256:1ae7da88be3693df40ad27bdf57a765a55c075c86c971bcddd67927403eb0069 \
--hash=sha256:b9f21e77f71a6576a43864e85066b8cc3f0f778d0d50961563f8981377a6dcb1
@@ -443,9 +410,7 @@ httpcore==1.0.9 \
httpx==0.28.1 \
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
# via
# mcp
# safety
# via mcp
httpx-sse==0.4.3 \
--hash=sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc \
--hash=sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d
@@ -461,18 +426,6 @@ importlib-metadata==8.7.1 \
--hash=sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb \
--hash=sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151
# via opentelemetry-api
jinja2==3.1.6 \
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
# via safety
joblib==1.6.0 \
--hash=sha256:2ccc96785b12046c08fd6d55839c12857831b54a3c1673ffadd2f04bfc4eda03 \
--hash=sha256:3dbbf9f6e4b592a2357b854608e980fe6390d131d7a82f011a377ef2ebef7aba
# via nltk
joserfc==1.7.5 \
--hash=sha256:add2c2c84e8373b084d526a8b53daba5d7a513a118cd2dcd9fc9f979d0922159 \
--hash=sha256:d5ff536e658e17664f8c1b1ab60dc4aa62aa973fcef1edd33cc44bda45d6f5ea
# via authlib
jq==1.12.0 \
--hash=sha256:02112ca560f90c6b1ea31829bb7777fbc5b1f1d13f78b2c6ce5cefa8233cee7e \
--hash=sha256:067ea0d3ee2cd7f7ba9c5d5c1925b9b0f83e1869c97a65ef11d8d76bd91ece6e \
@@ -549,101 +502,6 @@ markdown-it-py==4.2.0 \
--hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \
--hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a
# via rich
markupsafe==3.0.3 \
--hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \
--hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \
--hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \
--hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
--hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
--hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
--hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
--hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
--hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \
--hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \
--hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \
--hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
--hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \
--hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \
--hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
--hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
--hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \
--hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
--hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
--hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \
--hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \
--hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \
--hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \
--hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
--hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \
--hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
--hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
--hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
--hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
--hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
--hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \
--hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
--hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \
--hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \
--hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
--hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \
--hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
--hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
--hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
--hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \
--hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \
--hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
--hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
--hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \
--hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
--hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
--hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
--hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
--hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
--hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \
--hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
--hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \
--hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
--hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \
--hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \
--hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \
--hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \
--hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \
--hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \
--hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \
--hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
--hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
--hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
--hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \
--hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \
--hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
# via jinja2
marshmallow==4.3.1 \
--hash=sha256:e65accfbe277546df92ed7996a678c90e063e9a7c2a2f5e03f7d0b90e3768c42 \
--hash=sha256:fb6b8048af08d4ab061610d5b7d3696a7e4c95337dbda880edb9f95812cabc20
# via safety
mcp==1.29.0 \
--hash=sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36 \
--hash=sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7
@@ -652,10 +510,6 @@ mdurl==0.1.2 \
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
# via markdown-it-py
nltk==3.10.3 \
--hash=sha256:bb9327a461c3811c2fa4900e03840401f2126adfb30c0072827c433bd2444ea4 \
--hash=sha256:ff9598a8e20518ee0d557745890cc4435b9578489e2dcbc69c4f81fa060caf7c
# via safety
opentelemetry-api==1.37.0 \
--hash=sha256:540735b120355bd5112738ea53621f8d5edb35ebcd6fe21ada3ab1c61d1cd9a7 \
--hash=sha256:accf2024d3e89faec14302213bc39550ec0f4095d1cf5ca688e1bfb1c8612f47
@@ -716,10 +570,7 @@ packaging==26.3 \
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
# via
# dparse
# opentelemetry-instrumentation
# safety
# safety-schemas
# semgrep
peewee==3.19.0 \
--hash=sha256:de220b94766e6008c466e00ce4ba5299b9a832117d9eb36d45d0062f3cfd7417 \
@@ -749,8 +600,6 @@ pydantic==2.13.5 \
# via
# mcp
# pydantic-settings
# safety
# safety-schemas
pydantic-core==2.46.5 \
--hash=sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942 \
--hash=sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821 \
@@ -976,122 +825,6 @@ referencing==0.37.0 \
# via
# jsonschema
# jsonschema-specifications
regex==2026.8.31 \
--hash=sha256:0087dfa879bf01c5eb290848c7de22f717d8d4218a997080e63ae4813bc55104 \
--hash=sha256:026a7cd6c20a2a5bf3249a4a1c7f076af86b17188e2ffd17722e2ed24f433f9a \
--hash=sha256:073b9cb8c44e197a4d1d8b819a3329f6b20866d83d2700f78b9d33e1f1a75116 \
--hash=sha256:0abb98dd76a3ffe3b401fe93aadac135ecd6ba4a71d7b4be4a333de8d691e834 \
--hash=sha256:0bb6121dbf90c7de42610459398a81cbb90bc870e2cc003248f3f2b65d45f2b6 \
--hash=sha256:0ec77a1ce2350c74fe3821d1c6555107d41f6969c369f4ee197a10cec97632ec \
--hash=sha256:0ee80c5d20a62ae819f39a4f5b0c7f1dbbeb28186de6138840eb8c138e96f99e \
--hash=sha256:13f036b42889e8cad5f1ee2eadb48c656b2f44c5944035e0f697cb6ef81757ba \
--hash=sha256:15e9e862c6e905ef66ea5f019deb5ac5fdeebf8fc134ea4c7b5d5c2eb7bdcdd8 \
--hash=sha256:18ac65e72e8454343df30ca1d8a4ad604d3419b96e0ef8e2dc3a69642bb557b4 \
--hash=sha256:18c7e0348286f5073867d339d7cab60ed200b77b48d7a9be4edbcdc2c996a62b \
--hash=sha256:1930ade186f2b519fe9c4bdfd3a77410e469bd91423a995888b91f3beb12679b \
--hash=sha256:1e74e38c5a9ed3a70a0e0a89498eb664211b97c162d77b1131f37636779f36b4 \
--hash=sha256:222c906a555bdbd5322f15778bb2b4f238c26e1d52c9445f1e50f5e4452909b3 \
--hash=sha256:241c614ab811e29f2e67e2828404dd10a2dc675ec2c75a6017ec310fd09117b9 \
--hash=sha256:26a6ddc85198558b0c74b856f6440132d6f97248c22589bf52cf13df2fa44fdc \
--hash=sha256:2c5f4fc5463ac732ed49cb87ffdf2eab3d909a0df4100211ce4be3af1ad729cb \
--hash=sha256:2d28ad9d016ac681843b059ddca376b9ff833ec218c938035d925c8af44c6de7 \
--hash=sha256:34c8d36a5f70c16e3f406ae1c93a47ea4b2a40e29b02639cf41915b6fea5ce26 \
--hash=sha256:360c916117c988b120ba05aa106cd3c1aa7c0f4575a2db0d605d502b4ee334f4 \
--hash=sha256:38179404d70581402831c2c0de0c8ec3483d272beab2244095cb09b4eeb30ef7 \
--hash=sha256:3b3a020f2a43e9016624047ecc15cd0d472c11dfbe4d12fe030f574570467f35 \
--hash=sha256:3e139e792b016a614b9af4a43e036b259a8d32f751e9b5bda77b4af652ad8a17 \
--hash=sha256:40f4cdf6d38663cf8f56a52edde25ca6dbfb857f5a7d49cd7de3e0e1a0883bf4 \
--hash=sha256:4301de5a58a28fe95b6a865d3b97b5cea073bb4c6ad743211c32b004f32d5096 \
--hash=sha256:43581e1f0c1f624cb7e2e8195c443f6e3004fc376bd12d644cdc8e613c973323 \
--hash=sha256:453e9ffb310eede3f35303d7fb2e891382c98888d54f162e5a2e0174d1b75331 \
--hash=sha256:45537c0d48a84dd0f840ea7c308445ad1e83a04d28d6fc394d71ad24f9f55d2b \
--hash=sha256:45b0450d6ae52e2dfcdb5e58987b829ed5fc01b709fc5ff09a1e81ab13c5262a \
--hash=sha256:4c3ac1eec883a1d0fbba167e90bb1beb72289e765966b464f9b333090dfcae2e \
--hash=sha256:50a8677cca3d4df536776380161744d41ea5001f99cc2c4638e6b0625839fa61 \
--hash=sha256:520b14582a59f43ba9ba595938349e70238009f8deb8c35d5bbfe33e44fd0ba9 \
--hash=sha256:52f03cd8f259d8fb482a9e142ad17c8d1c931a69a7a932922f2222df05875d59 \
--hash=sha256:56f7516b00f720231b26fdcd41ac13cceab7a8c1c903b1ab98e173b0962a771d \
--hash=sha256:66df1812cf0fd5f0f59e4341c54247a15397354ee01231e1c2620b08032f3361 \
--hash=sha256:69c42c35758cf46c31d976d63c79fbbcb114fe192aa4c721c734204d0e3d7555 \
--hash=sha256:69fbc60c1c34790037cfd350dd1600436fdfea9ca221761c614fc5e633c7cabd \
--hash=sha256:6d5537087013e5ce841b9d0f19a564f18f33fa79489a7e8865f5a38ba2a4de7d \
--hash=sha256:6d5c9841dd924437e34d43bdbecbb31bc1a01c57bd974af8e1a0a98b0a7a731c \
--hash=sha256:6fcbf68a10dd6a564c737147e013e5dea6180c032e3c363629cf4d0f9d258752 \
--hash=sha256:7010dae7e7064ee091703cafce0143693e56931bb3d21a82483bb96ad8a37751 \
--hash=sha256:722c2dba81c28494dae77f06c0fd33f0ad215e1b7cc6e2b0f3bad36656413f84 \
--hash=sha256:75b888caf9469df3826876ae0e2f92f37e7bbad0455cfa028852d99815af9dd0 \
--hash=sha256:75cc2d43987040df8655c25b47c1d452c7d59b28df108d7b2c19a003d021601f \
--hash=sha256:79c7b6bd11620dc722a94e160965fa0e64124ca8841afaf9683d8fa659431cf5 \
--hash=sha256:7aa0688964b66ac50e2bf3b04b9e88bdab58fa5ea8130b403d72668df6f54cb9 \
--hash=sha256:7c06a4cbe33f8ad72c3bd9590630c07e55c7a7c581253d287b6ca645e2879051 \
--hash=sha256:7daf31011e73c16f8b824bc6a6992f0de8a9ae13133001d757668c852bcc6502 \
--hash=sha256:81391983ff052f922baebb0955a3be455d5731351b3a93e0638a8150bd44b8b5 \
--hash=sha256:8231dfdbb4baf59d35a10fc1115846bdcc43b30ab6ec8809ec807bfeea48a119 \
--hash=sha256:861a12bd9e8d3f26a9a36cc1b3426edacc70395b2e4f37c1402f40345e9c06db \
--hash=sha256:868d9113a744f2bfffa31197cadcda5b7fc3951a8621dd5899f9c0e4208ca196 \
--hash=sha256:897c2e301226fdfaf1a0c68219607718c40699df82dff09fd366b489b4c6e6d8 \
--hash=sha256:8b6bcc66372b493faa2b6153cd16a44db3bfa316411f81c4ba5d0ffa693244df \
--hash=sha256:8b7f1bdf1f36555fa0317f4f6cbbd5312f886edf9f2a41c8c298ffb9ad9f4a1a \
--hash=sha256:8d3e98b55372aa36b1e046a56a10f13cf0ef782ad6c86dbd64f3897c7e7a7a02 \
--hash=sha256:91a478b9a76b7f2b4cc704ec5f438041012ae7914716f8de0d56c11c9706203f \
--hash=sha256:9350fd448a6442ae27853ab9d4b8d5a0bcb6d7774923a4fdfddd104c4458b35f \
--hash=sha256:95c25f91b7c3f8121946e175a731eccf097dfeff065ab1204dbaad1ebf8ada6e \
--hash=sha256:976c265b3a42b806cf58afd3c5a64417e1bbd804289bf4abd38ea7395623531d \
--hash=sha256:98183eb943ebcd2e89fd9fcb4103bfafc5369cff9479561a5c96de2fe90cae68 \
--hash=sha256:98381539ee2dd88794f3ce6e40166f59b93e6e3ee9cd27dea9f2dd6b857f3dbc \
--hash=sha256:9a991b561615498877b042b13a788cc2f33c99087a9540627c397037c58ae795 \
--hash=sha256:9acbc6901bea11ad2f21d32b0790cbe2cb0194b521ea239231e1ee9627efd585 \
--hash=sha256:9b9e48a4ae2378c7bb29df0cbe2426cf0929ddbbae5819225c1fe133e6bb368d \
--hash=sha256:9fe2540d8da1bbf12f7c1b909a9ae47c2b343fa2a2084280c21ead1c9fb0e6f7 \
--hash=sha256:a1c9cd392daa08d3a3d5b663443a08071f4efbc1476f902142d51a229c60e852 \
--hash=sha256:a54f6b1b418e40b908ff9b9dd3e5fa638a2bd1bbe6e24180dc097c92b1deed0f \
--hash=sha256:a55bfb3914b760d5103d313a1053d301b2776f4677eb7f4d09f6420c625d97dd \
--hash=sha256:a679703a46574dcfbbae42acbc538d37653fa78dd2a3826f27c2dab386ea194d \
--hash=sha256:a75efe8109ebfaa5574aff49882fe471287ecb7959d96d29660cec937e5af1ce \
--hash=sha256:aac83eab8d47e3c290b9d30a34f94e3d888b7dd42f7cc45b8d204154cec3017b \
--hash=sha256:abd6b935adcd6c19733f20080a85972c6199cc9599dd8d16c9bbd1bbada569d8 \
--hash=sha256:aea17d86e7581e589fb8c43b70dc5f6588b1897390442536697a551bc66e2fd6 \
--hash=sha256:b40aee7f8df89d239943a932bfb53809f6b2c2ad53c049ee329100a54d3e1cfd \
--hash=sha256:b94165c6b98404ca40838852febd60df4fa6380dc0898f28dedaf5fca638e7ca \
--hash=sha256:bb1ca9e722c7270fb4267abee42cf8cfa97bc8e361b73839a50f00fcd2b76636 \
--hash=sha256:bb392c55059edb1bda593ee12218f5198a337535ff5e52f806c224c57b98716b \
--hash=sha256:bc00f39b7201fca5a15f12580f9dfb84b226323ad24043ec71b1132b5dbab711 \
--hash=sha256:bdbc6e87c9868ab2e7f29eed32b04583420df1b9b19e718f212e140c01f8b026 \
--hash=sha256:c01865f6a72c776064e4f58030e59f925e5fef32066aab3cb1a97be191f7bdd1 \
--hash=sha256:c72238cc48cd020f415e9dd3cba6c6b1af559d613358d282f7957cf61f0bcf6b \
--hash=sha256:c7ffcdf6fe74cedd4e36a9de2fb072b526a978e9b2d4fd2431edca96d80a67cd \
--hash=sha256:c9ba0b56ca6547e238323452178e5d9889886c99cdd17a4333d026f3c84471c5 \
--hash=sha256:c9c7a13d018f4f84503986564a543c2f7657a4bec4895f2c2cc584fb09d7429b \
--hash=sha256:caa959da9bb21394131eaf5c57698b47926ebada98c6796cfb4e754a52de001f \
--hash=sha256:cf427a3bebc873a2601601fc5e8453d1396b52d694ad65788fa2b22fe7b0f920 \
--hash=sha256:cf6c32d2a6bdaac692915ab81f28b62525d937abeac80149260db2c904a5df97 \
--hash=sha256:d27a3bdd19aa00974ac53ba14faea80ecef412f2d957c0071a869d7baea820f4 \
--hash=sha256:d59beef8054a851b2a3f42f56f94770981973699ab4c7f0b5f6984c26205b76c \
--hash=sha256:d84db4aaf4b5c5c4d512ce06420850c909865fa7d6223081dc8e9dbde7a83754 \
--hash=sha256:d9759f4cc91880cfafdb11b7b2bc83e34f2f16d103fd94f936d804cbfdb9c1aa \
--hash=sha256:dacc364aa1c06cb3fffb1705ff313cb3622c94d8c248f29e57bac2acadd77bf7 \
--hash=sha256:dbed5cea80c5a67c3f95f16d011d68174eb81a5efccf87a3ad0822b79d74baae \
--hash=sha256:deab998bd9314f7e93f519d3f62f1fd9e83a2db654f579cadac3968fbc1b5976 \
--hash=sha256:def853717c37661f59942c76ad06e060630f6e297257bcfb6f203d2daf497d41 \
--hash=sha256:dfc722cb60e40e6fefa483a7583baa4af55ac87babb5ecfc8989e54e5e182d1d \
--hash=sha256:e169081d7ae955f4bd1a590a7ec29f1032eae6889539cf7047bd0f7b09daedc9 \
--hash=sha256:e5578ad134fa81286622faff397650cfa2249f640af783b8c2abbae1c70dacdd \
--hash=sha256:e67af1dcebc0663cd90253cfb4653f991d0995160ec9ca3132924d7956e17c6e \
--hash=sha256:ebe363e5c252dc9011b0380c9b0b8ef559573dcc325ec8f3165129d21af10b63 \
--hash=sha256:ec9a66ed2ed23611dcfaa87a860f1511a56ded56f01dd161eeebddb6e25590c3 \
--hash=sha256:ed723dc78dd6f676f38083bd86194dbe91befd8c3ecb9cd2f47147bfe7d26dd1 \
--hash=sha256:ed865d560365bb3797e4e05dcbd83fb7a045893cc54f0d72588f90eb05c68fee \
--hash=sha256:efefb4c85414b6e4be19a53f90d58b573f551b7e4d1dc1e566f7030b6ca4fa8f \
--hash=sha256:f078f774d094ea32302163419141fda36176b954069956296406ae1cf4b00222 \
--hash=sha256:f2ecb87363dd9e13fa9def0a5c7a61ef5ccc952c08b99672e6f95fdb2463ccd9 \
--hash=sha256:f59d36c5356ca6ff79b1a91ef39845c0dd71eeee6b98d71cd0972307eba77260 \
--hash=sha256:f696d058d233923b7259d2d963f92b9cf2906063820f27cbd4085529d78861c3 \
--hash=sha256:f69c363342b81fce87f2e9dafd05ec041b67ee3b74c08ee9d2be5aeab8d484da \
--hash=sha256:f8b784a28492f4020dc90ef6b6d0bb3ca591cb1331de6362968308ed5243b550 \
--hash=sha256:f9594423bace86d47d080ae92329315b977fe6466ac998e36a88563c9c6d0259 \
--hash=sha256:fb7df717e6c9f2b59aebdf558242da87b2b5cd5961b9469efe8f01762dfe4cc1 \
--hash=sha256:ff7cc959f3535028c03c201bbe6703ce1cb5051164f08bca9f814e04333fbb48
# via nltk
requests==2.34.2 \
--hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
--hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
@@ -1104,7 +837,6 @@ rich==15.0.0 \
# via
# bandit
# semgrep
# typer
rpds-py==2026.6.3 \
--hash=sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5 \
--hash=sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680 \
@@ -1228,10 +960,7 @@ rpds-py==2026.6.3 \
ruamel-yaml==0.19.1 \
--hash=sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93 \
--hash=sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993
# via
# safety
# safety-schemas
# semgrep
# via semgrep
ruamel-yaml-clib==0.2.15 \
--hash=sha256:014181cdec565c8745b7cbc4de3bf2cc8ced05183d986e6d1200168e5bb59490 \
--hash=sha256:04d21dc9c57d9608225da28285900762befbb0165ae48482c15d8d4989d4af14 \
@@ -1295,14 +1024,6 @@ ruamel-yaml-clib==0.2.15 \
--hash=sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51 \
--hash=sha256:fe239bdfdae2302e93bd6e8264bd9b71290218fff7084a9db250b55caaccf43f
# via semgrep
safety==3.8.1 \
--hash=sha256:953c1c3c60c873f53a6cc250b2a9c4b38bb6ef45f0625990e43f20bff916c965 \
--hash=sha256:e646123b976bbb6707cfaacae8c926e2f886b744a60e0f410e8610a3a4eaf7be
# via -r .github/requirements/security-scan-tools.in
safety-schemas==0.0.16 \
--hash=sha256:3bb04d11bd4b5cc79f9fa183c658a6a8cf827a9ceec443a5ffa6eed38a50a24e \
--hash=sha256:6760515d3fd1e6535b251cd73014bd431d12fe0bfb8b6e8880a9379b5ab7aa44
# via safety
semantic-version==2.10.0 \
--hash=sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c \
--hash=sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177
@@ -1317,10 +1038,6 @@ semgrep==1.175.0 \
--hash=sha256:e8b14c91558f765b9dd155a99b0071bfe64f61577cda8eb4964132155232c1af \
--hash=sha256:e8ecd7ee8ef1033c9635111c6e162e778834d61416968c5c1c5e7b7fba35c34e
# via -r .github/requirements/security-scan-tools.in
shellingham==1.5.4 \
--hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 \
--hash=sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de
# via typer
sse-starlette==3.4.8 \
--hash=sha256:6e82314c786709a3cd9520f2285cf9fff90e181e598e8a357b0cf80f66afba0d \
--hash=sha256:ed89ffbb75cbf78a5fe2f2109cd584792ee7f9dfac96f791db546df8f15f3f9c
@@ -1335,10 +1052,6 @@ stevedore==5.9.1 \
--hash=sha256:5c8ff3a9f336cc1a06ac0f597bc79d11a2f950bfd32e290ca56b5a301fafafbf \
--hash=sha256:e97a2667923efda926e8713fde6a73616df68210a3cbc6f02b48967b676fd8bf
# via bandit
tenacity==9.1.4 \
--hash=sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55 \
--hash=sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a
# via safety
tomli==2.4.1 \
--hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \
--hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \
@@ -1388,22 +1101,6 @@ tomli==2.4.1 \
--hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \
--hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049
# via semgrep
tomlkit==0.15.1 \
--hash=sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304 \
--hash=sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97
# via safety
tqdm==4.70.0 \
--hash=sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220 \
--hash=sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953
# via nltk
truststore==0.10.4 \
--hash=sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301 \
--hash=sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981
# via safety
typer==0.25.1 \
--hash=sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89 \
--hash=sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc
# via safety
typing-extensions==4.16.0 \
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
@@ -1417,8 +1114,6 @@ typing-extensions==4.16.0 \
# pydantic
# pydantic-core
# referencing
# safety
# safety-schemas
# semgrep
# starlette
# typing-inspection
+1 -1
View File
@@ -65,4 +65,4 @@ jobs:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5
+145 -84
View File
@@ -3,6 +3,7 @@ name: Security Scan
on:
schedule:
- cron: '30 1 * * 1,4' # Mon/Thu 7 AM IST
workflow_dispatch:
push:
branches: [main]
paths-ignore:
@@ -45,94 +46,100 @@ jobs:
- name: Install dependencies
run: |
pip install -r .github/requirements/bootstrap.txt --require-hashes
# Install the pinned dependency set FIRST so Safety scans Semantica's
# exact CI/release dependency tree (requirements-ci.txt is generated
# from pyproject.toml extras, so this covers the project's real deps).
# Install the pinned dependency set FIRST so pip-audit scans
# Semantica's exact CI/release dependency tree (requirements-ci.txt
# is generated from pyproject.toml extras, so this covers the
# project's real deps).
pip install -r requirements-ci.txt --require-hashes
# Tooling AFTER the pinned set: installing safety/bandit/semgrep/jq
# first lets the pinned requirements overwrite their transitive deps
# (e.g. rich), which breaks the safety CLI at runtime.
# Tooling AFTER the pinned set: installing it first would let the
# pinned requirements overwrite the tooling's own transitive deps.
pip install -r .github/requirements/pip-audit.txt --require-hashes
pip install -r .github/requirements/security-scan-tools.txt --require-hashes
- name: Run Safety Check (Package Vulnerabilities)
- name: Run pip-audit (Package Vulnerabilities)
continue-on-error: true
run: |
# NOTE: Safety 3.x repurposed --output to select a console format
# (json/text/screen/...), not a file path. Writing JSON to a file
# now requires --save-json; the previous `--output safety-report.json`
# usage was silently invalid and never produced a report.
#
# Scan requirements-ci.txt directly instead of the installed environment
# to avoid crashes from packages like cuda-toolkit that Safety cannot
# parse. This also ensures we're auditing the declared dependency tree
# rather than transitive dependencies of the security tooling itself.
safety check --file requirements-ci.txt --save-json safety-report.json || true
# Keep publishing reports and the PR comment even when the audit
# gate fails. The final gate below preserves the failure status.
echo 'AUDIT_SCAN_STATUS=failed' >> "$GITHUB_ENV"
# Guard 1: fail loudly if Safety exited before writing a report at all
# (network error, API auth failure, tool crash). Without this check a
# missing or empty file causes jq to fall back to "0", making a broken
# Same dependency tree Safety used to scan, and the same tool and
# invocation already proven reliable in security.yml.
pip-audit -r requirements-ci.txt --format=json --output=pip-audit-report.json || true
# Guard 1: fail loudly if pip-audit exited before writing a report
# at all (network error, tool crash). Without this check a missing
# or empty file causes jq to fall back to "0", making a broken
# scanner indistinguishable from a clean scan.
if [ ! -s safety-report.json ]; then
echo "::error::Safety scan produced no report (safety-report.json is missing or empty). Treating as failure — check for network errors, API auth failures, or Safety crashes in the logs above."
if [ ! -s pip-audit-report.json ]; then
echo "::error::pip-audit produced no report (pip-audit-report.json is missing or empty). Treating as failure — check for network errors or pip-audit crashes in the logs above."
exit 1
fi
# Guard 2: fail closed when the report doesn't have the shape the
# checks below assume: a non-empty dependencies array, each entry
# either carrying an array-valued vulns field or being a dependency
# pip-audit couldn't resolve/audit, which it reports as
# {"name": ..., "skip_reason": ...} with no vulns field at all
# (see pip_audit._format.json.JsonFormat._format_dep). That's a
# normal, documented report shape, not a malformed one — treating
# it as invalid would fail the whole job over a single unauditable
# package, the same kind of scan-unrelated CI break this migration
# away from Safety was meant to fix.
if ! jq -e '
(.dependencies | type == "array" and length > 0)
and all(.dependencies[]; type == "object" and ((.vulns | type == "array") or (.skip_reason | type == "string")))
' pip-audit-report.json >/dev/null 2>&1; then
echo "::error::pip-audit report has an invalid dependency structure. Expected a non-empty dependencies array where every entry has either a vulns array or a skip_reason. Treating as failure."
exit 1
fi
echo "Checking for package vulnerabilities..."
# Guard 2 above already confirmed pip-audit-report.json is valid
# JSON with a well-shaped dependencies array, so this count is
# always a plain non-negative integer.
SKIPPED=$(jq '[.dependencies[] | select(has("skip_reason"))] | length' pip-audit-report.json)
if [ "$SKIPPED" -gt 0 ]; then
echo "⚠️ pip-audit could not audit $SKIPPED dependencies (see pip-audit-report.json for skip_reason):"
jq -r '.dependencies[] | select(has("skip_reason")) | " - \(.name): \(.skip_reason)"' pip-audit-report.json
fi
# Vulnerability IDs reviewed and accepted as non-actionable for this
# project. Filtered out here with jq rather than passed to Safety's
# own --ignore flag: --ignore crashes ("Unhandled exception happened:
# 'cuda-toolkit'") when it has to apply itself against a live-matched
# vulnerability for cuda-toolkit, apparently the same class of
# unguarded dependency-graph lookup that broke the plain environment
# scan (see git history on this file). The un-ignored scan above is
# the one path confirmed - by an actual CI run - not to crash even
# with a live cuda-toolkit match, so all filtering happens after the
# fact in jq instead of inside Safety.
#
# - SFTY-20260120-40557 (CVE-2025-33228): cuda-toolkit<13.1.0. torch
# 2.13.0 (latest available; no newer release exists) hard-pins
# cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,
# cusparse,nvjitlink,nvrtc,nvtx]==13.0.3 on Linux - not a version we
# control. The CVE is OS command injection in NVIDIA Nsight
# Systems' gfx_hotspot recipe (process_nsys_rep_cli.py), requiring
# manual invocation with an attacker-supplied string; unreachable
# from Semantica, and Nsight Systems isn't among the extras torch
# requests above. Re-evaluate once torch pins a patched
# cuda-toolkit.
#
# - SFTY-20260723-60537 (CVE-2026-65918): torchvision<=0.28.0, an
# out-of-bounds heap read in the GIF decoder's read_from_tensor
# callback (unclamped length passed to memcpy while parsing GIF
# metadata). torchvision isn't a direct Semantica dependency; it
# comes in transitively via safetensors/sentence-transformers, and
# nothing in this codebase decodes GIFs through torchvision or
# calls into torchvision at all. Fixed upstream in commit 4e05dc2;
# no released version has it yet. Re-evaluate once one does.
IGNORED_VULN_IDS="SFTY-20260120-40557,SFTY-20260723-60537"
# project. Empty for now: pip-audit's OSV-backed database doesn't
# currently carry either of the findings Safety used to flag here
# (cuda-toolkit CVE-2025-33228, torchvision CVE-2026-65918), so
# there's nothing to exclude. Left in place so a future finding can
# be added the same way without restructuring this step - see git
# history on this file for the reasoning behind past entries.
IGNORED_VULN_IDS=""
# Exported so the "Comment PR with Security Results" step below can
# apply the same exclusion list to the raw report - it reads
# safety-report.json independently in JS, so without this the PR
# comment would show the accepted CVE as a live finding even though
# this gate correctly treats it as non-actionable.
# pip-audit-report.json independently in JS, so without this the PR
# comment would show an accepted finding as live even though this
# gate correctly treats it as non-actionable.
echo "IGNORED_VULN_IDS=$IGNORED_VULN_IDS" >> "$GITHUB_ENV"
# No []? / || echo "0" fallback on a missing/null "vulnerabilities"
# key: iterating over null raises inside jq, leaving VULNS empty, so
# guard 2 below catches it rather than silently treating a broken
# report as zero.
# No []? / || echo "0" fallback: if jq fails (malformed JSON) VULNS
# will be empty or "null" so Guard 3 below catches it rather than
# silently treating the broken report as zero.
# `.vulns // []` guards against skipped dependencies, which carry
# no vulns field at all (see the skip_reason handling above) -
# without the fallback, iterating `null[]` raises inside jq and
# this whole computation silently evaluates to empty.
VULNS=$(jq --arg ignored "$IGNORED_VULN_IDS" '
($ignored | split(",")) as $ignore_list
| [.vulnerabilities[] | select(.vulnerability_id as $id | ($ignore_list | index($id)) | not)]
($ignored | split(",") | map(select(length > 0))) as $ignore_list
| [.dependencies[] | (.vulns // [])[] | select(.id as $id | ($ignore_list | index($id)) | not)]
| length
' safety-report.json 2>/dev/null)
' pip-audit-report.json 2>/dev/null)
# Guard 2: ensure VULNS is a non-negative integer before the -gt
# Guard 3: ensure VULNS is a non-negative integer before the -gt
# comparison. "null" (missing/null key) or "" (jq parse failure) would
# cause bash's -gt to throw an arithmetic error and fall through to the
# success branch — the same silent-pass bug as a missing file.
if ! [[ "$VULNS" =~ ^[0-9]+$ ]]; then
echo "::error::Safety report exists but 'vulnerabilities' is missing or non-numeric (got: '${VULNS}'). The report may be malformed or Safety may have written an error-only JSON. Treating as failure."
echo "::error::pip-audit report exists but dependency vulnerabilities are missing or non-numeric (got: '${VULNS}'). The report may be malformed or contain an error-only JSON response. Treating as failure."
exit 1
fi
@@ -142,15 +149,17 @@ jobs:
echo ""
echo "Vulnerability details:"
jq --arg ignored "$IGNORED_VULN_IDS" -r '
($ignored | split(",")) as $ignore_list
| .vulnerabilities[] | select(.vulnerability_id as $id | ($ignore_list | index($id)) | not)
| "- \(.package_name)==\(.analyzed_version): \(.vulnerability_id) (\(.CVE // "no CVE assigned"))"
' safety-report.json || true
($ignored | split(",") | map(select(length > 0))) as $ignore_list
| .dependencies[] as $dependency
| ($dependency.vulns // [])[] | select(.id as $id | ($ignore_list | index($id)) | not)
| "- \($dependency.name)==\($dependency.version): \(.id)"
' pip-audit-report.json || true
exit 1
else
echo "✅ No actionable security vulnerabilities found (ignored: $IGNORED_VULN_IDS)"
echo "✅ No actionable security vulnerabilities found${IGNORED_VULN_IDS:+ (ignored: $IGNORED_VULN_IDS)}"
echo 'AUDIT_SCAN_STATUS=passed' >> "$GITHUB_ENV"
fi
- name: Run Bandit (Code Security Linter)
run: |
bandit -r semantica/ -f json -o bandit-report.json || true
@@ -188,17 +197,18 @@ jobs:
fi
- name: Upload Security Reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: security-reports
retention-days: 14
path: |
safety-report.json
pip-audit-report.json
bandit-report.json
semgrep-report.json
- name: Comment PR with Security Results
if: github.event_name == 'pull_request'
if: always() && github.event_name == 'pull_request'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
@@ -221,6 +231,12 @@ jobs:
}
const items = parse(data);
if (items === null) {
return [
'### ' + title,
'⚠️ Invalid report structure in ' + reportPath + ' — check the job logs.',
].join('\n');
}
if (items.length === 0) {
return [`### ${title}`, `✅ No findings.`].join('\n');
}
@@ -240,23 +256,60 @@ jobs:
// Mirrors the shell step's own IGNORED_VULN_IDS (passed through
// $GITHUB_ENV) so an accepted, non-actionable CVE that the CI
// gate already excluded doesn't reappear here as a live finding -
// this reads the same raw, unfiltered safety-report.json.
// this reads the same raw, unfiltered pip-audit-report.json.
const ignoredVulnIds = (process.env.IGNORED_VULN_IDS || '')
.split(',')
.map((id) => id.trim())
.filter(Boolean);
const safetySection = renderSection(
'Safety — dependency vulnerabilities',
'safety-report.json',
(data) => (data.vulnerabilities || [])
.filter((v) => !ignoredVulnIds.includes(v.vulnerability_id))
.map(
(v) => `- \`${v.package_name}==${v.analyzed_version}\`: ${v.vulnerability_id}` +
(v.CVE ? ` (${v.CVE})` : '') + ` — ${v.advisory || 'no advisory text'}`
)
// A dependency pip-audit couldn't resolve/audit is reported as
// {"name": ..., "skip_reason": ...} with no vulns field at all
// (see pip_audit._format.json.JsonFormat._format_dep) - that's a
// normal report shape, not a malformed one, so it must not be
// treated as an invalid dependency below.
const isSkipped = (dependency) => typeof dependency.skip_reason === 'string';
let skippedDeps = [];
try {
const auditData = JSON.parse(fs.readFileSync('pip-audit-report.json', 'utf8'));
skippedDeps = (auditData.dependencies || []).filter(
(dependency) => dependency && typeof dependency === 'object' && isSkipped(dependency)
);
} catch (e) {
// Unreadable/unparseable report - renderSection's own
// report-missing branch below surfaces this.
}
const pipAuditSection = renderSection(
'pip-audit — dependency vulnerabilities',
'pip-audit-report.json',
(data) => {
if (
!Array.isArray(data.dependencies) ||
data.dependencies.length === 0 ||
data.dependencies.some(
(dependency) =>
!dependency ||
typeof dependency !== 'object' ||
(!Array.isArray(dependency.vulns) && !isSkipped(dependency))
)
) {
return null;
}
return data.dependencies.flatMap((dependency) =>
(dependency.vulns || [])
.filter((vulnerability) => !ignoredVulnIds.includes(vulnerability.id))
.map(
(vulnerability) => `- \`${dependency.name}==${dependency.version}\`: ${vulnerability.id}` +
(vulnerability.fix_versions?.length ? ` (fixed by ${vulnerability.fix_versions.join(', ')})` : '')
)
);
}
) + (ignoredVulnIds.length
? `\n\n_Excluded as accepted, non-actionable findings: ${ignoredVulnIds.join(', ')} — see the workflow file's inline comments for why._`
: '') + (skippedDeps.length
? `\n\n_Could not be audited: ${skippedDeps.map((d) => `\`${d.name}\` (${d.skip_reason})`).join(', ')}_`
: '');
const banditSection = renderSection(
@@ -278,7 +331,7 @@ jobs:
const comment = [
'# 🔒 Security Scan Results',
'',
safetySection,
pipAuditSection,
'',
banditSection,
'',
@@ -288,7 +341,7 @@ jobs:
'',
'*This security scan runs automatically on source-code PRs and bi-weekly (skipped for doc/markdown-only changes).*',
'',
'📊 **Security Policy**: CI fails on Safety vulnerabilities and Bandit HIGH-severity findings. Semgrep findings above are informational and do not block merge.',
'📊 **Security Policy**: CI fails on pip-audit vulnerabilities and Bandit HIGH-severity findings. Semgrep findings above are informational and do not block merge.',
].join('\n');
try {
@@ -303,3 +356,11 @@ jobs:
console.log('⚠️ Could not post security comment:', error.message);
console.log('📋 Security scan results saved to artifacts');
}
- name: Enforce Audit Gate
if: always()
run: |
if [ "${AUDIT_SCAN_STATUS:-failed}" != "passed" ]; then
echo "::error::pip-audit scan failed. See the pip-audit output and uploaded reports above."
exit 1
fi
-42
View File
@@ -1,42 +0,0 @@
name: Security
on:
schedule:
- cron: '0 0 * * 1'
workflow_dispatch:
pull_request:
branches: [main]
paths:
- 'pyproject.toml'
- 'requirements-ci.txt'
- '.github/workflows/security.yml'
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.11'
# Upgrade first: actions/setup-python's baked-in setuptools has been
# behind known-vulnerable floors before (e.g. PYSEC-2026-3447 /
# setuptools 75.1.0), so don't trust the preinstalled one.
- run: pip install -r .github/requirements/bootstrap.txt --require-hashes
# Audit the pinned dependency set (requirements-ci.txt is compiled from
# pyproject.toml with --extra all — the same coverage as the [all]
# extra, minus the Linux-only gpu set — so this keeps scan parity with
# CI/release builds without a time-dependent resolution). This is the
# fix for PYSEC-2024-38 (#869): the bare-env job never had fastapi or
# python-multipart installed to look at.
- run: pip install -r requirements-ci.txt --require-hashes
# PR runs gate on findings, since they're scoped to actual
# pyproject.toml changes under review. The schedule/workflow_dispatch
# runs stay non-blocking until a full pass over pre-existing findings
# across the whole [all] tree has been done.
- run: pip install -r .github/requirements/pip-audit.txt --require-hashes
- run: pip-audit -r requirements-ci.txt
continue-on-error: ${{ github.event_name != 'pull_request' }}
BIN
View File
Binary file not shown.
+21
View File
@@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Salesforce ingestor** (#1240) by @Sameer6305
- New `SalesforceConnector` / `SalesforceData` / `SalesforceIngestor` (`semantica.ingest`, lazy export), following the same Connector + Data + Ingestor pattern already used for Snowflake/Databricks/SAP
- Auth covers both landscapes Salesforce actually uses: username + password + security token (SOAP login), session_id + instance_url (reusing an existing session), and username + consumer_key + private key (JWT Bearer); production and sandbox are selected via `domain`, and credentials can come from environment variables. Credential material is never intentionally written to logs, exceptions, or `repr()`
- `ingest_sobject()`, `ingest_query()`, `list_sobjects()`, `get_sobject_schema()`, `export_as_documents()` against standard sObjects, custom objects (`__c`), custom metadata (`__mdt`), platform events (`__e`), namespaced objects, and relationship-field traversal (e.g. `Owner.Name`); pagination follows `nextRecordsUrl`/`query_more()` and stops once a caller's `limit` is satisfied
- New `pip install semantica[db-salesforce]` extra (`simple-salesforce>=1.12.0`)
- New `tests/test_salesforce_ingestor.py`
- Docs: `docs/integrations/salesforce.md`
- **`ErasureCoordinator` completes the erasure workflow `purge_node()` only starts — the graph node was removed while the same content survived verbatim in `AgentMemory` and as an embedding** (closes #1018) by @pravit-amp
- New `semantica/context/erasure.py`, exporting `ErasureCoordinator` and `ErasureReceipt` from `semantica.context`. `purge_node()`/`purge_edge()` (#957) are graph-scope by design and their changelog entry documents this gap explicitly; the changelog also names GDPR Article 17 as the motivation, and an Article 17 erasure that removes the node while the content stays retrievable by similarity search is not an erasure — it is worse than not offering one, because `purge_node()` returns `True` and writes a tombstone attesting the content is gone
- The coordinator **composes** the existing public APIs — nothing in `context_graph.py` or `agent_memory.py` changes behaviorally, and `ContextGraph` keeps its documented graph-scope contract rather than acquiring references to `AgentMemory`/`vector_store` that would invert the dependency
@@ -151,6 +159,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Also fixed, on the JSON-LD paths**: the first fix covered the Turtle, N-Triples and RDF/XML serializers, and left both JSON-LD writers interpolating the entity's own text into `f"semantica:entity/{text}"` and the endpoints into `f"semantica:rel/{source}_{target}"`. Three consequences, all live in 0.6.5: an entity whose text contained a space produced an invalid IRI, and a JSON-LD parser dropped that node in full rather than reporting it, so the entity disappeared from the export; every relationship carrying `source`/`target` rather than `source_id`/`target_id` minted the identical `semantica:rel/_`, collapsing all of them onto one node whose types and endpoints merged; and the JSON-LD `@id` disagreed with the Turtle IRI for the same entity, so the two serializations of one knowledge graph were two different graphs. Both JSON-LD writers now use `mint_entity_iri`/`mint_relationship_iri`, and `JSONExporter.export_entities`/`export_relationships` declare the `semantica` prefix their `@context` was already writing `semantica:entities` against — without it a processor reads that as an IRI in the scheme `semantica`, which is the original #1101 defect on a third path
- `tests/export/test_jsonld_iri_minting.py` parses each export with a real JSON-LD processor and asserts the entity survives, the relationships stay distinct, no term expands into the `semantica` scheme, and the JSON-LD `@id` equals the Turtle IRI
- 236 export and ontology tests pass
- **`semantica.evals` runner gains per-metric objectives** (#1091)
- `evaluate()` now accepts `config={"<evaluator>": {"objective": {"direction": "maximize"|"minimize", "threshold": X}}}` to override the evaluator's default pass verdict with a threshold; `{"objective": {"expect": bool}}` expresses a Boolean expectation
- `minimize` requires a `threshold` — omitting it or setting it to `None` raises `ValueError`; `maximize` without a threshold is a no-op (the evaluator's own verdict stands); `expect` cannot be combined with `direction`/`threshold`; invalid config raises `ValueError` before any evaluator runs
- Error metrics are never affected by objectives (error wins over fail)
- Backward compatible: no `objective` key → existing behavior unchanged
- New tests in `tests/evals/test_runner.py::TestObjective`
- **`semantica.evals` is now a fully implemented evaluation module** (was a "Coming Soon" stub in the package layout)
- `evaluate(cases, evaluators, config=None, target_fn=None)` runner with per-case `pass`/`fail`/`error` status and an aggregate `pass_rate`, using a registry of named evaluators (`list_evaluators()`)
- 10 built-in evaluators: `exact_match`, `regex_match`, `numeric_range`, `temporal_range`, `length_range`, `keyword_check`, `levenshtein` (edit-distance similarity), `rouge` (in-house token F1, no new dependencies), `llm_as_judge` (lazy: caller-supplied `judge_fn`), and `decision_scores` (composite over `semantica.context.Decision`)
- `decision_scores` validates field-level (expected outcome, confidence bounds, non-empty maker/reasoning/scenario) and governance-level (provenance record presence; opt-in `PolicyEngine.check_compliance`) checks, coercing dict inputs via `Decision(**actual)` and never crashing on malformed input; an interface slot for causal-chain/embedding checks is reserved and raises `NotImplementedError` (V2)
- `__version__` is `0.1.0`, and the module ships a usage guide at `semantica/evals/usage.md` with worked import/run/interpret examples
- `semantica.evals` is reachable through the root package lazy module proxy (`semantica.evals`)
- 99 unit tests in `tests/evals/` covering every evaluator, registry errors, runner aggregation, decision coercion, and per-metric objectives; `python -m pytest tests/evals -q` → 99 passed
- **First-class CrewAI integration** (#988, closes #962) by @Shindevrp
- New `pip install semantica[crewai]` extra (`crewai>=0.80.0`) — crewai core provides `BaseTool`/`BaseKnowledgeSource`, so `crewai-tools` is intentionally not included, and the extra is intentionally **not** part of the `all` bundle: crewai hard-requires `chromadb~=1.1.0`, which is affected by the unpatched pre-auth code-injection CVE-2026-45829 (see `integrations/crewai/README.md`)
+13 -16
View File
@@ -20,7 +20,7 @@
**Context Management &nbsp;·&nbsp; Knowledge Modeling &nbsp;·&nbsp; Deterministic Reasoning &nbsp;·&nbsp; Ontology Management &nbsp;·&nbsp; Decision Intelligence &nbsp;·&nbsp; End-to-End Traceability**
**Open Source &nbsp;·&nbsp; Self-Hostable &nbsp;·&nbsp; Auditable &nbsp;·&nbsp; Governed &nbsp;·&nbsp; Zero Vendor Lock-In**
**Open Source &nbsp;·&nbsp; Governed &nbsp;·&nbsp; Zero Vendor Lock-In**
**Polyglot Graph Storage &nbsp;·&nbsp; RDF & LPG Support &nbsp;·&nbsp; W3C Standards &nbsp;·&nbsp; Interoperable**
@@ -62,12 +62,12 @@ Most AI agents run on embeddings, not meaning: similarity scores with no structu
**Who it's for:**
- **AI/ML platform teams** shipping agents that make consequential decisions and need structured, queryable context built from fragmented raw data, not just a vector index
- **Data platform teams on Databricks or Snowflake** who need to turn tables already sitting in Unity Catalog or a Snowflake warehouse into a governed, lineage-tracked knowledge graph, without exporting that data to a third-party SaaS first
- **Compliance, risk, and audit teams** who need a straight answer to "why did the AI do that?" in a format a regulator will actually accept
- **Regulated enterprises** (finance, healthcare, legal, government, defense) that can't ship a black box, and can't send their data to someone else's SaaS to get one
- **AI/ML platform teams** shipping agents that make consequential decisions and need structured, queryable context, not just a vector index
- **Data platform teams on Databricks or Snowflake** turning tables already in Unity Catalog or a warehouse into a governed, lineage-tracked knowledge graph, without exporting to a third-party SaaS
- **Compliance, risk, and audit teams** who need a straight answer to "why did the AI do that?" in a format a regulator accepts
- **Regulated enterprises** (finance, healthcare, legal, government, defense) that can't ship a black box or send their data to someone else's SaaS to get one
- **Platform and infra engineers** who want the KG, reasoning, and provenance stack self-hosted and swappable, not locked to one vendor's backend
- **Data and knowledge engineers** building a KG from messy, multi-source data: entities and relationships get extracted, conflicting or contradictory facts are flagged instead of silently overwritten, and duplicates are merged before they turn into noise
- **Data and knowledge engineers** building a KG from messy, multi-source data, where conflicting facts get flagged and duplicates get merged, not silently overwritten
**[Quick Start](#quick-start)** &nbsp;·&nbsp; **[Architecture](#architecture)** &nbsp;·&nbsp; **[What You Get](#what-semantica-gives-you)** &nbsp;·&nbsp; **[Why Semantica](#why-semantica)** &nbsp;·&nbsp; **[Decision Intelligence](#decision-intelligence)** &nbsp;·&nbsp; **[Context Graphs](#context-graphs)** &nbsp;·&nbsp; **[Recipe: Audit Trail](#recipe-audit-trail-for-a-regulated-decision)** &nbsp;·&nbsp; **[Module Reference](#module-reference)** &nbsp;·&nbsp; **[Integrations](#integrations)** &nbsp;·&nbsp; **[CLI](#cli)** &nbsp;·&nbsp; **[Performance](#performance)** &nbsp;·&nbsp; **[Install](#installation)**
@@ -81,7 +81,7 @@ Most AI agents run on embeddings, not meaning: similarity scores with no structu
- **Full Auditability:** W3C PROV-O provenance on every fact, with audit trails exportable to JSON, CSV, or RDF
- **Deterministic Reasoning:** Forward chaining, Rete network, Datalog, and SPARQL with fully explainable paths, not black boxes
- **Knowledge Pipeline:** Multi-source ingestion, entity-aware chunking, NER/relation/event extraction, and knowledge graph construction, with semantic deduplication and provenance-preserving merges throughout
- **Enterprise Data Platforms:** Native connectors for Databricks (Unity Catalog + Delta Lake, PAT/OAuth M2M auth, catalog/schema/table/lineage introspection) and Snowflake (warehouse/database/schema, key-pair and OAuth auth), so tables already living in your lakehouse or warehouse become graph nodes with provenance, not another export/import hop
- **Enterprise Data Platforms:** Native connectors for Databricks (Unity Catalog + Delta Lake, PAT/OAuth M2M auth, catalog/schema/table/lineage introspection), Snowflake (warehouse/database/schema, key-pair and OAuth auth), and SAP OData (Business Partners, Sales Orders, OAuth2/Basic auth), so data already living in your lakehouse or warehouse becomes graph nodes with provenance, not another export/import hop
- **Graph Analytics:** Centrality, community detection, link prediction, and shortest-path queries over the graph you just built
- **Polyglot Graph Storage:** Native RDF (embedded Oxigraph, Blazegraph, Apache Jena, Eclipse RDF4J via SPARQL) and Labeled Property Graphs (Neo4j, FalkorDB, Apache AGE, AWS Neptune via Cypher), plus vector stores, all swappable without touching your code
- **Visualization:** Explore any graph, ontology, or timeline in an interactive browser workbench
@@ -139,10 +139,6 @@ compliant = graph.check_decision_rules({"category": "vendor_selection"}) # poli
```bash
semantica doctor
# Python 3.11.9 pass
# semantica 0.6.7 pass
# faiss vector store pass
# Config file pass ~/.semantica/config.yaml
```
**Running in a script or CI?** Progress bars are written only when stdout is an interactive terminal (or a Jupyter notebook), so piping and redirecting stay clean by default. Override with `SEMANTICA_DISABLE_PROGRESS=1` to silence progress everywhere, or `SEMANTICA_FORCE_PROGRESS=1` to keep it when stdout is redirected. `SEMANTICA_DISABLE_PROGRESS` takes precedence.
@@ -167,7 +163,7 @@ Sources → Ingest → Parse → Normalize → Split → Extract → Conflict De
→ Vector Store + Polyglot Graph Store (RDF & LPG) → Export / Visualize / REST · MCP · CLI
```
- **Ingest:** files, web, databases, enterprise data platforms (Databricks, Snowflake), cloud (Google Drive, Elasticsearch), streams (Kafka, Kinesis), Git, email, MCP
- **Ingest:** files, web, databases, enterprise data platforms (Databricks, Snowflake, SAP), cloud (Google Drive, Elasticsearch), streams (Kafka, Kinesis), Git, email, MCP
- **Parse → Normalize → Split:** document parsing, text/entity/date normalization, GraphRAG-native entity-aware chunking
- **Extract → Conflict Detection → Deduplication:** NER, relations, events, triplets; conflicting facts flagged and resolved before they merge
- **Knowledge Graph:** `GraphBuilder` constructs the graph; bi-temporal facts and full graph analytics (centrality, communities, link prediction) run on top of it
@@ -320,7 +316,7 @@ Every module below is independently importable, with working code samples verifi
| Module | What it does |
| --- | --- |
| [`semantica.ingest`](#semanticaingest-multi-source-ingestion) | Files, web, databases, APIs, streams, email, Git, Parquet, Databricks, Snowflake, MCP |
| [`semantica.ingest`](#semanticaingest-multi-source-ingestion) | Files, web, databases, APIs, streams, email, Git, Parquet, Databricks, Snowflake, SAP, MCP |
| [`semantica.semantic_extract`](#semanticasemantic_extract-ner-relations-events-triplets) | NER, relation extraction, event detection, triplet generation |
| [`semantica.kg`](#semanticakg-knowledge-graph-construction--analysis) | Graph construction, centrality, communities, link prediction |
| [`semantica.reasoning`](#semanticareasoning-forward-chaining-rete-datalog-sparql) | Forward chaining, Rete, Datalog, SPARQL, fully explainable |
@@ -349,7 +345,7 @@ Expand any module below for its runnable example.
<summary><b><code>semantica.ingest</code></b>: Multi-Source Ingestion</summary>
<a id="semanticaingest-multi-source-ingestion"></a>
Ingest from files, web, databases, APIs, streams, email, Git repos, Parquet, Databricks, Snowflake, or MCP servers, all through a unified interface.
Ingest from files, web, databases, APIs, streams, email, Git repos, Parquet, Databricks, Snowflake, SAP, or MCP servers, all through a unified interface.
```python
from semantica.ingest import FileIngestor, WebIngestor, ParquetIngestor, DBIngestor
@@ -400,7 +396,7 @@ orders = snowflake.ingest_table("ORDERS", limit=10_000)
> **Security Note:** Never hardcode credentials (`token`, `password`, `private_key`) in production code; pass them via environment variables (e.g., `DATABRICKS_TOKEN`, `SNOWFLAKE_PASSWORD`) or a secrets manager.
**Supported sources:** Local files (PDF, DOCX, PPTX, HTML, TXT, CSV, JSON, YAML, Excel, XML) · Web pages · RSS/Atom feeds · REST APIs · Databases (PostgreSQL, MySQL, SQLite, Oracle, SQL Server) · Parquet datasets · Databricks (Unity Catalog + Delta Lake) · Snowflake · Git repositories · Email (IMAP/POP3) · Message streams (Kafka, RabbitMQ, Kinesis, Pulsar) · MCP resources · Apache Arrow/Feather/IPC (`ArrowIngestor`)
**Supported sources:** Local files (PDF, DOCX, PPTX, HTML, TXT, CSV, JSON, YAML, Excel, XML) · Web pages · RSS/Atom feeds · REST APIs · Databases (PostgreSQL, MySQL, SQLite, Oracle, SQL Server) · Parquet datasets · Databricks (Unity Catalog + Delta Lake) · Snowflake · SAP (OData v2/v4) · Git repositories · Email (IMAP/POP3) · Message streams (Kafka, RabbitMQ, Kinesis, Pulsar) · MCP resources · Apache Arrow/Feather/IPC (`ArrowIngestor`)
DuckDB, Elasticsearch, Google Drive, HuggingFace, MongoDB, and Pandas ingestion also ship (`DuckDBIngestor`, `ElasticIngestor`, `GDriveIngestor`, `HuggingFaceIngestor`, `MongoIngestor`, `PandasIngestor`) but aren't re-exported from the top-level `semantica.ingest` namespace yet — import them directly: `from semantica.ingest.duckdb_ingestor import DuckDBIngestor`.
@@ -1145,7 +1141,7 @@ if report.valid:
| **Vector Store** | FAISS · Pinecone · Weaviate · Qdrant · Milvus · PgVector · hybrid + filtered search |
| **Graph Databases (LPG)** | Neo4j · FalkorDB · Apache AGE · AWS Neptune |
| **Triple Stores (RDF)** | Oxigraph (embedded) · Blazegraph · Apache Jena · Eclipse RDF4J · unified `TripletStore` interface · SPARQL query & bulk load |
| **Enterprise Data Platforms** | Databricks (`DatabricksIngestor`: Unity Catalog + Delta Lake, PAT/OAuth M2M, table/query ingestion, catalog/schema/table/lineage introspection) · Snowflake (`SnowflakeIngestor`: warehouse/database/schema, password/key-pair/OAuth auth) |
| **Enterprise Data Platforms** | Databricks (`DatabricksIngestor`: Unity Catalog + Delta Lake, PAT/OAuth M2M, table/query ingestion, catalog/schema/table/lineage introspection) · Snowflake (`SnowflakeIngestor`: warehouse/database/schema, password/key-pair/OAuth auth) · SAP (`SAPIngestor`: OData v2/v4, OAuth2/Basic auth, Business Partners/Sales Orders) |
| **LLM Providers** | **All already supported today:** OpenAI (GPT-4o, o1, o3) · Anthropic (Claude) · Google Gemini · Mistral · Meta Llama · Groq · Cohere · Azure OpenAI · AWS Bedrock · Ollama · DeepSeek · Perplexity · Together AI · Fireworks AI · Replicate · HuggingFace · via `semantica.llms` and LiteLLM |
---
@@ -1517,6 +1513,7 @@ pip install semantica[vectorstore-qdrant] # Qdrant vector store
pip install semantica[vectorstore-pinecone] # Pinecone vector store
pip install semantica[db-snowflake] # Snowflake
pip install semantica[db-databricks] # Databricks (SDK + SQL connector)
pip install semantica[ingest-sap] # SAP OData
pip install semantica[ingest-parquet] # Parquet / PyArrow
pip install semantica[ingest-arrow] # Apache Arrow, Feather, IPC
pip install semantica[viz] # HTML interactive visualization
+2 -3
View File
@@ -153,7 +153,7 @@ that attack chain.
- **Risk**: a PR merges without its security/CI checks passing.
**Control**: merges require the `build`, `Analyze Python` (CodeQL), and `security-scan` checks to pass, in strict mode (checks must be re-run against the latest `main`).
- **Risk**: a compromised scanner job reaches secrets or write access.
**Control**: scanning jobs (`CodeQL`, `security-scan.yml`, `security.yml`, `defender-for-devops.yml`) run with read-only, least-privilege permissions (typically `contents: read` + `security-events: write` only) and never share a job, environment, or secret scope with the publish job.
**Control**: scanning jobs (`CodeQL`, `security-scan.yml`, `defender-for-devops.yml`) run with read-only, least-privilege permissions (typically `contents: read` + `security-events: write` only) and never share a job, environment, or secret scope with the publish job.
- **Risk**: secrets are committed accidentally.
**Control**: GitHub secret scanning and push protection are both enabled at the repository level, rejecting pushes that contain recognizable credential patterns before they land in history.
@@ -164,8 +164,7 @@ Every scan below runs continuously in CI, not just at release time:
- **CodeQL** (`security-and-quality` query pack) — Python source: injection, unsafe deserialization, and other code-level vulnerability classes. Runs in `codeql.yml` on every push/PR to `main` and weekly.
- **Bandit** — Python-specific security anti-patterns (hardcoded secrets, unsafe `eval`/`pickle`, weak crypto, etc.); CI fails on any HIGH-severity finding. Runs in `security-scan.yml` on every push/PR to `main` and twice weekly.
- **Semgrep** (`p/security` ruleset) — cross-language static-analysis security patterns. Runs in `security-scan.yml` on every push/PR to `main` and twice weekly.
- **Safety** — known CVEs in Semantica's own installed dependencies, including optional LLM-provider extras such as LiteLLM; CI fails on any match. Runs in `security-scan.yml` on every push/PR to `main` and twice weekly.
- **pip-audit** — independent, PyPA-maintained vulnerability database cross-check against installed dependencies (Safety and pip-audit use different advisory sources, so both run). Runs in `security.yml` weekly.
- **pip-audit** — PyPA-maintained, OSV-backed vulnerability database cross-check against Semantica's pinned dependency tree, including optional LLM-provider extras such as LiteLLM; CI fails on any match. Runs in `security-scan.yml` on every push/PR to `main` and twice weekly, and can be triggered on demand via `workflow_dispatch`.
- **Microsoft Defender for DevOps** (`eslint`, `templateanalyzer`, `terrascan`) — JavaScript/TypeScript lint-security rules and infrastructure-as-code misconfigurations. Runs in `defender-for-devops.yml` on every push/PR to `main` and weekly.
- **Checkov** — Kubernetes, Helm, Dockerfile, GitHub Actions, and secrets-pattern IaC scanning; results upload to the same Security tab as CodeQL. Runs in `defender-for-devops.yml` on every push/PR to `main` and weekly.
- **GitGuardian** — secret-detection check on every pull request, installed as a GitHub App integration (not a repo-local workflow). Runs on every PR.
+206 -49
View File
@@ -1,64 +1,221 @@
---
title: "Evals Module"
description: "Evaluation framework for measuring Knowledge Graph quality, extraction accuracy, and pipeline performance: coming soon."
description: "Score decision records, audit trails, and reasoning output with deterministic and model-backed evaluators plus a small run harness."
icon: "chart-line"
---
**`semantica.evals`** is planned as a comprehensive evaluation framework for measuring **extraction accuracy, graph quality, and pipeline performance**.
`semantica.evals` measures the quality of decision intelligence outputs. It takes
the decisions, audit trails, and reasoning text your pipeline produces and scores
them against expectations you define, returning a structured summary you can log,
assert on in tests, or track across runs.
<Warning>
**`semantica.evals` is not yet implemented.** The module is a placeholder with `__all__ = []`. No classes or functions are available for import. This page describes the planned API only.
</Warning>
- A registry of named evaluators, from exact string matching to ROUGE overlap and
LLM-as-judge
- `decision_scores`, a composite evaluator for `Decision` objects that checks
outcome, confidence bounds, required fields, provenance, and (optionally)
policy compliance
- A `evaluate()` runner that applies several evaluators to a list of cases and
aggregates pass / fail / error counts
- Per-evaluator **objectives** that let you override an evaluator's built-in
verdict at the run level
## Planned Features
<Note>
The module is versioned separately from the package: `semantica.evals.__version__`
is `"0.1.0"`. The public surface described here is stable, but expect additive
changes (new evaluators, new objective options) before it reaches 1.0.
</Note>
When released, `semantica.evals` will provide:
## Public API
| Planned Class | Role |
| :--- | :--- |
| `KGEvaluator` | Completeness, consistency, schema compliance, coverage, and orphan node detection |
| `ExtractionEvaluator` | NER precision / recall / F1 and relation extraction metrics against gold datasets |
| `PipelineBenchmark` | Throughput (docs/sec), per-step latency, peak memory, and error rate |
| `RegressionTracker` | Record runs and compare metrics across commits or config changes |
| `EvalReport` | Structured report: `{scores, regressions, recommendations}` |
| `DeduplicationEvaluator` | Merge precision, false positive / false negative rates |
| `ReasoningEvaluator` | Inference accuracy, rule coverage, and derivation depth |
## Current Workaround
Until `semantica.evals` ships, use `semantica.ontology.OntologyEvaluator` for ontology quality metrics:
| Name | Kind | Role |
| :--- | :--- | :--- |
| `evaluate(cases, evaluators, config=None, target_fn=None)` | function | Run named evaluators over each case, return an `EvalSummary` |
| `list_evaluators()` | function | Sorted names of every registered evaluator |
| `get_evaluator(name)` | function | Look up a single evaluator function by name |
| `EvalMetric` | dataclass (frozen) | One evaluator's result: `score`, `passed`, `meta` |
| `CaseResult` | namedtuple | One case's result: `case_id`, `status`, `metrics`, `details` |
| `EvalSummary` | dataclass | Aggregate across cases: `total`, `passed`, `failed`, `errors`, `pass_rate`, `cases` |
```python
from semantica.ontology import OntologyEvaluator
evaluator = OntologyEvaluator()
# evaluate_ontology takes the ontology dict only
result = evaluator.evaluate_ontology(ontology)
print("Coverage: ", result.coverage_score)
print("Completeness:", result.completeness_score)
print("Gaps: ", result.gaps)
print("Suggestions: ", result.suggestions)
# Full report with class granularity and relation completeness
report = evaluator.generate_report(ontology)
print("Coverage score: ", report["evaluation"]["coverage_score"])
print("Completeness score:", report["evaluation"]["completeness_score"])
print("Relation coverage: ", report["relation_completeness"]["relation_coverage"])
import semantica.evals as evals
from semantica.evals import evaluate, list_evaluators, get_evaluator
```
`EvaluationResult` fields returned by `evaluate_ontology()`:
## Built-in evaluators
| Field | Type | Description |
| :----- | :---- | :----------- |
| `coverage_score` | `float` | Fraction of competency questions answerable by the ontology |
| `completeness_score` | `float` | Average of class and property completeness scores |
| `gaps` | `List[str]` | Identified gaps in coverage |
| `suggestions` | `List[str]` | Improvement suggestions |
| `metrics` | `dict` | Detailed sub-metrics |
Every evaluator is a plain function `fn(actual, expected, config=None) -> EvalMetric`
registered under a stable name. `list_evaluators()` returns the current set:
- [Semantic Extract](semantic_extract) — Extraction module.
- [Knowledge Graph](kg) — Graph quality assessment.
- [Pipeline](pipeline) — Pipeline performance metrics.
- [Ontology Evaluator](ontology) — Available now for ontology quality metrics.
```python
>>> list_evaluators()
['decision_scores', 'exact_match', 'keyword_check', 'length_range',
'levenshtein', 'llm_as_judge', 'numeric_range', 'regex_match', 'rouge',
'temporal_range']
```
| Name | Passes when | Relevant `config` keys |
| :--- | :--- | :--- |
| `exact_match` | `actual == expected` | none |
| `regex_match` | `re.search(expected, actual)` matches | none |
| `keyword_check` | every required term appears in `actual` (word-boundary) | `required` (falls back to `expected`) |
| `numeric_range` | `min <= actual <= max` | `min`, `max` (both required) |
| `temporal_range` | ISO datetime `actual` falls in `[min, max]` | `min`, `max` as ISO strings (both required) |
| `length_range` | `min <= len(actual) <= max` | `min` (default 0), `max` (required) |
| `levenshtein` | normalized similarity `>= threshold` | `threshold` (default 0.8) |
| `rouge` | ROUGE-1 F1 `> 0` and `>= threshold` | `threshold` (default 0.0) |
| `llm_as_judge` | caller-supplied `judge_fn(actual, expected)` returns truthy | `judge_fn` (required callable) |
| `decision_scores` | all configured sub-checks on a `Decision` pass | see below |
An evaluator that cannot run (bad regex, missing bound, no `judge_fn`) returns an
`EvalMetric` with an `"error"` key in `meta` rather than raising.
### `decision_scores`
`decision_scores` accepts a `Decision` (from `semantica.context.decision_models`)
or its dict form and runs a set of field-level and governance checks. The score is
the fraction of checks that passed; `passed` is `True` only when all of them did.
| Sub-check | Controlled by |
| :--- | :--- |
| Outcome matches | `expected_outcome` in config, or the case's `expected` |
| Confidence in range | `min_confidence` (default 0.0), `max_confidence` (default 1.0) |
| `decision_maker`, `reasoning`, `scenario` non-empty | always run |
| Provenance present in metadata | `provenance_key` (default `"provenance"`) |
| Policy compliance | `policy_engine` and `policy_id` both set; skipped otherwise |
Passing `causal_chain_exists` in config raises `NotImplementedError`. That key is a
reserved slot for a future release.
## Running an evaluation
`evaluate()` takes a list of cases and a list of evaluator names. A case is either
a `(expected, actual)` tuple or a dict:
```python
{
"id": "loan-001", # optional, generated if absent
"expected": ..., # optional; some evaluators read it, some don't
"actual": ..., # the value under test
"config": {...}, # optional, per-evaluator settings for this case
"target_fn": callable, # optional, called with the case to produce `actual`
}
```
If `actual` is missing, the runner calls the case's `target_fn` (or the
`target_fn` passed to `evaluate()`) to produce it. Per-case `config` is deep-merged
over the top-level `config`, so a case can override one evaluator's settings
without discarding the rest.
```python
from datetime import datetime
from semantica.context.decision_models import Decision
from semantica.evals import evaluate
decision = Decision(
decision_id="d-1",
category="loan",
scenario="loan-request",
reasoning="vetted against lending policy v3",
outcome="approve",
confidence=0.87,
timestamp=datetime.now(),
decision_maker="approver-a",
metadata={"provenance": "workflow:loan/v3"},
)
cases = [
{
"id": "loan-001",
"actual": decision,
"config": {
"decision_scores": {
"expected_outcome": "approve",
"min_confidence": 0.7,
}
},
},
]
summary = evaluate(cases, ["decision_scores"])
print(summary.pass_rate) # 1.0
```
Evaluators run independently per case. If one raises, that case's `status` becomes
`"error"` and the exception text is captured in the metric's `meta`; the rest of
the run continues.
## Objectives
By default each evaluator decides its own pass / fail. An **objective** overrides
that verdict at the run level, keyed by evaluator name under `config`:
```python
# Raise levenshtein's bar from its default 0.8 to 0.9
evaluate(
[("apple", "aple")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "maximize", "threshold": 0.9}}},
)
# Lower is better
evaluate(
[("night", "nacht")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize", "threshold": 0.5}}},
)
# Expect the metric NOT to match
evaluate(
[("ok", "ok")],
evaluators=["exact_match"],
config={"exact_match": {"objective": {"expect": False}}},
)
```
Rules:
- `maximize` with `threshold`: pass iff `score >= threshold`. `maximize` with no
threshold is a no-op and the evaluator's own verdict stands.
- `minimize` with `threshold`: pass iff `score <= threshold`. `minimize`
**requires** a threshold; omitting it raises `ValueError`.
- `expect` (`True` / `False`): pass iff `bool(score)` equals it. Cannot be combined
with `direction` or `threshold`, and must be a real boolean.
- A metric that already carries an `"error"` in its `meta` is unaffected by any
objective.
- Invalid objective config is validated for every case before any evaluator runs,
so a bad objective fails the whole run up front rather than partway through.
## Reading the summary
```python
summary = evaluate(cases, ["decision_scores"])
summary.total, summary.passed, summary.failed, summary.errors
summary.pass_rate # passed / total, or 1.0 for an empty case list
for case in summary.cases:
print(case.case_id, case.status) # status: "pass" | "fail" | "error"
for name, metric in case.metrics.items():
print(name, metric.score, metric.passed)
print(metric.meta.get("reasons", {})) # per-sub-check failure reasons
```
`EvalMetric` is frozen (`score: float`, `passed: bool`, `meta: dict`). `CaseResult`
is a namedtuple, and `EvalSummary` is a plain dataclass, so all three are
straightforward to serialize for logging or regression tracking.
## Notes
- `llm_as_judge` needs `config["judge_fn"]`, a callable
`judge_fn(actual, expected) -> bool` you supply. No LLM backend is imported
unless you pass one in.
- `decision_scores` governance checks are opt-in: policy compliance is only
evaluated when both `policy_engine` and `policy_id` are present.
## See also
- [Decision Intelligence](../guides/decision-intelligence) — producing the `Decision` records this module scores
- [Reasoning](reasoning) — inference output that reasoning-text evaluators can measure
- [Policy Engine](../guides/policy-engine) — the `policy_engine` used by `decision_scores`
- [Ontology Evaluator](ontology) — separate tooling for ontology quality metrics
@@ -0,0 +1,338 @@
# Objective Layer for semantica.evals Runner — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add per-metric objective support (direction + threshold, or Boolean expectation) to the `evaluate()` runner, overriding evaluator default pass verdicts, backward-compatible when no objective is configured.
**Architecture:** The runner already iterates evaluators and computes per-case status. Objectives are read from `config["<name>"]["objective"]`, validated up front, and applied to each returned metric's `passed` field (and `details`) before aggregation. Error metrics always win over objectives.
**Tech Stack:** Python 3.8+, stdlib only (typing, dataclasses). pytest for tests.
## Global Constraints
- Python >= 3.8: use `typing.Dict/List/Optional/Union`, never builtin generics or `|`.
- Zero new dependencies.
- Do not change the `EvalMetric` shape, the `evaluate()` signature, or the evaluator function signature.
- Existing behavior with no `objective` configured must be byte-for-byte unchanged (all 62 existing tests keep passing).
- Error metrics (`meta` contains `"error"`) always classify the case as `error`, regardless of objective.
- Config errors are programmer errors: raise `ValueError` from `evaluate()` before any evaluator runs (fail-fast).
- Tests go in `tests/evals/`, pytest class style, no new files outside the listed paths.
---
### Task 1: Objective parsing, validation, and re-decision in the runner
**Files:**
- Modify: `semantica/evals/runner.py`
- Test: `tests/evals/test_runner.py`
**Interfaces:**
- Consumes: `EvalMetric` from `.types` (fields: `score`, `passed`, `meta`); `evaluate(cases, evaluators, config=None, target_fn=None)` existing signature.
- Produces: private helpers `_parse_objective(name, eval_config) -> Optional[Dict]` (returns `None` when no objective configured, raises `ValueError` on invalid config) and `_apply_objective(metric, objective) -> bool` (returns the re-decided `passed`). Public `evaluate()` behavior extended as specified.
- [ ] **Step 1: Write the failing tests**
Append a new test class to `tests/evals/test_runner.py`:
```python
class TestObjective:
def test_maximize_with_threshold_pass(self):
# levenshtein similarity 1.0 for identical, objective demands >= 0.5
result = evaluate(
[("apple", "apple")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "maximize", "threshold": 0.5}}},
)
assert result.cases[0].status == "pass"
assert result.cases[0].metrics["levenshtein"].passed is True
def test_maximize_with_threshold_fail(self):
result = evaluate(
[("apple", "aple")], # similarity < 1.0
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "maximize", "threshold": 0.99}}},
)
assert result.cases[0].status == "fail"
assert result.cases[0].metrics["levenshtein"].passed is False
assert "levenshtein" in result.cases[0].details
def test_minimize_with_threshold_pass(self):
# edit distance normalized ~0.2; objective: distance <= 0.5
result = evaluate(
[("night", "nacht")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize", "threshold": 0.5}}},
)
assert result.cases[0].status == "pass"
assert result.cases[0].metrics["levenshtein"].passed is True
def test_minimize_with_threshold_fail(self):
result = evaluate(
[("night", "nacht")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize", "threshold": 0.1}}},
)
assert result.cases[0].status == "fail"
def test_expect_true_on_boolean_metric(self):
result = evaluate(
[("ok", "ok")],
evaluators=["exact_match"],
config={"exact_match": {"objective": {"expect": True}}},
)
assert result.cases[0].status == "pass"
def test_expect_false_overrides_passing_metric(self):
# exact_match passes (score 1.0) but expectation is false -> fail
result = evaluate(
[("ok", "ok")],
evaluators=["exact_match"],
config={"exact_match": {"objective": {"expect": False}}},
)
assert result.cases[0].status == "fail"
assert result.cases[0].metrics["exact_match"].passed is False
assert "exact_match" in result.cases[0].details
def test_maximize_without_threshold_is_noop(self):
# identical behavior to no objective: evaluator's own verdict stands
result = evaluate(
[("ok", "no")],
evaluators=["exact_match"],
config={"exact_match": {"objective": {"direction": "maximize"}}},
)
assert result.cases[0].status == "fail"
def test_minimize_without_threshold_raises(self):
with pytest.raises(ValueError):
evaluate(
[("a", "b")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize"}}},
)
def test_bad_direction_raises(self):
with pytest.raises(ValueError):
evaluate(
[("a", "b")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "sideways", "threshold": 0.5}}},
)
def test_expect_with_direction_raises(self):
with pytest.raises(ValueError):
evaluate(
[("a", "b")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"expect": True, "direction": "maximize"}}},
)
def test_error_metric_wins_over_objective(self):
result = evaluate(
[("[invalid", "x")],
evaluators=["regex_match"],
config={"regex_match": {"objective": {"direction": "maximize", "threshold": 0.0}}},
)
assert result.cases[0].status == "error"
assert result.errors == 1
assert result.failed == 0
def test_no_objective_unchanged(self):
result = evaluate([("ok", "no")], evaluators=["exact_match"])
assert result.cases[0].status == "fail"
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `python3 -m pytest tests/evals/test_runner.py -q`
Expected: the new `TestObjective` tests fail (objective config ignored → `exact_match` passes under `expect:false` etc.); the pre-existing tests in the file still pass.
- [ ] **Step 3: Implement objective parsing, validation, and re-decision**
In `semantica/evals/runner.py`, add two helpers before `evaluate` and wire them into the evaluator loop.
```python
def _parse_objective(name, eval_config):
"""Return the validated objective dict, or None when not configured.
Raises ValueError for invalid configurations (programmer error).
"""
objective = (eval_config or {}).get("objective")
if objective is None:
return None
direction = objective.get("direction")
threshold = objective.get("threshold")
expect = objective.get("expect")
if expect is not None:
if direction is not None or threshold is not None:
raise ValueError(
f"objective for '{name}': 'expect' cannot be combined with "
"'direction' or 'threshold'"
)
return {"expect": bool(expect)}
if direction == "minimize":
if threshold is None:
raise ValueError(
f"objective for '{name}': 'minimize' requires a 'threshold'"
)
return {"direction": "minimize", "threshold": float(threshold)}
if direction == "maximize":
if threshold is None:
# no bar to re-decide against; treat as absent (evaluator default stands)
return None
return {"direction": "maximize", "threshold": float(threshold)}
raise ValueError(
f"objective for '{name}': 'direction' must be 'maximize' or 'minimize' "
f"(got {direction!r})"
)
def _apply_objective(metric, objective):
"""Return the objective-adjusted pass verdict for a non-error metric."""
if "expect" in objective:
return bool(metric.score) == objective["expect"]
if objective["direction"] == "minimize":
return metric.score <= objective["threshold"]
return metric.score >= objective["threshold"]
```
Then modify the evaluator loop in `evaluate()` so the parsed objective is computed once per case (outside the evaluator loop, since it only depends on merged config), and applied inside the loop:
```python
objective_by_name = {
name: _parse_objective(name, merged.get(name) or {})
for name in evaluators
}
metrics: Dict[str, EvalMetric] = {}
details: Dict[str, Any] = {}
failed, errored = False, False
for name in evaluators:
eval_config = merged.get(name) or {}
try:
metric = get_evaluator(name)(actual, expected, config=eval_config)
objective = objective_by_name.get(name)
if objective is not None and "error" not in metric.meta:
metric = EvalMetric(metric.score, _apply_objective(metric, objective), metric.meta)
metrics[name] = metric
if "error" in metric.meta:
errored = True
details[name] = metric.meta
elif not metric.passed:
failed = True
details[name] = metric.meta
except Exception as exc: # noqa: BLE001
errored = True
metrics[name] = EvalMetric(0.0, False, {"error": str(exc)})
details[name] = {"error": str(exc)}
```
Note: `objective_by_name` is computed once per case (it depends only on merged config), so invalid config raises `ValueError` at the first case — satisfying the fail-fast requirement. `EvalMetric` is a frozen dataclass, so the re-verdict constructs a new instance preserving score/meta.
- [ ] **Step 4: Run tests to verify they pass**
Run: `python3 -m pytest tests/evals/test_runner.py -q`
Expected: all `TestObjective` tests pass; pre-existing tests still pass.
- [ ] **Step 5: Run the full evals suite**
Run: `python3 -m pytest tests/evals -q`
Expected: 62 existing + new tests all pass (no regressions).
- [ ] **Step 6: Commit**
```bash
git add semantica/evals/runner.py tests/evals/test_runner.py
git commit -m "feat(evals): add per-metric objective support to runner"
```
---
### Task 2: Documentation — usage.md and CHANGELOG
**Files:**
- Modify: `semantica/evals/usage.md`
- Modify: `CHANGELOG.md`
**Interfaces:**
- Consumes: the objective config surface implemented in Task 1 (exact keys: `objective.direction`, `objective.threshold`, `objective.expect`; validation rules).
- Produces: docs only.
- [ ] **Step 1: Add objective section to usage.md**
Append a section after the existing "Run the runner over decision records" section:
```markdown
## Set per-evaluator objectives
By default each evaluator decides its own pass/fail. To override that
verdict at the run level, configure an **objective** per evaluator name:
```python
from semantica.evals import evaluate
# Require a minimum similarity (default direction is maximize):
evaluate(
[("apple", "aple")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "maximize", "threshold": 0.7}}},
)
# Lower is better — override the direction:
evaluate(
[("night", "nacht")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize", "threshold": 0.5}}},
)
# Boolean expectation on a 0/1 metric:
evaluate(
[("ok", "ok")],
evaluators=["exact_match"],
config={"exact_match": {"objective": {"expect": False}}},
)
```
Rules:
- `maximize` + `threshold`: pass iff `score >= threshold`. `maximize` without
a threshold is a no-op (the evaluator's own verdict stands).
- `minimize` + `threshold`: pass iff `score <= threshold`. `minimize`
**requires** a threshold — omitting it raises `ValueError`.
- `expect` (`true`/`false`): pass iff `bool(score)` matches; cannot be
combined with `direction`/`threshold`.
- A metric whose `meta` contains `"error"` is always an error, never affected
by an objective.
- Invalid objective config raises `ValueError` before any evaluator runs.
```
- [ ] **Step 2: Add CHANGELOG entry**
Under `## [Unreleased]` → `### Added`, insert a new bullet at the top (before the `semantica.evals` module entry), following existing style:
```markdown
- **`semantica.evals` runner gains per-metric objectives** (#1091)
- `evaluate()` now accepts `config={"<evaluator>": {"objective": {"direction": "maximize"|"minimize", "threshold": X}}}` to override the evaluator's default pass verdict with a threshold; `{"objective": {"expect": bool}}` expresses a Boolean expectation
- `minimize` requires a `threshold`; `maximize` without one is a no-op; `expect` cannot be combined with `direction`/`threshold`; invalid config raises `ValueError` before any evaluator runs
- Error metrics are never affected by objectives (error wins over fail)
- Backward compatible: no `objective` key → existing behavior unchanged
- New tests in `tests/evals/test_runner.py::TestObjective`
```
- [ ] **Step 3: Verify docs examples run**
Run the three examples from Step 1 as a Python script (import `evaluate`, run each snippet) to confirm they don't raise unexpectedly. No test output assertion needed beyond "no exception" and sensible status values.
- [ ] **Step 4: Commit**
```bash
git add semantica/evals/usage.md CHANGELOG.md
git commit -m "docs(evals): document per-metric objectives"
```
---
## Self-Review Notes
- **Spec coverage:** §3.1 (config surface) → Task 1 helpers + Task 2 docs; §3.2 (semantics: maximize/minimize/expect) → Task 1 `_apply_objective`; §3.3 (error wins) → Task 1 error branch + `test_error_metric_wins_over_objective`; §3.4 rules 1-3 (validation) → Task 1 `_parse_objective` + 4 validation tests; §3.4 rule 4 → error branch; §3.5 (aggregation unchanged, details on final verdict) → Task 1 loop + `test_expect_false_overrides_passing_metric` asserts `details`; §4 (fail-fast ValueError) → `_parse_objective` at case top; §5 (tests) → Task 1 test class; §6 (compat) → `test_no_objective_unchanged` + full-suite green.
- **Type consistency:** `_parse_objective(name, eval_config) -> Optional[Dict]`, `_apply_objective(metric, objective) -> bool`; `EvalMetric(score, passed, meta)` positional construction preserved everywhere.
- **Backward compat:** objective parsed to `None` for absent config → loop behavior identical to before.
@@ -0,0 +1,115 @@
# Design: Objective layer for `semantica.evals` runner
**Date:** 2026-08-19
**Issue:** semantica-agi/semantica#1091 (assigned to pkupt)
**Base:** PR #1090 (`semantica.evals` module)
## 1. Problem
`semantica.evals` runs named evaluators and aggregates per-case pass/fail, but the pass judgement is hard-coded inside each evaluator — a higher score always means "better". There is no way to express an evaluation objective at the run level:
- apply a threshold the evaluator does not encode (e.g. "F1 must be ≥ 0.7");
- reverse the direction (e.g. "lower edit distance is better");
- express a Boolean expectation (e.g. "this metric should be `false`").
This blocks the domain-specific benchmark harnesses `docs/community-projects.md` says `semantica.evals` supports. Palantir AIP Evals models exactly this: each metric has an **objective** (Boolean expected value, or numeric `maximize`/`minimize` direction with an optional threshold), and a test case passes when **all** its metrics meet their objectives.
## 2. Scope
In scope:
- A per-metric objective configuration consumed by the `evaluate()` runner.
- Runner-level pass/fail re-decision for numeric scores and Boolean metrics.
- Backward-compatible behavior when no objective is configured.
- Tests and docs.
Out of scope:
- Changing the evaluator signature or the `EvalMetric` shape.
- Multi-iteration test cases (AIP Evals has them; Semantica's runner is single-iteration per case).
- Objective-aware aggregation beyond per-case `pass`/`fail` (existing `pass_rate` semantics are kept).
## 3. Design
### 3.1 Configuration surface
Objective is configured per evaluator inside the runner's `config`, under the evaluator name:
```python
config = {
"<evaluator_name>": {
"objective": {
"direction": "maximize" | "minimize",
"threshold": <float>, # optional
}
}
}
```
Boolean-form objective (shorthand): for metrics whose score is Boolean-like (0.0/1.0) or for semantic clarity, `{"objective": {"expect": true}}` / `{"objective": {"expect": false}}` is also supported.
### 3.2 Evaluation semantics
For each metric produced by an evaluator during a case run, if an objective exists for that evaluator name, the runner recomputes the metric's pass verdict:
- **maximize**: pass iff `score >= threshold`. If no `threshold` is given, the objective is treated as absent (evaluator's own verdict stands) — see 3.4 rule 2.
- **minimize**: pass iff `score <= threshold` (threshold required, see 3.4 rule 1).
- **expect**: pass iff `bool(score)` equals `expect` (for Boolean-style metrics).
When an objective is present, the runner **overrides** `metric.passed` with the objective verdict. When absent, `metric.passed` is used unchanged (existing behavior).
The `objective` key is a **reserved runner-level key**: it is consumed by the runner and is passed through to the evaluator function inside `eval_config` (evaluators already ignore unknown config keys via `cfg.get(...)`, so this is harmless); evaluators must not rely on it. The runner re-decision happens on the metric the evaluator returns, so no evaluator change is required.
### 3.3 Interaction with errors
An `EvalMetric` whose `meta` contains `"error"` remains classified as an error regardless of objective (error wins over fail, per the existing contract). Objectives only affect non-error metrics.
### 3.4 Ambiguity rules (explicit decisions)
1. **`minimize` without `threshold`** is rejected at config-validation time with a clear error (`ValueError`), because "lowest is best" has no absolute pass bar without a threshold. (AIP Evals allows direction-only; we require threshold to keep pass/fail well-defined.) — *Chosen for determinism; revisit if a use case demands direction-only minimize.*
2. **`maximize` without `threshold`** behaves like no objective (pass iff evaluator's own `passed`), because the evaluator's default is already "higher is better".
3. **`expect` with a numeric `direction`/`threshold`** is a config error (`ValueError`): pick one form.
4. **Objective on a metric that errors** → the error wins (3.3), objective ignored.
### 3.5 Aggregation
Unchanged:
- Case `status`: `"error"` if any metric errored, else `"fail"` if any failed, else `"pass"`.
- `pass_rate` = passed / total (1.0 on empty).
- `metrics` dict holds the (possibly re-verdict'd) `EvalMetric`; the re-verdict is observable via `metric.passed`.
- `details[name]` is populated when a metric ends up failed **after** objective re-decision (i.e. objective-failed metrics appear in `details`; metrics that pass under objective are not recorded there). This mirrors the existing "record failures in details" behavior applied to the final verdict.
### 3.6 Files
- `semantica/evals/runner.py` — add objective parsing/validation and re-decision inside the evaluator loop.
- `tests/evals/test_runner.py` — new test class(es) for objective semantics.
- `semantica/evals/usage.md` — document the objective config and examples.
- `CHANGELOG.md``[Unreleased]` entry.
No new dependencies; Python ≥ 3.8 (stdlib `typing`).
## 4. Error handling
- Invalid objective config (`direction` not in {maximize, minimize}, both `expect` and `direction`, `minimize` without threshold, non-numeric threshold) → `ValueError` raised at runner config parse, before any evaluator runs. Deterministic, fail-fast.
- These are programmer errors, not per-case data errors — no per-case `error` status involved.
## 5. Testing
New tests in `tests/evals/test_runner.py`:
1. maximize + threshold: score ≥ threshold → pass; below → fail.
2. minimize + threshold: score ≤ threshold → pass; above → fail (e.g. levenshtein on a close pair).
3. minimize without threshold → `ValueError`.
4. expect=true / expect=false on a Boolean metric (exact_match) — pass/fail per expectation.
5. no objective → existing behavior unchanged (evaluator's own verdict).
6. objective + error metric → error wins (status=error, not fail).
7. config error (bad direction) → `ValueError` raised by `evaluate()`.
8. objective turns a passing metric into failing → `details` records it; case status becomes fail.
9. backward-compat: all existing 62 tests keep passing.
## 6. Compatibility
- Public API (`evaluate`, `list_evaluators`, `get_evaluator`, types) unchanged in signature.
- `EvalMetric` shape unchanged (score, passed, meta) — only `passed` may be recomputed by the runner.
- Existing configs (no `objective` key) behave identically.
+17 -6
View File
@@ -1,10 +1,21 @@
"""
Semantica Evals Module
"""Semantica Evals — evaluation layer for decision intelligence outputs.
Coming Soon
Provides a small library of deterministic and model-backed evaluators plus a
runner for measuring decision records, audit trails, and reasoning output.
"""
__version__ = "0.1.1"
__status__ = "coming_soon"
__all__ = []
from . import decision_evaluators # noqa: F401 (registers decision_scores)
from . import evaluators # noqa: F401 (registers the generic evaluators)
from .registry import get_evaluator, list_evaluators
from .runner import evaluate
from .types import CaseResult, EvalMetric, EvalSummary
__version__ = "0.1.0"
__all__ = [
"evaluate",
"get_evaluator",
"list_evaluators",
"CaseResult",
"EvalMetric",
"EvalSummary",
]
+90
View File
@@ -0,0 +1,90 @@
"""Decision-specialized evaluator.
``decision_scores`` validates a ``Decision`` (or dict) against field-level and
governance-level checks: expected outcome, confidence bounds, non-empty
required fields, provenance presence, and (when configured) policy compliance
via ``PolicyEngine.check_compliance``.
"""
from typing import Any, Dict, Optional
from .registry import register
from .types import EvalMetric
def _coerce_decision(actual: Any):
"""Return a Decision or None; never raise for dict inputs."""
from semantica.context.decision_models import Decision
if isinstance(actual, Decision):
return actual
if isinstance(actual, dict):
try:
return Decision(**actual)
except (TypeError, ValueError, KeyError):
return None
return None
@register("decision_scores")
def decision_scores(actual, expected=None, config=None, **kwargs):
"""Composite evaluator over a Decision; see module docstring for sub-checks."""
cfg = config or {}
decision = _coerce_decision(actual)
if decision is None:
return EvalMetric(0.0, False, {"error": "input is not a valid Decision or dict"})
checks: Dict[str, bool] = {}
reasons: Dict[str, str] = {}
expected_outcome = cfg.get("expected_outcome", expected)
if expected_outcome is not None:
checks["decision_outcome"] = decision.outcome == expected_outcome
if not checks["decision_outcome"]:
reasons["decision_outcome"] = f"expected {expected_outcome!r}, got {decision.outcome!r}"
lo = cfg.get("min_confidence", 0.0)
hi = cfg.get("max_confidence", 1.0)
checks["decision_confidence"] = lo <= decision.confidence <= hi
if not checks["decision_confidence"]:
reasons["decision_confidence"] = f"{decision.confidence} not in [{lo}, {hi}]"
for field in ("decision_maker", "reasoning", "scenario"):
value = getattr(decision, field, None)
checks[field] = isinstance(value, str) and bool(value.strip())
if not checks[field]:
reasons[field] = f"field {field!r} is empty"
metadata = decision.metadata if isinstance(decision.metadata, dict) else {}
prov = metadata.get(cfg.get("provenance_key", "provenance"))
checks["provenance"] = bool(prov)
if not checks["provenance"]:
reasons["provenance"] = "no provenance record found in metadata"
policy_engine = cfg.get("policy_engine")
policy_id = cfg.get("policy_id")
if policy_engine is not None and policy_id is not None:
try:
compliant = bool(policy_engine.check_compliance(decision, policy_id))
checks["policy"] = compliant == cfg.get("expected_policy_compliant", True)
if not checks["policy"]:
reasons["policy"] = f"compliance={compliant}"
except Exception as exc: # noqa: BLE001
checks["policy"] = False
reasons["policy"] = str(exc)
if cfg.get("causal_chain_exists"):
raise NotImplementedError(
"decision_scores causal_chain_exists is an interface slot reserved for V2"
)
passed_count = sum(checks.values())
total = len(checks)
passed = total > 0 and passed_count == total
meta = dict(checks)
meta["reasons"] = reasons
return EvalMetric(
score=passed_count / total if total else 0.0,
passed=passed,
meta=meta,
)
+181
View File
@@ -0,0 +1,181 @@
"""Generic (non-decision) evaluators for the evals module.
Each evaluator takes ``(actual, expected, config=None, **kwargs)`` and returns
an ``EvalMetric``. Config uses ``min``/``max`` bounds where relevant.
"""
from datetime import datetime
from typing import Any, Dict, List, Optional
from .registry import register
from .types import EvalMetric
def _default_config(config):
return config or {}
@register("exact_match")
def exact_match(actual, expected, config=None, **kwargs):
"""Score 1.0 if ``actual`` equals ``expected`` (scalar or list)."""
matched = actual == expected
return EvalMetric(
score=1.0 if matched else 0.0,
passed=matched,
meta={} if matched else {"reason": f"expected {expected!r}, got {actual!r}"},
)
@register("regex_match")
def regex_match(actual, expected, config=None, **kwargs):
"""Score 1.0 if string ``actual`` matches regex ``expected``."""
import re
try:
matched = re.search(expected, actual) is not None
return EvalMetric(
score=1.0 if matched else 0.0,
passed=matched,
meta={} if matched else {"reason": f"'{actual}' does not match {expected}"},
)
except re.error as exc:
return EvalMetric(0.0, False, {"error": str(exc)})
@register("numeric_range")
def numeric_range(actual, expected=None, config=None, **kwargs):
"""Score 1.0 if number ``actual`` is within inclusive ``[min, max]``."""
cfg = _default_config(config)
lo, hi = cfg.get("min"), cfg.get("max")
passed = lo is not None and hi is not None and lo <= actual <= hi
return EvalMetric(
score=1.0 if passed else 0.0,
passed=passed,
meta={} if passed else {"reason": f"{actual} not in [{lo}, {hi}]"},
)
@register("temporal_range")
def temporal_range(actual, expected=None, config=None, **kwargs):
"""Score 1.0 if datetime ``actual`` is within inclusive ISO-datetime window."""
cfg = _default_config(config)
try:
stamp = datetime.fromisoformat(actual)
lo = datetime.fromisoformat(cfg["min"])
hi = datetime.fromisoformat(cfg["max"])
passed = lo <= stamp <= hi
return EvalMetric(
score=1.0 if passed else 0.0,
passed=passed,
meta={} if passed else {"reason": f"{actual} not in [{cfg['min']}, {cfg['max']}]"},
)
except (KeyError, TypeError, ValueError) as exc:
return EvalMetric(0.0, False, {"error": str(exc)})
@register("length_range")
def length_range(actual, expected=None, config=None, **kwargs):
"""Score 1.0 if length of ``actual`` is within inclusive ``[min, max]``."""
cfg = _default_config(config)
size = len(actual)
lo = cfg.get("min", 0)
hi = cfg.get("max")
passed = hi is not None and lo <= size <= hi
return EvalMetric(
score=1.0 if passed else 0.0,
passed=passed,
meta={} if passed else {"reason": f"length {size} not in [{lo}, {hi}]"},
)
@register("keyword_check")
def keyword_check(actual, expected=None, config=None, **kwargs):
"""Score 1.0 if all required terms appear in ``actual`` (word-boundary matching)."""
cfg = _default_config(config)
required = cfg.get("required") or (expected or [])
import re
tokens = set(re.findall(r"\w+", str(actual).lower()))
missing = [term for term in required if str(term).lower() not in tokens]
passed = not missing
return EvalMetric(
score=1.0 if passed else 0.0,
passed=passed,
meta={} if passed else {"missing": missing},
)
def _levenshtein(a: str, b: str) -> int:
"""Classic Levenshtein edit distance."""
if a == b:
return 0
if not a:
return len(b)
if not b:
return len(a)
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
cur = [i]
for j, cb in enumerate(b, 1):
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
prev = cur
return prev[-1]
@register("levenshtein")
def levenshtein(actual, expected, config=None, **kwargs):
"""Score normalized similarity (1 - distance/max_len) vs ``threshold`` (default 0.8)."""
cfg = _default_config(config)
threshold = cfg.get("threshold", 0.8)
a, b = str(actual), str(expected)
max_len = max(len(a), len(b))
similarity = 1.0 if max_len == 0 else 1.0 - _levenshtein(a, b) / max_len
passed = similarity >= threshold
return EvalMetric(
score=similarity,
passed=passed,
meta={"similarity": similarity},
)
def _tokenize(text: str) -> List[str]:
import re
return re.findall(r"\w+", str(text).lower())
@register("rouge")
def rouge(actual, expected, config=None, **kwargs):
"""ROUGE-1 precision/recall/F1 over tokens; pass on F1 >= ``threshold`` (default 0.0)."""
cfg = _default_config(config)
threshold = cfg.get("threshold", 0.0)
hyp, ref = _tokenize(actual), _tokenize(expected)
from collections import Counter
hyp_c, ref_c = Counter(hyp), Counter(ref)
overlap = sum((hyp_c & ref_c).values())
precision = overlap / len(hyp) if hyp else 0.0
recall = overlap / len(ref) if ref else 0.0
f1 = 0.0 if (precision + recall) == 0 else 2 * precision * recall / (precision + recall)
passed = f1 > 0 and f1 >= threshold
return EvalMetric(
score=f1,
passed=passed,
meta={"precision": precision, "recall": recall, "f1": f1},
)
@register("llm_as_judge")
def llm_as_judge(actual, expected, config=None, **kwargs):
"""Score 1.0 when a caller-supplied ``judge_fn(actual, expected) -> bool`` passes.
The judge resolver stays lazy: no LLM backend is imported unless the caller
provides one in config.
"""
cfg = _default_config(config)
judge_fn = cfg.get("judge_fn")
if judge_fn is None:
return EvalMetric(
0.0, False, {"error": "config['judge_fn'] required (callable(actual, expected) -> bool)"}
)
try:
verdict = bool(judge_fn(actual, expected))
return EvalMetric(score=1.0 if verdict else 0.0, passed=verdict)
except Exception as exc: # noqa: BLE001
return EvalMetric(0.0, False, {"error": str(exc)})
+34
View File
@@ -0,0 +1,34 @@
"""Evaluator registry for the evals module.
Evaluators are plain functions ``fn(actual, expected, config=None, **kwargs)
-> EvalMetric`` registered under a stable string name so the runner and users
can select them by name without importing individual modules.
"""
from typing import Callable, Dict, List
from .types import EvalMetric
EVALUATORS: Dict[str, Callable] = {}
def register(name: str) -> Callable:
"""Decorator registering an evaluator function under ``name``."""
def _register(fn: Callable) -> Callable:
if name in EVALUATORS:
raise ValueError(f"evaluator already registered: {name}")
EVALUATORS[name] = fn
return fn
return _register
def list_evaluators() -> List[str]:
"""Return sorted names of all registered evaluators."""
return sorted(EVALUATORS)
def get_evaluator(name: str) -> Callable:
"""Look up an evaluator by name, raising ValueError with a hint otherwise."""
if name not in EVALUATORS:
raise ValueError(f"unknown evaluator '{name}'. Available: {list_evaluators()}")
return EVALUATORS[name]
+211
View File
@@ -0,0 +1,211 @@
"""Evaluation runner: orchestrates evaluators over a list of cases."""
import math
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from .registry import get_evaluator
from .types import CaseResult, EvalMetric, EvalSummary
Case = Union[Dict[str, Any], Tuple[Any, Any]]
def _coerce_threshold(name, threshold):
"""Convert ``threshold`` to a finite float, raising ``ValueError`` otherwise.
Accepts any value that ``float()`` accepts (int, float, bool, numeric
strings) as long as the result is finite. Raises ``ValueError`` — never
``TypeError`` — for non-convertible types, NaN, and infinity so that
all invalid objective config produces the same exception type.
"""
try:
value = float(threshold)
except (TypeError, ValueError) as exc:
raise ValueError(
f"objective for '{name}': 'threshold' must be a finite number "
f"(got {threshold!r})"
) from exc
if not math.isfinite(value):
raise ValueError(
f"objective for '{name}': 'threshold' must be a finite number "
f"(got {threshold!r})"
)
return value
def _parse_objective(name, eval_config):
"""Return the validated objective dict, or None when not configured.
Raises ValueError for invalid configurations (programmer error).
"""
objective = (eval_config or {}).get("objective")
if objective is None:
return None
if not isinstance(objective, dict):
raise ValueError(
f"objective for '{name}': expected a dict, got {type(objective).__name__}"
)
direction = objective.get("direction")
threshold = objective.get("threshold")
expect = objective.get("expect")
if expect is not None:
if not isinstance(expect, bool):
raise ValueError(
f"objective for '{name}': 'expect' must be a bool (got {expect!r})"
)
if direction is not None or threshold is not None:
raise ValueError(
f"objective for '{name}': 'expect' cannot be combined with "
"'direction' or 'threshold'"
)
return {"expect": expect}
if direction == "minimize":
if threshold is None:
raise ValueError(
f"objective for '{name}': 'minimize' requires a 'threshold'"
)
return {"direction": "minimize", "threshold": _coerce_threshold(name, threshold)}
if direction == "maximize":
if threshold is None:
# no bar to re-decide against; treat as absent (evaluator default stands)
return None
return {"direction": "maximize", "threshold": _coerce_threshold(name, threshold)}
raise ValueError(
f"objective for '{name}': 'direction' must be 'maximize' or 'minimize' "
f"(got {direction!r})"
)
def _apply_objective(metric, objective):
"""Return the objective-adjusted pass verdict for a non-error metric."""
if "expect" in objective:
return bool(metric.score) == objective["expect"]
if objective["direction"] == "minimize":
return metric.score <= objective["threshold"]
return metric.score >= objective["threshold"]
def _extract(case: Case, target_fn: Optional[Callable]):
"""Return (case_id, expected, actual, config, per_case_target_fn)."""
if isinstance(case, tuple):
expected, actual = case[0], (case[1] if len(case) > 1 else None)
return str(id(case)), expected, actual, {}, None
case_id = case.get("id") or f"case-{id(case)}"
expected = case.get("expected")
actual = case.get("actual")
config = case.get("config") or {}
per_fn = case.get("target_fn")
return case_id, expected, actual, config, per_fn
def _merge_config(default_config: Dict[str, Any], case_config: Dict[str, Any]) -> Dict[str, Any]:
"""Deep-merge per-case config over the global config (two levels deep).
Level 1 (top-level keys, e.g. evaluator names): merged key-by-key so a
per-case override of one evaluator's settings does not erase the whole
global evaluator entry.
Level 2 (evaluator config keys, e.g. ``"objective"``): also merged
key-by-key so a per-case override that specifies only some objective fields
(e.g. just ``"threshold"``) inherits the rest from the global objective
(e.g. ``"direction"``). Per-case values always take precedence.
Depth-3+ values are replaced wholesale, consistent with the previous
single-level behaviour (no evaluator config currently nests beyond two
levels). Neither the caller's global config nor the case config is
mutated.
"""
merged = dict(default_config)
for key, value in (case_config or {}).items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
# Merge level-1 dict (evaluator config) key-by-key.
current = dict(merged[key])
for k, v in value.items():
if isinstance(v, dict) and isinstance(current.get(k), dict):
# Merge level-2 dict (e.g. objective sub-dict) key-by-key.
inner = dict(current[k])
inner.update(v)
current[k] = inner
else:
current[k] = v
merged[key] = current
else:
merged[key] = value
return merged
def evaluate(
cases: List[Case],
evaluators: List[str],
config: Optional[Dict[str, Any]] = None,
target_fn: Optional[Callable] = None,
) -> EvalSummary:
"""Run named evaluators over each case and aggregate metrics.
A per-case or top-level ``target_fn`` produces ``actual`` when the case
does not already carry one. Evaluator failures become ``error`` results.
"""
default_config = config or {}
case_results: List[CaseResult] = []
# Validate objective config for every case up front so an invalid objective
# rejects the run before any target_fn or evaluator executes (fail-fast),
# regardless of which case carries it.
pre_resolved = []
for case in cases:
_, _, _, case_config, _ = _extract(case, target_fn)
merged = _merge_config(default_config, case_config)
pre_resolved.append(
{
name: _parse_objective(name, merged.get(name) or {})
for name in evaluators
}
)
for case, objective_by_name in zip(cases, pre_resolved):
case_id, expected, actual, case_config, per_fn = _extract(case, target_fn)
merged = _merge_config(default_config, case_config)
if expected is None:
expected = merged.get("expected")
resolver = per_fn or target_fn
if actual is None and resolver is not None:
try:
actual = resolver(case)
except Exception as exc: # noqa: BLE001
case_results.append(
CaseResult(case_id, "error", {}, {"target_fn": str(exc)})
)
continue
metrics: Dict[str, EvalMetric] = {}
details: Dict[str, Any] = {}
failed, errored = False, False
for name in evaluators:
eval_config = merged.get(name) or {}
try:
metric = get_evaluator(name)(actual, expected, config=eval_config)
objective = objective_by_name.get(name)
if objective is not None and "error" not in metric.meta:
metric = EvalMetric(metric.score, _apply_objective(metric, objective), metric.meta)
metrics[name] = metric
if "error" in metric.meta:
errored = True
details[name] = metric.meta
elif not metric.passed:
failed = True
details[name] = metric.meta
except Exception as exc: # noqa: BLE001
errored = True
metrics[name] = EvalMetric(0.0, False, {"error": str(exc)})
details[name] = {"error": str(exc)}
status = "error" if errored else ("fail" if failed else "pass")
case_results.append(CaseResult(case_id, status, metrics, details))
total = len(case_results)
passed = sum(1 for c in case_results if c.status == "pass")
failed = sum(1 for c in case_results if c.status == "fail")
errors = sum(1 for c in case_results if c.status == "error")
pass_rate = (passed / total) if total else 1.0
return EvalSummary(
total, passed, failed, errors, pass_rate,
cases=case_results,
)
+37
View File
@@ -0,0 +1,37 @@
"""Evals result data models.
Defines the metric and result shapes produced by the evals module.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, NamedTuple
@dataclass(frozen=True)
class EvalMetric:
"""One evaluator's numeric score plus pass/fail verdict."""
score: float
passed: bool
meta: Dict[str, Any] = field(default_factory=dict)
class CaseResult(NamedTuple):
"""Evaluation output for a single case."""
case_id: str
status: str
metrics: Dict[str, EvalMetric]
details: Dict[str, Any]
@dataclass
class EvalSummary:
"""Aggregate evaluation output across cases."""
total: int
passed: int
failed: int
errors: int
pass_rate: float
cases: List[CaseResult] = field(default_factory=list)
+176
View File
@@ -0,0 +1,176 @@
# Semantica Evals — Usage
The evals module measures decision intelligence outputs: decision records,
audit trails, and reasoning output — with deterministic and model-backed
evaluators plus a small runner.
## Import
```python
import semantica.evals as evals # through the root lazy proxy
from semantica.evals import evaluate, list_evaluators
```
## Discover evaluators
```python
>>> evals.list_evaluators()
['decision_scores', 'exact_match', 'keyword_check', 'length_range',
'levenshtein', 'llm_as_judge', 'numeric_range', 'regex_match', 'rouge',
'temporal_range']
```
`list_evaluators` returns every name registered by importing the package —
the import wiring runs each evaluator module's `register()` side effects.
## Run the runner over decision records
`evaluate(cases, evaluators, config=None)` accepts a list of cases; each case is
a dict with `expected`, `actual`, optional `config`, and optional `id`. The
`actual` can be a finished `Decision` object or its dict form.
```python
from datetime import datetime
from semantica.context.decision_models import Decision
from semantica.evals import evaluate
decision = Decision(
decision_id="d-1",
category="loan",
scenario="loan-request",
reasoning="vetted by policy",
outcome="approve",
confidence=0.87,
timestamp=datetime.now(),
decision_maker="approver-a",
metadata={"provenance": "workflow:loan/v3"},
)
cases = [
{
"id": "loan-001",
"actual": decision,
"config": {
"decision_scores": {
"expected_outcome": "approve",
"min_confidence": 0.7,
}
},
},
{
"id": "loan-002",
"actual": {
"decision_id": "d-2",
"category": "loan",
"scenario": "loan-request",
"reasoning": "auto",
"outcome": "reject",
"confidence": 0.9,
"timestamp": datetime.now().isoformat(),
"decision_maker": "system",
"metadata": {},
},
"config": {
"decision_scores": {
"expected_outcome": "approve",
"min_confidence": 0.7,
}
},
},
]
summary = evaluate(cases, ["decision_scores"])
```
`evaluate` also runs high-level names like `exact_match`, `keyword_check`, or
`llm_as_judge`; per-case or top-level `config` may carry per-evaluator settings
(e.g. `config={"exact_match": {...}}`).
## Set per-evaluator objectives
By default each evaluator decides its own pass/fail. To override that
verdict at the run level, configure an **objective** per evaluator name:
```python
from semantica.evals import evaluate
# Require a minimum similarity (levenshtein's default bar is >= 0.8; here we set 0.7):
evaluate(
[("apple", "aple")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "maximize", "threshold": 0.7}}},
)
# Lower is better — override the direction:
evaluate(
[("night", "nacht")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize", "threshold": 0.7}}},
)
# Boolean expectation — the metric matches (score 1), but we expect it not to:
evaluate(
[("ok", "ok")],
evaluators=["exact_match"],
config={"exact_match": {"objective": {"expect": False}}},
)
```
Rules:
- `maximize` + `threshold`: pass iff `score >= threshold`. `maximize` without
a threshold is a no-op (the evaluator's own verdict stands).
- `minimize` + `threshold`: pass iff `score <= threshold`. `minimize`
**requires** a threshold — omitting it or setting it to `None` raises
`ValueError`.
- `expect` (`true`/`false`): pass iff `bool(score)` matches; cannot be
combined with `direction`/`threshold`. `expect` must be a real boolean
(a string like `"false"` is rejected).
- A metric whose `meta` contains `"error"` is always an error, never affected
by an objective.
- Invalid objective config (non-dict objective, bad `direction`, non-bool
`expect`, missing `minimize` threshold) raises `ValueError` before any
evaluator runs.
## Interpret the summary
```python
>>> summary.total, summary.passed, summary.failed, summary.errors
(2, 1, 1, 0)
>>> summary.pass_rate
0.5
>>> for case in summary.cases:
... print(case.case_id, case.status)
... for name, metric in case.metrics.items():
... print(" ", name, metric.score, metric.passed)
... print(" ", metric.meta.get("reasons"))
loan-001 pass
decision_scores 1.0 True
{}
loan-002 fail
decision_scores 0.667 False
{'decision_outcome': "expected 'approve', got 'reject'",
'provenance': 'no provenance record found in metadata'}
```
`EvalSummary` fields:
- `total` / `passed` / `failed` / `errors` — case counts by status.
- `pass_rate``passed / total` (1.0 on an empty case list).
- `cases` — one `CaseResult` per input case: `case_id`, `status`
(`pass` | `fail` | `error`), `metrics` (name → `EvalMetric` with `score`,
`passed`, `meta`), and `details`.
Evaluator failures do not crash the run; they surface as `status="error"` on
the affected case with the exception text captured in the metric meta.
## Notes
- **`llm_as_judge` needs `config["judge_fn"]`**: a callable
`judge_fn(actual, expected) -> bool` supplied by the caller. Without it the
evaluator fails with `config['judge_fn'] required`.
- **`decision_scores` governance checks are opt-in**: policy compliance is only
evaluated when both `config["policy_engine"]` and `config["policy_id"]` are
provided; otherwise those checks are skipped. The reserved
`causal_chain_exists` slot is not yet implemented.
+8 -41
View File
@@ -43,6 +43,11 @@ from .class_inferrer import ClassInferrer
from .namespace_manager import NamespaceManager
from .naming_conventions import NamingConventions
from .property_generator import PropertyGenerator
from .relationship_utils import (
build_entity_aliases,
get_relationship_endpoint,
resolve_relationship_endpoint_type,
)
from .ontology_validator import OntologyValidator
@@ -383,56 +388,18 @@ class OntologyGenerator:
@staticmethod
def _build_entity_aliases(entities: List[Dict[str, Any]]) -> Dict[str, set]:
"""Build an unambiguous alias-to-type index for relationship endpoints."""
aliases: Dict[str, set] = {}
for entity in entities:
entity_type = entity.get("type") or entity.get("entity_type")
if not entity_type:
continue
for key in ("id", "entity_id", "name", "text", "label"):
if key not in entity or entity[key] is None or entity[key] == "":
continue
aliases.setdefault(str(entity[key]), set()).add(entity_type)
return aliases
return build_entity_aliases(entities)
@staticmethod
def _get_relationship_endpoint(rel: Dict[str, Any], endpoint: str) -> Any:
"""Return an endpoint value from either ID or legacy relationship fields."""
for key in (f"{endpoint}_id", endpoint):
if key not in rel:
continue
value = rel[key]
if value is None or value == "":
continue
if isinstance(value, dict):
for alias_key in ("id", "entity_id", "name", "text", "label"):
if alias_key not in value:
continue
alias_value = value[alias_key]
if alias_value is not None and alias_value != "":
return alias_value
continue
return value
return None
return get_relationship_endpoint(rel, endpoint)
def _resolve_relationship_endpoint_type(
self, rel: Dict[str, Any], endpoint: str, aliases: Dict[str, set]
) -> Optional[str]:
"""Resolve an endpoint type without treating missing fields as aliases."""
explicit_type = rel.get(f"{endpoint}_type")
if explicit_type and explicit_type != "Entity":
return explicit_type
endpoint_value = self._get_relationship_endpoint(rel, endpoint)
if endpoint_value is not None:
candidates = aliases.get(str(endpoint_value), set())
if len(candidates) == 1:
return next(iter(candidates))
return explicit_type
return resolve_relationship_endpoint_type(rel, endpoint, aliases)
def _stage2_yaml_to_definition(
self, semantic_network: Dict[str, Any], **options
+10 -7
View File
@@ -34,6 +34,7 @@ from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
from .naming_conventions import NamingConventions
from .relationship_utils import build_entity_aliases, resolve_relationship_endpoint_type
class PropertyGenerator:
@@ -106,7 +107,7 @@ class PropertyGenerator:
tracking_id, message="Inferring object properties from relationships..."
)
object_properties = self._infer_object_properties(
relationships, classes, **options
relationships, classes, entities=entities, **options
)
properties.extend(object_properties)
@@ -136,6 +137,7 @@ class PropertyGenerator:
self,
relationships: List[Dict[str, Any]],
classes: List[Dict[str, Any]],
entities: Optional[List[Dict[str, Any]]] = None,
**options,
) -> List[Dict[str, Any]]:
"""Infer object properties from relationships."""
@@ -147,6 +149,7 @@ class PropertyGenerator:
# Create class map
class_map = {cls["name"]: cls for cls in classes}
entity_aliases = build_entity_aliases(entities or [])
properties = []
for rel_type, rels in rel_types.items():
@@ -156,12 +159,12 @@ class PropertyGenerator:
ranges = set()
for rel in rels:
source_type = rel.get(
"source_type"
) or self._infer_class_from_entity(rel.get("source_id"), classes)
target_type = rel.get(
"target_type"
) or self._infer_class_from_entity(rel.get("target_id"), classes)
source_type = resolve_relationship_endpoint_type(
rel, "source", entity_aliases
)
target_type = resolve_relationship_endpoint_type(
rel, "target", entity_aliases
)
if source_type:
domains.add(source_type)
+58
View File
@@ -0,0 +1,58 @@
"""Shared helpers for resolving ontology relationship endpoints."""
from typing import Any, Dict, List, Optional, Set
def build_entity_aliases(entities: List[Dict[str, Any]]) -> Dict[str, Set[str]]:
"""Build an alias-to-type index for relationship endpoints."""
aliases: Dict[str, Set[str]] = {}
for entity in entities:
entity_type = entity.get("type") or entity.get("entity_type")
if not entity_type:
continue
for key in ("id", "entity_id", "name", "text", "label"):
if key not in entity or entity[key] is None or entity[key] == "":
continue
aliases.setdefault(str(entity[key]), set()).add(str(entity_type))
return aliases
def get_relationship_endpoint(rel: Dict[str, Any], endpoint: str) -> Any:
"""Return an endpoint value from either ID or legacy relationship fields."""
for key in (f"{endpoint}_id", endpoint):
if key not in rel:
continue
value = rel[key]
if value is None or value == "":
continue
if isinstance(value, dict):
for alias_key in ("id", "entity_id", "name", "text", "label"):
if alias_key not in value:
continue
alias_value = value[alias_key]
if alias_value is not None and alias_value != "":
return alias_value
continue
return value
return None
def resolve_relationship_endpoint_type(
rel: Dict[str, Any], endpoint: str, aliases: Dict[str, Set[str]]
) -> Optional[str]:
"""Resolve an endpoint type without treating missing fields as aliases."""
explicit_type = rel.get(f"{endpoint}_type")
if explicit_type and explicit_type != "Entity":
return str(explicit_type)
endpoint_value = get_relationship_endpoint(rel, endpoint)
if endpoint_value is not None:
candidates = aliases.get(str(endpoint_value), set())
if len(candidates) == 1:
return next(iter(candidates))
return str(explicit_type) if explicit_type else None
+82 -11
View File
@@ -661,6 +661,15 @@ class MilvusStore:
except Exception:
return None
@staticmethod
def _record_to_result(item: Dict[str, Any]) -> Dict[str, Any]:
vec = item.get("vector")
return {
"id": str(item.get("id")),
"metadata": item.get("metadata") or {},
"vector": np.array(vec) if vec is not None else None,
}
def filter_by_metadata(
self, filters: Dict[str, Any], limit: int = 10
) -> List[Dict[str, Any]]:
@@ -705,21 +714,83 @@ class MilvusStore:
limit=limit,
output_fields=["id", "vector", "metadata"],
)
results = []
for item in query_results:
vec = item.get("vector")
results.append(
{
"id": str(item.get("id")),
"metadata": item.get("metadata") or {},
"vector": np.array(vec) if vec is not None else None,
}
)
return results
return [self._record_to_result(item) for item in query_results]
except Exception as e:
self.logger.warning(f"Failed to query Milvus vectors by metadata expression: {e}")
return []
def iter_all(self, batch_size: int = 500):
"""
Iterate over every stored entity using Milvus's query iterator.
Paginates by primary-key cursor rather than row offset, which is why
this exists instead of scan_vectors(offset, limit). query(offset=...)
is capped by the 16384 result window and would truncate anything
larger.
Assumes the schema create_collection() builds: a VARCHAR `id` primary
key plus vector and metadata fields, as get_vector() and
filter_by_metadata() already do. get_collection() does not validate
schema, so a collection with an integer key or no metadata field fails
here.
Args:
batch_size: Entities to request per iterator batch
Yields:
Result dicts with 'id', 'metadata', and 'vector', in cursor order
Raises:
ProcessingError: If the collection is not initialized, or the
installed pymilvus does not expose query_iterator().
"""
if self.collection is None:
raise ProcessingError(
"Collection not initialized. Call create_collection() or get_collection() first."
)
if not MILVUS_AVAILABLE:
raise ProcessingError("Milvus not available")
query_iterator = getattr(self.collection.collection, "query_iterator", None)
if not callable(query_iterator):
raise ProcessingError(
"This pymilvus version does not expose Collection.query_iterator(), "
"which full enumeration requires. Falling back to query(offset=...) "
"is not safe here: it is capped by the 16384 result window and would "
"silently truncate a larger collection."
)
# Query operations need a loaded collection. Idempotent, and once per
# scan rather than per batch.
self.collection.load()
# Milvus rejects an empty expression; this match-all form is what
# filter_by_metadata() already uses.
iterator = query_iterator(
batch_size=batch_size,
expr="id != ''",
output_fields=["id", "vector", "metadata"],
)
try:
while True:
batch = iterator.next()
if not batch:
return
for item in batch:
yield self._record_to_result(item)
finally:
# Release the server-side iterator even if the consumer stops early.
# Swallowed so a broken connection at cleanup time doesn't replace
# whatever real exception was already propagating out of the try.
close = getattr(iterator, "close", None)
if callable(close):
try:
close()
except Exception as e:
self.logger.warning(f"Failed to close Milvus query iterator: {e}")
def get_stats(self, collection_name: Optional[str] = None) -> Dict[str, Any]:
"""Get collection statistics."""
if self.collection is None and collection_name:
+123
View File
@@ -299,6 +299,44 @@ class PineconeSearch:
)
def _pinecone_listed_ids(response: Any) -> List[str]:
"""Extract vector IDs from a list_paginated() response.
Accepts record objects, bare id strings and dicts, since what listing
returns has changed across pinecone SDK major versions.
"""
records = getattr(response, "vectors", None)
if records is None and isinstance(response, dict):
records = response.get("vectors")
ids: List[str] = []
for record in records or []:
if isinstance(record, str):
ids.append(record)
elif isinstance(record, dict):
if record.get("id") is not None:
ids.append(record["id"])
else:
record_id = getattr(record, "id", None)
if record_id is not None:
ids.append(record_id)
return ids
def _pinecone_next_token(response: Any) -> Optional[str]:
"""Return the continuation token, or None when the listing is exhausted."""
pagination = getattr(response, "pagination", None)
if pagination is None and isinstance(response, dict):
pagination = response.get("pagination")
if pagination is None:
return None
token = getattr(pagination, "next", None)
if token is None and isinstance(pagination, dict):
token = pagination.get("next")
return token or None
class PineconeStore:
"""
Pinecone store for vector storage and similarity search.
@@ -735,6 +773,91 @@ class PineconeStore:
self.logger.warning(f"Failed to filter Pinecone vectors by metadata: {e}")
return []
def iter_all(self, batch_size: int = 500, namespace: str = ""):
"""
Iterate over every stored vector by listing IDs then fetching them.
Paginates with an opaque continuation token, which is why this exists
instead of scan_vectors(offset, limit): the token for page N cannot be
constructed without walking there.
Needs two calls per page, unlike the other backends, because listing
returns IDs only. Both calls are namespace scoped and must agree, and
listing covers one namespace rather than the whole index.
Args:
batch_size: IDs to request per list_paginated() call
namespace: Namespace to enumerate (default: the default namespace)
Yields:
Result dicts with 'id', 'metadata', and 'vector', in listing order
Raises:
ProcessingError: If the index is not initialized, if the installed
SDK does not expose list_paginated(), or if the listing stops
advancing.
"""
if self.index is None or not PINECONE_AVAILABLE:
raise ProcessingError(
"Index not initialized. Call create_index() or get_index() first."
)
# list_paginated() rather than list(): list() is an auto-paging
# iterator in current SDKs but reads as plain id lists in older
# examples. Threading the token explicitly is version-agnostic.
list_paginated = getattr(self.index.index, "list_paginated", None)
if not callable(list_paginated):
raise ProcessingError(
"This pinecone SDK version does not expose Index.list_paginated(), "
"which full enumeration requires."
)
token = None
while True:
kwargs: Dict[str, Any] = {"limit": batch_size, "namespace": namespace}
if token is not None:
kwargs["pagination_token"] = token
response = list_paginated(**kwargs)
vector_ids = _pinecone_listed_ids(response)
# A page listing zero ids is not necessarily exhaustion: Pinecone's
# contract is that a scan ends only when there's no pagination
# token, and a page can legitimately come back empty while
# pagination.next is still set (sparse/filtered namespaces,
# eventual-consistency windows on serverless indexes). Skip the
# fetch (nothing to hydrate) but still fall through to the token
# check below instead of returning early, or a gap like that
# silently truncates the scan with no error.
if vector_ids:
fetched = self.index.fetch_vectors(vector_ids, namespace=namespace)
vectors = fetched.get("vectors") or {}
for vector_id in vector_ids:
entry = vectors.get(vector_id)
if entry is None:
# fetch() omits ids it cannot find: deleted since listing.
continue
values = entry.get("values")
yield {
"id": vector_id,
"metadata": entry.get("metadata") or {},
"vector": np.array(values) if values is not None else None,
}
next_token = _pinecone_next_token(response)
if not next_token:
return
if next_token == token:
# Distinct from exhaustion above: a partial scan here would be
# indistinguishable from a complete one.
raise ProcessingError(
"Pinecone returned the same pagination token twice, so the "
"listing is not advancing. Refusing to return a truncated "
"scan."
)
token = next_token
def fetch_vectors(
self, vector_ids: List[str], namespace: str = "", **options
) -> Dict[str, Any]:
+67 -10
View File
@@ -590,20 +590,77 @@ class QdrantStore:
with_payload=True,
with_vectors=True,
)
results = []
for rec in records:
results.append(
{
"id": str(rec.id),
"metadata": rec.payload or {},
"vector": np.array(rec.vector) if rec.vector is not None else None,
}
)
return results
return [self._record_to_result(rec) for rec in records]
except Exception as e:
self.logger.warning(f"Failed to scroll Qdrant points by metadata filter: {e}")
return []
@staticmethod
def _record_to_result(rec: Any) -> Dict[str, Any]:
return {
"id": str(rec.id),
"metadata": rec.payload or {},
"vector": np.array(rec.vector) if rec.vector is not None else None,
}
def iter_all(self, batch_size: int = 500):
"""
Iterate over every stored point using Qdrant's scroll cursor.
Paginates by point-ID cursor rather than row offset, which is why this
exists instead of scan_vectors(offset, limit). An integer offset is a
point ID, not a rank.
Assumes a single unnamed vector per point, as insert_vectors() and
get_vector() already do. Named and multi-vector collections are not
handled.
Args:
batch_size: Points to request per scroll call
Yields:
Result dicts with 'id', 'metadata', and 'vector', in scroll order
Raises:
ProcessingError: If the collection or client is not initialized, or
if the cursor stops advancing before the scan completes.
"""
if self.collection is None or self.client is None or not QDRANT_AVAILABLE:
raise ProcessingError(
"Collection not initialized. Call create_collection() or get_collection() first."
)
next_offset = None
last_offset = object()
while True:
records, next_offset = self.client.scroll(
collection_name=self.collection.collection_name,
limit=batch_size,
offset=next_offset,
with_payload=True,
with_vectors=True,
)
for rec in records:
yield self._record_to_result(rec)
# A final page can carry records alongside a null cursor, so they
# are yielded above before stopping. Passing offset=None back to
# scroll() would restart from the beginning, not continue.
if next_offset is None:
return
# An empty page with a live cursor isn't necessarily truncation —
# a batch window that lands entirely on deleted points comes back
# this way too, and there's more to scan past it. Only treat it as
# stuck if the cursor itself stops moving.
if not records and next_offset == last_offset:
raise ProcessingError(
"Qdrant scroll cursor stopped advancing without reaching "
"the end of the collection, so the scan cannot complete."
)
last_offset = next_offset
def delete_vectors(
self, point_ids: List[Union[str, int]], **options
) -> Dict[str, Any]:
+11 -1
View File
@@ -867,12 +867,22 @@ class VectorStore:
"""
Iterate over every stored vector, one page at a time.
Cursor-based backends expose iter_all() because they cannot support a
positional offset; it takes precedence when present. Everything else
falls through to the scan_vectors() offset loop.
Args:
batch_size: Number of vectors to fetch per underlying scan_vectors() call
batch_size: Number of vectors to fetch per underlying call
Yields:
Result dicts with 'id', 'metadata', and 'vector', in scan order
"""
if self.backend != "inmemory" and self._backend_store is not None:
iter_all = getattr(self._backend_store, "iter_all", None)
if callable(iter_all):
yield from iter_all(batch_size=batch_size)
return
offset = 0
while True:
page = self.scan_vectors(offset=offset, limit=batch_size)
+142 -15
View File
@@ -488,6 +488,28 @@ class WeaviateStore:
self.logger.debug(f"Could not build native Weaviate filter: {e}")
return None
def _fetch_objects_offset_or_plain(self, kwargs: Dict[str, Any], scanned_count: int):
"""Retry a failed `after`-cursor fetch_objects() call with `offset`, then
with no pagination argument at all. Returns (objs, mode)."""
kwargs = dict(kwargs)
kwargs.pop("after", None)
kwargs["offset"] = scanned_count
try:
return self.collection.query.fetch_objects(**kwargs), "offset"
except TypeError:
kwargs.pop("offset", None)
return self.collection.query.fetch_objects(**kwargs), "single_page"
@staticmethod
def _extract_vector(raw_vector: Any) -> Optional[np.ndarray]:
"""weaviate-client v4 returns vector as {'default': [...]} rather than a
bare list; older clients and mocks may still hand back a bare list."""
if isinstance(raw_vector, dict):
raw_vector = raw_vector.get("default")
if raw_vector is None or len(raw_vector) == 0:
return None
return np.array(raw_vector)
def filter_by_metadata(
self, filters: Dict[str, Any], limit: int = 10
) -> List[Dict[str, Any]]:
@@ -533,22 +555,11 @@ class WeaviateStore:
try:
objs = self.collection.query.fetch_objects(**kwargs)
except TypeError:
if "after" in kwargs:
kwargs.pop("after", None)
kwargs["offset"] = scanned_count
try:
objs = self.collection.query.fetch_objects(**kwargs)
except TypeError:
kwargs.pop("offset", None)
objs = self.collection.query.fetch_objects(**kwargs)
if "after" not in kwargs:
raise
objs, _ = self._fetch_objects_offset_or_plain(kwargs, scanned_count)
elif "after" in kwargs:
kwargs.pop("after", None)
kwargs["offset"] = scanned_count
try:
objs = self.collection.query.fetch_objects(**kwargs)
except TypeError:
kwargs.pop("offset", None)
objs = self.collection.query.fetch_objects(**kwargs)
objs, _ = self._fetch_objects_offset_or_plain(kwargs, scanned_count)
else:
raise te
except Exception as fe:
@@ -611,6 +622,122 @@ class WeaviateStore:
self.logger.warning(f"Failed to fetch Weaviate objects by metadata filter: {e}")
return results if results else []
def iter_all(self, batch_size: int = 500):
"""
Iterate over every stored object using Weaviate's UUID cursor.
Paginates by the last object's UUID rather than a row offset, which is
why this exists instead of scan_vectors(offset, limit). An empty page
under that cursor falls back to offset pagination once before ending
the scan, since an empty page isn't on its own proof there's nothing
left past it (see the inline comment below).
Assumes a single unnamed vector per object, as get_vector() and
filter_by_metadata() already do. Named-vector collections return a
mapping and are not handled.
Args:
batch_size: Objects to request per fetch_objects() call
Yields:
Result dicts with 'id', 'metadata', and 'vector', in cursor order
Raises:
ProcessingError: If the collection is not initialized, or if the
scan cannot advance past a full page.
"""
if self.collection is None or not WEAVIATE_AVAILABLE:
raise ProcessingError(
"Collection not initialized. Call get_collection() first."
)
after_cursor = None
scanned_count = 0
# Degrades cursor -> offset -> single_page as the client rejects each
# form. Tracked across iterations, not just inside the except branch,
# or later pages go out with no pagination argument at all.
mode = "cursor"
while True:
kwargs = {"limit": batch_size, "include_vector": True}
if mode == "cursor" and after_cursor is not None:
kwargs["after"] = after_cursor
elif mode == "offset":
kwargs["offset"] = scanned_count
try:
objs = self.collection.query.fetch_objects(**kwargs)
except TypeError:
if mode == "cursor" and "after" in kwargs:
objs, mode = self._fetch_objects_offset_or_plain(kwargs, scanned_count)
elif mode == "offset":
mode = "single_page"
kwargs.pop("offset", None)
objs = self.collection.query.fetch_objects(**kwargs)
else:
raise
batch_objects = getattr(objs, "objects", None) if objs else None
if not batch_objects:
# An empty page in "cursor" mode isn't necessarily the end.
# Unlike an offset, `after` has no server-issued continuation
# value of its own - it's derived client-side from the last
# object's uuid - so an empty page gives nothing to advance
# it with. If Weaviate's cursor walks internal storage
# position rather than strict uuid order, a batch can in
# principle land entirely on a gap (e.g. tombstoned objects)
# with live data past it, the same risk already confirmed for
# Qdrant's scroll cursor (#1316). Offset pagination doesn't
# have that ambiguity - it addresses live rows by position -
# so fall back to it once to confirm before ending the scan.
if mode == "cursor":
mode = "offset"
continue
return
page_full = len(batch_objects) >= batch_size
next_cursor = after_cursor
# Checked before yielding: a page that can't advance is truncation,
# not completion, and the caller shouldn't see any of it go out
# before the error does.
if page_full:
if mode == "single_page":
raise ProcessingError(
"This Weaviate client accepts neither an `after` cursor nor a "
"numeric offset, so the scan cannot advance past the first "
"page. Refusing to return a truncated scan."
)
if mode == "cursor":
last_uuid = getattr(batch_objects[-1], "uuid", None)
if last_uuid is None:
raise ProcessingError(
"The last object of a full Weaviate page has no uuid, so the "
"cursor cannot advance. Refusing to return a truncated scan."
)
next_cursor = str(last_uuid)
if next_cursor == after_cursor:
raise ProcessingError(
"The Weaviate cursor stopped advancing, so the listing is "
"repeating a page. Refusing to return a truncated scan."
)
for obj in batch_objects:
obj_uuid = getattr(obj, "uuid", None)
yield {
"id": str(obj_uuid) if obj_uuid is not None else None,
"metadata": getattr(obj, "properties", None) or {},
"vector": self._extract_vector(getattr(obj, "vector", None)),
}
scanned_count += len(batch_objects)
if not page_full:
return
if mode == "cursor":
after_cursor = next_cursor
def query_vectors(
self,
+136
View File
@@ -0,0 +1,136 @@
"""Tests for the decision_scores composite evaluator."""
import pytest
from datetime import datetime
from semantica.context.decision_models import Decision
from semantica.evals import registry as reg
def _decision(**overrides):
base = dict(
decision_id="d1",
category="loan",
scenario="mortgage application",
reasoning="strong credit history",
outcome="approved",
confidence=0.95,
timestamp=datetime(2026, 1, 1),
decision_maker="loan_officer",
)
base.update(overrides)
return Decision(**base)
class TestDecisionScores:
def test_full_pass(self):
d = _decision(metadata={"provenance": {"prov_record": "rid-1"}})
r = reg.get_evaluator("decision_scores")(
d, config={"expected_outcome": "approved"}
)
assert r.passed
assert r.meta["decision_outcome"] is True
assert r.meta["provenance"] is True
def test_outcome_mismatch(self):
d = _decision(metadata={"provenance": {"prov_record": "rid-1"}})
r = reg.get_evaluator("decision_scores")(
d, config={"expected_outcome": "denied"}
)
assert not r.passed
assert r.meta["decision_outcome"] is False
def test_outcome_from_expected_argument(self):
d = _decision(metadata={"provenance": {"prov_record": "rid-1"}})
r = reg.get_evaluator("decision_scores")(d, expected="approved")
assert r.passed
assert r.meta["decision_outcome"] is True
def test_outcome_mismatch_via_expected_argument(self):
d = _decision(metadata={"provenance": {"prov_record": "rid-1"}})
r = reg.get_evaluator("decision_scores")(d, expected="denied")
assert not r.passed
assert r.meta["decision_outcome"] is False
assert "decision_outcome" in r.meta["reasons"]
def test_outcome_check_skipped_when_no_expected(self):
d = _decision(metadata={"provenance": {"prov_record": "rid-1"}})
r = reg.get_evaluator("decision_scores")(d)
assert "decision_outcome" not in r.meta
def test_confidence_out_of_range(self):
d = _decision(metadata={"provenance": {"prov_record": "rid-1"}}, confidence=0.4)
r = reg.get_evaluator("decision_scores")(
d, config={"expected_outcome": "approved", "min_confidence": 0.8}
)
assert not r.passed
assert r.meta["decision_confidence"] is False
def test_missing_provenance_fails(self):
d = _decision(metadata={})
r = reg.get_evaluator("decision_scores")(d, config={"expected_outcome": "approved"})
assert not r.passed
assert r.meta["provenance"] is False
def test_missing_required_fields(self):
d = _decision(reasoning="")
r = reg.get_evaluator("decision_scores")(d, config={"expected_outcome": "approved"})
assert not r.passed
assert r.meta["reasoning"] is False
def test_dict_input_coerced(self):
d = _decision(metadata={"provenance": {"prov_record": "rid-1"}})
as_dict = d.to_dict()
r = reg.get_evaluator("decision_scores")(
as_dict, config={"expected_outcome": "approved"}
)
assert r.passed
def test_malformed_dict_is_error_not_crash(self):
r = reg.get_evaluator("decision_scores")({"foo": "bar"}, config={})
assert not r.passed
assert r.meta.get("error")
def test_non_dict_metadata_is_error_not_crash(self):
bad = _decision(metadata="not-a-dict")
r = reg.get_evaluator("decision_scores")(bad, config={})
assert not r.passed
assert r.meta["provenance"] is False
def test_policy_compliance_check(self):
class FakePolicyEngine:
def check_compliance(self, decision, policy_id):
return True
d = _decision(metadata={"provenance": {"prov_record": "rid-1"}})
r = reg.get_evaluator("decision_scores")(
d, config={
"expected_outcome": "approved",
"policy_engine": FakePolicyEngine(),
"policy_id": "p1",
"expected_policy_compliant": True,
}
)
assert r.meta["policy"] is True
def test_policy_mismatch_fails(self):
class FakePolicyEngine:
def check_compliance(self, decision, policy_id):
return False
d = _decision(metadata={"provenance": {"prov_record": "rid-1"}})
r = reg.get_evaluator("decision_scores")(
d, config={
"policy_engine": FakePolicyEngine(),
"policy_id": "p1",
"expected_policy_compliant": True,
}
)
assert not r.passed
assert r.meta["policy"] is False
def test_causal_chain_gate(self):
d = _decision(metadata={"provenance": {"prov_record": "rid-1"}}, decision_id="only-decision")
with pytest.raises(NotImplementedError):
reg.get_evaluator("decision_scores")(
d, config={"causal_chain_exists": True, "graph_store": object()}
)
+81
View File
@@ -0,0 +1,81 @@
"""Tests for generic evaluators: exact, regex, ranges, length."""
import pytest
from semantica.evals import registry as reg
class TestExactMatch:
def test_exact_str(self):
r = reg.get_evaluator("exact_match")("approved", "approved")
assert r.passed and r.score == 1.0
def test_exact_str_negative(self):
r = reg.get_evaluator("exact_match")("approved", "denied")
assert not r.passed and r.score == 0.0
def test_exact_number(self):
r = reg.get_evaluator("exact_match")(5, 5)
assert r.passed
def test_exact_array(self):
r = reg.get_evaluator("exact_match")([1, 2], [1, 2])
assert r.passed
class TestRegexMatch:
def test_matching(self):
r = reg.get_evaluator("regex_match")("abc123", r"^[a-z]+\d+$")
assert r.passed
def test_non_matching(self):
r = reg.get_evaluator("regex_match")("ABC", r"^[a-z]+$")
assert not r.passed
assert "ABC" in r.meta.get("reason", "")
def test_invalid_regex_is_error_metric(self):
r = reg.get_evaluator("regex_match")("x", "[invalid")
assert not r.passed
assert r.meta.get("error")
class TestNumericRange:
def test_inside(self):
r = reg.get_evaluator("numeric_range")(0.9, config={"min": 0.8, "max": 1.0})
assert r.passed and r.score == 1.0
def test_outside(self):
r = reg.get_evaluator("numeric_range")(0.5, config={"min": 0.8, "max": 1.0})
assert not r.passed and r.score == 0.0
def test_bounds_inclusive(self):
assert reg.get_evaluator("numeric_range")(0.8, config={"min": 0.8, "max": 0.8}).passed
class TestTemporalRange:
def test_inside_window(self):
r = reg.get_evaluator("temporal_range")(
"2026-01-15T10:00:00",
config={"min": "2026-01-01T00:00:00", "max": "2026-02-01T00:00:00"},
)
assert r.passed
def test_outside_window(self):
r = reg.get_evaluator("temporal_range")(
"2026-03-01T00:00:00",
config={"min": "2026-01-01T00:00:00", "max": "2026-02-01T00:00:00"},
)
assert not r.passed
class TestLengthRange:
def test_ok(self):
r = reg.get_evaluator("length_range")("hello", config={"min": 3, "max": 5})
assert r.passed
def test_too_long(self):
r = reg.get_evaluator("length_range")([1, 2, 3], config={"min": 1, "max": 2})
assert not r.passed
def test_min_not_given_defaults_zero(self):
r = reg.get_evaluator("length_range")("abc", config={"max": 5})
assert r.passed
+67
View File
@@ -0,0 +1,67 @@
"""Tests for generic evaluators: keyword, levenshtein, rouge, llm-as-judge."""
import pytest
from semantica.evals import registry as reg
class TestKeywordCheck:
def test_all_required_present(self):
r = reg.get_evaluator("keyword_check")(
"the loan was approved", expected=["loan", "approved"]
)
assert r.passed
def test_missing_keyword(self):
r = reg.get_evaluator("keyword_check")(
"the loan was approved", expected=["loan", "denied"]
)
assert not r.passed
assert "denied" in r.meta.get("missing", [])
def test_short_words_ignored(self):
r = reg.get_evaluator("keyword_check")("x and y", expected=["and"])
assert r.passed
class TestLevenshtein:
def test_identical(self):
r = reg.get_evaluator("levenshtein")("credit approved", "credit approved")
assert r.passed
def test_close_above_threshold(self):
r = reg.get_evaluator("levenshtein")(
"credit approved", "credit denied", config={"threshold": 0.8}
)
assert not r.passed
def test_default_threshold(self):
assert reg.get_evaluator("levenshtein")("a", "a").passed
class TestRouge:
def test_identical(self):
r = reg.get_evaluator("rouge")("loan approved by committee", "loan approved by committee")
assert r.passed
assert r.meta["f1"] == pytest.approx(1.0)
def test_no_overlap(self):
r = reg.get_evaluator("rouge")("one two three", "four five six")
assert not r.passed
def test_partial_sets_meta(self):
r = reg.get_evaluator("rouge")("a b c", "a b d", config={"threshold": 0.5})
assert "precision" in r.meta and "recall" in r.meta
class TestLlmAsJudge:
def test_uses_supplied_judge(self):
judge = lambda actual, expected: actual == expected # noqa: E731
r = reg.get_evaluator("llm_as_judge")(
"x", "x", config={"judge_fn": judge}
)
assert r.passed
def test_missing_judge_is_error(self):
r = reg.get_evaluator("llm_as_judge")("x", "y", config={})
assert not r.passed
assert r.meta.get("error")
+32
View File
@@ -0,0 +1,32 @@
"""Tests for the evals public package API."""
from semantica import evals
from semantica.evals import evaluate, get_evaluator, list_evaluators
class TestPublicAPI:
def test_imports(self):
assert callable(evaluate)
assert callable(list_evaluators)
assert callable(get_evaluator)
def test_version_present(self):
assert hasattr(evals, "__version__")
def test_module_proxy_via_root(self):
# semantica.evals must resolve through the lazy proxy
assert hasattr(evals, "evaluate")
def test_all_populated(self):
assert len(evals.__all__) >= 2
assert "evaluate" in evals.__all__
assert "list_evaluators" in evals.__all__
assert "get_evaluator" in evals.__all__
def test_register_discovery(self):
names = evals.list_evaluators()
for expected in (
"exact_match", "regex_match", "numeric_range", "temporal_range",
"length_range", "keyword_check", "levenshtein", "rouge",
"llm_as_judge", "decision_scores",
):
assert expected in names
+36
View File
@@ -0,0 +1,36 @@
"""Tests for the evaluator registry."""
import pytest
from semantica.evals import registry as reg
from semantica.evals.types import EvalMetric
# A unique name that will not collide with any production evaluator.
_TEST_EVAL_NAME = "test_registry_demo_eval"
class TestRegistry:
def teardown_method(self, method):
# Remove the test evaluator after each test that may have registered it,
# so re-runs and randomised collection cannot see stale state.
reg.EVALUATORS.pop(_TEST_EVAL_NAME, None)
def test_register_and_get(self):
@reg.register(_TEST_EVAL_NAME)
def demo(actual, expected, config=None, **kwargs):
return EvalMetric(1.0, True)
assert reg.get_evaluator(_TEST_EVAL_NAME) is demo
assert _TEST_EVAL_NAME in reg.list_evaluators()
def test_registration_is_immutable_after_commit(self):
with pytest.raises(ValueError):
reg.get_evaluator("does_not_exist")
def test_unknown_evaluator_failure_message(self):
with pytest.raises(ValueError) as exc:
reg.get_evaluator("nope")
msg = str(exc.value)
assert "nope" in msg
# The error message lists available evaluators; verify using a name
# that is always registered at import time (independent of test order).
assert "exact_match" in msg
+459
View File
@@ -0,0 +1,459 @@
"""Tests for the evals runner."""
import pytest
from semantica.evals.runner import evaluate
class TestEvaluate:
def test_raw_tuple_cases(self):
result = evaluate(
[("approved", "approved"), ("approved", "denied")],
evaluators=["exact_match"],
)
assert result.total == 2
assert result.passed == 1
assert result.failed == 1
assert result.errors == 0
assert result.pass_rate == 0.5
def test_dict_cases_with_target_fn(self):
def fn(case):
return "ok" if case["id"] == "good" else "no"
result = evaluate(
[{"id": "good"}, {"id": "bad"}],
evaluators=["exact_match"],
target_fn=fn,
config={"expected": "ok"},
)
assert result.passed == 1
assert result.failed == 1
def test_error_capture(self):
result = evaluate([("x", "y")], evaluators=["does_not_exist"])
assert result.errors == 1
assert result.failed == 0
assert result.pass_rate == 0.0
def test_error_metric_classified_as_error(self):
result = evaluate(
[("[invalid", "x")],
evaluators=["regex_match"],
)
assert result.errors == 1
assert result.failed == 0
assert result.cases[0].status == "error"
def test_error_metric_and_fail_combine_as_error(self):
result = evaluate(
[("[invalid", "apple pie")],
evaluators=["regex_match", "exact_match"],
)
assert result.errors == 1
assert result.failed == 0
assert result.cases[0].status == "error"
def test_per_case_details(self):
result = evaluate([("a", "b")], evaluators=["exact_match"])
case = result.cases[0]
assert case.status == "fail"
assert "exact_match" in case.details
def test_empty_cases(self):
result = evaluate([], evaluators=["exact_match"])
assert result.total == 0 and result.pass_rate == 1.0
def test_multiple_evaluators(self):
result = evaluate(
[("apple pie", "apple pie")],
evaluators=["exact_match", "keyword_check"],
config={"keyword_check": {"required": ["apple"]}},
)
assert result.passed == 1
assert "exact_match" in result.cases[0].metrics
assert "keyword_check" in result.cases[0].metrics
class TestObjective:
def test_maximize_with_threshold_pass(self):
# levenshtein similarity 1.0 for identical, objective demands >= 0.5
result = evaluate(
[("apple", "apple")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "maximize", "threshold": 0.5}}},
)
assert result.cases[0].status == "pass"
assert result.cases[0].metrics["levenshtein"].passed is True
def test_maximize_with_threshold_fail(self):
result = evaluate(
[("apple", "aple")], # similarity < 1.0
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "maximize", "threshold": 0.99}}},
)
assert result.cases[0].status == "fail"
assert result.cases[0].metrics["levenshtein"].passed is False
assert "levenshtein" in result.cases[0].details
def test_minimize_with_threshold_pass(self):
# levenshtein similarity 0.6 for ("night", "nacht"); objective: similarity <= 0.7
result = evaluate(
[("night", "nacht")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize", "threshold": 0.7}}},
)
assert result.cases[0].status == "pass"
assert result.cases[0].metrics["levenshtein"].passed is True
def test_minimize_with_threshold_fail(self):
result = evaluate(
[("night", "nacht")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize", "threshold": 0.1}}},
)
assert result.cases[0].status == "fail"
def test_expect_true_on_boolean_metric(self):
result = evaluate(
[("ok", "ok")],
evaluators=["exact_match"],
config={"exact_match": {"objective": {"expect": True}}},
)
assert result.cases[0].status == "pass"
def test_expect_false_overrides_passing_metric(self):
# exact_match passes (score 1.0) but expectation is false -> fail
result = evaluate(
[("ok", "ok")],
evaluators=["exact_match"],
config={"exact_match": {"objective": {"expect": False}}},
)
assert result.cases[0].status == "fail"
assert result.cases[0].metrics["exact_match"].passed is False
assert "exact_match" in result.cases[0].details
def test_maximize_without_threshold_is_noop(self):
# identical behavior to no objective: evaluator's own verdict stands
result = evaluate(
[("ok", "no")],
evaluators=["exact_match"],
config={"exact_match": {"objective": {"direction": "maximize"}}},
)
assert result.cases[0].status == "fail"
def test_minimize_without_threshold_raises(self):
# direction-only minimize has no well-defined pass bar; must be rejected
with pytest.raises(ValueError, match="'minimize' requires a 'threshold'"):
evaluate(
[("a", "b")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize"}}},
)
def test_minimize_with_explicit_none_threshold_raises(self):
# explicit threshold=None is the same as omitting it; must also be rejected
with pytest.raises(ValueError, match="'minimize' requires a 'threshold'"):
evaluate(
[("a", "b")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize", "threshold": None}}},
)
def test_bad_direction_raises(self):
with pytest.raises(ValueError):
evaluate(
[("a", "b")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "sideways", "threshold": 0.5}}},
)
def test_expect_with_direction_raises(self):
with pytest.raises(ValueError):
evaluate(
[("a", "b")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"expect": True, "direction": "maximize"}}},
)
def test_error_metric_wins_over_objective(self):
result = evaluate(
[("[invalid", "x")],
evaluators=["regex_match"],
config={"regex_match": {"objective": {"direction": "maximize", "threshold": 0.0}}},
)
assert result.cases[0].status == "error"
assert result.errors == 1
assert result.failed == 0
def test_no_objective_unchanged(self):
result = evaluate([("ok", "no")], evaluators=["exact_match"])
assert result.cases[0].status == "fail"
def test_non_dict_objective_raises(self):
with pytest.raises(ValueError):
evaluate(
[("a", "b")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": "maximize"}},
)
def test_non_bool_expect_raises(self):
with pytest.raises(ValueError):
evaluate(
[("a", "b")],
evaluators=["exact_match"],
config={"exact_match": {"objective": {"expect": "false"}}},
)
def test_invalid_per_case_objective_fails_fast_before_target_fn(self):
calls = []
def side_effectful_target_fn(case):
calls.append(case)
return "line"
with pytest.raises(ValueError):
evaluate(
[{"id": "c1"}, {"id": "c2", "config": {"levenshtein": {"objective": {"direction": "diagonal"}}}}],
evaluators=["levenshtein"],
target_fn=side_effectful_target_fn,
)
# validation must reject the run before any case is processed
assert calls == []
def test_case_config_keeps_global_objective(self):
# global objective on the evaluator must survive a per-case override
# that touches other settings for the same evaluator (deep merge)
result = evaluate(
[{"id": "c1", "expected": "abc", "actual": "abd",
"config": {"levenshtein": {"ignore_case": False}}}],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize", "threshold": 0.0}}},
)
# levenshtein("abc","abd") == 1 > 0 -> objective fails the case
assert result.cases[0].status == "fail"
class TestMergeConfig:
"""Focused tests for _merge_config two-level deep-merge semantics."""
def test_partial_per_case_objective_inherits_global_direction(self):
# Per-case overrides only threshold; direction must come from global.
result = evaluate(
[{"id": "c1", "expected": "abc", "actual": "abd",
"config": {"levenshtein": {"objective": {"threshold": 0.99}}}}],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize", "threshold": 0.0}}},
)
# Effective objective: minimize, threshold=0.99.
# levenshtein("abc","abd") similarity ~0.667; 0.667 <= 0.99 -> pass.
assert result.cases[0].status == "pass"
assert result.cases[0].metrics["levenshtein"].passed is True
def test_partial_per_case_objective_inherits_global_threshold(self):
# Per-case overrides only direction; threshold must come from global.
result = evaluate(
[{"id": "c1", "expected": "abc", "actual": "abd",
"config": {"levenshtein": {"objective": {"direction": "maximize"}}}}],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize", "threshold": 0.99}}},
)
# Effective objective: maximize, threshold=0.99.
# levenshtein("abc","abd") similarity ~0.667; 0.667 >= 0.99 -> fail.
assert result.cases[0].status == "fail"
assert result.cases[0].metrics["levenshtein"].passed is False
def test_per_case_threshold_overrides_global_threshold(self):
# Global: minimize, threshold=0.0 (would fail for any positive score).
# Per-case: threshold=0.99 (almost everything passes minimize).
result = evaluate(
[{"id": "c1", "expected": "abc", "actual": "abd",
"config": {"levenshtein": {"objective": {"threshold": 0.99}}}}],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize", "threshold": 0.0}}},
)
# Effective: minimize, threshold=0.99 -> ~0.667 <= 0.99 -> pass.
assert result.cases[0].status == "pass"
def test_per_case_direction_overrides_global_direction(self):
# Global: maximize, threshold=0.99 (would fail for ~0.667).
# Per-case: direction=minimize (with inherited threshold=0.99).
result = evaluate(
[{"id": "c1", "expected": "abc", "actual": "abd",
"config": {"levenshtein": {"objective": {"direction": "minimize"}}}}],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "maximize", "threshold": 0.99}}},
)
# Effective: minimize, threshold=0.99 -> ~0.667 <= 0.99 -> pass.
assert result.cases[0].status == "pass"
def test_fully_specified_per_case_objective_replaces_global(self):
# Both direction and threshold specified per-case; nothing from global.
result = evaluate(
[{"id": "c1", "expected": "abc", "actual": "abd",
"config": {"levenshtein": {"objective": {"direction": "maximize", "threshold": 0.5}}}}],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize", "threshold": 0.0}}},
)
# Effective: maximize, threshold=0.5 -> ~0.667 >= 0.5 -> pass.
assert result.cases[0].status == "pass"
def test_per_case_non_objective_keys_do_not_erase_global_objective(self):
# Per-case touches only non-objective evaluator keys; global objective intact.
result = evaluate(
[{"id": "c1", "expected": "abc", "actual": "abd",
"config": {"levenshtein": {"threshold": 0.5}}}],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize", "threshold": 0.0}}},
)
# Effective: minimize, threshold=0.0 -> ~0.667 > 0.0 -> fail.
assert result.cases[0].status == "fail"
def test_no_objective_anywhere_unchanged(self):
# No objectives anywhere; evaluator's own verdict stands throughout.
result = evaluate(
[{"id": "c1", "expected": "ok", "actual": "ok",
"config": {"exact_match": {"some_key": "v"}}}],
evaluators=["exact_match"],
config={"exact_match": {"other_key": "w"}},
)
assert result.cases[0].status == "pass"
def test_global_config_not_mutated(self):
import copy
global_config = {"levenshtein": {"objective": {"direction": "minimize", "threshold": 0.5}}}
case_config = {"levenshtein": {"objective": {"threshold": 0.2}}}
original_global = copy.deepcopy(global_config)
original_case = copy.deepcopy(case_config)
evaluate(
[{"id": "c1", "expected": "abc", "actual": "abd", "config": case_config}],
evaluators=["levenshtein"],
config=global_config,
)
assert global_config == original_global
assert case_config == original_case
class TestThresholdValidation:
"""Threshold coercion and validation: types, NaN, infinity."""
# --- valid numeric thresholds ---
def test_maximize_integer_threshold(self):
# int is a valid threshold; coerced to float
result = evaluate(
[("apple", "apple")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "maximize", "threshold": 1}}},
)
assert result.cases[0].status == "pass"
assert result.cases[0].metrics["levenshtein"].passed is True
def test_minimize_integer_threshold(self):
result = evaluate(
[("night", "nacht")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize", "threshold": 1}}},
)
# similarity 0.6 <= 1 -> pass
assert result.cases[0].status == "pass"
# --- invalid threshold types ---
def test_non_numeric_string_threshold_raises(self):
with pytest.raises(ValueError, match="'threshold' must be a finite number"):
evaluate(
[("a", "b")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "maximize", "threshold": "high"}}},
)
def test_list_threshold_raises_value_error(self):
# Must be ValueError, not TypeError
with pytest.raises(ValueError, match="'threshold' must be a finite number"):
evaluate(
[("a", "b")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "maximize", "threshold": [0.5]}}},
)
def test_dict_threshold_raises_value_error(self):
with pytest.raises(ValueError, match="'threshold' must be a finite number"):
evaluate(
[("a", "b")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "maximize", "threshold": {"v": 1}}}},
)
# --- NaN and infinity ---
def test_nan_threshold_raises(self):
with pytest.raises(ValueError, match="'threshold' must be a finite number"):
evaluate(
[("a", "b")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "maximize", "threshold": float("nan")}}},
)
def test_positive_infinity_threshold_raises(self):
with pytest.raises(ValueError, match="'threshold' must be a finite number"):
evaluate(
[("a", "b")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "maximize", "threshold": float("inf")}}},
)
def test_negative_infinity_threshold_raises(self):
with pytest.raises(ValueError, match="'threshold' must be a finite number"):
evaluate(
[("a", "b")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "maximize", "threshold": float("-inf")}}},
)
def test_nan_minimize_threshold_raises(self):
with pytest.raises(ValueError, match="'threshold' must be a finite number"):
evaluate(
[("a", "b")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize", "threshold": float("nan")}}},
)
# --- preserved behaviors ---
def test_maximize_without_threshold_still_noop(self):
# maximize without threshold remains a no-op regardless of threshold validation
result = evaluate(
[("ok", "no")],
evaluators=["exact_match"],
config={"exact_match": {"objective": {"direction": "maximize"}}},
)
assert result.cases[0].status == "fail"
def test_minimize_explicit_none_threshold_still_raises(self):
# threshold=None for minimize hits the None check before coercion
with pytest.raises(ValueError, match="'minimize' requires a 'threshold'"):
evaluate(
[("a", "b")],
evaluators=["levenshtein"],
config={"levenshtein": {"objective": {"direction": "minimize", "threshold": None}}},
)
def test_threshold_errors_are_fail_fast(self):
# Invalid threshold on case 2 must reject the whole run before case 1 executes
calls = []
def recording_fn(case):
calls.append(case)
return "x"
with pytest.raises(ValueError, match="'threshold' must be a finite number"):
evaluate(
[
{"id": "c1"},
{"id": "c2", "config": {"levenshtein": {"objective": {"direction": "maximize", "threshold": [0.5]}}}},
],
evaluators=["levenshtein"],
target_fn=recording_fn,
)
assert calls == []
+43
View File
@@ -0,0 +1,43 @@
"""Tests for evals result models."""
import pytest
from semantica.evals.types import CaseResult, EvalMetric, EvalSummary
class TestEvalMetric:
def test_construction(self):
m = EvalMetric(score=1.0, passed=True, meta={"threshold": 1.0})
assert m.score == 1.0 and m.passed and m.meta["threshold"] == 1.0
def test_default_meta(self):
m = EvalMetric(0.0, False)
assert m.meta == {}
def test_default_meta_is_not_shared(self):
m1 = EvalMetric(0.0, False)
m2 = EvalMetric(0.0, False)
m1.meta["mutated"] = True
assert "mutated" not in m2.meta
class TestCaseResult:
def test_status_fail_on_any_failed_metric(self):
r = CaseResult(
case_id="c1",
status="fail",
metrics={"exact_match": EvalMetric(0.0, False)},
details={},
)
assert r.status == "fail"
assert r.metrics["exact_match"].passed is False
class TestEvalSummary:
def test_pass_rate(self):
s = EvalSummary(total=10, passed=8, failed=1, errors=1, pass_rate=0.8)
assert s.pass_rate == 0.8
def test_cases_are_mutable(self):
s = EvalSummary(0, 0, 0, 0, 1.0)
s.cases.append(CaseResult("c", "pass", {}, {}))
assert len(s.cases) == 1
@@ -96,3 +96,29 @@ def test_nested_endpoint_alias_skips_empty_id_and_uses_name():
works_for = _object_property(ontology, "worksFor")
assert works_for["domain"] == ["Person"]
assert works_for["range"] == ["Organization"]
def test_public_infer_properties_resolves_id_endpoints():
data = {
"entities": [
{"id": "p1", "type": "Person", "name": "Alice"},
{"id": "p2", "type": "Person", "name": "Bob"},
{"id": "o1", "type": "Organization", "name": "Acme"},
{"id": "o2", "type": "Organization", "name": "Beta"},
],
"relationships": [
{"source_id": "p1", "target_id": "o1", "type": "works_for"},
{"source_id": "p2", "target_id": "o2", "type": "works_for"},
],
}
generator = OntologyGenerator()
classes = generator.infer_classes(data)
works_for = next(
prop
for prop in generator.infer_properties(data, classes)
if prop["name"] == "worksFor"
)
assert works_for["domain"] == ["Person"]
assert works_for["range"] == ["Organization"]
+33 -47
View File
@@ -6,54 +6,40 @@ import os
# Add project root to path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# Mock dependencies to avoid import hangs and external calls
sys.modules['spacy'] = MagicMock()
sys.modules['semantica.semantic_extract.methods'] = MagicMock()
sys.modules['semantica.utils.logging'] = MagicMock()
sys.modules['semantica.utils.progress_tracker'] = MagicMock()
sys.modules['semantica.semantic_extract.providers'] = MagicMock()
# The extractors are imported for real. Mocks are installed per test in setUp
# rather than at module scope: pytest imports every test module during
# collection, so anything assigned into sys.modules here is still in place when
# later test modules are imported, and they bind the mocks into their own
# globals. A tearDownModule cannot undo that — by then collection is finished.
from semantica.semantic_extract.ner_extractor import NERExtractor, Entity # noqa: E402
from semantica.semantic_extract.relation_extractor import RelationExtractor, Relation # noqa: E402
from semantica.semantic_extract.triplet_extractor import TripletExtractor # noqa: E402
# Mock get_logger and get_progress_tracker
mock_logger = MagicMock()
sys.modules['semantica.utils.logging'].get_logger.return_value = mock_logger
mock_tracker = MagicMock()
sys.modules['semantica.utils.progress_tracker'].get_progress_tracker.return_value = mock_tracker
# Mock the methods module functions specifically
mock_methods = sys.modules['semantica.semantic_extract.methods']
mock_methods.get_entity_method = MagicMock()
mock_methods.get_relation_method = MagicMock()
mock_methods.get_triplet_method = MagicMock()
# Mock specific extraction functions
mock_extract_entities_hf = MagicMock()
mock_extract_relations_hf = MagicMock()
mock_extract_triplets_hf = MagicMock()
# Setup the registry mocks to return our mock functions
mock_methods.get_entity_method.return_value = mock_extract_entities_hf
mock_methods.get_relation_method.return_value = mock_extract_relations_hf
mock_methods.get_triplet_method.return_value = mock_extract_triplets_hf
# Now import the classes under test
# We need to patch where they import 'methods' locally if they do
with patch.dict(sys.modules):
from semantica.semantic_extract.ner_extractor import NERExtractor
from semantica.semantic_extract.relation_extractor import RelationExtractor
from semantica.semantic_extract.triplet_extractor import TripletExtractor
from semantica.semantic_extract.ner_extractor import Entity
from semantica.semantic_extract.relation_extractor import Relation
class TestExtractorsDispatch(unittest.TestCase):
def setUp(self):
self.mock_extract_entities_hf = mock_extract_entities_hf
self.mock_extract_relations_hf = mock_extract_relations_hf
self.mock_extract_triplets_hf = mock_extract_triplets_hf
self.mock_extract_entities_hf.reset_mock()
self.mock_extract_relations_hf.reset_mock()
self.mock_extract_triplets_hf.reset_mock()
# The extractors resolve `from .methods import get_entity_method` lazily
# inside their methods, so the stand-in only has to be in sys.modules
# while a test runs. patch.dict removes it again afterwards.
self.mock_methods = MagicMock()
patcher = patch.dict(
sys.modules,
{"semantica.semantic_extract.methods": self.mock_methods},
)
patcher.start()
self.addCleanup(patcher.stop)
self.mock_extract_entities_hf = MagicMock()
self.mock_extract_relations_hf = MagicMock()
self.mock_extract_triplets_hf = MagicMock()
self.mock_methods.get_entity_method.return_value = self.mock_extract_entities_hf
self.mock_methods.get_relation_method.return_value = (
self.mock_extract_relations_hf
)
self.mock_methods.get_triplet_method.return_value = (
self.mock_extract_triplets_hf
)
# Configure mocks to return something iterable/valid
self.mock_extract_entities_hf.return_value = [MagicMock(spec=Entity, confidence=0.9, text="Test Entity")]
@@ -71,7 +57,7 @@ class TestExtractorsDispatch(unittest.TestCase):
extractor.extract_entities(text, model="my-custom-ner-model")
# Verify get_entity_method was called with "huggingface"
mock_methods.get_entity_method.assert_called_with("huggingface")
self.mock_methods.get_entity_method.assert_called_with("huggingface")
# Verify the extraction function was called with correct model
# We need to check the call args to see if 'model' was passed correctly
@@ -96,7 +82,7 @@ class TestExtractorsDispatch(unittest.TestCase):
extractor.extract_relations(text, entities, model="my-relation-model")
# Verify dispatch
mock_methods.get_relation_method.assert_called_with("huggingface")
self.mock_methods.get_relation_method.assert_called_with("huggingface")
call_args = self.mock_extract_relations_hf.call_args
self.assertIsNotNone(call_args, "extract_relations_huggingface should have been called")
@@ -116,7 +102,7 @@ class TestExtractorsDispatch(unittest.TestCase):
extractor.extract_triplets(text, model="my-triplet-model")
# Verify dispatch
mock_methods.get_triplet_method.assert_called_with("huggingface")
self.mock_methods.get_triplet_method.assert_called_with("huggingface")
call_args = self.mock_extract_triplets_hf.call_args
self.assertIsNotNone(call_args, "extract_triplets_huggingface should have been called")
+176
View File
@@ -0,0 +1,176 @@
"""Tests for MilvusStore.iter_all() query-iterator enumeration.
pymilvus is not installed in this environment, so these drive the real
MilvusStore against MagicMocks, following the pattern already used for milvus
in test_backend_metadata_filtering.py.
"""
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from semantica.utils.exceptions import ProcessingError
from semantica.vector_store.milvus_store import MilvusStore
def _store_with_batches(*batches):
"""MilvusStore whose query_iterator yields the given batches then stops.
The attribute path is doubled here: the pymilvus Collection sits at
wrapper.collection.
"""
store = MilvusStore()
wrapper = MagicMock()
inner = MagicMock()
iterator = MagicMock()
iterator.next.side_effect = list(batches)
inner.query_iterator.return_value = iterator
wrapper.collection = inner
store.collection = wrapper
return store, wrapper, inner, iterator
@patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", True)
def test_iter_all_yields_batches_until_exhausted():
"""Exhaustion is an empty list, not StopIteration."""
store, _, _, iterator = _store_with_batches(
[{"id": 1, "vector": [0.1], "metadata": {}}],
[{"id": 2, "vector": [0.2], "metadata": {}}],
[],
)
result = list(store.iter_all(batch_size=1))
assert [item["id"] for item in result] == ["1", "2"]
assert iterator.next.call_count == 3
@patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", True)
def test_iter_all_requests_the_fields_needed_for_the_result_shape():
store, _, inner, _ = _store_with_batches([])
list(store.iter_all(batch_size=64))
kwargs = inner.query_iterator.call_args[1]
assert kwargs["batch_size"] == 64
assert kwargs["output_fields"] == ["id", "vector", "metadata"]
# Milvus rejects an empty expression, so a match-all form is required.
assert kwargs["expr"] == "id != ''"
@patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", True)
def test_iter_all_loads_the_collection_before_querying():
"""Milvus requires a loaded collection for query operations."""
store, wrapper, _, _ = _store_with_batches([])
list(store.iter_all())
assert wrapper.load.called
@patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", True)
def test_iter_all_closes_the_iterator_on_exhaustion():
store, _, _, iterator = _store_with_batches([])
list(store.iter_all())
assert iterator.close.called
@patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", True)
def test_iter_all_closes_the_iterator_when_consumer_stops_early():
"""Abandoning the generator early must still release the iterator."""
store, _, _, iterator = _store_with_batches(
[{"id": 1, "vector": [0.1], "metadata": {}}],
[{"id": 2, "vector": [0.2], "metadata": {}}],
[],
)
generator = store.iter_all(batch_size=1)
next(generator)
assert not iterator.close.called
generator.close()
assert iterator.close.called
@patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", True)
def test_iter_all_converts_entities_to_the_shared_result_shape():
store, _, _, _ = _store_with_batches(
[{"id": 7, "vector": [0.1, 0.2, 0.3], "metadata": {"tag": "x"}}], []
)
item = list(store.iter_all())[0]
assert item["id"] == "7"
assert item["metadata"] == {"tag": "x"}
np.testing.assert_allclose(item["vector"], np.array([0.1, 0.2, 0.3]))
@patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", True)
def test_iter_all_handles_missing_vector_and_metadata():
store, _, _, _ = _store_with_batches([{"id": 1, "vector": None, "metadata": None}], [])
item = list(store.iter_all())[0]
assert item["metadata"] == {}
assert item["vector"] is None
@patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", True)
def test_iter_all_empty_collection_yields_nothing():
store, _, _, _ = _store_with_batches([])
assert list(store.iter_all()) == []
@patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", True)
def test_iter_all_raises_when_query_iterator_is_unavailable():
"""Older pymilvus lacks query_iterator; falling back to query(offset=...)
would truncate at the 16384 window."""
store = MilvusStore()
wrapper = MagicMock()
wrapper.collection = MagicMock(spec=["query"])
store.collection = wrapper
with pytest.raises(ProcessingError, match="query_iterator"):
list(store.iter_all())
@patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", True)
def test_iter_all_raises_when_collection_not_initialized():
"""Must fail loudly: an empty scan reads the same as an empty source."""
store = MilvusStore()
with pytest.raises(ProcessingError, match="Collection not initialized"):
list(store.iter_all())
@patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", False)
def test_iter_all_raises_when_milvus_unavailable():
store = MilvusStore()
store.collection = MagicMock()
with pytest.raises(ProcessingError):
list(store.iter_all())
@patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", True)
def test_iter_all_propagates_iterator_errors():
store, _, _, iterator = _store_with_batches()
iterator.next.side_effect = RuntimeError("connection reset")
with pytest.raises(RuntimeError, match="connection reset"):
list(store.iter_all())
@patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", True)
def test_iter_all_closes_the_iterator_when_a_batch_fails():
store, _, _, iterator = _store_with_batches()
iterator.next.side_effect = RuntimeError("connection reset")
with pytest.raises(RuntimeError):
list(store.iter_all())
assert iterator.close.called
+185
View File
@@ -249,6 +249,191 @@ class TestPineconeIndex(unittest.TestCase):
mock_index.query.assert_called_once()
class TestPineconeIterAll(unittest.TestCase):
"""PineconeStore.iter_all() list-then-fetch enumeration."""
def _page(self, ids, next_token):
"""Stand-in for a list_paginated() response."""
response = MagicMock()
response.vectors = [MagicMock(id=vector_id) for vector_id in ids]
response.pagination = MagicMock(next=next_token)
return response
def _store(self, pages, fetch_results):
store = PineconeStore()
wrapper = MagicMock()
raw_index = MagicMock()
raw_index.list_paginated.side_effect = list(pages)
wrapper.index = raw_index
wrapper.fetch_vectors.side_effect = list(fetch_results)
store.index = wrapper
return store, wrapper, raw_index
@patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True)
def test_threads_pagination_token_across_pages(self):
store, _, raw_index = self._store(
[self._page(["a", "b"], "token-1"), self._page(["c"], None)],
[
{"vectors": {"a": {"values": [0.1], "metadata": {}},
"b": {"values": [0.2], "metadata": {}}}},
{"vectors": {"c": {"values": [0.3], "metadata": {}}}},
],
)
result = list(store.iter_all(batch_size=2))
self.assertEqual([item["id"] for item in result], ["a", "b", "c"])
calls = raw_index.list_paginated.call_args_list
self.assertNotIn("pagination_token", calls[0][1])
self.assertEqual(calls[1][1]["pagination_token"], "token-1")
@patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True)
def test_hydrates_listed_ids_with_a_fetch(self):
"""Listing returns ids only, so each page needs a fetch()."""
store, wrapper, _ = self._store(
[self._page(["a"], None)],
[{"vectors": {"a": {"values": [0.1, 0.2], "metadata": {"tag": "x"}}}}],
)
item = list(store.iter_all())[0]
self.assertEqual(item["id"], "a")
self.assertEqual(item["metadata"], {"tag": "x"})
np.testing.assert_allclose(item["vector"], np.array([0.1, 0.2]))
wrapper.fetch_vectors.assert_called_once_with(["a"], namespace="")
@patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True)
def test_list_and_fetch_use_the_same_namespace(self):
store, wrapper, raw_index = self._store(
[self._page(["a"], None)],
[{"vectors": {"a": {"values": [0.1], "metadata": {}}}}],
)
list(store.iter_all(namespace="prod"))
self.assertEqual(raw_index.list_paginated.call_args[1]["namespace"], "prod")
wrapper.fetch_vectors.assert_called_once_with(["a"], namespace="prod")
@patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True)
def test_skips_ids_deleted_between_list_and_fetch(self):
"""fetch() omits ids it cannot find rather than returning blanks."""
store, _, _ = self._store(
[self._page(["a", "gone"], None)],
[{"vectors": {"a": {"values": [0.1], "metadata": {}}}}],
)
result = list(store.iter_all())
self.assertEqual([item["id"] for item in result], ["a"])
@patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True)
def test_raises_when_pagination_token_repeats(self):
"""A stalled token must not loop forever, nor quietly return a partial
scan that reads as a complete one."""
store, _, raw_index = self._store(
[self._page(["a"], "same"), self._page(["b"], "same")],
[
{"vectors": {"a": {"values": [0.1], "metadata": {}}}},
{"vectors": {"b": {"values": [0.2], "metadata": {}}}},
],
)
with self.assertRaises(ProcessingError):
list(store.iter_all())
self.assertEqual(raw_index.list_paginated.call_count, 2)
@patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True)
def test_empty_listing_yields_nothing_without_fetching(self):
store, wrapper, _ = self._store([self._page([], None)], [])
self.assertEqual(list(store.iter_all()), [])
wrapper.fetch_vectors.assert_not_called()
@patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True)
def test_continues_past_an_empty_page_with_a_live_token(self):
"""An empty page is not necessarily the end: Pinecone can legitimately
list zero ids for a page while pagination.next is still set (sparse
or filtered namespaces, eventual-consistency windows on serverless
indexes). Only the absence of a next token means exhaustion."""
store, wrapper, raw_index = self._store(
[
self._page(["a"], "token-1"),
self._page([], "token-2"), # empty page, but the token still advances
self._page(["b"], None),
],
[
{"vectors": {"a": {"values": [0.1], "metadata": {}}}},
{"vectors": {"b": {"values": [0.2], "metadata": {}}}},
],
)
result = list(store.iter_all(batch_size=1))
self.assertEqual([item["id"] for item in result], ["a", "b"])
self.assertEqual(raw_index.list_paginated.call_count, 3)
# Nothing to hydrate on the empty page, so only two fetches happen.
self.assertEqual(wrapper.fetch_vectors.call_count, 2)
@patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True)
def test_accepts_plain_string_ids_from_listing(self):
"""SDK generations differ on what listing yields."""
store, _, _ = self._store(
[self._page([], None)],
[{"vectors": {"a": {"values": [0.1], "metadata": {}}}}],
)
response = MagicMock()
response.vectors = ["a"]
response.pagination = MagicMock(next=None)
store.index.index.list_paginated.side_effect = [response]
self.assertEqual([item["id"] for item in store.iter_all()], ["a"])
@patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True)
def test_handles_missing_values_and_metadata(self):
store, _, _ = self._store(
[self._page(["a"], None)],
[{"vectors": {"a": {"values": None, "metadata": None}}}],
)
item = list(store.iter_all())[0]
self.assertIsNone(item["vector"])
self.assertEqual(item["metadata"], {})
@patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True)
def test_raises_when_list_paginated_unavailable(self):
store = PineconeStore()
wrapper = MagicMock()
wrapper.index = MagicMock(spec=["query", "fetch"])
store.index = wrapper
with self.assertRaises(ProcessingError):
list(store.iter_all())
@patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True)
def test_raises_when_index_not_initialized(self):
"""Must fail loudly: an empty scan reads the same as an empty source."""
with self.assertRaises(ProcessingError):
list(PineconeStore().iter_all())
@patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', False)
def test_raises_when_pinecone_unavailable(self):
store = PineconeStore()
store.index = MagicMock()
with self.assertRaises(ProcessingError):
list(store.iter_all())
@patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True)
def test_propagates_listing_errors(self):
store, _, raw_index = self._store([], [])
raw_index.list_paginated.side_effect = RuntimeError("connection reset")
with self.assertRaises(RuntimeError):
list(store.iter_all())
if __name__ == '__main__':
print("DEBUG: Starting unittest.main()")
unittest.main()
+160
View File
@@ -0,0 +1,160 @@
"""Tests for QdrantStore.iter_all() cursor enumeration.
Qdrant is not installed in this environment, so these drive the real
QdrantStore against a MagicMock standing in for the qdrant_client, following
the pattern already used for qdrant in test_backend_metadata_filtering.py.
"""
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from semantica.utils.exceptions import ProcessingError
from semantica.vector_store.qdrant_store import QdrantStore
def _record(point_id, payload=None, vector=None):
"""Build a stand-in for a qdrant_client Record."""
rec = MagicMock()
rec.id = point_id
rec.payload = payload
rec.vector = vector
return rec
def _store_with_scroll(*pages):
"""QdrantStore whose client.scroll() returns the given (records, cursor) pages."""
store = QdrantStore()
store.client = MagicMock()
store.client.scroll.side_effect = list(pages)
store.collection = MagicMock()
store.collection.collection_name = "test_collection"
return store
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_iter_all_threads_cursor_across_pages():
"""The next call continues from the previous page's cursor."""
store = _store_with_scroll(
([_record(1), _record(2)], "cursor-1"),
([_record(3)], None),
)
result = list(store.iter_all(batch_size=2))
assert [item["id"] for item in result] == ["1", "2", "3"]
calls = store.client.scroll.call_args_list
assert len(calls) == 2
assert calls[0][1]["offset"] is None
assert calls[0][1]["limit"] == 2
assert calls[1][1]["offset"] == "cursor-1"
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_iter_all_yields_final_page_that_reports_no_next_cursor():
"""Records and a null cursor can arrive together; those records must still
be yielded or every scan loses its tail."""
store = _store_with_scroll(([_record(1), _record(2)], None))
result = list(store.iter_all(batch_size=10))
assert [item["id"] for item in result] == ["1", "2"]
assert store.client.scroll.call_count == 1
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_iter_all_converts_records_to_the_shared_result_shape():
store = _store_with_scroll(
([_record(7, payload={"tag": "x"}, vector=[0.1, 0.2, 0.3])], None),
)
item = list(store.iter_all())[0]
assert item["id"] == "7"
assert item["metadata"] == {"tag": "x"}
np.testing.assert_allclose(item["vector"], np.array([0.1, 0.2, 0.3]))
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_iter_all_handles_missing_payload_and_vector():
store = _store_with_scroll(([_record(1, payload=None, vector=None)], None))
item = list(store.iter_all())[0]
assert item["metadata"] == {}
assert item["vector"] is None
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_iter_all_empty_collection_yields_nothing():
store = _store_with_scroll(([], None))
assert list(store.iter_all()) == []
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_iter_all_continues_past_empty_page_with_advancing_cursor():
store = _store_with_scroll(
([], "cursor-1"),
([_record(1)], None),
)
result = list(store.iter_all())
assert [item["id"] for item in result] == ["1"]
assert store.client.scroll.call_count == 2
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_iter_all_raises_when_cursor_stops_advancing():
store = _store_with_scroll(
([], "stuck-cursor"),
([], "stuck-cursor"),
)
with pytest.raises(ProcessingError, match="stopped advancing"):
list(store.iter_all())
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_iter_all_raises_when_collection_not_initialized():
"""Must fail loudly: an empty scan reads the same as an empty source."""
store = QdrantStore()
with pytest.raises(ProcessingError, match="Collection not initialized"):
list(store.iter_all())
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", False)
def test_iter_all_raises_when_qdrant_unavailable():
store = QdrantStore()
store.client = MagicMock()
store.collection = MagicMock()
with pytest.raises(ProcessingError):
list(store.iter_all())
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_iter_all_propagates_scroll_errors():
store = QdrantStore()
store.client = MagicMock()
store.client.scroll.side_effect = RuntimeError("connection reset")
store.collection = MagicMock()
store.collection.collection_name = "test_collection"
with pytest.raises(RuntimeError, match="connection reset"):
list(store.iter_all())
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_iter_all_requests_payload_and_vectors():
store = _store_with_scroll(([], None))
list(store.iter_all())
kwargs = store.client.scroll.call_args[1]
assert kwargs["with_payload"] is True
assert kwargs["with_vectors"] is True
assert kwargs["collection_name"] == "test_collection"
@@ -26,6 +26,7 @@ from unittest.mock import MagicMock, patch
import numpy as np
from semantica.utils.exceptions import ProcessingError
from semantica.vector_store.vector_store import VectorStore, VectorManager
@@ -138,6 +139,34 @@ class _NonScanningBackendStore:
"""Fake persistent backend store without any scan capability."""
class _IterAllBackendStore:
"""Fake cursor-based store: iter_all() only, no usable scan_vectors()."""
def __init__(self, items):
self._items = items
self.batch_sizes = []
def iter_all(self, batch_size=500):
self.batch_sizes.append(batch_size)
for item in self._items:
yield item
def scan_vectors(self, offset=0, limit=100):
raise AssertionError("scan_vectors() must not be called when iter_all() exists")
class _MisShapedIterAllBackendStore:
"""Backend store whose ``iter_all`` attribute is not callable."""
iter_all = 42 # plain attribute, not a method
def __init__(self, items):
self._items = items
def scan_vectors(self, offset=0, limit=100):
return self._items[offset:offset + limit]
class VectorStoreScanVectorsTests(unittest.TestCase):
"""VectorStore.scan_vectors() / iter_vectors() backend-agnostic accessors."""
@@ -192,6 +221,72 @@ class VectorStoreScanVectorsTests(unittest.TestCase):
self.assertEqual(list(store.iter_vectors(batch_size=2)), [])
# ---------------------------------------------------------------------------
# VectorStore.iter_vectors() preference for a native iter_all()
# ---------------------------------------------------------------------------
class VectorStoreIterAllDispatchTests(unittest.TestCase):
"""iter_vectors() prefers a backend's native iter_all() when present."""
def _persistent_store(self, backend_store, backend_name="qdrant"):
store = VectorStore(backend="inmemory", dimension=2)
store.backend = backend_name
store._backend_store = backend_store
return store
def test_iter_vectors_uses_iter_all_when_available(self):
items = [
{"id": "a", "vector": None, "metadata": {"n": 1}},
{"id": "b", "vector": None, "metadata": {"n": 2}},
]
backend = _IterAllBackendStore(items)
store = self._persistent_store(backend)
self.assertEqual(list(store.iter_vectors(batch_size=7)), items)
def test_iter_vectors_forwards_batch_size_to_iter_all(self):
backend = _IterAllBackendStore([])
store = self._persistent_store(backend)
list(store.iter_vectors(batch_size=32))
self.assertEqual(backend.batch_sizes, [32])
def test_iter_vectors_falls_back_to_scan_vectors_without_iter_all(self):
items = [{"id": "a", "vector": None, "metadata": {}}]
store = self._persistent_store(_ScanningBackendStore(items))
self.assertEqual(list(store.iter_vectors(batch_size=2)), items)
def test_iter_vectors_falls_back_when_iter_all_not_callable(self):
# Mirrors the count() precedent in _MisShapedBackendStore.
items = [{"id": "a", "vector": None, "metadata": {}}]
store = self._persistent_store(_MisShapedIterAllBackendStore(items))
self.assertEqual(list(store.iter_vectors(batch_size=2)), items)
def test_iter_vectors_inmemory_ignores_iter_all(self):
store = VectorStore(backend="inmemory", dimension=2)
store.store_vectors([np.array([1.0, 0.0])], [{"type": "a"}])
store._backend_store = _IterAllBackendStore([{"id": "wrong"}])
collected = list(store.iter_vectors(batch_size=2))
self.assertEqual([item["metadata"] for item in collected], [{"type": "a"}])
def test_iter_vectors_propagates_iter_all_errors(self):
# Silently yielding nothing would read as an empty source (#1083).
class _FailingIterAll:
def iter_all(self, batch_size=500):
raise ProcessingError("backend unreachable")
yield # pragma: no cover - makes this a generator
store = self._persistent_store(_FailingIterAll())
with self.assertRaises(ProcessingError):
list(store.iter_vectors(batch_size=2))
# ---------------------------------------------------------------------------
# VectorManager tests — inmemory backend
# ---------------------------------------------------------------------------
+260
View File
@@ -0,0 +1,260 @@
"""Tests for WeaviateStore.iter_all() cursor enumeration.
weaviate-client is not installed in this environment, so these drive the real
WeaviateStore against MagicMocks, following the pattern already used for
weaviate in test_backend_metadata_filtering.py.
"""
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from semantica.utils.exceptions import ProcessingError
from semantica.vector_store.weaviate_store import WeaviateStore
def _obj(uuid, properties=None, vector=None):
"""Stand-in for a weaviate v4 returned object."""
obj = MagicMock()
obj.uuid = uuid
obj.properties = properties
obj.vector = vector
return obj
def _page(objects):
"""Stand-in for a fetch_objects() response."""
response = MagicMock()
response.objects = objects
return response
def _store_with_pages(*pages):
store = WeaviateStore()
store.collection = MagicMock()
store.collection.query.fetch_objects.side_effect = list(pages)
return store
@patch("semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE", True)
def test_iter_all_threads_uuid_cursor_across_pages():
"""The next page must continue after the last object's UUID."""
store = _store_with_pages(
_page([_obj("uuid-1"), _obj("uuid-2")]),
_page([_obj("uuid-3")]),
)
result = list(store.iter_all(batch_size=2))
assert [item["id"] for item in result] == ["uuid-1", "uuid-2", "uuid-3"]
calls = store.collection.query.fetch_objects.call_args_list
assert "after" not in calls[0][1]
assert calls[1][1]["after"] == "uuid-2"
@patch("semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE", True)
def test_iter_all_stops_on_short_page():
"""A page smaller than batch_size means the collection is exhausted."""
store = _store_with_pages(_page([_obj("uuid-1")]))
result = list(store.iter_all(batch_size=5))
assert [item["id"] for item in result] == ["uuid-1"]
assert store.collection.query.fetch_objects.call_count == 1
@patch("semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE", True)
def test_iter_all_raises_when_cursor_stops_advancing():
"""A stalled cursor must terminate, but not quietly: a partial scan reads
as a complete one."""
store = WeaviateStore()
store.collection = MagicMock()
store.collection.query.fetch_objects.return_value = _page(
[_obj("same-uuid"), _obj("same-uuid")]
)
with pytest.raises(ProcessingError, match="stopped advancing"):
list(store.iter_all(batch_size=2))
@patch("semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE", True)
def test_iter_all_continues_past_empty_page_in_cursor_mode():
"""A full page followed by an empty page must not be read as the end of
the collection: the empty page could be a gap (e.g. a window landing on
tombstoned objects) with real data past it, the same failure mode
already confirmed for Qdrant's scroll cursor (#1316). The `after` cursor
has no server-issued value to advance past an empty page with, so this
must fall back to offset pagination rather than silently stopping."""
store = _store_with_pages(
_page([_obj("uuid-1"), _obj("uuid-2")]), # full page, cursor -> uuid-2
_page([]), # empty page: not the end
_page([_obj("uuid-3")]), # real data past the gap
)
result = [item["id"] for item in store.iter_all(batch_size=2)]
assert result == ["uuid-1", "uuid-2", "uuid-3"]
calls = store.collection.query.fetch_objects.call_args_list
assert len(calls) == 3
assert calls[1][1]["after"] == "uuid-2" # the empty page still queried by cursor
assert calls[2][1].get("offset") == 2 # then the fallback used position, not the cursor
@patch("semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE", True)
def test_iter_all_offset_fallback_advances_across_pages():
"""Regression: the offset was only set inside the except branch, so pages
after the fallback went out with no pagination at all and the scan
restarted from page one."""
store = WeaviateStore()
store.collection = MagicMock()
calls = []
def _fetch(**kwargs):
calls.append(dict(kwargs))
if "after" in kwargs:
raise TypeError("unexpected keyword argument 'after'")
page_number = len(calls)
if page_number < 4:
return _page([_obj(f"u{page_number}a"), _obj(f"u{page_number}b")])
return _page([_obj("last")])
store.collection.query.fetch_objects.side_effect = _fetch
ids = [item["id"] for item in store.iter_all(batch_size=2)]
assert len(set(ids)) == len(ids), f"duplicate ids means the scan restarted: {ids}"
assert [c.get("offset") for c in calls] == [None, None, 2, 4]
@patch("semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE", True)
def test_iter_all_raises_when_no_pagination_is_supported():
"""A client rejecting both `after` and `offset` cannot page past the first
result."""
store = WeaviateStore()
store.collection = MagicMock()
def _fetch(**kwargs):
if "after" in kwargs or "offset" in kwargs:
raise TypeError("unsupported")
return _page([_obj("a"), _obj("b")])
store.collection.query.fetch_objects.side_effect = _fetch
with pytest.raises(ProcessingError, match="neither an .after. cursor nor a"):
list(store.iter_all(batch_size=2))
@patch("semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE", True)
def test_iter_all_empty_collection_yields_nothing():
"""A genuinely empty collection needs two empty pages to confirm: the
first (in cursor mode) triggers the offset fallback, and the second
(in offset mode, which has no gap ambiguity) is what actually ends the
scan. See test_iter_all_continues_past_empty_page_in_cursor_mode for the
case where the first empty page is *not* the end."""
store = _store_with_pages(_page([]), _page([]))
assert list(store.iter_all()) == []
assert store.collection.query.fetch_objects.call_count == 2
@patch("semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE", True)
def test_iter_all_converts_objects_to_the_shared_result_shape():
store = _store_with_pages(
_page([_obj("uuid-7", properties={"tag": "x"}, vector=[0.1, 0.2, 0.3])]),
)
item = list(store.iter_all())[0]
assert item["id"] == "uuid-7"
assert item["metadata"] == {"tag": "x"}
np.testing.assert_allclose(item["vector"], np.array([0.1, 0.2, 0.3]))
@patch("semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE", True)
def test_iter_all_handles_missing_properties_and_vector():
store = _store_with_pages(_page([_obj("uuid-1", properties=None, vector=None)]))
item = list(store.iter_all())[0]
assert item["metadata"] == {}
assert item["vector"] is None
@patch("semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE", True)
def test_iter_all_treats_empty_vector_as_none():
store = _store_with_pages(_page([_obj("uuid-1", vector=[])]))
assert list(store.iter_all())[0]["vector"] is None
@patch("semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE", True)
def test_iter_all_requests_vectors():
"""Weaviate omits vectors unless include_vector is set."""
store = _store_with_pages(_page([]), _page([]))
list(store.iter_all(batch_size=64))
kwargs = store.collection.query.fetch_objects.call_args[1]
assert kwargs["include_vector"] is True
assert kwargs["limit"] == 64
@patch("semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE", True)
def test_iter_all_falls_back_to_offset_when_after_unsupported():
"""Older clients reject `after`; the scan degrades to numeric offset."""
store = WeaviateStore()
store.collection = MagicMock()
seen = {"calls": 0}
def _fetch(**kwargs):
if "after" in kwargs:
raise TypeError("unexpected keyword argument 'after'")
seen["calls"] += 1
if seen["calls"] == 1:
return _page([_obj("uuid-1"), _obj("uuid-2")])
return _page([_obj("uuid-3")])
store.collection.query.fetch_objects.side_effect = _fetch
result = list(store.iter_all(batch_size=2))
assert [item["id"] for item in result] == ["uuid-1", "uuid-2", "uuid-3"]
offsets = [
c[1]["offset"]
for c in store.collection.query.fetch_objects.call_args_list
if "offset" in c[1]
]
assert offsets == [2]
@patch("semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE", True)
def test_iter_all_raises_when_collection_not_initialized():
"""Must fail loudly, not yield nothing.
An empty scan is indistinguishable from an empty source, which would let
`store migrate` report success having copied nothing (issue #1083).
"""
store = WeaviateStore()
with pytest.raises(ProcessingError, match="Collection not initialized"):
list(store.iter_all())
@patch("semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE", False)
def test_iter_all_raises_when_weaviate_unavailable():
store = WeaviateStore()
store.collection = MagicMock()
with pytest.raises(ProcessingError):
list(store.iter_all())
@patch("semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE", True)
def test_iter_all_propagates_fetch_errors():
store = WeaviateStore()
store.collection = MagicMock()
store.collection.query.fetch_objects.side_effect = RuntimeError("connection reset")
with pytest.raises(RuntimeError, match="connection reset"):
list(store.iter_all())