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
Ahmad Bilal 170b4215a6 fix(vector_store): persist vector_ids and metadata across FAISS index save/load (#1272) (#1314)
`FAISSIndex.save()` previously wrote only the raw FAISS index. `load()` then rebuilt the wrapper with empty `vector_ids` and `metadata`, so that state was never restored.

That made a save/load round trip effectively unusable through the wrapper API: `scan_vectors()` returned no vectors, `count()` returned `0`, and `get_vector(id)` returned `None` for IDs that were present in the underlying FAISS index.

This also affected migration. `semantica store migrate --from faiss` could load a valid FAISS index, see zero vectors through `scan_vectors()`, migrate nothing, and still exit successfully. Since FAISS is a supported migration source, this was a silent data-loss path rather than just a persistence bug.

The fix adds a `.meta.json` sidecar next to the FAISS binary. It stores:

* `vector_ids`
* `metadata`
* `dimension`
* `index_type`

The sidecar is written atomically using a temporary file and rename. `save()` also serializes the metadata before writing the FAISS binary, so a serialization error fails before either persistence artifact is created. That avoids leaving a valid-looking index file behind without the metadata needed to use it correctly.

On load, `dimension` and `index_type` come from the sidecar rather than the caller's arguments. This makes the reconstructed wrapper reflect the index that was actually saved instead of relying on the caller to provide matching values.

There are also explicit checks for incomplete or inconsistent persisted state. If the sidecar is missing, which can happen with indexes written by older versions or when only the FAISS binary was copied, `load()` emits a `RuntimeWarning` instead of silently returning an apparently usable wrapper with no IDs or metadata. If the number of saved vector IDs doesn't match the FAISS index's `ntotal`, `load()` raises `ProcessingError` rather than returning a state where vectors exist in FAISS but can't be reached through `scan_vectors()`.

Metadata serialization changed during review as well. The first version used `json.dumps(..., default=str)`. That avoided failures for values such as `datetime`, `UUID`, and `set`, but it was lossy: those values came back as strings instead of their original Python types.

That was replaced with a tagged encoder/decoder that preserves the supported types across a round trip. It currently handles sets, datetimes, dates, UUIDs, NumPy scalars and arrays, and bytes, with bytes stored as base64.

The decoder also uses an exact-schema check for tagged values. A normal dictionary that happens to contain a reserved tag key alongside other fields is left alone instead of being interpreted as an encoded type.

The final implementation was spread across fourteen commits, mostly following review feedback. Those changes included cleaning up conflict markers from an unfinished stash pop, expanding round-trip and retry coverage, adding the missing-sidecar warning, adding an end-to-end `scan_vectors()` persistence test, replacing lossy metadata serialization with the tagged format, checking FAISS/sidecar count mismatches, adding `bytes` support, and reordering `save()` so metadata serialization happens before the FAISS index is written.
2026-09-02 13:44:42 +05:00
Zohaib Hassnain 5f600a3f36 fix(ci): ignore SFTY-20260723-60537 (CVE-2026-65918) in torchvision, unreachable transitive dep (#1385) 2026-09-02 13:37:03 +05:00
Mohd Kaif 1d18755a4e Delete cookbook/advanced/13_Manual_Ontology_Snowflake_Mapping.ipynb 2026-09-02 13:54:13 +05:30
Mohd Kaif 3acf801273 Merge pull request #1361 from taoche/fix/semantic-layer-basics-intro
docs(cookbook): rewrite Semantic Layer Basics as an introductory workflow
2026-09-02 13:22:42 +05:30
KaifAhmad1 796f181c75 docs(cookbook): fix stale RDFExporter claim, pin oxigraph install version
Step 5 explicitly avoids RDFExporter's compact projection and builds
the Turtle export from the TripletStore's own triples instead, but the
Summary cell still credited RDFExporter -- a leftover from before the
rdflib-based export replaced it. Correct the claim to match the code.

Also pin the install to >=0.6.7: earlier releases could return
ontology classes with an empty uri (#1103), which made entity_type
mappings silently resolve to None instead of raising, so the notebook
would appear to pass while never actually typing its instances.
2026-09-02 13:15:14 +05:30
Mohd Kaif 8e73ed8d4c Merge branch 'main' into fix/semantic-layer-basics-intro 2026-09-02 12:44:19 +05:30
Mohd Kaif 114641b39d Merge pull request #1359 from taoche/fix/cookbook-08-end-to-end
docs(cookbook): make notebook 08 a real rerunnable KG workflow
2026-09-02 12:26:00 +05:30
Mohd Kaif f71b711205 Merge branch 'main' into fix/cookbook-08-end-to-end 2026-09-02 12:11:43 +05:30
Mohd Kaif f9a661a4ed security(deps-dev): bump browserslist from 4.28.2 to 4.28.8 in /explorer (#1382)
Fixes GHSA-73wf-gq98-2v4g (prototype pollution / DoS via unguarded
browserslist-stats.json parsing) and GHSA-c83g-rgw3-j3cx (unbounded
cache growth leading to OOM), both patched upstream in 4.28.7.
2026-09-02 11:49:55 +05:30
Kevin 8e7aaee4f5 fix(vector-store): validate collection schema in MilvusStore.get_collection (#1344)
`get_collection()` attached any collection right after the existence check, with no look at its schema. A collection with an INT64 primary key, or one missing the `metadata` field entirely, would attach without complaint and only fail later, inside `get_vector()` or `get_metadata()`, with an error that gave no hint the real problem was upstream at attach time.

This adds a schema check between the attach and the assignment to `self.collection`, so a mismatch is caught at the point of failure instead of surfacing three calls later as an unrelated-looking error. The check validates against exactly the shape `create_collection()` builds: a `VARCHAR` primary key named `id` with `auto_id=False`, a `FLOAT_VECTOR` field named `vector`, and a `JSON` field named `metadata`. Anything else, wrong dtype, wrong name, a missing field, or an auto-generated id, is rejected before the store ever holds a reference to it.

The auto_id and metadata-dtype checks were added in a second pass after review. A collection with `auto_id=True` still attached cleanly and only broke once the store tried to insert with the explicit ids it always sends, and a `metadata` field that existed but wasn't `JSON`-typed only broke during a later write or metadata filter, for the same reason: schema drift that looked fine at attach time and failed downstream instead of at the source.

Nine tests cover this: the one matching-schema case that should succeed, and each rejection path independently, wrong pk dtype, missing pk, wrong pk name, auto_id pk, missing vector field, wrong vector dtype, missing metadata field, and non-JSON metadata.

Closes #1331.
2026-09-02 00:58:26 +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
e040d84d59 feat(context): add ErasureCoordinator for cross-store entity erasure (#1027)
* feat(context): add ErasureCoordinator for cross-store entity erasure

purge_node() is graph-scope by design (#957), so an entity removed from the
graph can survive verbatim as an AgentMemory item and as an embedding. The
changelog names GDPR Article 17 as purge's motivation, and an Article 17
erasure the vector store can still answer queries from is not an erasure --
it is worse than none, because purge_node() returns True and writes a
tombstone attesting the content is gone.

ErasureCoordinator composes the existing public APIs to drive the cascade and
returns an ErasureReceipt recording what each store reported. Nothing in
context_graph.py or agent_memory.py changes behaviorally; ContextGraph keeps
its documented graph-scope contract instead of acquiring references that would
invert the dependency.

Honest partial reporting is the point. Stores report erased / not_found /
not_configured / unsupported / failed, and complete is False when any store
reports unsupported or failed. FAISS, Milvus and Weaviate expose no delete at
all, so erasure genuinely cannot be completed on them today -- the receipt
says so rather than reporting a success it did not achieve.

Erasure runs outward-in (vectors, memory, graph). The tombstone is the durable
attestation, so writing it first would let a crash mid-cascade leave a record
claiming more than happened; erasing the graph last leaves a partial failure
recoverable and honest.

The memory sweep pages until dry and re-queries afterwards rather than
trusting one find_by_entity() call, whose limit=10 default silently truncates
the very check a caller uses to decide the erasure is done. Unsupported vector
backends are detected by probing the wrapped backend, since the VectorStore
facade declares delete_vectors() for every backend and only raises
NotImplementedError once called.

27 tests against real ContextGraph/AgentMemory instances, including the
25-items-on-one-entity regression that fails against a naive single-call
sweep. Full tests/context/ suite: 596 passed.

Closes #1018

* docs(context): document ErasureCoordinator in the context API reference

* test(context): exercise ErasureCoordinator against a real VectorStore

The vector-leg tests asserted the three backend shapes the coordinator
expects -- delete_vectors / delete / neither -- against fakes, which is worth
exactly as much as the assumption that a real store looks like one of them.
VectorStore(backend="inmemory") runs without external services, so it can
hold that assumption to account.

Adds the end-to-end case the receipt actually attests to: a real ContextGraph,
AgentMemory and VectorStore, where the embedding is written by
AgentMemory.store() and has to be gone afterwards. That exercises the memory
leg's own delete_memory() vector cascade rather than the coordinator's model
of it.

The real backend also pins a limit worth knowing before trusting the receipt:
it pops the ids and returns True whether or not they were there, and no
backend offers a portable existence check, so `erased` on the vectors leg
means the store accepted the delete for the ids given -- not that embeddings
were really removed. The memory leg re-queries to confirm and so is the
stronger claim. Documented on STATUS_ERASED, _erase_vectors(), and both status
tables.

tests/context/: 599 passed.

* fix(context): address review findings on ErasureCoordinator

Timestamp drift (high). erase_entity() resolved erased_at up front but passed
the caller's original `at` down to purge_node(), so on the default at=None
path the coordinator and the graph each took their own now() and the receipt
attested to a different instant than the tombstone it points at -- breaking
the invariant this module states most loudly. The resolved value is now what
the graph receives. The existing test passed only because it supplied an
explicit `at`, which hides the drift; the regression test covers at=None,
which is what callers actually use.

Backend delete results. The vectors leg treated anything other than the
literal False as success, but no in-repo backend returns a bool -- Qdrant
returns {"status": <UpdateStatus>} and Pinecone {"deleted": True}, so every
dict read as success and the backend's own account of the delete was thrown
away. Results are now interpreted by shape and the payload is kept in the
receipt as backend_result, stringified so it stays JSON-serializable as an
audit record. Bool markers match by identity so a 0 count isn't read as
False; string markers match as substrings so an enum rendering as
"UpdateStatus.FAILED" isn't read as success.

Falsey vector store. The "at least one store" guard used `not vector_store`,
rejecting a valid store whose __bool__/__len__ makes an empty instance falsey
and then reporting vector_store=None when an object had been passed. It now
separates None (absent) from False (deliberately disabled) from provided, and
echoes what it received.

`at` annotations. Widened to int/float, matching the ContextGraph normalizer
they delegate to, so the coordinator stops advertising less than the API it
wraps.

tests/context/: 608 passed.

* fix(context): report memory-owned vectors that survive erasure (#1018)

A receipt could read complete while an embedding was still in the vector
store. The vector leg deleted `vector_ids` or `[entity_id]`, and the memory
leg relied on `AgentMemory.delete_memory()` to cascade to the vectors each
item owns. That cascade is best-effort: `_delete_vector_ids()` raises when a
backend returns False, `delete_memory()` catches it, logs a warning, and
still returns True. So `batch_delete` counted the item, the residual re-query
found no items, the memory leg reported `erased`, and nothing in the receipt
recorded that the embedding was refused.

Reproduced with a store that deletes the entity-keyed id and refuses the
memory-owned one: `receipt.complete` was True with the embedding still live.
That is the failure mode this module exists to prevent -- a receipt is a
compliance artifact, and one that overstates is worse than none.

Fix by deleting memory-owned vector ids through the coordinator's own vector
leg, which reports honestly, instead of trusting the memory leg's cascade.
The ids are collected before anything is deleted, while the items still exist
to be enumerated, and are unioned with any caller-supplied ids rather than
replacing them.

This needs one addition to AgentMemory: `vector_ids_for(memory_id)`, a
read-only accessor mirroring the fallback in `delete_memory` (an item stored
without tracked ids is keyed by its own memory id). Reaching into
`_vector_ids` from the coordinator would have been the internals-access
pattern this repo keeps getting bitten by. No existing AgentMemory behaviour
changes -- `delete_memory()` still cascades best-effort, so other callers are
unaffected; the coordinator simply no longer depends on that being reliable.
It does mean the vectors are attempted twice, which is a no-op on a working
store and only ever costs a log line.

Note this deviates from the PR's stated "nothing in agent_memory.py changes"
constraint. The constraint could not hold: with `_vector_ids` private and no
portable way to ask a vector store what it still holds, the coordinator had
no way to make the claim truthful without it.

Four tests: the refused-vector case (receipt must be incomplete), that
memory-owned ids reach the store, that explicit `vector_ids` do not displace
them, and the accessor's fallback. The first three were confirmed to fail
against the previous coordinator, on the `receipt.complete` assertion rather
than incidentally. 655 tests pass across tests/context and the agno
integration.

* fix(context): make erasure receipt vector failures honest

* fix(context): optimize erasure pagination handling

* fix(context): use one timestamp for batch erasure

* style: strip trailing whitespace from erasure.py and test file

* docs(changelog): correct test counts to 48 / 738 after review rounds

---------

Co-authored-by: Pravit Ampapathini <pravit.amp@gmail.com>
Co-authored-by: Sameer6305 <sskadam6305@gmail.com>
2026-09-01 23:07:02 +05:30
Mohd Kaif 18fb7c3ec0 Merge branch 'main' into fix/semantic-layer-basics-intro 2026-09-01 21:51:31 +05:30
Mohd Kaif 2eab7ab876 fix(ci): drop --ignore from Safety check, filter accepted CVEs in jq instead (#1371)
* fix(ci): drop --ignore from Safety check, filter accepted CVEs in jq instead

The follow-up to #1370: adding `--ignore SFTY-20260120-40557` to the
`safety check` invocation reintroduced the exact crash #1131/#1157 had
just fixed - "Unhandled exception happened: 'cuda-toolkit'" - but only
once Safety actually has a live vulnerability match to apply the ignore
against (the plain, un-ignored scan against the same requirements-ci.txt
had already succeeded and correctly reported that same match on main,
per the run right before this one).

I couldn't reproduce this locally: my local Safety installation doesn't
surface the live cuda-toolkit CVE match at all (its open-source
vulnerability DB appears to lag CI's), so --ignore never had a real
match to crash on in my testing. That's on me - I should have caught
that my "0 vulnerabilities" local result meant the DB hadn't even seen
the finding yet, not that the fix worked.

Since I can't safely iterate against Safety's own --ignore path without
live-DB access, this moves the "should we still fail on ID X" decision
out of Safety entirely: run the plain scan (the one path an actual CI
run has now proven doesn't crash), then filter the accepted vulnerability
ID out of the report ourselves in jq before counting/printing. Verified
the jq expression directly against a synthetic report shaped like a real
one (id present + one other unrelated id): filters exactly the intended
entry, and - as a bonus - iterating over a null/missing "vulnerabilities"
key with jq now raises inside jq the way the existing guard comment always
assumed it did, rather than silently coming back as 0.

* fix(ci): apply the accepted-CVE exclusion list to the PR comment too

Qodo caught a real gap on this PR: the jq-based exclusion I added only
covers the CI gate (the VULNS count and the failure-path detail print).
The "Comment PR with Security Results" step reads safety-report.json
independently in its own JS, with no filtering at all, so a PR touching
only the accepted cuda-toolkit CVE would still get a comment saying
"Found 1" even though the gate itself correctly treats it as
non-actionable and passes.

Export IGNORED_VULN_IDS via $GITHUB_ENV from the shell step so the JS
step can read the same list, and filter data.vulnerabilities there
before rendering - with a footnote naming what was excluded and why,
so the comment stays transparent about the accepted finding rather than
just silently hiding it.

Verified the JS logic standalone against two synthetic reports: one with
the accepted CVE plus an unrelated real one (shows only the real one,
plus the footnote), and one with only the accepted CVE (shows "No
findings" plus the footnote, rather than misleadingly looking identical
to a clean scan with no explanation).
2026-09-01 19:45:36 +05:30
Mohd Kaif d8822198cf fix(ci): ignore CVE-2025-33228 in cuda-toolkit - unfixable transitive pin, unreachable code path (#1370)
Merging #1357 surfaced a real (not crashed) Safety finding: cuda-toolkit
13.0.3.0 < 13.1.0 is affected by SFTY-20260120-40557 / CVE-2025-33228.

This can't be fixed with a version bump on our end: torch 2.13.0 (the
latest release on PyPI - there is no newer one) hard-pins
`cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,cusparse,
nvjitlink,nvrtc,nvtx]==13.0.3` on Linux via its own METADATA, not a loose
transitive requirement we control.

The underlying CVE is OS command injection in NVIDIA Nsight Systems'
gfx_hotspot recipe (process_nsys_rep_cli.py), which requires a human to
manually invoke that script with an attacker-supplied string. It isn't
reachable from any Semantica code path, and Nsight Systems isn't even
part of the extras torch requests here (cublas/cudart/cufft/cufile/
cupti/curand/cusolver/cusparse/nvjitlink/nvrtc/nvtx - no Nsight extra
among them).

Ignoring this one vulnerability ID only (not the whole package or a
blanket policy) so CI reflects actionable risk. Re-evaluate once torch
ships a release that pins a patched cuda-toolkit.
2026-09-01 19:14:21 +05:30
Mohd Kaif e6409217dd Merge pull request #1357 from taoche/fix/cookbook-07-graph-mapping
docs(cookbook): correct graph mapping and deduplication in notebook 07
2026-09-01 18:41:18 +05:30
taoche e6c05df33e docs(cookbook): index semantic layer capstone 2026-09-01 19:51:49 +08:00
taoche c20a46f026 docs(cookbook): preserve complete semantic layer RDF 2026-09-01 19:45:40 +08:00
taoche 0dbe9274eb docs(cookbook): install notebook 08 NER model 2026-09-01 19:45:39 +08:00
taoche 3ed31b9182 docs(cookbook): make notebook 07 setup deterministic 2026-09-01 19:45:39 +08:00
Mohd Kaif 7300fb41b1 Merge pull request #1363 from 7487/fix/plugin-manifest-agents-array
fix(plugins): declare agents as an array of file paths in plugin.json
2026-09-01 16:57:52 +05:30
Mohd Kaif 218e5a33f3 Merge branch 'main' into fix/plugin-manifest-agents-array 2026-09-01 16:38:36 +05:30
Mohd Kaif 635f6e52f4 Merge pull request #1332 from semantica-agi/test/backend-facade-contract
Pin facade contract gaps for cloud backends
2026-09-01 15:20:08 +05:30
Mohd Kaif 9240a1b1f7 Merge branch 'main' into test/backend-facade-contract 2026-09-01 15:08:35 +05:30
3254b9be80 Serve the RDF export formats the MCP tool already offers (#1131) (#1157)
* feat(explorer): serve the RDF export formats the MCP tool already offers (#1131)

`POST /api/export` accepted only `json` and `csv` and answered 422 for everything
else, while the MCP `export_graph` tool resolved Turtle, N-Triples, RDF/XML,
JSON-LD, GraphML and Parquet through `semantica.export`. Two surfaces of one
product disagreeing about what the product can do — and for an RDF-native project,
a graph that loads as JSON-LD and cannot be exported as RDF is a one-way door.

The route now reaches the same exporters the MCP tool uses. Nothing is
reimplemented: `RDFExporter.export_to_rdf` and `GraphMLExporter.export` receive the
dict `session.build_graph_dict()` already builds.

The alias table is a copy of `mcp/tools/export.py::_FORMAT_ALIASES` plus the
spellings the issue mentioned (`ntriples`, `rdf-xml`), and a test asserts the two
tables agree — if either drifts, the formats a caller can use would depend on which
door they came through.

Media types and extensions per serializer, so a Turtle export is `text/turtle` and
not `application/json` with a `.json` name.

The 422 message now names what IS supported. The old one said only that the format
was unsupported, which reads as "this format does not exist" rather than "this door
does not open it" — that is what sent me looking through the library.

Parquet is left out on purpose: `ParquetExporter.export` writes a file and returns a
path, so serving it over HTTP is a different shape of change and deserves its own
review.

Tests, in `TestImportExport`: the seven RDF spellings, each **parsed with rdflib**
rather than asserted on strings — a response that merely looks like Turtle is what
lets this class of gap survive a suite. Plus the alias-agreement canary and the
error message. Three mutations (rejecting RDF again, breaking one alias, emptying
the message) each turn the matching tests red.

110 tests in `tests/explorer/test_explorer_api.py` pass.

* fix(explorer): complete RDF export support

* fix(explorer): secure GraphML temporary file handling

* fix(ci): scan declared dependencies with Safety

---------

Co-authored-by: 13g4d0 <13g4d0@users.noreply.github.com>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-09-01 15:01:13 +05:30
Guofang.Tang dc81acaefd fix(ontology): reject normalized class name collisions (#1230)
ClassInferrer.infer_classes() groups entities by their type string, then
normalizes each group name (PascalCase + singularize) when it builds the
ontology class. Two source types that only differ in casing or plurality,
like Person and person, both normalize to the same class name. Nothing
caught that, so the second type's entities silently got treated as
instances of the first type's class, and property inference downstream
picked up whichever properties happened to win.

Added a pass right after entities get grouped by type: normalize every
type name that meets min_occurrences, and if two different source types
land on the same normalized name, raise ValidationError before any class
gets built. The check reuses the exact same min_occurrences filter the
real class-emission loop uses, so it only fires on collisions that would
actually produce duplicate classes, not on types that get filtered out
anyway.

The error carries validation_context with the normalized name mapped to
every source type that collided into it, so whoever's calling this can
see exactly what to rename instead of just getting a generic message.

Added test_class_inference_rejects_normalized_type_collisions covering
Person/person landing on the same class.

Follow-up to #1171.
2026-09-01 14:25:54 +05:00
7487 f44020c742 fix(plugins): declare agents as an array of file paths in plugin.json
Claude Code's plugin schema rejects "agents": "./agents" (a bare
directory string) with:

    Validation errors: agents: Invalid input

so the bundled plugin has never been installable. Unlike "skills",
which accepts a directory string, "agents" must be an array of .md
file paths.

Replaced the string with the explicit list of the three agent files.
Verified with `claude plugin validate plugins` (2.1.231): fails on the
old manifest with the error above, passes after this change.

Added tests/test_plugin_manifest.py to guard the manifest shape: agents
is a non-empty array of existing .md paths that stays in sync with
plugins/agents/, and the skills directory exists.

Fixes #1350
2026-09-01 17:02:52 +08:00
taocheandClaude Fable 5 0e7cef4677 docs(cookbook): move Semantic Layer Basics from Advanced to Introduction
Advanced chapter 09 labeled an introductory composition of
already-taught APIs as an enterprise semantic layer: TripletStore was
imported but never used, property_mappings stayed empty, mappings were
derived by fragile name matching (works_for never matched worksFor),
and the exported RDF was the original graph rather than an
ontology-aligned one.

Replace it with introduction/26_Semantic_Layer_Basics.ipynb, which
demonstrates the minimal semantic-layer composition honestly:

- build a small graph, generate an ontology (min_occurrences=1 so all
  demo classes are inferred, base_uri in the user's namespace)
- explicit entity-type, relationship-type, and property mappings read
  from the ontology's inferred_from metadata instead of name matching
- apply the mappings to produce an ontology-aligned graph, export it
  as Turtle, and note the file exporter's property projection
- store the aligned graph in the embedded Oxigraph TripletStore and
  answer a business question with one SPARQL query
- distinguish teaching mappings from governed production mappings and
  point to Advanced 13 as the production continuation

Advanced 13 gains a positioning note naming the new lesson as its
prerequisite; introduction/14_Ontology links forward to the new
lesson. All cells execute top to bottom (verified with the embedded
Oxigraph backend).

Closes #1325

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:56:49 +08:00
taocheandClaude Fable 5 5c366c6b7e docs(cookbook): make notebook 08 a real rerunnable end-to-end workflow
The first-knowledge-graph lesson read the parser output from a key it
never returns (content vs text), then masked the failure with
hard-coded entities, a manually assembled NetworkX graph, and an
uninvoked KGVisualizer; the final cell deleted the sample file, so
rerunning intermediate cells failed. Rework the notebook so every
stage consumes the previous stage's output:

- parse via parsed_document["text"] with an assertion that content
  was actually extracted
- real NERExtractor/RelationExtractor output replaces the simulated
  entities and wrong hard-coded offsets
- GraphBuilder builds the graph from actual relation endpoints via a
  mention-span -> graph-ID map (consistent with notebook 07)
- KGVisualizer.visualize_network renders the graph and saves HTML
- deletion moved to an explicit optional cleanup cell, so parsing and
  downstream cells stay rerunnable

Closes #1289

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:45:28 +08:00
taocheandClaude Fable 5 abb65feff0 docs(cookbook): build notebook 07 graph edges from real relation endpoints
The knowledge-graph lesson fabricated relationship endpoints from loop
indices, hid the corruption behind count-only output, and displayed
only merged duplicate groups as the deduplicated result. Rework the
notebook so that:

- graph edges come from Relation.subject/Relation.object mapped
  through a mention-span -> graph-ID table
- the sample text keeps two separate "Apple Inc." mentions without the
  sentence-boundary merge edge case
- entity resolution shows which mentions merged (merged_from) and
  remaps relationship endpoints onto the canonical entity
- deduplication reports merge operations separately from the complete
  deduplicated set (merged + untouched entities)
- each stage prints its transformed records, and lightweight
  assertions pin the expected canonical entities and edges

Closes #1287

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:39:25 +08:00
Wei TaoandSameer Kadam 8b125d6476 fix(explorer): keep small Full Graph relationships readable (#1277)
* fix(explorer): keep small full graphs readable

* perf(explorer): avoid redundant realtime edge sync

---------

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-09-01 11:11:53 +05:30
Mohd Kaif f3c540cfd2 docs(readme): reposition Semantica as the semantic/context layer (#1348)
Lead the README with Semantica's identity as a semantic/context/knowledge
layer (Context Graph, KG, ontology and vocabulary governance via OWL/SHACL/SKOS),
with decision provenance and audit trails framed as a property of that
structure rather than the flagship pitch.
2026-08-31 22:11:13 +05:30
Sameer Kadam 46b18fbee3 fix(docs): use GraphStore facade in Neo4j quickstart (#1340)
The Neo4j example in the persistent graph store section was passing a raw
Neo4jStore straight into GraphBuilder. GraphBuilder calls add_nodes() and
add_edges() on whatever it's given, and those only exist on the GraphStore
facade, not on Neo4jStore itself. Anyone who copied the example got:

AttributeError: 'Neo4jStore' object has no attribute 'add_nodes'

Swapped the import and construction to GraphStore(backend="neo4j", uri=...,
user=..., password=...), which wraps Neo4jStore internally and actually has
the methods GraphBuilder needs.

Added a test in tests/kg/test_graph_builder_with_graph_store.py that builds
a small graph through GraphBuilder with a mocked GraphStore and checks
add_nodes/add_edges get called. Also kept a test for GraphBuilder without a
graph_store at all, so that path doesn't regress either.

Closes #1135
2026-08-31 20:34:04 +05:00
Mohd Kaif b0679d4f67 fix(ci): stop checkov's suppressed checks from reopening as new alerts (#1346)
* fix(ci): drop unpinnable benchmarks/requirements.txt install

Scorecard flagged this pip install as unpinned-by-hash (#6082). Can't
hash-pin it - benchmarks/requirements.txt doesn't exist in this repo, so
there's nothing to compile a lockfile from. Dropping it instead of
leaving it unpinned: the job already fails on the next real step
(benchmarks/benchmarks_runner.py, also missing), so this line wasn't
doing anything useful to begin with.

* fix(ci): hash-pin the spacy model download in benchmark.yml

Qodo review on this PR: dropping the benchmarks/requirements.txt install
(the previous failure point) let the job actually reach
`python -m spacy download en_core_web_sm`, which fetches an unpinned,
unhashed wheel from spacy-models' GitHub releases - undoing the point of
this PR by exposing a real unpinned-install path instead of a dead one.

Replaced with a hash-pinned direct-URL entry in benchmark-extra.in/.txt
for en_core_web_sm-3.8.0 (matches the spacy==3.8.15 already pinned in
base-deps.txt). uv independently computed the same sha256 I got via a
manual curl+sha256 of the release asset, and a --require-hashes dry-run
install verifies clean.

* fix(ci): stop checkov's suppressed checks from reopening as new alerts

Root cause found, not just worked around: checkov's SARIF exporter
includes every evaluated check as an ordinary result, including ones it
internally marked SKIPPED via the inline # checkov:skip= comments and
checkov.io/skipN annotations already on the Helm chart. It never uses
SARIF's own `suppressions` field and never drops them - so the exact same
already-suppressed finding reopens as a brand-new code scanning alert
number on every single run, forever (#6035/#6036, #6112-6115,
#6128-6131 are all the same 4 findings, manually dismissed 3 times now).

checkov's JSON output *does* correctly record which checks were skipped.
Added .github/scripts/filter_checkov_skipped.py, which cross-references
the JSON's skipped_checks against the SARIF's results (matched by check
ID + the last two path segments, since the two outputs use different path
roots) and drops anything checkov itself already decided to suppress,
before upload. Verified locally against a real checkov+helm run: removed
exactly the 4 known-suppressed helm chart results, left the 2 genuinely
real findings (deploy/gcp/cloudrun-service.yaml, deploy/kubernetes/
deployment.yaml) untouched.
2026-08-31 19:29:24 +05:30
Mohd Kaif d135ad185f fix(ci): drop unpinnable benchmarks/requirements.txt install (#1345)
* fix(ci): drop unpinnable benchmarks/requirements.txt install

Scorecard flagged this pip install as unpinned-by-hash (#6082). Can't
hash-pin it - benchmarks/requirements.txt doesn't exist in this repo, so
there's nothing to compile a lockfile from. Dropping it instead of
leaving it unpinned: the job already fails on the next real step
(benchmarks/benchmarks_runner.py, also missing), so this line wasn't
doing anything useful to begin with.

* fix(ci): hash-pin the spacy model download in benchmark.yml

Qodo review on this PR: dropping the benchmarks/requirements.txt install
(the previous failure point) let the job actually reach
`python -m spacy download en_core_web_sm`, which fetches an unpinned,
unhashed wheel from spacy-models' GitHub releases - undoing the point of
this PR by exposing a real unpinned-install path instead of a dead one.

Replaced with a hash-pinned direct-URL entry in benchmark-extra.in/.txt
for en_core_web_sm-3.8.0 (matches the spacy==3.8.15 already pinned in
base-deps.txt). uv independently computed the same sha256 I got via a
manual curl+sha256 of the release asset, and a --require-hashes dry-run
install verifies clean.
2026-08-31 18:57:28 +05:30
Mohd Kaif 96dbd3f0d4 fix(security): bump checkov to 3.3.16, fix aiohttp CVEs in its lockfile (#1342)
Dependabot flagged 12 aiohttp advisories (1 high, rest moderate/low - CVE
range covering request smuggling, websocket/parser bugs, cookie/redirect
issues) against aiohttp==3.13.5 pinned in checkov.txt. checkov==3.3.1
itself pinned `aiohttp<3.14.0`, which excludes every fixed release;
3.3.16 (latest) relaxes that to `<3.15.0`, so bumping checkov also lets
aiohttp resolve to 3.14.3 (fixes all of them).

Two alerts remain open, both genuinely blocked upstream rather than
something a version bump here can fix:
- asteval: checkov 3.3.16 (latest, still) hard-pins asteval==1.0.6 with
  no range; the fix (1.0.9) is unresolvable without violating checkov's
  own declared dependency - confirmed via `uv pip compile` refusing to
  solve it. Needs checkov itself to bump the pin upstream.
- ecdsa: 0.19.2 is already the latest release; the Minerva timing-attack
  advisory has no patched version, since python-ecdsa's maintainers have
  stated side-channel attacks are out of scope for the project.

Both are checkov's own transitive deps, used only for local static IaC
analysis in defender-for-devops.yml (no network signing/cloud-auth calls
that would actually exercise ecdsa's signing path) - dismissing on
GitHub with that reasoning as a separate step.
2026-08-31 18:21:36 +05:30
Zohaib Hassnain 73b14c00ba test(vector_store): make contract xfails reachable and cover the backend roster 2026-08-31 13:27:45 +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 e9a756eac2 test(vector_store): pin facade contract gaps for cloud backends 2026-08-31 12:59:40 +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
84 changed files with 8279 additions and 1587 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
View File
@@ -11,3 +11,4 @@ python-docx
beautifulsoup4
chardet
langdetect
en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
+3
View File
@@ -408,6 +408,9 @@ cuda-toolkit==13.0.3.0 \
# via
# -c requirements-ci.txt
# torch
en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl \
--hash=sha256:1932429db727d4bff3deed6b34cfc05df17794f4a52eeb26cf8928f7c1a0fb85
# via -r .github/requirements/benchmark-extra.in
et-xmlfile==2.0.0 \
--hash=sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa \
--hash=sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54
@@ -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
+76
View File
@@ -0,0 +1,76 @@
"""Drop checkov-suppressed results from its SARIF output before upload.
checkov's SARIF exporter includes every evaluated check as an ordinary
result, including ones it internally marked SKIPPED via an inline
`# checkov:skip=` comment or a `checkov.io/skipN` resource annotation - it
never uses SARIF's `suppressions` field, and never drops them. checkov's
JSON output *does* correctly record which checks were skipped, so this
cross-references the two: any SARIF result whose (check_id, file) pair
appears in the JSON's skipped_checks is removed before GitHub ever sees it.
Without this, every already-suppressed finding reopens as a brand new code
scanning alert on every run, forever (see #6035/#6036, #6112-6115,
#6128-6131 for the pattern this was chasing before this script existed).
Usage: filter_checkov_skipped.py <json_path> <sarif_in_path> <sarif_out_path>
"""
import json
import sys
def path_suffix(path: str, segments: int = 2) -> str:
"""Last N path segments, normalized to forward slashes, lowercased.
checkov's JSON file_path and SARIF artifactLocation.uri are relative to
different roots (the scanned directory vs. a temp helm-render dir), so
they can't be compared directly - but the last couple of segments
(e.g. "templates/service.yaml") are stable across both and specific
enough in practice to avoid cross-file collisions.
"""
normalized = path.replace("\\", "/").strip("/")
return "/".join(normalized.split("/")[-segments:]).lower()
def main() -> None:
json_path, sarif_in_path, sarif_out_path = sys.argv[1:4]
with open(json_path, encoding="utf-8") as f:
checkov_json = json.load(f)
if isinstance(checkov_json, dict):
checkov_json = [checkov_json]
skipped = set()
for block in checkov_json:
for check in block.get("results", {}).get("skipped_checks", []):
skipped.add((check["check_id"], path_suffix(check["file_path"])))
with open(sarif_in_path, encoding="utf-8") as f:
sarif = json.load(f)
removed = 0
for run in sarif.get("runs", []):
kept = []
for result in run.get("results", []):
rule_id = result.get("ruleId")
locations = result.get("locations") or [{}]
uri = (
locations[0]
.get("physicalLocation", {})
.get("artifactLocation", {})
.get("uri", "")
)
if (rule_id, path_suffix(uri)) in skipped:
removed += 1
continue
kept.append(result)
run["results"] = kept
with open(sarif_out_path, "w", encoding="utf-8") as f:
json.dump(sarif, f)
print(f"Removed {removed} checkov-suppressed result(s) from the SARIF before upload.")
if __name__ == "__main__":
main()
+14 -6
View File
@@ -44,12 +44,20 @@ jobs:
pip install -r .github/requirements/pep517-build.txt --require-hashes
pip install --no-deps --no-build-isolation -e .
pip install -r .github/requirements/base-deps.txt --require-hashes
# NOTE: benchmarks/ does not currently exist in this repo, so this
# step and the run below it fail on any real invocation - pre-existing,
# unrelated to this pinning change. Left as-is since there's nothing
# to hash without knowing what belongs there.
pip install -r benchmarks/requirements.txt
python -m spacy download en_core_web_sm
# NOTE: benchmarks/ does not currently exist in this repo (neither
# requirements.txt nor benchmarks_runner.py below), so this job
# already fails on any real invocation - pre-existing, unrelated to
# this pinning change. The `pip install -r benchmarks/requirements.txt`
# step that used to be here is dropped rather than fixed: there's
# nothing to hash-pin without knowing what that file should
# contain, and an unpinned install here would just re-trip
# Scorecard's Pinned-Dependencies check for no real benefit, since
# the job can't run to completion regardless.
#
# `python -m spacy download en_core_web_sm` fetches an unpinned,
# unhashed wheel from spacy-models' GitHub releases - replaced with
# a hash-pinned direct-URL install of the same 3.8.0 model (matches
# the spacy==3.8.15 pinned in base-deps.txt) via benchmark-extra.txt.
pip install -r .github/requirements/benchmark-extra.txt --require-hashes
- name: Execute Benchmarks (Real Mode)
+19 -3
View File
@@ -76,12 +76,28 @@ jobs:
PYTHONUTF8: "1"
run: |
New-Item -ItemType Directory -Force reports | Out-Null
checkov --directory . --framework kubernetes helm dockerfile github_actions secrets bicep arm --soft-fail --output sarif --output-file-path reports/checkov.sarif
if (-not (Test-Path reports/checkov.sarif)) {
checkov --directory . --framework kubernetes helm dockerfile github_actions secrets bicep arm --soft-fail --output sarif --output json --output-file-path reports
if (-not (Test-Path reports/results_sarif.sarif)) {
$sarif = Get-ChildItem -Path reports -Recurse -Filter *.sarif | Select-Object -First 1
if ($null -eq $sarif) { throw "Checkov did not produce a SARIF file" }
Copy-Item $sarif.FullName reports/checkov.sarif
Copy-Item $sarif.FullName reports/results_sarif.sarif
}
if (-not (Test-Path reports/results_json.json)) {
$json = Get-ChildItem -Path reports -Recurse -Filter *.json | Select-Object -First 1
if ($null -eq $json) { throw "Checkov did not produce a JSON file" }
Copy-Item $json.FullName reports/results_json.json
}
# checkov's SARIF exporter includes checks it internally marked SKIPPED
# (via the inline `# checkov:skip=` comments / `checkov.io/skipN`
# annotations already on the Helm chart) as ordinary un-suppressed
# results - it never uses SARIF's own `suppressions` field, so GitHub
# opens a fresh alert for the same already-suppressed finding on every
# single run (see #6035/#6036, #6112-6115, #6128-6131). checkov's JSON
# output does correctly record the skip, so cross-reference it here
# instead of re-dismissing the same alerts by hand forever.
- name: Filter checkov's own suppressed checks out of the SARIF
run: python .github/scripts/filter_checkov_skipped.py reports/results_json.json reports/results_sarif.sarif reports/checkov.sarif
- name: Upload Checkov results to Security tab
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
+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
+165 -38
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,45 +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.
safety check --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..."
# No || echo "0" fallback: if jq fails (malformed JSON, missing key,
# vulnerabilities:null) VULNS will be empty or "null" so guard 2 below
# catches it rather than silently treating the broken report as zero.
VULNS=$(jq '.vulnerabilities | length' safety-report.json 2>/dev/null)
# 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
# Guard 2: ensure VULNS is a non-negative integer before the -gt
# Vulnerability IDs reviewed and accepted as non-actionable for this
# 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
# 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: 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(",") | map(select(length > 0))) as $ignore_list
| [.dependencies[] | (.vulns // [])[] | select(.id as $id | ($ignore_list | index($id)) | not)]
| length
' pip-audit-report.json 2>/dev/null)
# 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
@@ -92,12 +148,18 @@ jobs:
echo "CI will fail to prevent merging of vulnerable dependencies"
echo ""
echo "Vulnerability details:"
jq -r '.vulnerabilities[] | "- \(.package_name)==\(.analyzed_version): \(.vulnerability_id) (\(.CVE // "no CVE assigned"))"' safety-report.json || true
jq --arg ignored "$IGNORED_VULN_IDS" -r '
($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 security vulnerabilities found"
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
@@ -135,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: |
@@ -168,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');
}
@@ -184,14 +253,64 @@ jobs:
return lines.join('\n');
}
const safetySection = renderSection(
'Safety — dependency vulnerabilities',
'safety-report.json',
(data) => (data.vulnerabilities || []).map(
(v) => `- \`${v.package_name}==${v.analyzed_version}\`: ${v.vulnerability_id}` +
(v.CVE ? ` (${v.CVE})` : '') + ` — ${v.advisory || 'no advisory text'}`
)
);
// 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 pip-audit-report.json.
const ignoredVulnIds = (process.env.IGNORED_VULN_IDS || '')
.split(',')
.map((id) => id.trim())
.filter(Boolean);
// 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(
'Bandit — HIGH-severity code issues',
@@ -212,7 +331,7 @@ jobs:
const comment = [
'# 🔒 Security Scan Results',
'',
safetySection,
pipAuditSection,
'',
banditSection,
'',
@@ -222,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 {
@@ -237,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.
+44
View File
@@ -9,6 +9,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### 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
- `erase_entity(entity_id, reason=..., at=..., vector_ids=...)` returns an `ErasureReceipt`; `erase_entities([...])` returns one receipt per entity, in order, so one entity's failure does not stop the rest
- **Honest partial reporting is the point.** Each store reports one of five statuses — `erased`, `not_found`, `not_configured` (store never bound; normal), `unsupported` (store cannot delete at all; retrying will not help), `failed` — and `receipt.complete` is `False` when any store reports `unsupported`/`failed`, with `receipt.incomplete_stores` naming them. A receipt reading `graph: erased, memory: 14 erased, vectors: unsupported on faiss` is actionable; a bare `True` is a compliance liability
- **Erasure runs outward-in: vectors → memory → graph.** The graph tombstone is the durable attestation that an erasure happened, so writing it first would let a crash mid-cascade leave a record claiming more than occurred. Erasing the graph last means a partial failure leaves the node present and the receipt incomplete — recoverable and honest; the reverse is neither
- **Partial failure is a result, not an exception**: a store that raises is recorded as `failed` (with the exception type) and the remaining legs still run, rather than aborting into a half-erased state with no record of which half
- **The memory sweep cannot be silently truncated.** `find_by_entity(entity_id, limit=10)` returned `results[:limit]`, so the obvious hand-rolled cascade erases the first ten items and reports success — an erasure check computed from a page already truncated by the very `limit` it was called with. The coordinator sweeps in pages until dry (deleting as it goes, so the next page is the remainder) rather than passing one large number that is only correct until someone exceeds it, then **re-queries once after the sweep** and reports `failed` with the residual count if anything survived. It also stops rather than spinning if `batch_delete` reports no progress on a non-empty page. Note `find_by_entity` returns items keyed `memory_id`, not `id`
- **`unsupported` vector backends are detected by probing, not by calling and catching.** `faiss_store.py`, `milvus_store.py` and `weaviate_store.py` expose no delete at all (FAISS cannot remove from a flat index without a rebuild), while the `VectorStore` facade declares `delete_vectors()` for *every* backend and only raises `NotImplementedError` once called — so probing the facade alone cannot tell a deletable backend from a delete-less one, and the coordinator looks at the backend it wraps. Probing also keeps a missing method distinguishable from an `AttributeError` raised *inside* a working one, which is exactly where guessing wrong produces a false clean bill of health. `NotImplementedError` at call time is still caught and reported as `unsupported`; a store returning `False` is reported as `failed`
- Backends are reached under either supported name — `delete_vectors(ids)` (pinecone/qdrant) or `delete(ids)` (pgvector/sqlite-vec) — and the receipt records which was used
- `vector_store` defaults to `memory.vector_store` when a memory is supplied, stays overridable for deployments binding a store the memory does not own, and accepts `False` to disable the vector leg. Vectors owned by memory items are removed by the memory leg's own `delete_memory()` cascade; the explicit vector leg covers entity-keyed embeddings written by something other than `AgentMemory`
- The receipt's `erased_at` is normalized through `ContextGraph`'s own temporal normalizer, so the receipt and the tombstone written by the same erasure cannot disagree about when it happened; an unparseable `at` is rejected before any store is touched rather than half way through the cascade
- `purge_node()`'s docstring now points at the coordinator, so callers reading the graph-scope caveat find the thing that completes the workflow
- New `tests/context/test_erasure_coordinator.py`: 48 tests against **real** `ContextGraph`/`AgentMemory` instances rather than mocks — the bug lives in the interaction between them, so mocking it away would test nothing. Covers the 25-items-on-one-entity regression that fails against a naive single `find_by_entity()` call, all three vector-backend shapes (`delete_vectors`/`delete`/neither) plus the facade-over-delete-less-backend shape, residual/no-progress/no-identifier memory failures, partial failure continuing the cascade, idempotency, receipt serialization, and `at` normalization
- Full `tests/context/` suite: 738 passed
- **Fixed during review** (Qodo): `erase_entity()` resolved `erased_at` up front but passed the caller's original `at` down to `purge_node()`, so on the default `at=None` path the coordinator and the graph each took their own `now()` and the receipt attested to a different instant than the tombstone it points at — breaking the one invariant this module states most loudly. The resolved timestamp is now passed to the graph. The existing test passed only because it supplied an explicit `at`, which hides the drift; a regression test now covers the `at=None` path that callers actually use
- **Fixed during review** (Qodo): the vectors leg treated any return value other than the literal `False` as success, but no in-repo backend returns a bool — Qdrant returns `{"status": <UpdateStatus>}` and Pinecone `{"deleted": True}`, so every dict was read as a success and the backend's own account of the delete was discarded. Delete results are now interpreted by shape (bool, dict with explicit failure markers, `None` for a void method, anything else at face value) and the backend payload is recorded in the receipt as `backend_result`, stringified so the receipt stays JSON-serializable as the audit record it is meant to be. Bool markers are matched by identity so a `0` count is not read as `False`, and string markers match as substrings so an enum rendering as `"UpdateStatus.FAILED"` is not read as a success
- **Fixed during review** (Qodo): the constructor's "at least one store" guard used `not vector_store`, rejecting a valid store whose `__bool__`/`__len__` makes an empty instance falsey, and reporting `vector_store=None` in the error when an object had been passed; it now distinguishes `None` (absent) from `False` (deliberately disabled) from any other value (provided), and echoes what it actually received
- **Fixed during review** (Qodo): `at` annotations accepted only `str`/`datetime` while the shared `ContextGraph` normalizer they delegate to also takes epoch seconds; widened to `int`/`float` with the docstrings updated, so the coordinator no longer advertises less than the graph API it wraps
- **Known limitation, unchanged by this PR**: erasure still cannot be *completed* on FAISS/Milvus/Weaviate — `delete_vectors()` is declared on the `VectorStore` facade (`vector_store.py:786`) but not implemented across the backend set, under at least three different names. That is worth its own issue; the coordinator ships reporting `unsupported` and starts reporting `erased` for those backends once it is fixed, with no API change here
## [0.6.7] - 2026-08-28
### Added
@@ -128,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`)
+17 -22
View File
@@ -18,9 +18,9 @@
> Ingest your enterprise data, extract what matters, build a Context Graph and knowledge graph (KG), and run graph analytics and causal reasoning over all of it, with full decision provenance baked in. Explainable, traceable, and trustworthy by design.
**Decision Intelligence &nbsp;·&nbsp; Context Management &nbsp;·&nbsp; Deterministic Reasoning &nbsp;·&nbsp; Ontology Management &nbsp;·&nbsp; Knowledge Modeling &nbsp;·&nbsp; End-to-End Traceability**
**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**
@@ -56,20 +56,18 @@ pip install semantica
---
Most AI agents act without a trail. They store embeddings, not meaning: context that can't be explained, decisions that can't be audited. In lending, that gap is a compliance exposure, not an inconvenience: an underwriting agent's approval has to survive a regulator's "why" months later.
Semantica sits underneath your LLM, vector store, and agent framework as a deterministic infrastructure layer: no LLM required for graph construction, reasoning, or provenance.
Most AI agents run on embeddings, not meaning: similarity scores with no structure, no relationships, and no way to explain why a result came back. Semantica is the semantic/context layer underneath your LLM, vector store, and agent framework: a deterministic infrastructure layer (no LLM required for graph construction, reasoning, or provenance) that turns fragmented enterprise data into a structured, queryable Context Graph and knowledge graph, governed by ontologies and controlled vocabularies (OWL, SHACL, SKOS) so the meaning of your data is explicit, not just its embedding. Decision provenance and audit trails fall out of that structure as a property, not the product itself; in domains a regulator can question, that same structure just happens to double as a straight answer to "why."
> ⚠️ **System-level explainability, not foundation-model explainability.** Semantica does not expose or reconstruct what happens *inside* the LLM — its internal reasoning or chain-of-thought stays opaque, as it does for any external system. Semantica explains what's *outside* the model: the context and data fed in, the decision produced, its provenance, relevant relationships, applied policies, and the full execution trail.
**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)**
@@ -83,7 +81,7 @@ Semantica sits underneath your LLM, vector store, and agent framework as a deter
- **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
@@ -141,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.
@@ -169,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
@@ -279,7 +273,7 @@ retrieved = ctx.retrieve("who approved the Acme contract?")
## Recipe: Audit Trail for a Regulated Decision
The flagship pattern: record a causally-linked decision chain, attach provenance to every entity, and export a regulator-ready audit trail.
One pattern built on the same Context Graph: record a causally-linked decision chain, attach provenance to every entity, and export a regulator-ready audit trail.
```python
from semantica.context import ContextGraph
@@ -322,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 |
@@ -351,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
@@ -402,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`.
@@ -1030,7 +1024,7 @@ team = Team(agents=[researcher, analyst], mode="coordinate")
## More Recipes
The flagship audit-trail recipe is [above](#recipe-audit-trail-for-a-regulated-decision). Here are three more common patterns.
The audit-trail recipe is [above](#recipe-audit-trail-for-a-regulated-decision). Here are three more common patterns.
<details>
<summary><b>End-to-End GraphRAG Pipeline</b></summary>
@@ -1147,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 |
---
@@ -1519,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.
@@ -1,222 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/semantica-agi/semantica/blob/main/cookbook/advanced/09_Semantic_Layer_Construction.ipynb)\n",
"\n",
"# Semantic Layer Construction\n",
"\n",
"## Overview\n",
"\n",
"Build an enterprise semantic layer: construct knowledge graph, generate ontology, create semantic layer, export RDF, and store in triplet store.\n",
"\n",
"\n",
"**Documentation**: [API Reference](https://semantica.readthedocs.io/concepts/)\n",
"\n",
"## Installation\n",
"\n",
"Install Semantica from PyPI:\n",
"\n",
"```bash\n",
"pip install semantica\n",
"# Or with all optional dependencies:\n",
"pip install semantica[all]\n",
"```\n",
"\n",
"## Workflow: Build KG → Generate Ontology → Create Semantic Layer → Export RDF \n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"!pip install -qU semantica\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from semantica.kg import GraphBuilder\n",
"from semantica.ontology import OntologyGenerator\n",
"from semantica.export import RDFExporter\n",
"from semantica.triplet_store import TripletStore\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 1: Build Knowledge Graph\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"builder = GraphBuilder()\n",
"\n",
"entities = [\n",
" {\"id\": \"e1\", \"type\": \"Person\", \"name\": \"Alice\", \"properties\": {\"age\": 30, \"role\": \"Engineer\"}},\n",
" {\"id\": \"e2\", \"type\": \"Person\", \"name\": \"Bob\", \"properties\": {\"age\": 35, \"role\": \"Manager\"}},\n",
" {\"id\": \"e3\", \"type\": \"Organization\", \"name\": \"Tech Corp\", \"properties\": {\"founded\": 2010}},\n",
" {\"id\": \"e4\", \"type\": \"Project\", \"name\": \"Project Alpha\", \"properties\": {\"status\": \"active\"}},\n",
"]\n",
"\n",
"relationships = [\n",
" {\"source\": \"e1\", \"target\": \"e2\", \"type\": \"reports_to\"},\n",
" {\"source\": \"e1\", \"target\": \"e3\", \"type\": \"works_for\"},\n",
" {\"source\": \"e2\", \"target\": \"e3\", \"type\": \"works_for\"},\n",
" {\"source\": \"e1\", \"target\": \"e4\", \"type\": \"works_on\"},\n",
"]\n",
"\n",
"knowledge_graph = builder.build(entities, relationships)\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 2: Generate Ontology\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"generator = OntologyGenerator()\n",
"ontology = generator.generate_from_graph(knowledge_graph)\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 3: Create Semantic Layer\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def create_mappings(kg, ontology):\n",
" mappings = {\n",
" \"entity_type_mappings\": {},\n",
" \"relationship_type_mappings\": {},\n",
" \"property_mappings\": {}\n",
" }\n",
" \n",
" entity_types = set(e.get(\"type\") for e in entities)\n",
" ontology_classes = ontology.get(\"classes\", [])\n",
" \n",
" for entity_type in entity_types:\n",
" matching_class = next((cls for cls in ontology_classes if cls.get(\"name\") == entity_type), None)\n",
" if matching_class:\n",
" mappings[\"entity_type_mappings\"][entity_type] = matching_class.get(\"uri\", entity_type)\n",
" \n",
" relationship_types = set(r.get(\"type\") for r in relationships)\n",
" ontology_properties = ontology.get(\"properties\", [])\n",
" \n",
" for rel_type in relationship_types:\n",
" matching_prop = next((prop for prop in ontology_properties if prop.get(\"name\") == rel_type), None)\n",
" if matching_prop:\n",
" mappings[\"relationship_type_mappings\"][rel_type] = matching_prop.get(\"uri\", rel_type)\n",
" \n",
" return mappings\n",
"\n",
"mappings = create_mappings(knowledge_graph, ontology)\n",
"\n",
"semantic_layer = {\n",
" \"graph\": knowledge_graph,\n",
" \"ontology\": ontology,\n",
" \"mappings\": mappings,\n",
" \"metadata\": {\n",
" \"version\": \"1.0\",\n",
" \"created_at\": \"2024-01-01\",\n",
" \"description\": \"Enterprise semantic layer\"\n",
" }\n",
"}\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 4: Export RDF\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"exporter = RDFExporter()\n",
"# Export Knowledge Graph\n",
"exporter.export(knowledge_graph, \"knowledge_graph.ttl\", format=\"turtle\")\n",
"print(\"Exported knowledge graph to knowledge_graph.ttl\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Summary\n",
"\n",
"Enterprise semantic layer construction:\n",
"- Knowledge Graph Built\n",
"- Ontology Generated\n",
"- Semantic Layer Created with Mappings\n",
"- RDF Export Completed\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.9"
}
},
"nbformat": 4,
"nbformat_minor": 2
}
@@ -1,435 +0,0 @@
{
"nbformat": 4,
"nbformat_minor": 5,
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.10.0"
}
},
"cells": [
{
"cell_type": "markdown",
"id": "cell-0",
"metadata": {},
"source": [
"[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/semantica-agi/semantica/blob/main/cookbook/advanced/13_Manual_Ontology_Snowflake_Mapping.ipynb)\n",
"\n",
"# Manual Ontology + Snowflake Mapping\n",
"\n",
"This notebook answers a specific workflow:\n",
"\n",
"> *\"I want to design the ontology myself — not have AI infer it from my tables — and then map Snowflake data to it explicitly.\"*\n",
"\n",
"### What this notebook demonstrates\n",
"\n",
"| Step | What happens | Who controls it |\n",
"|---|---|---|\n",
"| 1 | Design ontology classes and properties | **You** (Python dict) |\n",
"| 2 | Model n-ary facts with reification | **You** (`AssociativeClassBuilder`) |\n",
"| 3 | Pull rows from Snowflake | Semantica `SnowflakeIngestor` |\n",
"| 4 | Map columns → ontology-aligned graph | **You** (explicit transform) |\n",
"| 5 | Validate + export OWL / SHACL | Semantica `OntologyEngine` |\n",
"| 6 | Load to triplet store and query | Semantica `TripletStore` |\n",
"\n",
"### What this notebook does NOT do\n",
"\n",
"- No LLM-driven ontology generation\n",
"- No schema introspection or table-to-class inference\n",
"- No \"suggest ontology from my data\"\n",
"\n",
"### Standards coverage\n",
"\n",
"| Feature | Status |\n",
"|---|---|\n",
"| OWL 2 (Turtle / RDF-XML) | Supported |\n",
"| SHACL 1.1 shapes | Supported |\n",
"| SPARQL 1.1 | Supported |\n",
"| Reification / n-ary facts | Supported via `AssociativeClassBuilder` |\n",
"| SPARQL 1.2 (reifier annotation, `LATERAL`) | Planned |\n",
"| SHACL 1.2 (`sh:severity` extensions, SHACL-AF) | Planned |"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-1",
"metadata": {},
"outputs": [],
"source": [
"!pip install -qU semantica"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-2",
"metadata": {},
"outputs": [],
"source": [
"import os\n",
"from typing import Any, Dict, List\n",
"\n",
"from semantica.ingest import SnowflakeIngestor\n",
"from semantica.kg.methods import build_kg\n",
"from semantica.ontology import AssociativeClassBuilder, OntologyEngine\n",
"from semantica.triplet_store import TripletStore"
]
},
{
"cell_type": "markdown",
"id": "cell-3",
"metadata": {},
"source": [
"## Step 1: Hand-Design the Ontology in Python\n",
"\n",
"You define every class and property explicitly. Nothing is read from Snowflake at this stage.\n",
"\n",
"**Design decisions that belong to you:**\n",
"- Which classes exist and what they mean\n",
"- Which properties are datatype vs. object properties\n",
"- Domain, range, and cardinality constraints\n",
"- Which properties are required (later enforced by SHACL)\n",
"\n",
"This dict versions with your code. It does not change when your database schema changes."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-4",
"metadata": {},
"outputs": [],
"source": "BASE_URI = \"https://example.com/hr/\"\n\n# Your ontology — designed by you, not inferred by Semantica.\nontology: Dict[str, Any] = {\n \"name\": \"EmploymentDomainOntology\",\n \"uri\": f\"{BASE_URI}EmploymentDomainOntology\",\n \"namespace\": {\"base_uri\": BASE_URI},\n\n # You decide the class taxonomy\n \"classes\": [\n {\"name\": \"Person\", \"uri\": f\"{BASE_URI}Person\"},\n {\"name\": \"Organization\", \"uri\": f\"{BASE_URI}Organization\"},\n {\"name\": \"Role\", \"uri\": f\"{BASE_URI}Role\"},\n # EmploymentEvent is a reification node.\n # It connects Person + Organization + Role and carries salary/date context.\n {\"name\": \"EmploymentEvent\", \"uri\": f\"{BASE_URI}EmploymentEvent\"},\n ],\n\n # Each property carries a full URI so TripletStore stores it as hr:<name>\n # rather than the default urn:property:<name>.\n # This ensures SPARQL queries using PREFIX hr: match what is actually stored.\n \"properties\": [\n # Datatype properties\n {\"name\": \"name\", \"uri\": f\"{BASE_URI}name\", \"type\": \"datatype\", \"domain\": \"Person\", \"range\": \"string\", \"required\": True},\n {\"name\": \"legalName\", \"uri\": f\"{BASE_URI}legalName\", \"type\": \"datatype\", \"domain\": \"Organization\", \"range\": \"string\", \"required\": True},\n {\"name\": \"title\", \"uri\": f\"{BASE_URI}title\", \"type\": \"datatype\", \"domain\": \"Role\", \"range\": \"string\", \"required\": True},\n {\"name\": \"startDate\", \"uri\": f\"{BASE_URI}startDate\", \"type\": \"datatype\", \"domain\": \"EmploymentEvent\", \"range\": \"date\"},\n {\"name\": \"endDate\", \"uri\": f\"{BASE_URI}endDate\", \"type\": \"datatype\", \"domain\": \"EmploymentEvent\", \"range\": \"date\"},\n {\"name\": \"salary\", \"uri\": f\"{BASE_URI}salary\", \"type\": \"datatype\", \"domain\": \"EmploymentEvent\", \"range\": \"decimal\"},\n\n # Object properties — reification spokes (required)\n {\"name\": \"employee\", \"uri\": f\"{BASE_URI}employee\", \"type\": \"object\", \"domain\": \"EmploymentEvent\", \"range\": \"Person\", \"required\": True},\n {\"name\": \"employer\", \"uri\": f\"{BASE_URI}employer\", \"type\": \"object\", \"domain\": \"EmploymentEvent\", \"range\": \"Organization\", \"required\": True},\n {\"name\": \"role\", \"uri\": f\"{BASE_URI}role\", \"type\": \"object\", \"domain\": \"EmploymentEvent\", \"range\": \"Role\", \"required\": True},\n\n # Shortcut edges — direct person→org / person→role without traversing the event node\n {\"name\": \"worksFor\", \"uri\": f\"{BASE_URI}worksFor\", \"type\": \"object\", \"domain\": \"Person\", \"range\": \"Organization\"},\n {\"name\": \"hasRole\", \"uri\": f\"{BASE_URI}hasRole\", \"type\": \"object\", \"domain\": \"Person\", \"range\": \"Role\"},\n ],\n}\n\nontology"
},
{
"cell_type": "markdown",
"id": "cell-5",
"metadata": {},
"source": [
"## Step 2: Reification — Modeling N-Ary Facts\n",
"\n",
"**The problem with binary triples:**\n",
"A simple triple `(Alice, worksFor, Acme)` cannot carry extra context such as salary, start date, or role.\n",
"Standard RDF reification and OWL n-ary patterns solve this by introducing an intermediate node.\n",
"\n",
"Semantica's `AssociativeClassBuilder` is the Pythonic API for this pattern:\n",
"\n",
"```\n",
"EmploymentEvent\n",
" ├── employee → Person (required)\n",
" ├── employer → Organization (required)\n",
" ├── role → Role (required)\n",
" ├── startDate → xsd:date\n",
" ├── endDate → xsd:date\n",
" └── salary → xsd:decimal\n",
"```\n",
"\n",
"**On SPARQL 1.1 vs. SPARQL 1.2:**\n",
"- **SPARQL 1.1 (current):** traverse the event node explicitly — `?event hr:employee ?person ; hr:salary ?salary`\n",
"- **SPARQL 1.2 (planned):** the draft reifier annotation syntax allows attaching context to triples directly, without a separate intermediate node. Semantica will adopt this once the spec is ratified.\n",
"\n",
"**On SHACL 1.1 vs. SHACL 1.2:**\n",
"- **SHACL 1.1 (current):** `sh:NodeShape` + `sh:PropertyShape` constraints are exported for all `required` properties and enforced at load time.\n",
"- **SHACL 1.2 (planned):** `sh:severity` profile extensions and SHACL-AF rules are on the roadmap."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-6",
"metadata": {},
"outputs": [],
"source": "assoc_builder = AssociativeClassBuilder()\n\nemployment_assoc = assoc_builder.create_associative_class(\n name=\"EmploymentEvent\",\n connects=[\"Person\", \"Organization\", \"Role\"],\n temporal=True, # adds startDate / endDate handling\n properties={\n \"startDate\": \"xsd:date\",\n \"endDate\": \"xsd:date\",\n \"salary\": \"xsd:decimal\",\n },\n)\n\nvalidation_result = assoc_builder.validate_associative_class(employment_assoc)\n\n# AssociativeClass is a dataclass — use attribute access, not .get()\nprint(\"AssociativeClass structure:\")\nprint(f\" name: {employment_assoc.name}\")\nprint(f\" connects: {employment_assoc.connects}\")\nprint(f\" temporal: {employment_assoc.temporal}\")\nprint(f\" properties: {list(employment_assoc.properties.keys())}\")\nprint(f\"\\nValidation passed: {validation_result}\")"
},
{
"cell_type": "markdown",
"id": "cell-7",
"metadata": {},
"source": [
"## Step 3: Ingest Snowflake Rows (Extraction Only)\n",
"\n",
"`SnowflakeIngestor` retrieves rows — nothing more. It does **not**:\n",
"- Inspect your table schema\n",
"- Suggest classes or properties\n",
"- Infer relationships from column names\n",
"\n",
"Set `USE_LIVE_SNOWFLAKE=true` plus the env vars below to connect to a real warehouse.\n",
"Otherwise the stub data is used."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-8",
"metadata": {},
"outputs": [],
"source": [
"def fetch_rows_from_snowflake() -> List[Dict[str, Any]]:\n",
" if os.getenv(\"USE_LIVE_SNOWFLAKE\", \"false\").lower() != \"true\":\n",
" return [\n",
" {\n",
" \"EMPLOYEE_ID\": \"E100\",\n",
" \"EMPLOYEE_NAME\": \"Alice Johnson\",\n",
" \"ORG_ID\": \"O10\",\n",
" \"ORG_NAME\": \"Acme Corp\",\n",
" \"ROLE_ID\": \"R7\",\n",
" \"ROLE_TITLE\": \"Senior Engineer\",\n",
" \"START_DATE\": \"2025-01-15\",\n",
" \"END_DATE\": None,\n",
" \"SALARY\": 160000,\n",
" },\n",
" {\n",
" \"EMPLOYEE_ID\": \"E101\",\n",
" \"EMPLOYEE_NAME\": \"Bob Singh\",\n",
" \"ORG_ID\": \"O10\",\n",
" \"ORG_NAME\": \"Acme Corp\",\n",
" \"ROLE_ID\": \"R9\",\n",
" \"ROLE_TITLE\": \"Data Architect\",\n",
" \"START_DATE\": \"2024-09-01\",\n",
" \"END_DATE\": None,\n",
" \"SALARY\": 185000,\n",
" },\n",
" ]\n",
"\n",
" ingestor = SnowflakeIngestor(\n",
" account=os.getenv(\"SNOWFLAKE_ACCOUNT\"),\n",
" user=os.getenv(\"SNOWFLAKE_USER\"),\n",
" password=os.getenv(\"SNOWFLAKE_PASSWORD\"),\n",
" warehouse=os.getenv(\"SNOWFLAKE_WAREHOUSE\"),\n",
" database=os.getenv(\"SNOWFLAKE_DATABASE\"),\n",
" schema=os.getenv(\"SNOWFLAKE_SCHEMA\", \"PUBLIC\"),\n",
" )\n",
" query = (\n",
" \"SELECT EMPLOYEE_ID, EMPLOYEE_NAME, \"\n",
" \"ORG_ID, ORG_NAME, ROLE_ID, ROLE_TITLE, \"\n",
" \"START_DATE, END_DATE, SALARY \"\n",
" \"FROM HR_EMPLOYMENT_FACT\"\n",
" )\n",
" data = ingestor.ingest_query(query)\n",
" ingestor.close()\n",
" return data.data\n",
"\n",
"\n",
"rows = fetch_rows_from_snowflake()\n",
"rows[:2]"
]
},
{
"cell_type": "markdown",
"id": "cell-9",
"metadata": {},
"source": [
"## Step 4: Map Rows to Ontology Concepts Explicitly\n",
"\n",
"This is the semantic transformation layer — the part that makes your ontology real.\n",
"\n",
"Semantica does not guess which column becomes which entity or property.\n",
"Every assignment is code you write and own:\n",
"\n",
"- **Stable node IDs** — deterministic, collision-safe, derived from business keys\n",
"- **Class assignment** — matches what you declared in Step 1\n",
"- **Property routing** — each column value goes to the correct ontology property\n",
"- **Reification wiring** — `EmploymentEvent` is linked to its three participants\n",
"\n",
"When your Snowflake schema changes, only this function needs updating. The ontology stays stable."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-10",
"metadata": {},
"outputs": [],
"source": "def map_rows_to_kg(rows: List[Dict[str, Any]]) -> Dict[str, Any]:\n entities: Dict[str, Dict[str, Any]] = {}\n relationships: List[Dict[str, Any]] = []\n\n for row in rows:\n # Stable, deterministic node IDs derived from business keys\n person_id = f\"person:{row['EMPLOYEE_ID']}\"\n org_id = f\"org:{row['ORG_ID']}\"\n role_id = f\"role:{row['ROLE_ID']}\"\n # Event ID includes all three participants + start date so that\n # a re-hired employee gets a distinct event node, not an overwrite.\n event_id = f\"employment:{row['EMPLOYEE_ID']}:{row['ORG_ID']}:{row['START_DATE']}\"\n\n # Entities — \"type\" must match a class name from Step 1\n entities[person_id] = {\n \"id\": person_id,\n \"type\": \"Person\",\n \"properties\": {\"name\": row[\"EMPLOYEE_NAME\"]},\n }\n entities[org_id] = {\n \"id\": org_id,\n \"type\": \"Organization\",\n \"properties\": {\"legalName\": row[\"ORG_NAME\"]},\n }\n entities[role_id] = {\n \"id\": role_id,\n \"type\": \"Role\",\n \"properties\": {\"title\": row[\"ROLE_TITLE\"]},\n }\n\n # Reification node — filter out None values so TripletStore does not\n # stringify None as the literal \"None\" for open-ended employment.\n event_props = {\n \"startDate\": row[\"START_DATE\"],\n \"endDate\": row[\"END_DATE\"],\n \"salary\": row[\"SALARY\"],\n }\n entities[event_id] = {\n \"id\": event_id,\n \"type\": \"EmploymentEvent\",\n \"properties\": {k: v for k, v in event_props.items() if v is not None},\n }\n\n # Full URIs for relationship types so TripletStore stores hr:<type>\n # instead of the default urn:property:<type>, keeping SPARQL consistent.\n relationships.extend([\n # Shortcut edges — fast SPARQL when context is not needed\n {\"source\": person_id, \"target\": org_id, \"type\": f\"{BASE_URI}worksFor\"},\n {\"source\": person_id, \"target\": role_id, \"type\": f\"{BASE_URI}hasRole\"},\n # Reification spokes — full context via the event node\n {\"source\": event_id, \"target\": person_id, \"type\": f\"{BASE_URI}employee\"},\n {\"source\": event_id, \"target\": org_id, \"type\": f\"{BASE_URI}employer\"},\n {\"source\": event_id, \"target\": role_id, \"type\": f\"{BASE_URI}role\"},\n ])\n\n return build_kg([{\"entities\": list(entities.values()), \"relationships\": relationships}])\n\n\nkg = map_rows_to_kg(rows)\nprint(f\"Entities built: {len(kg.get('entities', []))}\")\nprint(f\"Relationships built: {len(kg.get('relationships', []))}\")\n\nsample = next((e for e in kg[\"entities\"] if e[\"type\"] == \"EmploymentEvent\"), None)\nprint(f\"\\nSample EmploymentEvent node: {sample}\")"
},
{
"cell_type": "markdown",
"id": "cell-11",
"metadata": {},
"source": [
"## Step 5: Validate Ontology and Export OWL + SHACL\n",
"\n",
"`OntologyEngine` validates your ontology dict and serialises it to standards-compliant files.\n",
"\n",
"**Output files:**\n",
"- `employment_manual_ontology.ttl` — OWL 2 Turtle\n",
"- `employment_manual_shapes.ttl` — SHACL 1.1 node and property shapes\n",
"\n",
"**Standards status:**\n",
"\n",
"| Standard | Semantica support |\n",
"|---|---|\n",
"| SPARQL 1.1 | Full |\n",
"| SHACL 1.1 (`sh:NodeShape`, `sh:PropertyShape`, `sh:minCount`, `sh:datatype`, `sh:class`) | Full |\n",
"| SPARQL 1.2 (reifier annotation syntax, `LATERAL`) | Tracked — not yet implemented |\n",
"| SHACL 1.2 (`sh:severity` profiles, SHACL-AF extensions) | Tracked — not yet implemented |"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-12",
"metadata": {},
"outputs": [],
"source": [
"engine = OntologyEngine(base_uri=BASE_URI)\n",
"\n",
"validation = engine.validate(ontology)\n",
"owl_ttl = engine.to_owl(ontology, format=\"turtle\")\n",
"shacl_ttl = engine.to_shacl(ontology, format=\"turtle\")\n",
"\n",
"engine.export_owl(ontology, \"employment_manual_ontology.ttl\", format=\"turtle\")\n",
"engine.export_shacl(ontology, \"employment_manual_shapes.ttl\", format=\"turtle\")\n",
"\n",
"print(f\"Ontology valid: {validation.valid}\")\n",
"print(f\"Ontology consistent: {validation.consistent}\")\n",
"print(f\"OWL output: {len(owl_ttl):,} chars → employment_manual_ontology.ttl\")\n",
"print(f\"SHACL output: {len(shacl_ttl):,} chars → employment_manual_shapes.ttl\")\n",
"\n",
"print(\"\\n--- SHACL shapes (first 20 lines) ---\")\n",
"print(\"\\n\".join(shacl_ttl.splitlines()[:20]))"
]
},
{
"cell_type": "markdown",
"id": "cell-13",
"metadata": {},
"source": [
"## Best-Practice Architecture\n",
"\n",
"```\n",
"┌──────────────────────────────────┐\n",
"│ Ontology as code (Python dict) │ ← versioned alongside your application\n",
"│ + AssociativeClass for n-ary │\n",
"└───────────────┬──────────────────┘\n",
" │ validate + export\n",
" ▼\n",
"┌───────────────────────────────────┐\n",
"│ OWL 2 Turtle │ SHACL 1.1 │ ← standards-compliant artifacts\n",
"└───────────────┬───────────────────┘\n",
" │\n",
" ▼\n",
"┌──────────────────────────────────┐\n",
"│ Snowflake — raw data access │ ← no schema introspection\n",
"└───────────────┬──────────────────┘\n",
" │ explicit mapping layer\n",
" ▼\n",
"┌──────────────────────────────────┐\n",
"│ Ontology-aligned KG │ ← types, IDs, edges match Step 1\n",
"└───────────────┬──────────────────┘\n",
" │ optional\n",
" ▼\n",
"┌──────────────────────────────────┐\n",
"│ Triplet store + SPARQL 1.1 │\n",
"└──────────────────────────────────┘\n",
"```\n",
"\n",
"**Why this split matters:**\n",
"If Semantica inferred the ontology from your Snowflake schema, every schema migration would risk silently changing your semantic model.\n",
"With this pattern, schema changes only touch the mapping function in Step 4 — the ontology remains stable and under your control."
]
},
{
"cell_type": "markdown",
"id": "cell-14",
"metadata": {},
"source": [
"## SPARQL Query Patterns\n",
"\n",
"Two query styles are available because we wrote both shortcut edges and reification spokes.\n",
"\n",
"### Simple lookup — shortcut edge (no context needed)\n",
"\n",
"```sparql\n",
"PREFIX hr: <https://example.com/hr/>\n",
"\n",
"SELECT ?personName ?orgName\n",
"WHERE {\n",
" ?person a hr:Person ;\n",
" hr:name ?personName ;\n",
" hr:worksFor ?org .\n",
" ?org hr:legalName ?orgName .\n",
"}\n",
"```\n",
"\n",
"### Contextual lookup — via reification node (salary, dates, role)\n",
"\n",
"```sparql\n",
"PREFIX hr: <https://example.com/hr/>\n",
"\n",
"SELECT ?personName ?roleTitle ?salary ?startDate\n",
"WHERE {\n",
" ?event a hr:EmploymentEvent ;\n",
" hr:employee ?person ;\n",
" hr:role ?role ;\n",
" hr:salary ?salary ;\n",
" hr:startDate ?startDate .\n",
" ?person hr:name ?personName .\n",
" ?role hr:title ?roleTitle .\n",
"}\n",
"ORDER BY DESC(?salary)\n",
"```\n",
"\n",
"### Future: SPARQL 1.2 reifier syntax\n",
"\n",
"The SPARQL 1.2 draft introduces annotation syntax that lets you attach context directly to triples, without a separate intermediate node.\n",
"Once the spec is ratified Semantica will adopt it, and the contextual query above may be expressible more concisely."
]
},
{
"cell_type": "markdown",
"id": "cell-15",
"metadata": {},
"source": [
"## Step 6 (Optional): Load to Triplet Store and Run SPARQL\n",
"\n",
"Set `STORE_TO_TRIPLET=true` to load the KG into a live triplet store and run the contextual reification query."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-16",
"metadata": {},
"outputs": [],
"source": [
"if os.getenv(\"STORE_TO_TRIPLET\", \"false\").lower() == \"true\":\n",
" store = TripletStore(\n",
" backend=os.getenv(\"TRIPLET_BACKEND\", \"blazegraph\"),\n",
" endpoint=os.getenv(\"TRIPLET_ENDPOINT\", \"http://localhost:9999/blazegraph\"),\n",
" namespace=os.getenv(\"TRIPLET_NAMESPACE\", \"kb\"),\n",
" )\n",
" store_result = store.store(knowledge_graph=kg, ontology=ontology)\n",
" print(\"Store result:\", store_result)\n",
"\n",
" # Contextual reification query — person + role + salary via EmploymentEvent\n",
" query = \"\"\"\n",
" PREFIX hr: <https://example.com/hr/>\n",
"\n",
" SELECT ?personName ?roleTitle ?salary ?startDate\n",
" WHERE {\n",
" ?event a hr:EmploymentEvent ;\n",
" hr:employee ?person ;\n",
" hr:role ?role ;\n",
" hr:salary ?salary ;\n",
" hr:startDate ?startDate .\n",
" ?person hr:name ?personName .\n",
" ?role hr:title ?roleTitle .\n",
" }\n",
" ORDER BY DESC(?salary)\n",
" LIMIT 10\n",
" \"\"\"\n",
" result = store.execute_query(query)\n",
" print(result)\n",
"else:\n",
" print(\"Skipping triplet-store load/query (set STORE_TO_TRIPLET=true to enable)\")"
]
}
]
}
@@ -10,15 +10,16 @@
"\n",
"## Overview\n",
"\n",
"This notebook demonstrates how to build knowledge graphs from entities and relationships using Semantica's graph building modules. You'll learn to use `GraphBuilder` and `EntityResolver`.\n",
"This notebook demonstrates how to build knowledge graphs from extracted entities and relationships using Semantica's graph building modules. You'll learn to use `GraphBuilder` and `EntityResolver`.\n",
"\n",
"**Documentation**: [API Reference](https://semantica.readthedocs.io/reference/kg/)\n",
"\n",
"### Learning Objectives\n",
"\n",
"- Use `GraphBuilder` to construct knowledge graphs\n",
"- Use `EntityResolver` to resolve entity conflicts\n",
"**Note**: For deduplication, use the `semantica.deduplication` module.\n",
"- Extract entity mentions and relations, and map them into graph records\n",
"- Use `GraphBuilder` to construct a graph whose edges come from the actual extracted relations\n",
"- Use `EntityResolver` to merge duplicate mentions and remap relationship endpoints\n",
"- Use the `semantica.deduplication` module and report the complete deduplicated entity set\n",
"\n",
"## Installation\n",
"\n",
@@ -32,120 +33,217 @@
"\n",
"---\n",
"\n",
"## Step 1: Build Knowledge Graph\n",
"## Step 1: Extract Entities and Relations\n",
"\n",
"Construct a knowledge graph from entities and relationships.\n"
"Extract entity mentions and relations from text. The sample text mentions `Apple Inc.` in two separate sentences, so we can later show how duplicate mentions are resolved into one canonical entity.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"!pip install semantica\n"
]
"%pip install semantica\n",
"\n",
"# spaCy models are distributed separately from the spaCy library. This lesson\n",
"# relies on the English model to recognize standalone places such as Cupertino.\n",
"import sys\n",
"import subprocess\n",
"import spacy\n",
"\n",
"try:\n",
" spacy.load(\"en_core_web_sm\")\n",
"except OSError:\n",
" subprocess.check_call([sys.executable, \"-m\", \"spacy\", \"download\", \"en_core_web_sm\"])\n"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from semantica.kg import GraphBuilder\n",
"from semantica.semantic_extract import NERExtractor, RelationExtractor\n",
"\n",
"builder = GraphBuilder()\n",
"text = (\n",
" \"Apple Inc. is headquartered in Cupertino, California. \"\n",
" \"Tim Cook is the CEO of Apple Inc. \"\n",
" \"The company is a technology company.\"\n",
")\n",
"\n",
"ner_extractor = NERExtractor()\n",
"relation_extractor = RelationExtractor()\n",
"\n",
"text = \"Apple Inc. is a technology company. Tim Cook is the CEO of Apple Inc. Apple Inc. is headquartered in Cupertino, California.\"\n",
"mentions = ner_extractor.extract(text)\n",
"relations = relation_extractor.extract(text, mentions)\n",
"\n",
"entities_list = ner_extractor.extract(text)\n",
"relationships_list = relation_extractor.extract(text, entities_list)\n",
"print(\"Entity mentions:\")\n",
"for mention in mentions:\n",
" print(f\" {mention.text!r:<13} {mention.label:<7} span=[{mention.start_char}:{mention.end_char}]\")\n",
"\n",
"entities = []\n",
"for i, entity in enumerate(entities_list[:5], 1):\n",
" entities.append({\n",
" \"id\": f\"e{i}\",\n",
" \"type\": entity.label,\n",
" \"name\": entity.text,\n",
" \"properties\": {}\n",
" })\n",
"\n",
"relationships = []\n",
"for i, rel in enumerate(relationships_list[:3], 1):\n",
" relationships.append({\n",
" \"source\": f\"e{1}\",\n",
" \"target\": f\"e{i+1}\",\n",
" \"type\": rel.predicate,\n",
" \"properties\": {}\n",
" })\n",
"\n",
"knowledge_graph = builder.build(entities, relationships)\n",
"\n",
"print(f\"Built knowledge graph with {len(knowledge_graph.get('entities', []))} entities\")\n",
"print(f\"Relationships: {len(knowledge_graph.get('relationships', []))}\")"
]
"print(\"\\nExtracted relations:\")\n",
"for rel in relations:\n",
" print(f\" {rel.subject.text!r} --{rel.predicate}--> {rel.object.text!r}\")"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 2: Entity Resolution\n",
"## Step 2: Build the Knowledge Graph\n",
"\n",
"Resolve entity conflicts and duplicates.\n"
"Give every mention a graph ID, then translate each relation's `subject` and `object` into those IDs. Building edges from the actual relation endpoints — rather than guessing endpoints from list positions — is what keeps the graph faithful to the text.\n"
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"from semantica.kg import GraphBuilder\n",
"\n",
"entities = []\n",
"span_to_id = {}\n",
"for i, mention in enumerate(mentions, 1):\n",
" graph_id = f\"e{i}\"\n",
" span_to_id[(mention.start_char, mention.end_char)] = graph_id\n",
" entities.append({\n",
" \"id\": graph_id,\n",
" \"type\": mention.label,\n",
" \"name\": mention.text,\n",
" \"properties\": {},\n",
" })\n",
"\n",
"relationships = []\n",
"for rel in relations:\n",
" source_id = span_to_id.get((rel.subject.start_char, rel.subject.end_char))\n",
" target_id = span_to_id.get((rel.object.start_char, rel.object.end_char))\n",
" if source_id is None or target_id is None:\n",
" print(f\"Skipping relation with unmapped endpoint: \"\n",
" f\"{rel.subject.text!r} --{rel.predicate}--> {rel.object.text!r}\")\n",
" continue\n",
" relationships.append({\n",
" \"source\": source_id,\n",
" \"target\": target_id,\n",
" \"type\": rel.predicate,\n",
" \"properties\": {},\n",
" })\n",
"\n",
"builder = GraphBuilder()\n",
"knowledge_graph = builder.build({\"entities\": entities, \"relationships\": relationships})\n",
"\n",
"id_to_name = {entity[\"id\"]: entity[\"name\"] for entity in entities}\n",
"\n",
"print(f\"Graph entities ({len(knowledge_graph['entities'])}):\")\n",
"for entity in knowledge_graph[\"entities\"]:\n",
" print(f\" {entity['id']}: {entity['name']} ({entity['type']})\")\n",
"\n",
"print(f\"\\nGraph relationships ({len(knowledge_graph['relationships'])}):\")\n",
"for relationship in knowledge_graph[\"relationships\"]:\n",
" print(f\" {id_to_name[relationship['source']]} \"\n",
" f\"--{relationship['type']}--> {id_to_name[relationship['target']]}\")\n",
"\n",
"edges = {\n",
" (id_to_name[r[\"source\"]], r[\"type\"], id_to_name[r[\"target\"]])\n",
" for r in knowledge_graph[\"relationships\"]\n",
"}\n",
"assert (\"Apple Inc.\", \"located_in\", \"Cupertino\") in edges\n",
"assert (\"Tim Cook\", \"works_for\", \"Apple Inc.\") in edges"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 3: Entity Resolution\n",
"\n",
"The graph currently contains two nodes for the same organization. `EntityResolver` merges duplicate mentions into one canonical entity and records which source IDs were merged (`merged_from`), so relationship endpoints can be remapped onto the canonical entity.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from semantica.kg import EntityResolver\n",
"\n",
"entity_resolver = EntityResolver()\n",
"\n",
"resolved_entities = entity_resolver.resolve_entities(entities)\n",
"\n",
"print(f\"Original entities: {len(entities)}\")\n",
"print(f\"Resolved entities: {len(resolved_entities)}\")"
]
"canonical_id = {}\n",
"for entity in resolved_entities:\n",
" for source_id in entity.get(\"merged_from\", [entity[\"id\"]]):\n",
" canonical_id[source_id] = entity[\"id\"]\n",
" if entity.get(\"merged_from\"):\n",
" print(f\"Merged {entity['merged_from']} -> {entity['id']}: {entity['name']}\")\n",
"\n",
"print(f\"\\nMentions in: {len(entities)}, resolved entities out: {len(resolved_entities)}\")\n",
"\n",
"resolved_names = {entity[\"id\"]: entity[\"name\"] for entity in resolved_entities}\n",
"print(\"\\nRelationships remapped onto canonical entities:\")\n",
"for relationship in relationships:\n",
" source = canonical_id[relationship[\"source\"]]\n",
" target = canonical_id[relationship[\"target\"]]\n",
" print(f\" {resolved_names[source]} --{relationship['type']}--> {resolved_names[target]}\")\n",
"\n",
"canonical_entities = {(entity[\"name\"], entity[\"type\"]) for entity in resolved_entities}\n",
"assert canonical_entities == {\n",
" (\"Apple Inc.\", \"ORG\"),\n",
" (\"Tim Cook\", \"PERSON\"),\n",
" (\"Cupertino\", \"GPE\"),\n",
" (\"California\", \"GPE\"),\n",
"}\n",
"assert len(resolved_entities) == 4"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 3: Deduplication\n",
"## Step 4: Deduplication\n",
"\n",
"Remove duplicate entities from the graph.\n"
"The `semantica.deduplication` module gives finer control over the same problem. Note that `merge_duplicates` returns one `MergeOperation` per duplicate *group* — the complete deduplicated collection is those merged entities plus every entity that was not part of any group.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from semantica.deduplication import DuplicateDetector, EntityMerger, MergeStrategy\n",
"\n",
"# Detect duplicates\n",
"detector = DuplicateDetector(similarity_threshold=0.8)\n",
"duplicate_groups = detector.detect_duplicate_groups(knowledge_graph.get('entities', []))\n",
"duplicate_groups = detector.detect_duplicate_groups(entities)\n",
"print(f\"Duplicate groups: {len(duplicate_groups)}\")\n",
"for group in duplicate_groups:\n",
" print(f\" {[entity['name'] for entity in group.entities]} \"\n",
" f\"(confidence={group.confidence:.2f})\")\n",
"\n",
"# Merge duplicates\n",
"merger = EntityMerger()\n",
"merge_operations = merger.merge_duplicates(\n",
" knowledge_graph.get('entities', []),\n",
" strategy=MergeStrategy.KEEP_MOST_COMPLETE\n",
" entities, strategy=MergeStrategy.KEEP_MOST_COMPLETE\n",
")\n",
"\n",
"deduplicated_entities = [op.merged_entity for op in merge_operations]\n",
"merged_source_ids = {\n",
" entity[\"id\"] for op in merge_operations for entity in op.source_entities\n",
"}\n",
"untouched_entities = [e for e in entities if e[\"id\"] not in merged_source_ids]\n",
"deduplicated_entities = untouched_entities + [\n",
" op.merged_entity for op in merge_operations\n",
"]\n",
"\n",
"print(f\"Original entities: {len(knowledge_graph.get('entities', []))}\")\n",
"print(f\"Deduplicated entities: {len(deduplicated_entities)}\")\n"
]
"print(f\"\\nMerge operations: {len(merge_operations)}\")\n",
"print(f\"Deduplicated entities ({len(deduplicated_entities)}):\")\n",
"for entity in deduplicated_entities:\n",
" print(f\" {entity['id']}: {entity['name']} ({entity['type']})\")\n",
"\n",
"assert len(merge_operations) == 1\n",
"assert len(deduplicated_entities) == 4"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
@@ -155,9 +253,10 @@
"\n",
"You've learned how to build knowledge graphs:\n",
"\n",
"- **GraphBuilder**: Construct knowledge graphs from entities and relationships\n",
"- **EntityResolver**: Resolve entity conflicts and duplicates\n",
"- **Deduplication**: Use `semantica.deduplication` module for removing duplicate entities\n",
"- **Extraction to graph**: map each mention to a graph ID and build edges from the actual `Relation.subject` / `Relation.object` endpoints\n",
"- **GraphBuilder**: construct knowledge graphs from explicit `{\"entities\": ..., \"relationships\": ...}` input\n",
"- **EntityResolver**: merge duplicate mentions into canonical entities and remap relationship endpoints\n",
"- **Deduplication**: combine `MergeOperation` results with untouched entities to get the complete deduplicated set\n",
"\n",
"Next: Learn how to analyze graphs in the Graph_Analytics notebook.\n"
]
@@ -10,7 +10,7 @@
"\n",
"## Overview\n",
"\n",
"This notebook walks you through creating your first knowledge graph from a simple document. You'll learn the complete end-to-end workflow from ingesting a file to visualizing the resulting knowledge graph.\n",
"This notebook walks you through creating your first knowledge graph from a simple document. You'll learn the complete end-to-end workflow from ingesting a file to visualizing the resulting knowledge graph — and every step consumes the real output of the step before it.\n",
"\n",
"> [!TIP]\n",
"> This is the perfect starting point if you are new to Semantica. No prior knowledge of knowledge graphs is required!\n",
@@ -19,10 +19,10 @@
"\n",
"### 🎯 Learning Objectives\n",
"\n",
"- **Understand the Workflow**: Learn the `File → Parse → Extract → Graph` pipeline\n",
"- **Understand the Workflow**: Learn the `File → Parse → Extract → Graph → Visualize` pipeline\n",
"- **Ingest Data**: Load documents using `FileIngestor`\n",
"- **Parse Content**: Extract text using `DocumentParser`\n",
"- **Extract Knowledge**: Identify entities using `NERExtractor`\n",
"- **Extract Knowledge**: Identify entities and relations using `NERExtractor` and `RelationExtractor`\n",
"- **Build Graph**: Construct a graph using `GraphBuilder`\n",
"- **Visualize**: See your graph come to life with `KGVisualizer`\n",
"\n",
@@ -40,71 +40,76 @@
"\n",
"## 🔄 Simple End-to-End Workflow\n",
"\n",
"The complete workflow consists of four main steps:\n",
"The complete workflow consists of five main steps:\n",
"\n",
"1. **📥 Ingest** - Load data from files or other sources\n",
"2. **📄 Parse** - Extract and structure content from documents\n",
"3. **⛏️ Extract** - Identify entities and relationships\n",
"4. **🕸️ Build Graph** - Construct the knowledge graph\n",
"5. **📊 Visualize** - Render and analyze the graph\n",
"\n",
"Each step is demonstrated in the code cells below.\n",
"Each step is demonstrated in the code cells below, and each cell can be rerun on its own: the sample file is only removed by the optional cleanup cell at the very end.\n",
"\n",
"> [!TIP]\n",
"> **Alternative: Using Semantica Framework**\n",
"> \n",
">\n",
"> For a simpler, high-level approach, you can use the `Semantica` framework class which orchestrates all these steps:\n",
"> \n",
">\n",
"> ```python\n",
"> from semantica.core import Semantica\n",
"> \n",
">\n",
"> framework = Semantica()\n",
"> framework.initialize()\n",
"> \n",
">\n",
"> result = framework.build_knowledge_base(\n",
"> sources=[\"sample_document.txt\"],\n",
"> embeddings=True,\n",
"> graph=True\n",
"> )\n",
"> \n",
">\n",
"> framework.shutdown()\n",
"> ```\n",
"> \n",
">\n",
"> This notebook shows the step-by-step approach for learning. See [Core Module Usage Guide](../../../semantica/core/core_usage.md) for more details.\n",
"\n",
"---\n",
"\n",
"## 📂 Step 1: Ingest a File\n",
"\n",
"In this step, we'll use `FileIngestor` to load a document. The ingestor supports various file formats including PDF, DOCX, TXT, and more.\n"
"In this step, we'll use `FileIngestor` to load a document. The ingestor supports various file formats including PDF, DOCX, TXT, and more. Writing the sample file is idempotent, so this cell can be rerun at any time.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"!pip install semantica"
]
"%pip install semantica\n",
"\n",
"# spaCy models are distributed separately from the spaCy library. This lesson\n",
"# relies on the English model to recognize standalone places such as Cupertino.\n",
"import sys\n",
"import subprocess\n",
"import spacy\n",
"\n",
"try:\n",
" spacy.load(\"en_core_web_sm\")\n",
"except OSError:\n",
" subprocess.check_call([sys.executable, \"-m\", \"spacy\", \"download\", \"en_core_web_sm\"])\n"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from semantica.ingest import FileIngestor\n",
"from pathlib import Path\n",
"\n",
"# Initialize the ingestor\n",
"ingestor = FileIngestor()\n",
"from semantica.ingest import FileIngestor\n",
"\n",
"# Create a sample document for demonstration\n",
"sample_text = \"\"\"\n",
"Apple Inc. is a technology company founded by Steve Jobs, Steve Wozniak, and Ronald Wayne in 1976.\n",
"The company is headquartered in Cupertino, California.\n",
"Tim Cook is the current CEO of Apple Inc.\n",
"Apple designs and manufactures consumer electronics, software, and online services.\n",
"sample_text = \"\"\"Apple Inc. is headquartered in Cupertino, California.\n",
"In 1976, Steve Jobs founded Apple Inc.\n",
"Tim Cook is the CEO of Apple Inc.\n",
"\"\"\"\n",
"\n",
"sample_file = Path(\"sample_document.txt\")\n",
@@ -113,12 +118,14 @@
"print(f\"File: {sample_file}\")\n",
"print(f\"Content length: {len(sample_text)} characters\")\n",
"\n",
"# Ingest the file\n",
"ingestor = FileIngestor()\n",
"file_object = ingestor.ingest_file(sample_file, read_content=True)\n",
"print(f\" File name: {file_object.name}\")\n",
"print(f\" File type: {file_object.file_type}\")\n",
"print(f\" Content available: {file_object.content is not None}\")\n"
]
"print(f\" Content available: {file_object.content is not None}\")"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
@@ -126,64 +133,58 @@
"source": [
"## 📄 Step 2: Parse the Document\n",
"\n",
"After ingesting the file, we need to parse it to extract the text content. The `DocumentParser` handles various file formats and extracts structured content.\n"
"After ingesting the file, we need to parse it to extract the text content. `DocumentParser.parse_document()` returns the extracted text under the `\"text\"` key.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from semantica.parse import DocumentParser\n",
"\n",
"parser = DocumentParser()\n",
"# Parse the document to extract text\n",
"parsed_document = parser.parse_document(str(sample_file))\n",
"parsed_content = parsed_document.get(\"content\", \"\")\n",
"print(f\" Parsed content length: {len(parsed_content) if parsed_content else 0} characters\")\n",
"print(f\" Preview: {parsed_content[:200] if parsed_content else 'N/A'}...\")"
]
"\n",
"parsed_content = parsed_document.get(\"text\", \"\")\n",
"assert parsed_content.strip(), \"Parsing produced no text — check the input file\"\n",
"\n",
"print(f\"Parsed content length: {len(parsed_content)} characters\")\n",
"print(f\"Preview: {parsed_content[:120]}...\")"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## ⛏️ Step 3: Extract Entities\n",
"## ⛏️ Step 3: Extract Entities and Relations\n",
"\n",
"Now we'll extract entities from the parsed text using Named Entity Recognition (NER). This identifies people, organizations, locations, dates, and other entities in the text.\n",
"\n",
"> [!NOTE]\n",
"> In a real scenario, you would use `NERExtractor` with an LLM or model backend. Here we simulate the output for demonstration purposes.\n"
"Now we'll extract entities and relations from the parsed text. `NERExtractor` identifies people, organizations, locations and dates; `RelationExtractor` finds relations between those mentions. Both operate on the *parsed content from Step 2* — not on a copy of the raw string.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from semantica.semantic_extract import NamedEntityRecognizer, NERExtractor\n",
"from semantica.semantic_extract import NERExtractor, RelationExtractor\n",
"\n",
"ner = NamedEntityRecognizer()\n",
"extractor = NERExtractor()\n",
"ner_extractor = NERExtractor()\n",
"relation_extractor = RelationExtractor()\n",
"\n",
"print(f\"\\nText: {parsed_content[:100]}...\")\n",
"mentions = ner_extractor.extract(parsed_content)\n",
"relations = relation_extractor.extract(parsed_content, mentions)\n",
"\n",
"# Simulated extraction results\n",
"expected_entities = [\n",
" {\"text\": \"Apple Inc.\", \"type\": \"Organization\", \"start\": 0, \"end\": 10},\n",
" {\"text\": \"Steve Jobs\", \"type\": \"Person\", \"start\": 50, \"end\": 60},\n",
" {\"text\": \"Steve Wozniak\", \"type\": \"Person\", \"start\": 62, \"end\": 75},\n",
" {\"text\": \"Ronald Wayne\", \"type\": \"Person\", \"start\": 81, \"end\": 93},\n",
" {\"text\": \"1976\", \"type\": \"Date\", \"start\": 97, \"end\": 101},\n",
" {\"text\": \"Cupertino, California\", \"type\": \"Location\", \"start\": 130, \"end\": 151},\n",
" {\"text\": \"Tim Cook\", \"type\": \"Person\", \"start\": 153, \"end\": 161},\n",
"]\n",
"print(\"Entity mentions:\")\n",
"for mention in mentions:\n",
" print(f\" {mention.text!r:<13} {mention.label:<7} span=[{mention.start_char}:{mention.end_char}]\")\n",
"\n",
"for entity in expected_entities:\n",
" print(f\" - {entity['text']} ({entity['type']})\")\n"
]
"print(\"\\nExtracted relations:\")\n",
"for rel in relations:\n",
" print(f\" {rel.subject.text!r} --{rel.predicate}--> {rel.object.text!r}\")"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
@@ -191,58 +192,68 @@
"source": [
"## 🕸️ Step 4: Build the Knowledge Graph\n",
"\n",
"Using the extracted entities and relationships, we'll construct a knowledge graph. The graph represents entities as nodes and relationships as edges.\n"
"Using the extracted entities and relations, we construct a knowledge graph with `GraphBuilder`. Every mention gets a graph ID, and each edge is built from the actual `Relation.subject` / `Relation.object` endpoints.\n",
"\n",
"> [!NOTE]\n",
"> The graph will contain one node per *mention*, so `Apple Inc.` appears three times. Merging duplicate mentions into one canonical entity is covered in [07_Building_Knowledge_Graphs.ipynb](./07_Building_Knowledge_Graphs.ipynb).\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from semantica.kg import GraphBuilder\n",
"import networkx as nx\n",
"\n",
"entities = []\n",
"span_to_id = {}\n",
"for i, mention in enumerate(mentions, 1):\n",
" graph_id = f\"e{i}\"\n",
" span_to_id[(mention.start_char, mention.end_char)] = graph_id\n",
" entities.append({\n",
" \"id\": graph_id,\n",
" \"type\": mention.label,\n",
" \"name\": mention.text,\n",
" \"properties\": {},\n",
" })\n",
"\n",
"relationships = []\n",
"for rel in relations:\n",
" source_id = span_to_id.get((rel.subject.start_char, rel.subject.end_char))\n",
" target_id = span_to_id.get((rel.object.start_char, rel.object.end_char))\n",
" if source_id is None or target_id is None:\n",
" print(f\"Skipping relation with unmapped endpoint: \"\n",
" f\"{rel.subject.text!r} --{rel.predicate}--> {rel.object.text!r}\")\n",
" continue\n",
" relationships.append({\n",
" \"source\": source_id,\n",
" \"target\": target_id,\n",
" \"type\": rel.predicate,\n",
" \"properties\": {},\n",
" })\n",
"\n",
"builder = GraphBuilder()\n",
"knowledge_graph = builder.build({\"entities\": entities, \"relationships\": relationships})\n",
"\n",
"# Prepare data for graph construction\n",
"entities_data = [\n",
" {\"id\": f\"entity_{i}\", \"name\": entity[\"text\"], \"type\": entity[\"type\"]}\n",
" for i, entity in enumerate(expected_entities)\n",
"]\n",
"id_to_name = {entity[\"id\"]: entity[\"name\"] for entity in entities}\n",
"\n",
"relationships_data = [\n",
" {\"source\": \"entity_0\", \"target\": \"entity_1\", \"type\": \"founded_by\"},\n",
" {\"source\": \"entity_0\", \"target\": \"entity_2\", \"type\": \"founded_by\"},\n",
" {\"source\": \"entity_0\", \"target\": \"entity_3\", \"type\": \"founded_by\"},\n",
" {\"source\": \"entity_0\", \"target\": \"entity_4\", \"type\": \"founded_in\"},\n",
" {\"source\": \"entity_0\", \"target\": \"entity_5\", \"type\": \"located_in\"},\n",
" {\"source\": \"entity_6\", \"target\": \"entity_0\", \"type\": \"ceo_of\"},\n",
"]\n",
"print(f\"Nodes (entities): {len(knowledge_graph['entities'])}\")\n",
"for entity in knowledge_graph[\"entities\"]:\n",
" print(f\" {entity['id']}: {entity['name']} ({entity['type']})\")\n",
"\n",
"# Build the graph using NetworkX\n",
"kg = nx.DiGraph()\n",
"print(f\"\\nEdges (relationships): {len(knowledge_graph['relationships'])}\")\n",
"for relationship in knowledge_graph[\"relationships\"]:\n",
" print(f\" {id_to_name[relationship['source']]} \"\n",
" f\"--{relationship['type']}--> {id_to_name[relationship['target']]}\")\n",
"\n",
"for entity in entities_data:\n",
" kg.add_node(entity[\"id\"], name=entity[\"name\"], type=entity[\"type\"])\n",
"\n",
"for rel in relationships_data:\n",
" source_name = entities_data[int(rel[\"source\"].split(\"_\")[1])][\"name\"]\n",
" target_name = entities_data[int(rel[\"target\"].split(\"_\")[1])][\"name\"]\n",
" kg.add_edge(rel[\"source\"], rel[\"target\"], type=rel[\"type\"])\n",
"\n",
"print(f\" Nodes (entities): {len(kg.nodes)}\")\n",
"print(f\" Edges (relationships): {len(kg.edges)}\")\n",
"\n",
"for node_id in kg.nodes():\n",
" node_data = kg.nodes[node_id]\n",
" print(f\" Node: {node_data['name']} ({node_data['type']})\")\n",
"\n",
"for source, target, data in kg.edges(data=True):\n",
" source_name = kg.nodes[source]['name']\n",
" target_name = kg.nodes[target]['name']\n",
" print(f\" {source_name} --[{data['type']}]--> {target_name}\")\n"
]
"edges = {\n",
" (id_to_name[r[\"source\"]], r[\"type\"], id_to_name[r[\"target\"]])\n",
" for r in knowledge_graph[\"relationships\"]\n",
"}\n",
"assert (\"Apple Inc.\", \"located_in\", \"Cupertino\") in edges\n",
"assert (\"Tim Cook\", \"works_for\", \"Apple Inc.\") in edges"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
@@ -250,49 +261,81 @@
"source": [
"## 📊 Step 5: Visualize and Analyze\n",
"\n",
"Finally, we'll visualize the knowledge graph and analyze its structure. This helps you understand the relationships and entities in your data.\n"
"Finally, we render the knowledge graph with `KGVisualizer` and look at its structure. `visualize_network()` accepts the `GraphBuilder` result directly and can save an interactive HTML file.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from semantica.visualization import KGVisualizer\n",
"\n",
"visualizer = KGVisualizer()\n",
"\n",
"print(f\" Total entities: {len(kg.nodes)}\")\n",
"print(f\" Total relationships: {len(kg.edges)}\")\n",
"fig = visualizer.visualize_network(\n",
" knowledge_graph, output=\"html\", file_path=\"knowledge_graph.html\"\n",
")\n",
"print(\"Saved interactive visualization to knowledge_graph.html\")\n",
"\n",
"entity_types = {}\n",
"for node_id in kg.nodes():\n",
" entity_type = kg.nodes[node_id]['type']\n",
" entity_types[entity_type] = entity_types.get(entity_type, 0) + 1\n",
"for entity in knowledge_graph[\"entities\"]:\n",
" entity_types[entity[\"type\"]] = entity_types.get(entity[\"type\"], 0) + 1\n",
"\n",
"for etype, count in entity_types.items():\n",
" print(f\" - {etype}: {count}\")\n",
"print(\"\\nEntities by type:\")\n",
"for entity_type, count in sorted(entity_types.items()):\n",
" print(f\" - {entity_type}: {count}\")\n",
"\n",
"rel_types = {}\n",
"for _, _, data in kg.edges(data=True):\n",
" rel_type = data.get('type', 'unknown')\n",
" rel_types[rel_type] = rel_types.get(rel_type, 0) + 1\n",
"relationship_types = {}\n",
"for relationship in knowledge_graph[\"relationships\"]:\n",
" relationship_types[relationship[\"type\"]] = (\n",
" relationship_types.get(relationship[\"type\"], 0) + 1\n",
" )\n",
"\n",
"for rtype, count in rel_types.items():\n",
" print(f\" - {rtype}: {count}\")\n",
"print(\"\\nRelationships by type:\")\n",
"for relationship_type, count in sorted(relationship_types.items()):\n",
" print(f\" - {relationship_type}: {count}\")\n",
"\n",
"# Cleanup\n",
"if sample_file.exists():\n",
" sample_file.unlink()\n"
"fig"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 🧹 Optional: Clean Up\n",
"\n",
"Run this cell only when you are done with the notebook. Earlier cells read `sample_document.txt`, so they stay rerunnable until you delete it here.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": []
"source": [
"for path in [sample_file, Path(\"knowledge_graph.html\")]:\n",
" if path.exists():\n",
" path.unlink()\n",
" print(f\"Removed {path}\")"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Summary\n",
"\n",
"You've built your first knowledge graph, end to end:\n",
"\n",
"- **FileIngestor** loaded the sample document\n",
"- **DocumentParser** returned its text under the `\"text\"` key\n",
"- **NERExtractor** / **RelationExtractor** produced real mentions and relations from that text\n",
"- **GraphBuilder** turned them into a graph whose edges come from the actual relation endpoints\n",
"- **KGVisualizer** rendered the result as an interactive network\n",
"\n",
"Next: merge duplicate mentions with `EntityResolver` in [07_Building_Knowledge_Graphs.ipynb](./07_Building_Knowledge_Graphs.ipynb), or explore graph metrics in the Graph Analytics notebook.\n"
]
}
],
"metadata": {
+2 -1
View File
@@ -497,7 +497,8 @@
"**Next Steps**:\n",
"* Try customizing the `NamespaceManager` to use your organization's URL.\n",
"* Explore `OntologyEvaluator` for deeper quality metrics.\n",
"* Feed the generated ontology into the **Knowledge Graph** module to start reasoning over your data!"
"* Feed the generated ontology into the **Knowledge Graph** module to start reasoning over your data!\n",
"* Put the graph, ontology, and explicit mappings together in [Semantic Layer Basics](./26_Semantic_Layer_Basics.ipynb)."
]
}
],
@@ -0,0 +1,418 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/semantica-agi/semantica/blob/main/cookbook/introduction/26_Semantic_Layer_Basics.ipynb)\n",
"\n",
"# Semantic Layer Basics: Putting the Knowledge Graph, Ontology, and Mappings Together\n",
"\n",
"## Overview\n",
"\n",
"This lesson connects three things you have already met — a knowledge graph, an ontology, and RDF export — into one minimal *semantic layer*: a knowledge graph whose types, relationships, and properties are **explicitly mapped** to ontology terms, so the resulting RDF can be queried with SPARQL against a shared vocabulary.\n",
"\n",
"**Documentation**: [API Reference](https://semantica.readthedocs.io/concepts/)\n",
"\n",
"### 🎯 Learning Objectives\n",
"\n",
"- Build a small knowledge graph with `GraphBuilder`\n",
"- Generate a starter ontology from the graph with `OntologyGenerator`\n",
"- Write **explicit** entity-type, relationship-type, and property mappings to ontology terms\n",
"- Produce ontology-aligned RDF and store it with `TripletStore`\n",
"- Answer a business question with one small SPARQL query\n",
"\n",
"### 📚 Prerequisites\n",
"\n",
"- [07_Building_Knowledge_Graphs.ipynb](./07_Building_Knowledge_Graphs.ipynb) — graphs from entities and relationships\n",
"- [14_Ontology.ipynb](./14_Ontology.ipynb) — ontology generation\n",
"- [20_Triplet_Store.ipynb](./20_Triplet_Store.ipynb) — triplet store backends\n",
"\n",
"> [!NOTE]\n",
"> **Teaching mappings vs. governed mappings.** The mappings in this lesson are a demo: they live in a Python dict and are derived from a generated ontology. A production semantic layer uses governed identifiers, hand-designed ontologies, explicit source mappings, validation (SHACL), provenance, and versioning — that workflow is covered in [Advanced: Manual Ontology + Snowflake Mapping](../advanced/13_Manual_Ontology_Snowflake_Mapping.ipynb).\n",
"\n",
"## Installation\n",
"\n",
"The triplet-store step uses the embedded Oxigraph backend, so install with that extra. Pin at least 0.6.7: earlier releases could generate ontology classes with no URI (#1103), which silently breaks the mappings below instead of failing loudly.\n",
"\n",
"```bash\n",
"pip install \"semantica[tripletstore-oxigraph]>=0.6.7\"\n",
"```\n",
"\n",
"---\n",
"\n",
"## Step 1: Build a Knowledge Graph\n",
"\n",
"Start from a small, explicit set of entities and relationships — two people, an organization, and a project.\n"
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"!pip install \"semantica[tripletstore-oxigraph]>=0.6.7\"\n"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "code",
"metadata": {},
"source": [
"from semantica.kg import GraphBuilder\n",
"\n",
"entities = [\n",
" {\"id\": \"e1\", \"type\": \"Person\", \"name\": \"Alice\", \"properties\": {\"age\": 30, \"role\": \"Engineer\"}},\n",
" {\"id\": \"e2\", \"type\": \"Person\", \"name\": \"Bob\", \"properties\": {\"age\": 35, \"role\": \"Manager\"}},\n",
" {\"id\": \"e3\", \"type\": \"Organization\", \"name\": \"Tech Corp\", \"properties\": {\"founded\": 2010}},\n",
" {\"id\": \"e4\", \"type\": \"Project\", \"name\": \"Project Alpha\", \"properties\": {\"status\": \"active\"}},\n",
"]\n",
"\n",
"relationships = [\n",
" {\"source\": \"e1\", \"target\": \"e2\", \"type\": \"reports_to\", \"properties\": {}},\n",
" {\"source\": \"e1\", \"target\": \"e3\", \"type\": \"works_for\", \"properties\": {}},\n",
" {\"source\": \"e2\", \"target\": \"e3\", \"type\": \"works_for\", \"properties\": {}},\n",
" {\"source\": \"e1\", \"target\": \"e4\", \"type\": \"works_on\", \"properties\": {}},\n",
"]\n",
"\n",
"builder = GraphBuilder()\n",
"knowledge_graph = builder.build({\"entities\": entities, \"relationships\": relationships})\n",
"\n",
"id_to_name = {entity[\"id\"]: entity[\"name\"] for entity in entities}\n",
"\n",
"print(f\"Entities ({len(knowledge_graph['entities'])}):\")\n",
"for entity in knowledge_graph[\"entities\"]:\n",
" print(f\" {entity['id']}: {entity['name']} ({entity['type']}) {entity['properties']}\")\n",
"\n",
"print(f\"\\nRelationships ({len(knowledge_graph['relationships'])}):\")\n",
"for relationship in knowledge_graph[\"relationships\"]:\n",
" print(f\" {id_to_name[relationship['source']]} \"\n",
" f\"--{relationship['type']}--> {id_to_name[relationship['target']]}\")"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 2: Generate a Starter Ontology\n",
"\n",
"`OntologyGenerator` infers OWL classes and properties from graph records. Because `GraphBuilder` keeps business attributes inside each entity's `properties` dictionary while ontology inference reads record fields, we first create a flat **inference view**. The knowledge graph itself remains unchanged. Two settings matter here:\n",
"\n",
"- `base_uri` puts every generated term in *your* namespace\n",
"- `min_occurrences=1` includes classes that occur only once (the default of 2 would drop `Organization` and `Project` from this tiny demo graph)\n",
"\n",
"Note that the generator normalizes names: the relationship type `works_for` becomes the ontology property `worksFor`. That is exactly why the next step maps terms **explicitly** instead of matching names.\n"
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"from semantica.ontology import OntologyGenerator\n",
"\n",
"BASE_URI = \"https://example.org/company/\"\n",
"\n",
"# Adapt the property-graph representation to the record shape consumed by\n",
"# OntologyGenerator, so age/role/founded/status become declared properties.\n",
"ontology_input = {\n",
" \"entities\": [\n",
" {\n",
" **{key: value for key, value in entity.items() if key != \"properties\"},\n",
" **entity.get(\"properties\", {}),\n",
" }\n",
" for entity in knowledge_graph[\"entities\"]\n",
" ],\n",
" \"relationships\": knowledge_graph[\"relationships\"],\n",
"}\n",
"\n",
"generator = OntologyGenerator(base_uri=BASE_URI, min_occurrences=1)\n",
"ontology = generator.generate_from_graph(ontology_input)\n",
"\n",
"# OntologyGenerator calls datatype properties `data`; TripletStore's public\n",
"# ontology contract calls them `datatype`. Normalize that boundary explicitly.\n",
"store_ontology = {\n",
" **ontology,\n",
" \"properties\": [\n",
" {**prop, \"type\": \"datatype\" if prop[\"type\"] == \"data\" else prop[\"type\"]}\n",
" for prop in ontology[\"properties\"]\n",
" ],\n",
"}\n",
"\n",
"print(\"Classes:\")\n",
"for ontology_class in ontology[\"classes\"]:\n",
" print(f\" {ontology_class['name']:<14} {ontology_class['uri']}\")\n",
"\n",
"print(\"\\nProperties:\")\n",
"for prop in ontology[\"properties\"]:\n",
" print(f\" {prop['name']:<14} {prop['type']:<7} {prop['uri']} \"\n",
" f\"(domain={prop['domain']}, range={prop['range']})\")\n",
"\n",
"assert len(ontology[\"classes\"]) == 3"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 3: Map the Graph to Ontology Terms\n",
"\n",
"The heart of a semantic layer is the mapping contract: which source type, relationship, and property corresponds to which ontology term.\n",
"\n",
"- **Entity types** and **relationship types**: each generated class/property records the source name it was inferred from (`metadata[\"inferred_from\"]`), so the mapping is read off the ontology itself — no fragile name matching between `works_for` and `worksFor`.\n",
"- **Properties**: the flat inference view makes `name`, `age`, `role`, `founded`, and `status` real generated datatype properties. Every mapping therefore points to a term declared in the ontology — no URI is invented only at mapping time.\n"
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"entity_type_mappings = {\n",
" ontology_class[\"metadata\"][\"inferred_from\"]: ontology_class[\"uri\"]\n",
" for ontology_class in ontology[\"classes\"]\n",
"}\n",
"\n",
"relationship_type_mappings = {\n",
" prop[\"metadata\"][\"inferred_from\"]: prop[\"uri\"]\n",
" for prop in ontology[\"properties\"]\n",
" if prop[\"type\"] == \"object\"\n",
"}\n",
"\n",
"datatype_property_uris = {\n",
" prop[\"metadata\"][\"inferred_from\"]: prop[\"uri\"]\n",
" for prop in ontology[\"properties\"]\n",
" if prop[\"type\"] != \"object\"\n",
"}\n",
"\n",
"property_mappings = datatype_property_uris\n",
"\n",
"semantic_layer = {\n",
" \"graph\": knowledge_graph,\n",
" \"ontology\": ontology,\n",
" \"mappings\": {\n",
" \"entity_type_mappings\": entity_type_mappings,\n",
" \"relationship_type_mappings\": relationship_type_mappings,\n",
" \"property_mappings\": property_mappings,\n",
" },\n",
"}\n",
"\n",
"for mapping_name, mapping in semantic_layer[\"mappings\"].items():\n",
" print(f\"{mapping_name}:\")\n",
" for source, target in mapping.items():\n",
" print(f\" {source:<12} -> {target}\")\n",
"\n",
"# Every type and relationship in the graph must have an ontology term\n",
"assert set(entity_type_mappings) == {entity[\"type\"] for entity in entities}\n",
"assert set(relationship_type_mappings) == {rel[\"type\"] for rel in relationships}\n",
"assert set(property_mappings) == {\"name\", \"age\", \"role\", \"founded\", \"status\"}\n",
"assert set(property_mappings.values()) <= {prop[\"uri\"] for prop in ontology[\"properties\"]}"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 4: Apply the Mappings\n",
"\n",
"Applying the semantic layer means rewriting the graph so every type, relationship, and property key is an ontology term. This *aligned* graph — not the original one — is what gets exported and stored.\n"
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"aligned_graph = {\n",
" \"entities\": [\n",
" {\n",
" **entity,\n",
" \"type\": entity_type_mappings[entity[\"type\"]],\n",
" \"properties\": {\n",
" property_mappings[\"name\"]: entity[\"name\"],\n",
" **{\n",
" property_mappings[key]: value\n",
" for key, value in entity[\"properties\"].items()\n",
" },\n",
" },\n",
" }\n",
" for entity in knowledge_graph[\"entities\"]\n",
" ],\n",
" \"relationships\": [\n",
" {**rel, \"type\": relationship_type_mappings[rel[\"type\"]]}\n",
" for rel in knowledge_graph[\"relationships\"]\n",
" ],\n",
"}\n",
"\n",
"print(\"Aligned entity sample:\")\n",
"sample = aligned_graph[\"entities\"][0]\n",
"print(f\" id: {sample['id']}\")\n",
"print(f\" type: {sample['type']}\")\n",
"for key, value in sample[\"properties\"].items():\n",
" print(f\" {key} = {value}\")\n",
"\n",
"print(\"\\nAligned relationship sample:\")\n",
"print(f\" {aligned_graph['relationships'][0]['type']}\")"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 5: Store and Export Complete Ontology-Aligned RDF\n",
"\n",
"`TripletStore.store()` materializes both the ontology declarations and the aligned instance graph. We then read those triples through the store's public API and serialize that complete RDF graph as Turtle. This avoids the compact `RDFExporter` entity projection, which does not include arbitrary entries from an entity's `properties` dictionary.\n"
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"from rdflib import Graph, Literal, URIRef\n",
"from rdflib.namespace import OWL, RDF\n",
"from semantica.triplet_store import TripletStore\n",
"\n",
"store = TripletStore(backend=\"oxigraph\")\n",
"result = store.store(aligned_graph, store_ontology)\n",
"print(f\"Stored triples: {result['processed']} (failed: {result['failed']})\")\n",
"\n",
"rdf_graph = Graph()\n",
"for triplet in store.get_triplets():\n",
" datatype = triplet.metadata.get(\"datatype\")\n",
" if datatype:\n",
" object_term = Literal(triplet.object, datatype=URIRef(datatype))\n",
" elif triplet.object.startswith((\"http://\", \"https://\", \"urn:\")):\n",
" object_term = URIRef(triplet.object)\n",
" else:\n",
" object_term = Literal(triplet.object)\n",
" rdf_graph.add((URIRef(triplet.subject), URIRef(triplet.predicate), object_term))\n",
"\n",
"rdf_graph.serialize(destination=\"semantic_layer.ttl\", format=\"turtle\")\n",
"turtle = open(\"semantic_layer.ttl\", encoding=\"utf-8\").read()\n",
"print(turtle[:600])\n",
"\n",
"# The exported RDF contains declarations plus mapped instance facts.\n",
"declared_datatype_properties = {\n",
" str(subject) for subject in rdf_graph.subjects(RDF.type, OWL.DatatypeProperty)\n",
"}\n",
"assert result[\"failed\"] == 0\n",
"assert set(property_mappings.values()) <= declared_datatype_properties\n",
"assert (\n",
" URIRef(BASE_URI + \"e1\"),\n",
" URIRef(property_mappings[\"role\"]),\n",
" Literal(\"Engineer\"),\n",
") in rdf_graph\n",
"assert (\n",
" URIRef(BASE_URI + \"e1\"),\n",
" URIRef(relationship_type_mappings[\"works_for\"]),\n",
" URIRef(BASE_URI + \"e3\"),\n",
") in rdf_graph\n",
"print(\"... exported semantic_layer.ttl\")"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 6: Query the Semantic Layer\n",
"\n",
"The embedded Oxigraph backend runs in memory, so there is nothing to start beyond installing the `tripletstore-oxigraph` extra. The organization is constrained by its mapped `name` predicate; the query therefore means *Tech Corp*, rather than accidentally matching employees of every organization.\n"
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"query = f\"\"\"\n",
"SELECT ?name ?role WHERE {{\n",
" ?person <{BASE_URI}worksFor> ?org .\n",
" ?org <{BASE_URI}name> \"Tech Corp\" .\n",
" ?person <{BASE_URI}name> ?name .\n",
" ?person <{BASE_URI}role> ?role .\n",
"}}\n",
"ORDER BY ?name\n",
"\"\"\"\n",
"query_result = store.execute_query(query)\n",
"\n",
"print(\"\\nWho works for Tech Corp, and in which role?\")\n",
"for binding in query_result.bindings:\n",
" print(f\" {binding['name']['value']} — {binding['role']['value']}\")\n",
"\n",
"assert [(row[\"name\"][\"value\"], row[\"role\"][\"value\"]) for row in query_result.bindings] == [\n",
" (\"Alice\", \"Engineer\"),\n",
" (\"Bob\", \"Manager\"),\n",
"]"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 🧹 Optional: Clean Up\n"
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"from pathlib import Path\n",
"\n",
"ttl_file = Path(\"semantic_layer.ttl\")\n",
"if ttl_file.exists():\n",
" ttl_file.unlink()\n",
" print(f\"Removed {ttl_file}\")"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Summary\n",
"\n",
"A minimal semantic layer is a composition, and you have now built each part:\n",
"\n",
"1. **Knowledge graph** — `GraphBuilder` from explicit entities and relationships\n",
"2. **Ontology** — `OntologyGenerator` with your `base_uri`\n",
"3. **Explicit mappings** — entity types, relationship types, and properties, each tied to an ontology term\n",
"4. **Ontology-aligned RDF** — the mappings applied to the graph, materialized with `TripletStore`, and serialized to Turtle from the store's own triples\n",
"5. **Queryable store** — `TripletStore` (embedded Oxigraph) answering a SPARQL question over the shared vocabulary\n",
"\n",
"### Where to go next\n",
"\n",
"The production version of this workflow — hand-designed governed ontologies, explicit source-to-ontology mappings from a warehouse, n-ary modeling, SHACL validation, provenance, and versioning — is covered in [Advanced: Manual Ontology + Snowflake Mapping](../advanced/13_Manual_Ontology_Snowflake_Mapping.ipynb).\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.9"
}
},
"nbformat": 4,
"nbformat_minor": 2
}
+1
View File
@@ -36,6 +36,7 @@ Essential guides to master the Semantica framework.
- **[Graph Store](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/09_Graph_Store.ipynb)** — Persisting knowledge graphs in Neo4j or FalkorDB. Topics: Neo4j, Cypher, Persistence · *Intermediate*
- **[Ontology](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/14_Ontology.ipynb)** — Defining domain schemas and ontologies to structure your data. Topics: OWL, RDF, Schema Design · *Intermediate*
- **[Seed Data](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/25_Seed_Data.ipynb)** — Bootstrapping a knowledge graph from trusted CSV, JSON, database, and API sources before extraction runs. Topics: SeedDataManager, Foundation Graphs · *Intermediate*
- **[Semantic Layer Basics](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/26_Semantic_Layer_Basics.ipynb)** — Capstone tutorial that combines a knowledge graph, generated ontology, explicit mappings, ontology-aligned RDF, and a SPARQL query. Topics: Semantic Layer, Ontology Mapping, Oxigraph, SPARQL · *Intermediate*
## Advanced Concepts
+3 -2
View File
@@ -327,10 +327,11 @@ print(f"Relationships active in 2023: {result_2023['num_relationships']}")
<Accordion title="Persistent graph store: Neo4j, FalkorDB, Apache AGE" icon="database">
```python
from semantica.graph_store import Neo4jStore
from semantica.graph_store import GraphStore
from semantica.kg import GraphBuilder
store = Neo4jStore(
store = GraphStore(
backend="neo4j",
uri="bolt://localhost:7687",
user="neo4j",
password="password",
+95
View File
@@ -25,6 +25,7 @@ icon: "brain"
| `DecisionRecorder` | Record decisions with embeddings, causal chains, and metadata |
| `PolicyEngine` | Policy management: `add_policy()`, `check_compliance()`, `get_applicable_policies()` |
| `CausalChainAnalyzer` | Trace how decisions influenced each other: `get_causal_chain(decision_id)` |
| `ErasureCoordinator` | Erase an entity across graph, memory, and vector store, returning an auditable `ErasureReceipt` |
## What You Get
@@ -634,6 +635,100 @@ queried together safely. Vector-store writes are deferred until the in-memory im
commits; adapter synchronization remains best-effort and logs failures.
## ErasureCoordinator
`ContextGraph.purge_node()` is scoped to one graph: the node is removed and a
tombstone is written, but the same content can still be live as an `AgentMemory`
item and as an embedding in the vector store. `ErasureCoordinator` drives the
cascade across every bound store and returns an `ErasureReceipt` recording what
each one reported.
```python
from semantica.context import AgentMemory, ContextGraph, ErasureCoordinator
coordinator = ErasureCoordinator(graph=graph, memory=memory)
receipt = coordinator.erase_entity(
"customer-4471",
reason="GDPR Art. 17 request #882",
)
if not receipt.complete:
# These stores may still hold the entity; handle them out of band.
print(receipt.incomplete_stores)
```
<Warning>
Check the receipt — the call returning is not proof the data is gone. FAISS,
Milvus, and Weaviate expose no delete method, so erasure cannot be completed on
those backends today; the receipt reports `unsupported` rather than a success it
did not achieve.
</Warning>
### Constructor Parameters
| Parameter | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| `graph` | `ContextGraph` | `None` | Anything exposing `purge_node()` |
| `memory` | `AgentMemory` | `None` | Anything exposing `find_by_entity()` and `batch_delete()` |
| `vector_store` | `VectorStore` | `memory.vector_store` | Store holding entity-keyed embeddings; pass `False` to disable the leg |
At least one store is required; a store that is not supplied reports
`not_configured` rather than being silently skipped.
### Methods
| Method | Returns | Description |
| :--- | :--- | :--- |
| `erase_entity(entity_id, reason, at, vector_ids)` | `ErasureReceipt` | Erase one entity from every bound store |
| `erase_entities(entity_ids, reason, at)` | `List[ErasureReceipt]` | One receipt per entity, in order; one failure does not stop the rest |
### Store Statuses
| Status | Meaning |
| :--- | :--- |
| `erased` | Reached, data removed. On the vectors leg this means the store accepted the delete for the ids given — backends offer no portable existence check, so it is not a count of embeddings that were really there |
| `not_found` | Reached, held nothing for this entity |
| `not_configured` | No such store was bound — normal, not a failure |
| `unsupported` | The store cannot delete at all; retrying will not help |
| `failed` | The store was reached and the deletion did not succeed |
### ErasureReceipt
| Member | Type | Description |
| :--- | :--- | :--- |
| `entity_id` | `str` | Entity the erasure was requested for |
| `reason` | `Optional[str]` | Recorded in the receipt and the graph tombstone |
| `erased_at` | `str` | ISO-8601; matches the tombstone's `purged_at` |
| `stores` | `Dict[str, Dict]` | Per-store outcome keyed `vectors`, `memory`, `graph` |
| `complete` | `bool` | `False` when any store reports `unsupported` or `failed` |
| `incomplete_stores` | `List[str]` | Stores that may still hold the entity's data |
| `to_dict()` | `Dict` | Serialized receipt, safe to persist as an audit record |
```python
receipt.to_dict()
# {
# "entity_id": "customer-4471",
# "reason": "GDPR Art. 17 request #882",
# "erased_at": "2026-08-16T09:03:36.813220",
# "complete": False,
# "stores": {
# "vectors": {"status": "unsupported", "backend": "faiss",
# "detail": "backend exposes no delete()/delete_vectors(); ..."},
# "memory": {"status": "erased", "items": 14},
# "graph": {"status": "erased", "nodes": 1, "edges": 3},
# },
# }
```
Erasure runs outward-in — vectors, then memory, then the graph. The tombstone is
the durable attestation that an erasure happened, so it is written last: a crash
mid-cascade leaves the node present and the receipt incomplete, rather than a
tombstone claiming more than actually happened. A store that raises is recorded
as `failed` and the remaining stores are still erased. Erasing the same entity
twice returns a receipt saying there was nothing left to do rather than raising.
## PolicyEngine
`PolicyEngine` manages versioned policies stored in the knowledge graph. Policies are stored as nodes and can be linked to decisions:
+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.
+37 -45
View File
@@ -80,7 +80,6 @@
"integrity": "sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.6",
@@ -1603,7 +1602,8 @@
"version": "2.0.46",
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/hast": {
"version": "3.0.5",
@@ -1642,7 +1642,6 @@
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1652,7 +1651,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1672,7 +1670,8 @@
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/@types/unist": {
"version": "3.0.3",
@@ -1725,7 +1724,6 @@
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/types": "8.58.2",
@@ -1995,7 +1993,6 @@
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2070,9 +2067,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.20",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz",
"integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==",
"version": "2.11.20",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
"integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -2096,9 +2093,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
"integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
"dev": true,
"funding": [
{
@@ -2115,13 +2112,12 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
"electron-to-chromium": "^1.5.328",
"node-releases": "^2.0.36",
"update-browserslist-db": "^1.2.3"
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809",
"electron-to-chromium": "^1.5.402",
"node-releases": "^2.0.53",
"update-browserslist-db": "^1.3.0"
},
"bin": {
"browserslist": "cli.js"
@@ -2131,9 +2127,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001788",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
"integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==",
"version": "1.0.30001810",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
"dev": true,
"funding": [
{
@@ -2221,7 +2217,8 @@
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/component-emitter": {
"version": "1.3.1",
@@ -2259,7 +2256,8 @@
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz",
"integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/csstype": {
"version": "3.2.3",
@@ -2324,7 +2322,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -2457,14 +2454,15 @@
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.340",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz",
"integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==",
"version": "1.5.420",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz",
"integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==",
"dev": true,
"license": "ISC"
},
@@ -2539,7 +2537,6 @@
"integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
"dev": true,
"license": "MIT",
"peer": true,
"workspaces": [
"packages/*"
],
@@ -3339,6 +3336,7 @@
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
@@ -4276,11 +4274,14 @@
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.37",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
"integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
"version": "2.0.54",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
"integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
"dev": true,
"license": "MIT"
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
@@ -4414,7 +4415,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4551,7 +4551,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4617,7 +4616,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -4863,7 +4861,6 @@
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz",
"integrity": "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"events": "^3.3.0",
"graphology-utils": "^2.5.2"
@@ -4989,7 +4986,6 @@
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.28.0"
},
@@ -5022,7 +5018,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5150,9 +5145,9 @@
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
"integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
"dev": true,
"funding": [
{
@@ -5246,7 +5241,6 @@
"resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.3.tgz",
"integrity": "sha512-jhnb6rJNqkKR1Qmlay0VuDXY9ZlvAnYN1udsrP4U+krgZEq7C0yNSKdZqmnCe13mdnf9AdVcdDGFOzy2mpPoqw==",
"license": "(Apache-2.0 OR MIT)",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/visjs"
@@ -5301,7 +5295,6 @@
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -5440,7 +5433,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+1 -1
View File
@@ -9,7 +9,7 @@
"lint": "eslint .",
"preview": "vite preview",
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts",
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts",
"test:deterministic-e2e": "node --import tsx --test tests/deterministicExplorerRendering.e2e.ts",
"test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs"
},
+1
View File
@@ -86,6 +86,7 @@ export interface EdgeAttributes {
dominantEdgeType?: string;
representativeWeight?: number;
bundleKind?: "parallel" | "bidirectional" | "community";
isSmallGraph?: boolean;
edgeType: string;
@@ -21,7 +21,6 @@ import type Graph from "graphology";
import { batchMergeEdges, batchMergeNodes, graph } from "../../store/graphStore";
import { logEvent } from "../../store/registryStore";
import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore";
import { curveGroupForPair } from "../../store/edgePairKeys.js";
import { InspectorPanel, MetricChip, SurfaceCard } from "../../ui/primitives";
import { lazy, Suspense } from "react";
import { SigmaSceneAdapter } from "./SigmaSceneAdapter";
@@ -42,6 +41,8 @@ import {
import { explorationEffectsShouldLoad, neighborhoodPanelShouldLoad, temporalOverlayShouldLoad } from "./pluginRegistryPredicates";
import { shouldFetchTemporalBounds, shouldFetchTemporalSnapshot } from "./temporalLifecyclePredicates";
import { createTemporalSnapshotGuards, type TemporalSnapshotResponse } from "./temporalSnapshotGuards";
import { SMALL_GRAPH_MAX_NODES } from "./smallGraphLayout";
import { buildRealtimeEdgeAttributes } from "./realtimeGraphAttributes";
import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel";
import type { GraphSceneHandle, GraphSceneRuntime } from "./scene";
import type {
@@ -1056,46 +1057,10 @@ function buildRealtimeNodeAttributes(payload: {
};
}
function buildRealtimeEdgeAttributes(payload: {
id: string;
familyId?: string;
source_id: string;
target_id: string;
type?: string;
weight?: number;
properties?: Record<string, unknown>;
}): EdgeAttributes {
const properties = payload.properties || {};
const isInferred = Boolean(properties.inferred);
const isBidirectional = graph.hasDirectedEdge(payload.target_id, payload.source_id);
const baseColor = isInferred ? GRAPH_THEME.palette.accent.path : GRAPH_THEME.palette.muted.edgeStructure;
return {
edgeId: payload.id,
familyId: payload.familyId || payload.id,
sourceId: payload.source_id,
targetId: payload.target_id,
weight: Number(payload.weight ?? 1),
edgeType: payload.type || "related_to",
properties,
size: 1,
baseSize: 1,
color: baseColor,
baseColor,
mutedColor: GRAPH_THEME.palette.muted.edgeOverview,
visualPriority: isInferred ? 0.95 : 0.5,
isBidirectional,
edgeFamily: isInferred ? "path" : isBidirectional ? "bidirectional" : "line",
curveGroup: isBidirectional ? curveGroupForPair(payload.source_id, payload.target_id) : null,
type: "line",
edgeVariant: isInferred ? "pathSignal" : isBidirectional ? "bidirectionalCurve" : "directional",
arrowVisibilityPolicy: isInferred ? "always" : "contextual",
relationshipStrength: isInferred ? 0.95 : 0.52,
isParallelPair: false,
parallelIndex: 0,
parallelCount: 1,
familySize: 1,
};
function synchronizeRealtimeSmallGraphEdges(isSmallGraph: boolean): void {
graph.forEachEdge((edgeId) => {
graph.setEdgeAttribute(edgeId, "isSmallGraph", isSmallGraph);
});
}
function buildSelectedNodeState(
@@ -1355,6 +1320,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
const lastExternalFocusTokenRef = useRef<number | undefined>(undefined);
const pluginRuntimeRef = useRef<GraphSceneRuntime | null>(null);
const appliedGraphSummarySignatureRef = useRef<string | null>(null);
const smallGraphModeRef = useRef(false);
const pluginInteractionStateRef = useRef<GraphInteractionState>({
hoveredNodeId: null,
selectedNodeId: "",
@@ -1382,6 +1348,12 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
}
appliedGraphSummarySignatureRef.current = signature;
smallGraphModeRef.current = Boolean(
graphSummary.layoutReady
&& !graphSummary.hasCoordinates
&& graphSummary.nodeCount > 0
&& graphSummary.nodeCount <= SMALL_GRAPH_MAX_NODES,
);
setGraphReady(true);
setGraphVersion((current) => current + 1);
setIsLayoutRunning(!graphSummary.layoutReady);
@@ -1893,18 +1865,26 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
attributes: buildRealtimeNodeAttributes(payload),
},
]);
if (graph.order > SMALL_GRAPH_MAX_NODES) {
smallGraphModeRef.current = false;
}
synchronizeRealtimeSmallGraphEdges(smallGraphModeRef.current);
logEvent("add-node", `Added node ${payload.label ?? payload.id}${payload.nodeType ? ` (${payload.nodeType})` : ""} via realtime ws`, { nodeId: payload.id, nodeType: payload.nodeType });
setGraphVersion((current) => current + 1);
sceneRef.current?.getRuntime()?.requestRender();
}
if (eventType === "ADD_EDGE") {
const isSmallGraph = smallGraphModeRef.current;
batchMergeEdges([
{
id: String(payload.id),
familyId: payload.familyId ? String(payload.familyId) : String(payload.id),
source: payload.source_id,
target: payload.target_id,
attributes: buildRealtimeEdgeAttributes(payload),
attributes: buildRealtimeEdgeAttributes(payload, {
isBidirectional: graph.hasDirectedEdge(payload.target_id, payload.source_id),
isSmallGraph,
}),
},
]);
logEvent("add-edge", `Added edge ${payload.edgeType ?? payload.id} (${payload.source_id}${payload.target_id}) via realtime ws`, { edgeId: payload.id, edgeType: payload.edgeType, source: payload.source_id, target: payload.target_id });
@@ -1783,6 +1783,7 @@ export function resolveEdgeElementStyle(
const isCommunityBundle = attrs.bundleKind === "community";
const baseSize = Number(attrs.baseSize || attrs.size || 0.9);
const visualPriority = Number(attrs.visualPriority ?? 0);
const isSmallGraphEdge = viewMode === "full" && attrs.isSmallGraph === true;
const isFullBridgeEdge = viewMode === "full" && fullEdgeClass === "bridge";
const isFullBackboneEdge = viewMode === "full" && fullEdgeClass === "backbone";
const shouldCurveBridge = isFullBridgeEdge
@@ -1790,11 +1791,13 @@ export function resolveEdgeElementStyle(
const visibilityPolicy = resolveEdgeVisibilityPolicy(theme, viewMode, zoomTier, isCommunityBundle);
const isContextEdge = isContextEdgeState(state);
const isNonCriticalEdge = isNonCriticalEdgeVariant(edgeVariant);
const belowPriorityThreshold = state === "default"
const belowPriorityThreshold = !isSmallGraphEdge && state === "default"
&& visualPriority < Math.max(tierConfig.edgePriorityThreshold, visibilityPolicy.defaultPriorityThreshold)
&& isNonCriticalEdge;
const hiddenByMutedState = (state === "muted" || state === "inactive") && visibilityPolicy.hideMuted;
const sampledOut = isNonCriticalEdge
const hiddenByMutedState = !isSmallGraphEdge
&& (state === "muted" || state === "inactive")
&& visibilityPolicy.hideMuted;
const sampledOut = !isSmallGraphEdge && isNonCriticalEdge
&& (
(state === "default" && !isContextEdge && shouldSampleOutBackgroundEdge(visibilityPolicy.backgroundSampleRate, visualPriority, edgeId, sourceId, targetId))
|| (
@@ -1837,11 +1840,14 @@ export function resolveEdgeElementStyle(
? resolveEdgeCurvature(theme, state, edgeVariant, attrs, sourceId, targetId)
: 0;
const baseColor = resolveEdgeColor(theme, zoomTier, state, attrs, attrs.color, fullEdgeClass);
const lodAlpha = resolveEdgeLodAlpha(theme, viewMode, zoomTier, state, attrs, isCommunityBundle, fullEdgeClass);
const resolvedLodAlpha = resolveEdgeLodAlpha(theme, viewMode, zoomTier, state, attrs, isCommunityBundle, fullEdgeClass);
const lodAlpha = isSmallGraphEdge
? Math.max(resolvedLodAlpha ?? 1, isContextEdge ? 0.62 : 0.46)
: resolvedLodAlpha;
const color = lodAlpha === null ? baseColor : withAlpha(baseColor, lodAlpha);
const rawSize = Math.max(
baseSize * sizeMultiplier * (isCommunityBundle ? theme.grouped.style.edgeSizeScale : 1),
stateConfig.minSize,
isSmallGraphEdge ? Math.max(stateConfig.minSize, 0.9) : stateConfig.minSize,
);
const interactionMaxSize = (fullEdgeClass === "path" || state === "path")
@@ -0,0 +1,50 @@
import type { EdgeAttributes } from "../../store/graphStore";
import { curveGroupForPair } from "../../store/edgePairKeys.js";
import { GRAPH_THEME } from "./graphTheme";
export type RealtimeEdgePayload = {
id: string;
familyId?: string;
source_id: string;
target_id: string;
type?: string;
weight?: number;
properties?: Record<string, unknown>;
};
export function buildRealtimeEdgeAttributes(
payload: RealtimeEdgePayload,
options: { isBidirectional: boolean; isSmallGraph: boolean },
): EdgeAttributes {
const properties = payload.properties || {};
const isInferred = Boolean(properties.inferred);
const baseColor = isInferred ? GRAPH_THEME.palette.accent.path : GRAPH_THEME.palette.muted.edgeStructure;
return {
edgeId: payload.id,
familyId: payload.familyId || payload.id,
sourceId: payload.source_id,
targetId: payload.target_id,
weight: Number(payload.weight ?? 1),
edgeType: payload.type || "related_to",
properties,
size: 1,
baseSize: 1,
color: baseColor,
baseColor,
mutedColor: GRAPH_THEME.palette.muted.edgeOverview,
visualPriority: isInferred ? 0.95 : 0.5,
isBidirectional: options.isBidirectional,
edgeFamily: isInferred ? "path" : options.isBidirectional ? "bidirectional" : "line",
curveGroup: options.isBidirectional ? curveGroupForPair(payload.source_id, payload.target_id) : null,
type: "line",
edgeVariant: isInferred ? "pathSignal" : options.isBidirectional ? "bidirectionalCurve" : "directional",
arrowVisibilityPolicy: isInferred ? "always" : "contextual",
relationshipStrength: isInferred ? 0.95 : 0.52,
isParallelPair: false,
parallelIndex: 0,
parallelCount: 1,
familySize: 1,
isSmallGraph: options.isSmallGraph,
};
}
@@ -0,0 +1,135 @@
export const SMALL_GRAPH_MAX_NODES = 48;
const PROVIDED_COORDINATE_COVERAGE = 0.92;
const MAX_COMPONENT_RADIUS = 78;
const COMPONENT_GAP = 48;
type LayoutEdge = {
source: string;
target: string;
};
export function shouldUseSmallGraphLayout(nodeCount: number, coordinateCoverage: number): boolean {
return nodeCount > 0
&& nodeCount <= SMALL_GRAPH_MAX_NODES
&& coordinateCoverage < PROVIDED_COORDINATE_COVERAGE;
}
export function resolveGraphLayoutDecision(nodeCount: number, coordinateCoverage: number): {
useProvidedCoordinates: boolean;
useSmallGraphLayout: boolean;
layoutReady: boolean;
} {
const useProvidedCoordinates = coordinateCoverage >= PROVIDED_COORDINATE_COVERAGE;
const useSmallGraphLayout = shouldUseSmallGraphLayout(nodeCount, coordinateCoverage);
return {
useProvidedCoordinates,
useSmallGraphLayout,
layoutReady: useProvidedCoordinates || useSmallGraphLayout,
};
}
export function resolveNodeLayoutPosition(
decision: ReturnType<typeof resolveGraphLayoutDecision>,
provided: { x: number | null; y: number | null },
seeded: { x: number; y: number } | undefined,
): { x: number; y: number } {
if (decision.useProvidedCoordinates) {
return { x: provided.x ?? 0, y: provided.y ?? 0 };
}
if (decision.useSmallGraphLayout) {
return { x: seeded?.x ?? 0, y: seeded?.y ?? 0 };
}
return {
x: provided.x ?? seeded?.x ?? 0,
y: provided.y ?? seeded?.y ?? 0,
};
}
/**
* Produce a compact deterministic layout for small graphs.
*
* ForceAtlas2 is useful for large connected datasets, but it makes tiny graphs
* with several disconnected components look like scattered dots. This layout
* keeps each connected component together and packs components into a centered
* grid so instance relationships remain legible on first render.
*/
export function buildSmallGraphSeedPositions(
nodeIds: string[],
edges: LayoutEdge[],
): Map<string, { x: number; y: number }> {
const ids = [...new Set(nodeIds)].sort((left, right) => left.localeCompare(right));
const adjacency = new Map(ids.map((id) => [id, new Set<string>()]));
edges.forEach(({ source, target }) => {
if (!adjacency.has(source) || !adjacency.has(target) || source === target) {
return;
}
adjacency.get(source)?.add(target);
adjacency.get(target)?.add(source);
});
const visited = new Set<string>();
const components: string[][] = [];
ids.forEach((start) => {
if (visited.has(start)) {
return;
}
const component: string[] = [];
const queue = [start];
visited.add(start);
while (queue.length > 0) {
const current = queue.shift();
if (!current) {
continue;
}
component.push(current);
[...(adjacency.get(current) ?? [])]
.sort((left, right) => left.localeCompare(right))
.forEach((neighbor) => {
if (!visited.has(neighbor)) {
visited.add(neighbor);
queue.push(neighbor);
}
});
}
component.sort((left, right) => {
const degreeDelta = (adjacency.get(right)?.size ?? 0) - (adjacency.get(left)?.size ?? 0);
return degreeDelta || left.localeCompare(right);
});
components.push(component);
});
components.sort((left, right) => right.length - left.length || left[0].localeCompare(right[0]));
const columns = Math.max(1, Math.ceil(Math.sqrt(components.length)));
const rows = Math.max(1, Math.ceil(components.length / columns));
// Adjacent cells must leave room for two maximum-radius components plus a
// readable gap. A smaller row height allows valid 12-node components to
// overlap vertically.
const cellWidth = MAX_COMPONENT_RADIUS * 2 + COMPONENT_GAP;
const cellHeight = MAX_COMPONENT_RADIUS * 2 + COMPONENT_GAP;
const positions = new Map<string, { x: number; y: number }>();
components.forEach((component, componentIndex) => {
const column = componentIndex % columns;
const row = Math.floor(componentIndex / columns);
const centerX = (column - (columns - 1) / 2) * cellWidth;
const centerY = (row - (rows - 1) / 2) * cellHeight;
if (component.length === 1) {
positions.set(component[0], { x: centerX, y: centerY });
return;
}
const radius = Math.min(MAX_COMPONENT_RADIUS, 30 + component.length * 9);
component.forEach((nodeId, nodeIndex) => {
const angle = -Math.PI / 2 + (nodeIndex * Math.PI * 2) / component.length;
positions.set(nodeId, {
x: centerX + Math.cos(angle) * radius,
y: centerY + Math.sin(angle) * radius,
});
});
});
return positions;
}
@@ -16,6 +16,11 @@ import {
} from "./graphTheme";
import { classifyEntityShape } from "./graphEntityShape";
import { createGraphLoadProgress } from "./graphLoading";
import {
buildSmallGraphSeedPositions,
resolveGraphLayoutDecision,
resolveNodeLayoutPosition,
} from "./smallGraphLayout";
import type { GraphLoadProgress, GraphLoadSummary } from "./types";
const SEMANTIC_COLOR_FIELDS = [
@@ -553,10 +558,19 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
: count;
}, 0);
const coordinateCoverage = fetchedNodes.length > 0 ? providedCoordinateCount / fetchedNodes.length : 0;
const useProvidedCoordinates = coordinateCoverage >= 0.92;
const {
useProvidedCoordinates,
useSmallGraphLayout,
layoutReady,
} = resolveGraphLayoutDecision(fetchedNodes.length, coordinateCoverage);
const seededPositions = useProvidedCoordinates
? null
: buildClusterSeedPositions(
: useSmallGraphLayout
? buildSmallGraphSeedPositions(
fetchedNodes.map((node) => node.id),
fetchedEdges,
)
: buildClusterSeedPositions(
draftAttributes.map(({ id, attributes }) => ({
id,
semanticGroup: semanticKeyByNodeId.get(id) ?? structuralColorKey(id, attributes),
@@ -569,7 +583,9 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
const colorIndex = hashString(semanticGroup) % GRAPH_THEME.palette.semantic.length;
const baseColor = GRAPH_THEME.palette.semantic[colorIndex];
const sizeRatio = nodePriorityById.get(id) ?? 0;
const dynamicSize = clamp(1.8, 1.8 + 8.8 * sizeRatio, 11.8);
const dynamicSize = useSmallGraphLayout
? clamp(5.2, 5.2 + 6.6 * sizeRatio, 11.8)
: clamp(1.8, 1.8 + 8.8 * sizeRatio, 11.8);
const hasTemporalBounds = Boolean(attributes.valid_from || attributes.valid_until);
const provenanceCount = getProvenanceCount(attributes.properties ?? {});
const properties = attributes.properties as Record<string, unknown>;
@@ -577,12 +593,11 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
const providedX = readFiniteCoordinate(properties?.x);
const providedY = readFiniteCoordinate(properties?.y);
const seededPosition = seededPositions?.get(id);
const x = useProvidedCoordinates
? providedX ?? 0
: providedX ?? seededPosition?.x ?? 0;
const y = useProvidedCoordinates
? providedY ?? 0
: providedY ?? seededPosition?.y ?? 0;
const { x, y } = resolveNodeLayoutPosition(
{ useProvidedCoordinates, useSmallGraphLayout, layoutReady },
{ x: providedX, y: providedY },
seededPosition,
);
return {
id,
attributes: {
@@ -603,6 +618,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
borderSize: 0.72,
entityShape,
...resolveNodeVariantMetadata(baseColor, sizeRatio, hasTemporalBounds, provenanceCount),
...(useSmallGraphLayout ? { labelVisibilityPolicy: "always" as const } : {}),
} as NodeAttributes,
};
});
@@ -659,6 +675,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
parallelIndex,
parallelCount,
familySize: familyCounts.get(edge.familyId) ?? 1,
isSmallGraph: useSmallGraphLayout,
...resolveEdgeVariantMetadata(edge, sourcePriority, targetPriority, isBidirectional),
} as EdgeAttributes,
};
@@ -701,7 +718,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
loadTimeMs: Math.round(performance.now() - startedAt),
hasCoordinates: useProvidedCoordinates,
layoutSource: useProvidedCoordinates ? "provided" : "runtime",
layoutReady: useProvidedCoordinates,
layoutReady,
} satisfies GraphLoadSummary;
onProgress?.(createGraphLoadProgress({
@@ -407,6 +407,31 @@ test("resolveEdgeElementStyle applies full-graph LOD to directional background e
assert.equal(style.hidden, true);
});
test("resolveEdgeElementStyle keeps small-graph relationships visible in overview", () => {
const style = resolveEdgeElementStyle(
GRAPH_THEME,
"overview",
"inactive",
{
edgeType: "related_to",
weight: 1,
properties: {},
edgeVariant: "directional",
visualPriority: 0.1,
baseSize: 0.5,
isSmallGraph: true,
},
"source",
"target",
"full",
"small-graph-low-priority",
"hidden",
);
assert.equal(style.hidden, false);
assert.ok(Number(style.size ?? 0) >= 0.9);
});
test("classifyFullGraphEdge applies deterministic priority order", () => {
const edgeClass = classifyFullGraphEdge(
"edge-priority",
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildRealtimeEdgeAttributes } from "../src/workspaces/GraphWorkspace/realtimeGraphAttributes.ts";
const payload = {
id: "edge-live",
source_id: "source",
target_id: "target",
type: "related_to",
properties: {},
};
test("realtime edges retain the active small-graph visibility marker", () => {
const attributes = buildRealtimeEdgeAttributes(payload, {
isBidirectional: false,
isSmallGraph: true,
});
assert.equal(attributes.isSmallGraph, true);
assert.equal(attributes.edgeVariant, "directional");
});
test("realtime edges do not retain the marker after graph leaves small-graph mode", () => {
const attributes = buildRealtimeEdgeAttributes(payload, {
isBidirectional: false,
isSmallGraph: false,
});
assert.equal(attributes.isSmallGraph, false);
});
+100
View File
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
SMALL_GRAPH_MAX_NODES,
buildSmallGraphSeedPositions,
resolveGraphLayoutDecision,
resolveNodeLayoutPosition,
shouldUseSmallGraphLayout,
} from "../src/workspaces/GraphWorkspace/smallGraphLayout.ts";
test("small graph layout is selected only when coordinates are not already usable", () => {
assert.equal(shouldUseSmallGraphLayout(12, 0), true);
assert.equal(shouldUseSmallGraphLayout(SMALL_GRAPH_MAX_NODES + 1, 0), false);
assert.equal(shouldUseSmallGraphLayout(12, 0.95), false);
});
test("small graph layout ignores isolated partial coordinates", () => {
const decision = resolveGraphLayoutDecision(12, 1 / 12);
assert.deepEqual(
resolveNodeLayoutPosition(decision, { x: 50_000, y: -50_000 }, { x: 24, y: -18 }),
{ x: 24, y: -18 },
);
assert.deepEqual(
resolveNodeLayoutPosition(decision, { x: 50_000, y: null }, { x: -12, y: 36 }),
{ x: -12, y: 36 },
);
});
test("small graph load is immediately ready and skips runtime stabilization", () => {
assert.deepEqual(resolveGraphLayoutDecision(12, 0), {
useProvidedCoordinates: false,
useSmallGraphLayout: true,
layoutReady: true,
});
assert.deepEqual(resolveGraphLayoutDecision(SMALL_GRAPH_MAX_NODES + 1, 0), {
useProvidedCoordinates: false,
useSmallGraphLayout: false,
layoutReady: false,
});
assert.deepEqual(resolveGraphLayoutDecision(12, 1), {
useProvidedCoordinates: true,
useSmallGraphLayout: false,
layoutReady: true,
});
});
test("small graph layout is deterministic and keeps connected nodes together", () => {
const nodes = ["Apple", "Steve", "Ronald", "Cupertino", "California"];
const edges = [
{ source: "Apple", target: "Steve" },
{ source: "Ronald", target: "Cupertino" },
];
const first = buildSmallGraphSeedPositions(nodes, edges);
const second = buildSmallGraphSeedPositions([...nodes].reverse(), [...edges].reverse());
assert.deepEqual([...first.entries()].sort(), [...second.entries()].sort());
assert.equal(first.size, nodes.length);
const distance = (left: string, right: string) => {
const a = first.get(left);
const b = first.get(right);
assert.ok(a && b);
return Math.hypot(a.x - b.x, a.y - b.y);
};
assert.ok(distance("Apple", "Steve") < distance("Apple", "California"));
assert.ok(distance("Ronald", "Cupertino") < distance("Ronald", "California"));
});
test("small graph layout keeps maximum-radius components separated", () => {
const componentCount = 4;
const nodesPerComponent = 12;
const nodes = Array.from(
{ length: componentCount * nodesPerComponent },
(_, index) => `component-${Math.floor(index / nodesPerComponent)}-node-${index % nodesPerComponent}`,
);
const edges = Array.from({ length: componentCount }).flatMap((_, componentIndex) => {
const prefix = `component-${componentIndex}-node-`;
return Array.from({ length: nodesPerComponent - 1 }, (_unused, nodeIndex) => ({
source: `${prefix}${nodeIndex}`,
target: `${prefix}${nodeIndex + 1}`,
}));
});
const positions = buildSmallGraphSeedPositions(nodes, edges);
for (let leftComponent = 0; leftComponent < componentCount; leftComponent += 1) {
for (let rightComponent = leftComponent + 1; rightComponent < componentCount; rightComponent += 1) {
let closestDistance = Number.POSITIVE_INFINITY;
for (let leftNode = 0; leftNode < nodesPerComponent; leftNode += 1) {
for (let rightNode = 0; rightNode < nodesPerComponent; rightNode += 1) {
const left = positions.get(`component-${leftComponent}-node-${leftNode}`);
const right = positions.get(`component-${rightComponent}-node-${rightNode}`);
assert.ok(left && right);
closestDistance = Math.min(closestDistance, Math.hypot(left.x - right.x, left.y - right.y));
}
}
assert.ok(closestDistance >= 48, `components are only ${closestDistance} units apart`);
}
}
});
+5 -1
View File
@@ -25,5 +25,9 @@
"mcp"
],
"skills": "./skills",
"agents": "./agents"
"agents": [
"./agents/decision-advisor.md",
"./agents/explainability.md",
"./agents/kg-assistant.md"
]
}
+4
View File
@@ -111,6 +111,7 @@ from .context_graph import ContextEdge, ContextGraph, ContextNode
from .context_retriever import ContextRetriever, RetrievedContext, TemporalGraphRetriever
from .decision_context import DecisionContext
from .entity_linker import EntityLink, EntityLinker, LinkedEntity
from .erasure import ErasureCoordinator, ErasureReceipt
# Decision tracking imports
from .decision_models import (
@@ -145,6 +146,9 @@ __all__ = [
"ContextRetriever",
"RetrievedContext",
"TemporalGraphRetriever",
# Cross-store erasure
"ErasureCoordinator",
"ErasureReceipt",
# Decision tracking models
"Decision",
"DecisionContextModel",
+21
View File
@@ -626,6 +626,27 @@ class AgentMemory:
self.logger.debug(f"Deleted memory item: {memory_id}")
return True
def vector_ids_for(self, memory_id: str) -> List[str]:
"""Return the vector-store ids owned by a memory item.
Read-only view of the ids ``delete_memory()`` would remove for this
item, so a caller that needs to *report* on vector removal can delete
them itself rather than relying on ``delete_memory()``'s best-effort
cascade, which logs a vector-store failure and still returns ``True``.
Mirrors the fallback in ``delete_memory``: an item stored without
tracked vector ids is keyed in the vector store by its own memory id.
Args:
memory_id: Memory identifier.
Returns:
The item's vector ids, or ``[]`` if the item is unknown.
"""
if memory_id not in self.memory_items:
return []
return list(self._vector_ids.get(memory_id, [])) or [memory_id]
def clear_memory(self, **filters) -> int:
"""
Clear memory items matching filters.
+5 -1
View File
@@ -2640,7 +2640,11 @@ class ContextGraph:
Scope is this graph only. Copies held elsewhere (``AgentMemory``, a
bound vector store, an exported file) are not reached, so this is one
step of an erasure workflow, not the whole of it.
step of an erasure workflow, not the whole of it. Callers who need the
whole workflow -- and a receipt recording which stores it actually
reached -- should drive this through
:class:`~semantica.context.erasure.ErasureCoordinator` rather than
treating a ``True`` here as proof the content is gone.
Args:
node_id: Node to purge.
+76
View File
@@ -239,6 +239,82 @@ print(f"Python importance score: {importance.get('degree', 0)}")
---
## 🧹 Erasing an Entity Everywhere - ErasureCoordinator
`purge_node()` removes an entity from **one graph**. The same content can still be
sitting in agent memory and in your vector store, so purge on its own is one step
of an erasure workflow rather than the whole of it.
`ErasureCoordinator` drives the whole cascade and hands you a receipt saying what
it actually managed to erase.
```python
from semantica.context import AgentMemory, ContextGraph, ErasureCoordinator
coordinator = ErasureCoordinator(graph=knowledge, memory=memory)
receipt = coordinator.erase_entity(
"customer-4471",
reason="GDPR Art. 17 request #882",
)
if receipt.complete:
print("Erased everywhere")
else:
print("Still holding data:", receipt.incomplete_stores)
```
### Always Check the Receipt
The receipt is the point of the feature — **do not treat the call itself as proof
the data is gone**. Each store reports one of five statuses:
| Status | Meaning |
|---|---|
| `erased` | Reached, data removed (on the vectors leg: the store accepted the delete for the ids given) |
| `not_found` | Reached, held nothing for this entity |
| `not_configured` | No such store was bound — normal, not a failure |
| `unsupported` | The store cannot delete at all; retrying will not help |
| `failed` | The store was reached and the deletion did not succeed |
```python
receipt.to_dict()
# {
# "entity_id": "customer-4471",
# "reason": "GDPR Art. 17 request #882",
# "erased_at": "2026-08-16T09:03:36.813220",
# "complete": False,
# "stores": {
# "vectors": {"status": "unsupported", "backend": "faiss",
# "detail": "backend exposes no delete()/delete_vectors(); ..."},
# "memory": {"status": "erased", "items": 14},
# "graph": {"status": "erased", "nodes": 1, "edges": 3},
# },
# }
```
`complete` is `False` when any store reports `unsupported` or `failed`, which is
your signal to handle that store out of band. FAISS, Milvus and Weaviate expose
no delete method today, so erasure genuinely cannot be completed on them — the
coordinator says so rather than reporting a success it did not achieve.
### Good to Know
- **Order is vectors → memory → graph.** The graph tombstone is the durable record
that an erasure happened, so it is written last: a crash mid-cascade leaves the
node present and the receipt incomplete, rather than a tombstone claiming more
than actually happened.
- **A failing store does not abort the rest.** Partial failure is recorded in the
receipt and the remaining stores are still erased.
- **Every store is optional.** `ErasureCoordinator(graph=graph)` is fine; the other
legs report `not_configured`.
- **It is idempotent.** Erasing the same entity twice returns a receipt saying
there was nothing left to do, rather than raising.
- **Batch:** `coordinator.erase_entities([...], reason=...)` returns one receipt per
entity, in order, so one entity's failure does not stop the others.
---
## 🔄 Using Both Together - The Complete Setup
### Your Smart Agent System
+673
View File
@@ -0,0 +1,673 @@
"""
Cross-store erasure coordination.
``ContextGraph.purge_node()`` is graph-scope by design (#957): it removes the
node and leaves a tombstone, but any copy of the same content held in
``AgentMemory`` or in a bound vector store is untouched. That makes purge one
step of an erasure workflow rather than the whole of it, and leaves the caller
to drive the remaining steps by hand -- with no record of which of them
actually succeeded.
:class:`ErasureCoordinator` drives the cascade across the stores it is given
and returns an :class:`ErasureReceipt` describing what was reached and what was
not. It *composes* the existing public APIs; nothing in ``context_graph.py`` or
``agent_memory.py`` changes, and ``ContextGraph`` keeps its graph-scope
contract.
The property that matters is honest partial reporting. Three vector backends
(FAISS, Milvus, Weaviate) expose no delete at all, so erasure is genuinely not
completable on them today. The receipt says ``unsupported`` for those rather
than reporting a success it did not achieve -- a receipt that reads
"graph: erased, memory: 14 erased, vectors: unsupported on faiss" is
actionable; a bare ``True`` is a compliance liability.
Example:
>>> from semantica.context import ContextGraph, AgentMemory
>>> from semantica.context.erasure import ErasureCoordinator
>>> coordinator = ErasureCoordinator(graph=graph, memory=memory)
>>> receipt = coordinator.erase_entity(
... "customer-4471", reason="GDPR Art. 17 request #882"
... )
>>> receipt.complete
False
>>> receipt.stores["vectors"]["status"]
'unsupported'
"""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
from ..utils.logging import get_logger
from .context_graph import _normalize_temporal_input
__all__ = [
"ErasureCoordinator",
"ErasureReceipt",
"STATUS_ERASED",
"STATUS_NOT_FOUND",
"STATUS_NOT_CONFIGURED",
"STATUS_UNSUPPORTED",
"STATUS_FAILED",
]
#: The store was reached and the entity's data removed from it. On the vectors
#: leg this means the store accepted the delete for the ids it was given: no
#: backend offers a portable "does this id exist" check, so it is not a count of
#: embeddings that were really there. The memory leg re-queries to confirm and
#: so is the stronger claim of the two.
STATUS_ERASED = "erased"
#: The store was reached and held nothing for this entity.
STATUS_NOT_FOUND = "not_found"
#: No such store was bound to the coordinator. Normal, not a failure.
STATUS_NOT_CONFIGURED = "not_configured"
#: The store exists but cannot delete -- e.g. a vector backend with no delete
#: method. Deliberately distinct from ``failed``: retrying will not help.
STATUS_UNSUPPORTED = "unsupported"
#: The store was reached and the deletion did not succeed.
STATUS_FAILED = "failed"
#: Statuses that leave data behind. A receipt containing any of these is not
#: complete, and the shortfall has to be handled out of band.
_INCOMPLETE_STATUSES = frozenset({STATUS_UNSUPPORTED, STATUS_FAILED})
#: Page size for the memory sweep. See ``_erase_memory`` for why the sweep
#: loops rather than passing one large limit.
_MEMORY_SWEEP_BATCH = 500
logger = get_logger("erasure")
@dataclass
class ErasureReceipt:
"""Auditable record of one entity's erasure across every bound store.
Attributes:
entity_id: The entity the erasure was requested for.
reason: Why it was erased, e.g. an erasure-request reference.
erased_at: ISO-8601 timestamp of the erasure.
stores: Per-store outcome keyed by ``"vectors"``, ``"memory"`` and
``"graph"``, each a dict with at least a ``status`` key drawn from
the ``STATUS_*`` constants in this module.
"""
entity_id: str
reason: Optional[str] = None
erased_at: str = ""
stores: Dict[str, Dict[str, Any]] = field(default_factory=dict)
@property
def complete(self) -> bool:
"""True when no bound store was left holding data.
``not_configured`` and ``not_found`` count as complete -- a store that
was never bound, or that held nothing, leaves no residue. Only
``unsupported`` and ``failed`` mean data survived the erasure.
"""
return not self.incomplete_stores
@property
def incomplete_stores(self) -> List[str]:
"""Names of the stores that may still hold the entity's data."""
return [
name
for name, result in self.stores.items()
if result.get("status") in _INCOMPLETE_STATUSES
]
def to_dict(self) -> Dict[str, Any]:
"""Serialize the receipt, deep-copying the per-store results."""
return {
"entity_id": self.entity_id,
"reason": self.reason,
"erased_at": self.erased_at,
"complete": self.complete,
"stores": {name: dict(result) for name, result in self.stores.items()},
}
class ErasureCoordinator:
"""Drives erasure of an entity across the graph, memory and vector stores.
Every store is optional; a store that is not supplied reports
``not_configured`` rather than being silently skipped, so the receipt still
shows the full shape of the workflow.
Args:
graph: A :class:`~semantica.context.ContextGraph` (or anything exposing
``purge_node``).
memory: An :class:`~semantica.context.AgentMemory` (or anything
exposing ``find_by_entity`` and ``batch_delete``).
vector_store: Vector store holding entity-keyed embeddings. Defaults to
``memory.vector_store`` when a memory is supplied, and stays
overridable for deployments that bind a store the memory does not
own. Pass ``False`` to disable the vector leg entirely.
Note:
Erasure runs outward-in -- vectors, then memory, then the graph. The
graph tombstone is the durable attestation that an erasure happened, so
writing it first would let a crash mid-cascade leave a record claiming
more than actually occurred. Erasing the graph last means a partial
failure leaves the node present and the receipt incomplete, which is
recoverable and honest.
"""
def __init__(
self,
graph: Optional[Any] = None,
memory: Optional[Any] = None,
vector_store: Optional[Any] = None,
):
# `is None` / `is False` rather than truthiness: a real store that
# defines __bool__ or __len__ (an empty one, say) is falsey while being
# a perfectly valid store to erase from.
vector_store_given = vector_store is not None and vector_store is not False
if graph is None and memory is None and not vector_store_given:
raise ValueError(
"ErasureCoordinator needs at least one store to erase from; got "
f"graph=None, memory=None, vector_store={vector_store!r}"
)
self.graph = graph
self.memory = memory
if vector_store is False:
self.vector_store: Optional[Any] = None
elif vector_store is not None:
self.vector_store = vector_store
else:
self.vector_store = getattr(memory, "vector_store", None)
self.logger = logger
def erase_entity(
self,
entity_id: str,
reason: Optional[str] = None,
at: Optional[Union[str, int, float, datetime]] = None,
vector_ids: Optional[Sequence[str]] = None,
) -> ErasureReceipt:
"""Erase one entity from every bound store and return a receipt.
A store that cannot be erased from is recorded in the receipt and the
cascade continues -- partial failure is a result, not an exception.
Aborting on the first failure would leave a half-erased state with no
record of which half.
Args:
entity_id: Entity to erase. Interpreted as a graph node id, an
``entities[].id`` in memory items, and a vector id.
reason: Why it was erased, e.g. an erasure-request reference.
Recorded in the receipt and in the graph tombstone.
at: When the erasure takes effect, used as the receipt's
``erased_at`` and passed to ``purge_node`` so both records
carry the same instant. Accepts anything ``ContextGraph``
accepts -- an ISO string, a ``datetime``, or epoch seconds --
and defaults to now, UTC.
vector_ids: Explicit vector ids to remove, in addition to the
ids owned by the entity's memory items, which are always
included. Defaults to ``[entity_id]``, covering entity-keyed
embeddings written by something other than ``AgentMemory``.
Returns:
An :class:`ErasureReceipt`. Check :attr:`ErasureReceipt.complete`
before treating the erasure as done.
"""
# Resolve the timestamp once and hand the *resolved* value to the graph.
# Passing the caller's `at` through instead would let purge_node take its
# own now() when `at` is None, so the receipt and the tombstone it
# attests to would disagree by however long the cascade took.
erased_at = _normalize_timestamp(at)
stores: Dict[str, Dict[str, Any]] = {}
# Outward-in: vectors, then memory, then the graph last.
#
# The vector leg must also cover the embeddings owned by memory items.
# AgentMemory.delete_memory() deletes an item's vectors best-effort: it
# catches a vector-store failure, logs it, and still returns True, so
# the memory leg cannot tell a full erasure from one that left the
# embedding behind. Deleting those ids here instead puts them behind
# the one leg that reports honestly. Collected before anything is
# deleted, while the items still exist to be enumerated.
stores["vectors"] = self._erase_vectors(
entity_id, self._all_vector_ids(entity_id, vector_ids)
)
stores["memory"] = self._erase_memory(entity_id)
stores["graph"] = self._erase_graph(entity_id, reason, erased_at)
receipt = ErasureReceipt(
entity_id=entity_id,
reason=reason,
erased_at=erased_at,
stores=stores,
)
if receipt.complete:
self.logger.info(
"Erased %r across %d store(s)%s",
entity_id,
len(stores),
f" ({reason})" if reason else "",
)
else:
self.logger.warning(
"Erasure of %r is incomplete; these stores may still hold it: %s",
entity_id,
", ".join(receipt.incomplete_stores),
)
return receipt
def erase_entities(
self,
entity_ids: Iterable[str],
reason: Optional[str] = None,
at: Optional[Union[str, int, float, datetime]] = None,
) -> List[ErasureReceipt]:
"""Erase several entities, returning one receipt per entity.
Each entity is erased independently, so one entity's failure does not
stop the rest. Receipts come back in the order the ids were given.
The timestamp is resolved once for the whole batch so that every
receipt and every graph tombstone record the same instant -- a batch
erasure under a single legal request must not produce tombstones with
diverging ``purged_at`` values.
"""
resolved_at = _normalize_timestamp(at)
return [
self.erase_entity(entity_id, reason=reason, at=resolved_at)
for entity_id in entity_ids
]
# Store legs
def _all_vector_ids(
self, entity_id: str, vector_ids: Optional[Sequence[str]]
) -> List[str]:
"""Caller-supplied vector ids plus the ids owned by memory items.
Best-effort by design: if memory cannot be enumerated here, the memory
leg makes the same call moments later and reports the failure, so the
receipt is still incomplete. Swallowing it there instead would be the
bug this method exists to fix.
Collects vector IDs from ALL memory items before deletion. Must call
find_by_entity with limit=None to get all items, since find_by_entity
doesn't support offset/cursor and we cannot delete while collecting.
"""
ids: List[str] = list(vector_ids) if vector_ids is not None else [entity_id]
if self.memory is None:
return ids
seen_vector_ids = set(ids)
try:
# Get ALL matching memory items in one call (limit=None).
# Pagination with deletion happens in _erase_memory(); here we must
# collect all vector IDs up front before any deletion occurs.
found = self.memory.find_by_entity(entity_id, limit=None)
for item in found:
memory_id = _memory_item_id(item)
if not memory_id:
continue
for vector_id in self.memory.vector_ids_for(memory_id):
if vector_id not in seen_vector_ids:
seen_vector_ids.add(vector_id)
ids.append(vector_id)
except Exception as exc:
self.logger.warning(
"Could not enumerate memory-owned vector ids for %r: %s; "
"the memory leg will report the same failure",
entity_id,
exc,
)
return ids
def _erase_vectors(
self, entity_id: str, vector_ids: Optional[Sequence[str]]
) -> Dict[str, Any]:
"""Remove entity-keyed embeddings from the bound vector store.
``vector_ids`` in the result is the number of ids the store accepted,
not the number of embeddings that existed: backends delete by id and
report success either way, with no portable way to ask what was
actually there. See :data:`STATUS_ERASED`.
"""
if self.vector_store is None:
return {"status": STATUS_NOT_CONFIGURED}
ids = list(vector_ids) if vector_ids is not None else [entity_id]
backend = _vector_backend_name(self.vector_store)
if not ids:
return {"status": STATUS_NOT_FOUND, "backend": backend}
method_name, target = _vector_delete_capability(self.vector_store)
if method_name is None:
# FAISS, Milvus and Weaviate expose no delete at all; FAISS in
# particular cannot remove from a flat index without a rebuild.
self.logger.warning(
"Vector backend %r exposes no delete; %d vector id(s) for %r "
"were not erased",
backend,
len(ids),
entity_id,
)
return {
"status": STATUS_UNSUPPORTED,
"backend": backend,
"vector_ids": len(ids),
"detail": (
"backend exposes no delete()/delete_vectors(); "
"removal requires an index rebuild or an out-of-band process"
),
}
try:
deleted = getattr(target, method_name)(ids)
except NotImplementedError as exc:
# The VectorStore facade declares delete_vectors() unconditionally
# and only fails on the call when its backend cannot delete.
self.logger.warning(
"Vector backend %r cannot delete %d id(s) for %r: %s",
backend,
len(ids),
entity_id,
exc,
)
return {
"status": STATUS_UNSUPPORTED,
"backend": backend,
"vector_ids": len(ids),
"detail": str(exc),
}
except Exception as exc:
self.logger.warning(
"Vector deletion failed for %r on backend %r: %s",
entity_id,
backend,
exc,
exc_info=True,
)
return {
"status": STATUS_FAILED,
"backend": backend,
"vector_ids": len(ids),
"detail": f"{type(exc).__name__}: {exc}",
}
accepted, detail = _interpret_delete_result(deleted)
result: Dict[str, Any] = {
"status": STATUS_ERASED if accepted else STATUS_FAILED,
"backend": backend,
"vector_ids": len(ids),
"via": method_name,
}
# Keep whatever the backend said. Qdrant returns {"status": ...} and
# Pinecone {"deleted": True}, and that detail is the only account of
# the delete anyone gets -- dropping it on the floor would leave the
# receipt less informative than the call it is attesting to.
if detail is not None:
result["backend_result"] = detail
if not accepted:
self.logger.warning(
"Vector backend %r reported no deletion for %r: %s",
backend,
entity_id,
detail,
)
result["detail"] = "store reported the ids were not deleted"
return result
def _erase_memory(self, entity_id: str) -> Dict[str, Any]:
"""Delete every memory item referencing the entity."""
if self.memory is None:
return {"status": STATUS_NOT_CONFIGURED}
deleted = 0
try:
# Sweep in pages until dry rather than passing one large limit:
# ``find_by_entity`` has historically defaulted to ``limit=10`` and
# truncated silently, and a single large number is only correct
# until someone exceeds it. Deleting as we go means the next page
# is the remainder.
while True:
found = self.memory.find_by_entity(entity_id, limit=_MEMORY_SWEEP_BATCH)
if not found:
break
memory_ids = [
memory_id
for memory_id in (_memory_item_id(item) for item in found)
if memory_id
]
if not memory_ids:
self.logger.warning(
"Memory returned %d item(s) for %r with no identifier; "
"cannot delete them",
len(found),
entity_id,
)
return {
"status": STATUS_FAILED,
"items": deleted,
"residual": len(found),
"detail": "memory items carry no 'memory_id'",
}
removed = self.memory.batch_delete(memory_ids)
deleted += removed
if removed == 0:
# No progress: another page would return the same items.
self.logger.warning(
"Memory sweep for %r stalled with %d item(s) remaining",
entity_id,
len(found),
)
return {
"status": STATUS_FAILED,
"items": deleted,
"residual": len(found),
"detail": "batch_delete removed nothing for a non-empty page",
}
if len(found) < _MEMORY_SWEEP_BATCH:
break
# Re-query once rather than trusting the loop's own bookkeeping;
# this is what keeps the leg's `failed` status honest.
residual = self.memory.find_by_entity(entity_id, limit=_MEMORY_SWEEP_BATCH)
except Exception as exc:
self.logger.warning(
"Memory erasure failed for %r after %d item(s): %s",
entity_id,
deleted,
exc,
exc_info=True,
)
return {
"status": STATUS_FAILED,
"items": deleted,
"detail": f"{type(exc).__name__}: {exc}",
}
if residual:
self.logger.warning(
"Memory still holds %d item(s) for %r after erasure",
len(residual),
entity_id,
)
return {
"status": STATUS_FAILED,
"items": deleted,
"residual": len(residual),
"detail": "items referencing the entity survived the sweep",
}
if deleted == 0:
return {"status": STATUS_NOT_FOUND, "items": 0}
return {"status": STATUS_ERASED, "items": deleted}
def _erase_graph(
self,
entity_id: str,
reason: Optional[str],
at: Optional[Union[str, int, float, datetime]],
) -> Dict[str, Any]:
"""Purge the node, and with it every edge that touches it."""
if self.graph is None:
return {"status": STATUS_NOT_CONFIGURED}
try:
# Counted before the purge because the edges are gone afterwards.
edge_count = _incident_edge_count(self.graph, entity_id)
purged = self.graph.purge_node(entity_id, reason=reason, at=at)
except Exception as exc:
self.logger.warning(
"Graph purge failed for %r: %s", entity_id, exc, exc_info=True
)
return {
"status": STATUS_FAILED,
"detail": f"{type(exc).__name__}: {exc}",
}
if not purged:
return {"status": STATUS_NOT_FOUND, "nodes": 0, "edges": 0}
return {"status": STATUS_ERASED, "nodes": 1, "edges": edge_count}
# Helpers
def _normalize_timestamp(at: Optional[Union[str, int, float, datetime]]) -> str:
"""Render ``at`` exactly as the graph tombstone will record it.
Reuses ``ContextGraph``'s own normalizer rather than formatting the value
here, so the receipt and the tombstone written by the same erasure cannot
disagree about when it happened -- an audit record that contradicts the
tombstone it attests to is worse than no record. Normalizing up front also
rejects an unparseable ``at`` before any store is touched, instead of half
way through the cascade.
``None`` resolves to now here rather than being passed along, so the
default path gets one timestamp for both records instead of two ``now()``
calls separated by the length of the cascade.
"""
return _normalize_temporal_input(
at if at is not None else datetime.now(timezone.utc)
)
def _memory_item_id(item: Any) -> Optional[str]:
"""Pull the identifier out of a memory dict as ``find_by_entity`` returns it."""
if not isinstance(item, dict):
return None
memory_id = item.get("memory_id") or item.get("id")
return str(memory_id) if memory_id else None
#: Dict keys a backend uses to report whether a delete succeeded, and the
#: values that mean it did not. Qdrant returns ``{"status": <UpdateStatus>}``
#: and Pinecone ``{"deleted": True}``; neither is a bool, so a bare
#: ``result is False`` check would call every dict a success.
_DELETE_FAILURE_MARKERS = {
"deleted": (False,),
"success": (False,),
"ok": (False,),
"acknowledged": (False,),
"status": ("failed", "error", "failure"),
}
def _interpret_delete_result(result: Any) -> Tuple[bool, Optional[str]]:
"""Decide whether a backend's delete return value reports success.
Returns ``(accepted, detail)``, where ``detail`` is a serializable
rendering of the backend's own response to keep in the receipt (``None``
when there was nothing worth recording).
``None`` counts as accepted: a delete implemented as a void method returns
it on success, and reporting ``failed`` there would be a false alarm --
the opposite of the honesty this module is for, in the other direction.
"""
if result is None:
return True, None
if isinstance(result, bool):
return result, None
if isinstance(result, dict):
rendered = {key: _stringify(value) for key, value in result.items()}
for key, failure_values in _DELETE_FAILURE_MARKERS.items():
if key in result and _is_failure_value(result[key], failure_values):
return False, rendered
return True, rendered
# Anything else (a count, a client response object) is taken at face value;
# there is no cross-backend contract to interpret it against.
return True, _stringify(result)
def _is_failure_value(value: Any, failure_values: Tuple[Any, ...]) -> bool:
"""True when a backend's marker value says the delete did not happen.
Bools are matched by identity so a ``0`` count is not read as ``False``.
String markers are matched as substrings of the rendered value, because a
backend may return an enum whose ``str()`` is ``"UpdateStatus.FAILED"``
rather than a bare ``"failed"``.
"""
for failure in failure_values:
if isinstance(failure, bool):
if value is failure:
return True
elif failure in str(value).lower():
return True
return False
def _stringify(value: Any) -> Any:
"""Render a backend payload value so the receipt stays serializable.
Qdrant's status is an enum, which would make ``to_dict()`` output
unserializable as the audit record it is meant to be.
"""
if isinstance(value, (str, int, float, bool)) or value is None:
return value
return str(value)
def _vector_delete_capability(store: Any) -> Tuple[Optional[str], Any]:
"""Find the delete method to call, and the object to call it on.
Returns ``(None, target)`` when no delete surface exists, which is the
``unsupported`` case.
The ``VectorStore`` facade declares ``delete_vectors()`` for every backend
and only raises ``NotImplementedError`` once called, so probing the facade
alone cannot tell a deletable backend from a delete-less one -- hence the
look at the backend it wraps. Probing rather than calling-and-catching also
keeps a missing method distinguishable from an ``AttributeError`` raised
*inside* a working one, which is exactly where guessing wrong would produce
a false clean bill of health.
"""
target = getattr(store, "_backend_store", None) or store
for name in ("delete_vectors", "delete"):
if callable(getattr(target, name, None)):
return name, target
return None, target
def _vector_backend_name(store: Any) -> str:
"""Best-effort backend label for the receipt."""
backend = getattr(store, "backend", None)
if isinstance(backend, str) and backend:
return backend
inner = getattr(store, "_backend_store", None)
return type(inner if inner is not None else store).__name__
def _incident_edge_count(graph: Any, node_id: str) -> int:
"""Count edges touching ``node_id`` through the graph's public API."""
find_edges = getattr(graph, "find_edges", None)
if not callable(find_edges):
return 0
return sum(
1
for edge in find_edges()
if edge.get("source") == node_id or edge.get("target") == node_id
)
+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.
+99 -1
View File
@@ -233,6 +233,31 @@ async def import_file(
)
#: Aliases kept consistent with `mcp/tools/export.py::_FORMAT_ALIASES` and
#: `RDFExporter._format_aliases` to ensure the two surfaces agree on format names.
#: Maps user-provided format strings to RDFExporter's canonical format names.
_RDF_FORMATS: dict[str, str] = {
"ttl": "turtle",
"turtle": "turtle",
"nt": "ntriples", # RDFExporter canonical is "ntriples", not "nt"
"ntriples": "ntriples",
"n-triples": "ntriples",
"xml": "rdfxml", # RDFExporter canonical is "rdfxml", not "xml"
"rdfxml": "rdfxml",
"rdf-xml": "rdfxml",
"json-ld": "jsonld", # RDFExporter canonical is "jsonld", not "json-ld"
"jsonld": "jsonld",
}
#: Media type and file extension per RDFExporter canonical format name.
_RDF_MEDIA_TYPES: dict[str, tuple[str, str]] = {
"turtle": ("text/turtle", "ttl"),
"ntriples": ("application/n-triples", "nt"),
"rdfxml": ("application/rdf+xml", "rdf"),
"jsonld": ("application/ld+json", "jsonld"),
}
@router.post("/api/export")
async def export_graph(
body: ExportRequest,
@@ -267,8 +292,81 @@ async def export_graph(
content = output.getvalue()
media_type = "text/csv"
extension = "csv"
elif fmt in _RDF_FORMATS:
# Reuses `semantica.export`, the same exporters the MCP `export_graph` tool calls.
# Before this, the Explorer answered 422 for every RDF format while the MCP surface
# offered them, so a graph could be loaded as JSON-LD and never exported back — the
# round trip had to leave the product. See #1131.
try:
from semantica.export import RDFExporter
from semantica.utils.exceptions import ValidationError
except ImportError as exc: # pragma: no cover - optional dependency
raise HTTPException(
status_code=503,
detail=f"RDF export unavailable: {exc}",
) from exc
try:
content = RDFExporter().export_to_rdf(graph_dict, format=_RDF_FORMATS[fmt])
except ValidationError as exc:
# Data validation or serialization failed
raise HTTPException(
status_code=422,
detail=f"RDF export failed: {exc}",
) from exc
except Exception as exc:
# Unexpected error during export
logger.exception("RDF export failed unexpectedly")
raise HTTPException(
status_code=500,
detail=f"RDF export error: {exc}",
) from exc
media_type, extension = _RDF_MEDIA_TYPES[_RDF_FORMATS[fmt]]
elif fmt == "graphml":
# GraphML support using GraphExporter (not GraphMLExporter which doesn't exist)
try:
from semantica.export import GraphExporter
from semantica.utils.exceptions import ValidationError
except ImportError as exc: # pragma: no cover - optional dependency
raise HTTPException(
status_code=503,
detail=f"GraphML export unavailable: {exc}",
) from exc
try:
# GraphExporter.export() writes to file, but we need string content for HTTP response.
# Use a temporary file that is automatically cleaned up.
import tempfile
from pathlib import Path
# Create temp file in a secure directory with automatic cleanup on exception
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir) / "export.graphml"
exporter = GraphExporter(format="graphml")
exporter.export(graph_dict, file_path=tmp_path)
content = tmp_path.read_text(encoding='utf-8')
except ValidationError as exc:
raise HTTPException(
status_code=422,
detail=f"GraphML export failed: {exc}",
) from exc
except Exception as exc:
logger.exception("GraphML export failed unexpectedly")
raise HTTPException(
status_code=500,
detail=f"GraphML export error: {exc}",
) from exc
media_type, extension = "application/xml", "graphml"
else:
raise HTTPException(status_code=422, detail=f"Unsupported export format '{fmt}'")
raise HTTPException(
status_code=422,
detail=(
f"Unsupported export format '{fmt}'. "
f"Supported: {', '.join(sorted({'json', 'csv', 'graphml'} | set(_RDF_FORMATS)))}"
),
)
return Response(
content=content,
+20
View File
@@ -148,6 +148,26 @@ class ClassInferrer:
entity_type = entity.get("type") or entity.get("entity_type", "Entity")
entity_types[entity_type].append(entity)
normalized_types = defaultdict(list)
for entity_type, type_entities in entity_types.items():
if len(type_entities) >= self.min_occurrences:
normalized_name = self.naming_conventions.normalize_class_name(
str(entity_type)
)
normalized_types[normalized_name].append(str(entity_type))
collisions = {
normalized_name: source_types
for normalized_name, source_types in normalized_types.items()
if len(source_types) > 1
}
if collisions:
raise ValidationError(
"Entity types normalize to duplicate class names; "
"rename the source types or provide an explicit mapping.",
validation_context={"normalized_type_collisions": collisions},
)
# Infer classes from entity types
self.progress_tracker.update_tracking(
tracking_id,
+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
+155 -13
View File
@@ -26,7 +26,7 @@ Example Usage:
>>> vector_ids = store.add_vectors(vectors, ids, metadata)
>>> results = store.search_similar(query_vector, k=10)
>>> store.save_index("index.faiss")
>>>
>>>
>>> from semantica.vector_store import FAISSIndexBuilder
>>> builder = FAISSIndexBuilder(dimension=768)
>>> index = builder.build_index(index_type="ivf", metric="L2", nlist=100)
@@ -35,8 +35,13 @@ Author: Semantica Contributors
License: MIT
"""
import base64
import json
import warnings
from datetime import date, datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
from uuid import UUID
import numpy as np
@@ -44,6 +49,60 @@ from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
class _LosslessJSONEncoder(json.JSONEncoder):
"""JSON encoder that preserves types that are not natively JSON-serializable.
- ``bytes`` are base64-encoded under a ``__bytes__`` wrapper.
- sets are serialized as sorted lists under a ``__set__`` wrapper.
- NumPy integers and floats are converted to native Python int/float.
- NumPy arrays are converted to lists.
- ``datetime`` and ``date`` objects are serialized under ``__datetime__`` /
``__date__`` wrappers with ISO-8601 strings.
- ``UUID`` objects are serialized under ``__uuid__`` wrapper.
"""
def default(self, obj: Any) -> Any:
if isinstance(obj, bytes):
return {"__bytes__": base64.b64encode(obj).decode("ascii")}
if isinstance(obj, set):
return {"__set__": sorted(obj, key=str)}
if isinstance(obj, np.integer):
return int(obj)
if isinstance(obj, np.floating):
return float(obj)
if isinstance(obj, np.ndarray):
return obj.tolist()
if isinstance(obj, datetime):
return {"__datetime__": obj.isoformat()}
if isinstance(obj, date):
return {"__date__": obj.isoformat()}
if isinstance(obj, UUID):
return {"__uuid__": str(obj)}
return super().default(obj)
def _lossless_object_hook(dct: Dict[str, Any]) -> Any:
"""Object hook for ``json.loads`` that restores types encoded by
``_LosslessJSONEncoder``.
Tagged dicts are checked with an exact-schema guard (``len(dct) == 1``)
so that dicts sharing a key name with a wrapper but carrying additional
keys are passed through unchanged.
"""
if len(dct) == 1:
if "__bytes__" in dct:
return base64.b64decode(dct["__bytes__"])
if "__set__" in dct:
return set(dct["__set__"])
if "__datetime__" in dct:
return datetime.fromisoformat(dct["__datetime__"])
if "__date__" in dct:
return date.fromisoformat(dct["__date__"])
if "__uuid__" in dct:
return UUID(dct["__uuid__"])
return dct
# Optional FAISS import
try:
import faiss
@@ -54,6 +113,11 @@ except (ImportError, OSError):
faiss = None
def _metadata_path(index_path: Union[str, Path]) -> Path:
"""Get the metadata file path for a given index path."""
return Path(str(index_path) + ".meta.json")
class FAISSIndex:
"""FAISS index wrapper."""
@@ -136,20 +200,90 @@ class FAISSIndex:
return self.metadata.get(vector_id)
def save(self, path: Union[str, Path]):
"""Save index to disk."""
if FAISS_AVAILABLE:
faiss.write_index(self.index, str(path))
else:
raise ProcessingError("FAISS not available")
"""Save index to disk.
@classmethod
def load(cls, path: Union[str, Path], dimension: int, index_type: str = "flat"):
"""Load index from disk."""
Serializes ``vector_ids``, ``metadata``, ``dimension`` and
``index_type`` *before* touching any files so that a serialization
error (e.g. unsupported metadata type) never leaves an orphaned
FAISS binary without its companion ``.meta.json``.
The companion file is written atomically (temp file + rename) so a
partially written JSON never leaves a corrupt state on disk.
"""
if not FAISS_AVAILABLE:
raise ProcessingError("FAISS not available")
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
meta_path = _metadata_path(path)
payload = json.dumps(
{
"vector_ids": self.vector_ids,
"metadata": self.metadata,
"dimension": self.dimension,
"index_type": self.index_type,
},
cls=_LosslessJSONEncoder,
)
faiss.write_index(self.index, str(path))
meta_path.parent.mkdir(parents=True, exist_ok=True)
tmp_meta = meta_path.with_suffix(meta_path.suffix + ".tmp")
tmp_meta.write_text(payload)
tmp_meta.replace(meta_path)
@classmethod
def load(cls, path: Union[str, Path], dimension: int, index_type: str = "flat"):
"""Load index from disk.
Restores ``vector_ids`` and ``metadata`` from the companion
``.meta.json`` file when present. When the companion file exists, its
persisted ``dimension`` and ``index_type`` take precedence over the
caller-supplied values so the loaded wrapper faithfully reflects what
was originally saved.
"""
if not FAISS_AVAILABLE:
raise ProcessingError("FAISS not available")
path = Path(path)
index = faiss.read_index(str(path))
return cls(index, dimension, index_type)
meta_path = _metadata_path(path)
if meta_path.exists():
data = json.loads(meta_path.read_text(), object_hook=_lossless_object_hook)
vector_ids = data.get("vector_ids", [])
metadata = data.get("metadata", {})
persisted_dimension = data.get("dimension")
persisted_index_type = data.get("index_type")
if persisted_dimension is not None:
dimension = int(persisted_dimension)
if persisted_index_type is not None:
index_type = persisted_index_type
# Check for vector count vs sidecar ID count mismatch
if len(vector_ids) != index.ntotal:
raise ProcessingError(
f"Sidecar metadata vector count ({len(vector_ids)}) does not match "
f"the binary FAISS index ntotal ({index.ntotal}). "
"This indicates data corruption or an incomplete save."
)
else:
warnings.warn(
"FAISS index loaded without a companion .meta.json file: "
"vector IDs and metadata could not be restored, so the index "
"will load without ID mappings.",
RuntimeWarning,
stacklevel=2,
)
vector_ids = []
metadata = {}
obj = cls(index, dimension, index_type)
obj.vector_ids = vector_ids
obj.metadata = metadata
return obj
class FAISSSearch:
@@ -184,11 +318,11 @@ class FAISSSearch:
if idx < len(self.index.vector_ids):
vector_id = self.index.vector_ids[idx]
dist_val = float(dist)
# Standardize score as similarity (0.0 to 1.0)
# while preserving original distance
similarity_score = 1.0 / (1.0 + max(0.0, dist_val))
results.append(
{
"id": vector_id,
@@ -481,6 +615,14 @@ class FAISSStore:
if not FAISS_AVAILABLE:
raise ProcessingError("FAISS not available")
path = Path(path)
if path.exists() and not _metadata_path(path).exists():
self.logger.warning(
f"Loaded FAISS index from {path} without a companion "
".meta.json file: vector IDs and metadata could not be "
"restored, so the index will load without ID mappings."
)
self.index = FAISSIndex.load(path, self.dimension, index_type)
self.search_engine = FAISSSearch(self.index)
@@ -567,7 +709,7 @@ class FAISSStore:
if self.index is None or limit <= 0:
return []
ids_page = self.index.vector_ids[offset:offset + limit]
ids_page = self.index.vector_ids[offset : offset + limit]
return [
{
"id": vector_id,
+122 -11
View File
@@ -438,6 +438,46 @@ class MilvusStore:
raise ProcessingError(f"Collection {collection_name} does not exist")
collection = Collection(collection_name)
# Reject schemas that don't match create_collection()'s shape:
# id/VARCHAR pk + vector + metadata. Otherwise an incompatible
# collection attaches and fails far later in get_vector/get_metadata.
schema = getattr(collection, "schema", None)
fields = list(getattr(schema, "fields", None) or [])
pk = [f for f in fields if getattr(f, "is_primary", False)]
if (
not pk
or pk[0].name != "id"
or getattr(getattr(pk[0], "dtype", None), "name", None) != "VARCHAR"
or getattr(pk[0], "auto_id", False)
):
raise ProcessingError(
f"Collection '{collection_name}' has an invalid primary key: "
"expected VARCHAR field 'id' without auto_id"
)
vector_field = next((f for f in fields if f.name == "vector"), None)
if vector_field is None:
raise ProcessingError(
f"Collection '{collection_name}' is missing required field 'vector'"
)
if (
getattr(getattr(vector_field, "dtype", None), "name", None)
!= "FLOAT_VECTOR"
):
raise ProcessingError(
f"Collection '{collection_name}' has an invalid vector field: "
"expected FLOAT_VECTOR 'vector'"
)
metadata_field = next((f for f in fields if f.name == "metadata"), None)
if metadata_field is None:
raise ProcessingError(
f"Collection '{collection_name}' is missing required field 'metadata'"
)
if getattr(getattr(metadata_field, "dtype", None), "name", None) != "JSON":
raise ProcessingError(
f"Collection '{collection_name}' has an invalid metadata field: "
"expected JSON 'metadata'"
)
self.collection = MilvusCollection(collection, collection_name)
self.search_engine = MilvusSearch(self.collection)
return self.collection
@@ -621,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]]:
@@ -665,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,
+928
View File
@@ -0,0 +1,928 @@
"""Tests for ErasureCoordinator (issue #1018).
``ContextGraph.purge_node()`` is graph-scope by design: it removes the node and
writes a tombstone attesting the content is gone, while the same content can
survive verbatim as an ``AgentMemory`` item and as an embedding. The
coordinator drives the cascade across every bound store and returns a receipt
saying what was reached -- and, just as importantly, what was not.
These tests run against real ``ContextGraph`` and ``AgentMemory`` instances
rather than mocks. The bug this feature exists to prevent lives in the
interaction between them (``find_by_entity`` truncating the sweep the caller
uses to decide the erasure is done), so mocking that interaction away would
test nothing. The vector stores *are* fakes, because the point of those tests
is backend shape -- ``delete_vectors`` vs ``delete`` vs neither -- and three of
the real backends cannot delete at all.
"""
import json
import unittest
import numpy as np
from semantica.context import AgentMemory, ContextGraph
from semantica.context.erasure import (
STATUS_ERASED,
STATUS_FAILED,
STATUS_NOT_CONFIGURED,
STATUS_NOT_FOUND,
STATUS_UNSUPPORTED,
ErasureCoordinator,
ErasureReceipt,
)
from semantica.vector_store import VectorStore
def _graph():
"""customer --purchased--> order, plus an unrelated supplier."""
graph = ContextGraph(advanced_analytics=False)
graph.add_node("customer-4471", "person")
graph.add_node("order-9", "order")
graph.add_node("supplier-1", "org")
graph.add_edge("customer-4471", "order-9", "purchased")
return graph
def _memory_with(entity_id, count, extra_entity=None):
"""A memory holding ``count`` items that reference ``entity_id``."""
memory = AgentMemory()
for index in range(count):
memory.store(
f"note {index} about {entity_id}",
entities=[{"id": entity_id, "name": entity_id}],
skip_graph=True,
)
if extra_entity:
memory.store(
f"unrelated note about {extra_entity}",
entities=[{"id": extra_entity, "name": extra_entity}],
skip_graph=True,
)
return memory
class _DeleteVectorsStore:
"""Backend shaped like qdrant/pinecone: exposes ``delete_vectors``."""
backend = "qdrant"
def __init__(self, result=True):
self._result = result
self.deleted = []
def delete_vectors(self, vector_ids, **options):
self.deleted.append(list(vector_ids))
return self._result
class _DeleteStore:
"""Backend shaped like pgvector/sqlite-vec: exposes ``delete``."""
backend = "pgvector"
def __init__(self):
self.deleted = []
def delete(self, ids):
self.deleted.append(list(ids))
return True
class _NoDeleteStore:
"""Backend shaped like FAISS/Milvus/Weaviate: no delete surface at all."""
backend = "faiss"
class _RaisingStore:
backend = "qdrant"
def delete_vectors(self, vector_ids, **options):
raise RuntimeError("connection reset")
class _FacadeOverNoDeleteBackend:
"""The ``VectorStore`` facade shape: declares delete_vectors for every
backend and only fails on the call, so the backend must be probed."""
backend = "faiss"
def __init__(self):
self._backend_store = _NoDeleteStore()
def delete_vectors(self, vector_ids, **options):
raise NotImplementedError("Backend store _NoDeleteStore has no delete")
class _MemoryVectorStore(_DeleteVectorsStore):
"""Delete-capable store that AgentMemory can also write embeddings to."""
def store_vectors(self, vectors, metadata=None, **options):
return [f"vec-{len(self.deleted)}-{index}" for index in range(len(vectors))]
class TestErasureAcrossStores(unittest.TestCase):
def test_erases_graph_and_memory_and_reports_both(self):
graph, memory = _graph(), _memory_with("customer-4471", 3, "supplier-1")
receipt = ErasureCoordinator(graph=graph, memory=memory).erase_entity(
"customer-4471", reason="GDPR Art. 17 request #882"
)
self.assertTrue(receipt.complete)
self.assertEqual(receipt.stores["graph"]["status"], STATUS_ERASED)
self.assertEqual(receipt.stores["graph"]["edges"], 1)
self.assertEqual(receipt.stores["memory"]["status"], STATUS_ERASED)
self.assertEqual(receipt.stores["memory"]["items"], 3)
self.assertFalse(graph.has_node("customer-4471"))
self.assertEqual(memory.find_by_entity("customer-4471", limit=500), [])
def test_leaves_other_entities_alone(self):
graph, memory = _graph(), _memory_with("customer-4471", 2, "supplier-1")
ErasureCoordinator(graph=graph, memory=memory).erase_entity("customer-4471")
self.assertTrue(graph.has_node("supplier-1"))
self.assertEqual(len(memory.find_by_entity("supplier-1", limit=500)), 1)
def test_graph_purge_records_the_reason_in_its_tombstone(self):
graph = _graph()
ErasureCoordinator(graph=graph).erase_entity(
"customer-4471", reason="GDPR Art. 17 request #882"
)
tombstone = graph.get_tombstone("customer-4471", "node")
self.assertIsNotNone(tombstone)
self.assertEqual(tombstone["reason"], "GDPR Art. 17 request #882")
def test_erase_entities_returns_one_receipt_per_id_in_order(self):
graph = _graph()
receipts = ErasureCoordinator(graph=graph).erase_entities(
["customer-4471", "supplier-1", "never-existed"], reason="offboarding"
)
self.assertEqual(
[receipt.entity_id for receipt in receipts],
["customer-4471", "supplier-1", "never-existed"],
)
self.assertEqual(receipts[0].stores["graph"]["status"], STATUS_ERASED)
self.assertEqual(receipts[1].stores["graph"]["status"], STATUS_ERASED)
self.assertEqual(receipts[2].stores["graph"]["status"], STATUS_NOT_FOUND)
def test_batch_erasure_all_receipts_carry_the_same_timestamp(self):
"""erase_entities() must resolve the timestamp once for the whole batch.
When ``at=None`` each call to ``erase_entity()`` independently calls
``_normalize_timestamp()``, generating a fresh ``now()`` per entity.
A GDPR batch request would then produce tombstones with diverging
``purged_at`` values, making it impossible to group them under a single
legal request by timestamp. This regression test pins that every
receipt and every graph tombstone share the same instant.
"""
graph = _graph()
receipts = ErasureCoordinator(graph=graph).erase_entities(
["customer-4471", "supplier-1"], reason="GDPR Art. 17 request #882"
)
# Both entities were erased.
self.assertEqual(receipts[0].stores["graph"]["status"], STATUS_ERASED)
self.assertEqual(receipts[1].stores["graph"]["status"], STATUS_ERASED)
# All receipts carry the same erased_at.
self.assertEqual(receipts[0].erased_at, receipts[1].erased_at)
# Each tombstone's purged_at matches its own receipt.
tombstone_0 = graph.get_tombstone("customer-4471", "node")
tombstone_1 = graph.get_tombstone("supplier-1", "node")
self.assertEqual(tombstone_0["purged_at"], receipts[0].erased_at)
self.assertEqual(tombstone_1["purged_at"], receipts[1].erased_at)
# The tombstones themselves agree with each other.
self.assertEqual(tombstone_0["purged_at"], tombstone_1["purged_at"])
class TestMemorySweepIsNotTruncated(unittest.TestCase):
"""The regression this feature exists to prevent.
``find_by_entity`` has historically defaulted to ``limit=10`` and truncated
silently, so the obvious hand-rolled cascade erases the first ten items and
reports success. 25 items is more than any such default, and a coordinator
that calls ``find_by_entity`` once with the default fails this test.
"""
def test_erases_far_more_items_than_the_default_limit(self):
memory = _memory_with("customer-4471", 25)
receipt = ErasureCoordinator(memory=memory).erase_entity("customer-4471")
self.assertEqual(receipt.stores["memory"]["items"], 25)
self.assertEqual(memory.find_by_entity("customer-4471", limit=500), [])
self.assertTrue(receipt.complete)
def test_residual_items_are_reported_as_failed_not_erased(self):
class _UndeletableMemory:
"""Deletes nothing, as a backend refusing the write would."""
def __init__(self):
self.items = [{"memory_id": f"m{i}"} for i in range(3)]
def find_by_entity(self, entity_id, limit=10):
return list(self.items)[:limit]
def batch_delete(self, memory_ids):
return 0
receipt = ErasureCoordinator(memory=_UndeletableMemory()).erase_entity("e1")
self.assertEqual(receipt.stores["memory"]["status"], STATUS_FAILED)
self.assertEqual(receipt.stores["memory"]["residual"], 3)
self.assertFalse(receipt.complete)
def test_memory_items_without_an_identifier_fail_rather_than_look_erased(self):
class _AnonymousMemory:
def find_by_entity(self, entity_id, limit=10):
return [{"content": "no id here"}]
def batch_delete(self, memory_ids): # pragma: no cover - never reached
raise AssertionError("should not delete items it cannot identify")
receipt = ErasureCoordinator(memory=_AnonymousMemory()).erase_entity("e1")
self.assertEqual(receipt.stores["memory"]["status"], STATUS_FAILED)
self.assertFalse(receipt.complete)
class TestVectorBackendShapes(unittest.TestCase):
def test_delete_vectors_backend_is_erased(self):
store = _DeleteVectorsStore()
receipt = ErasureCoordinator(vector_store=store).erase_entity("customer-4471")
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_ERASED)
self.assertEqual(receipt.stores["vectors"]["via"], "delete_vectors")
self.assertEqual(store.deleted, [["customer-4471"]])
def test_delete_backend_is_erased(self):
store = _DeleteStore()
receipt = ErasureCoordinator(vector_store=store).erase_entity("customer-4471")
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_ERASED)
self.assertEqual(receipt.stores["vectors"]["via"], "delete")
self.assertEqual(store.deleted, [["customer-4471"]])
def test_backend_without_delete_is_unsupported_not_erased(self):
receipt = ErasureCoordinator(vector_store=_NoDeleteStore()).erase_entity("e1")
vectors = receipt.stores["vectors"]
self.assertEqual(vectors["status"], STATUS_UNSUPPORTED)
self.assertEqual(vectors["backend"], "faiss")
self.assertIn("no delete", vectors["detail"])
self.assertFalse(receipt.complete)
def test_facade_declaring_delete_over_a_delete_less_backend_is_unsupported(self):
receipt = ErasureCoordinator(
vector_store=_FacadeOverNoDeleteBackend()
).erase_entity("e1")
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_UNSUPPORTED)
self.assertFalse(receipt.complete)
def test_store_reporting_no_deletion_is_failed(self):
store = _DeleteVectorsStore(result=False)
receipt = ErasureCoordinator(vector_store=store).erase_entity("e1")
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_FAILED)
self.assertFalse(receipt.complete)
def test_explicit_vector_ids_override_the_entity_id(self):
store = _DeleteVectorsStore()
ErasureCoordinator(vector_store=store).erase_entity(
"customer-4471", vector_ids=["vec-a", "vec-b"]
)
self.assertEqual(store.deleted, [["vec-a", "vec-b"]])
def test_vector_store_defaults_to_the_one_memory_holds(self):
store = _MemoryVectorStore()
memory = AgentMemory(vector_store=store)
self.assertIs(ErasureCoordinator(memory=memory).vector_store, store)
def test_memory_bound_vector_store_can_be_overridden(self):
owned, external = _MemoryVectorStore(), _DeleteVectorsStore()
memory = AgentMemory(vector_store=owned)
coordinator = ErasureCoordinator(memory=memory, vector_store=external)
self.assertIs(coordinator.vector_store, external)
def test_vector_leg_can_be_disabled_for_a_memory_bound_store(self):
memory = AgentMemory(vector_store=_MemoryVectorStore())
coordinator = ErasureCoordinator(memory=memory, vector_store=False)
receipt = coordinator.erase_entity("customer-4471")
self.assertIsNone(coordinator.vector_store)
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_NOT_CONFIGURED)
class TestPartialFailureIsAResultNotAnException(unittest.TestCase):
def test_a_raising_vector_store_does_not_stop_the_remaining_legs(self):
graph, memory = _graph(), _memory_with("customer-4471", 4)
receipt = ErasureCoordinator(
graph=graph, memory=memory, vector_store=_RaisingStore()
).erase_entity("customer-4471")
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_FAILED)
self.assertIn("RuntimeError", receipt.stores["vectors"]["detail"])
# The legs after the failure still ran.
self.assertEqual(receipt.stores["memory"]["status"], STATUS_ERASED)
self.assertEqual(receipt.stores["graph"]["status"], STATUS_ERASED)
self.assertFalse(graph.has_node("customer-4471"))
self.assertFalse(receipt.complete)
self.assertEqual(receipt.incomplete_stores, ["vectors"])
def test_a_raising_graph_is_reported_after_memory_was_erased(self):
class _RaisingGraph:
def find_edges(self):
return []
def purge_node(self, node_id, reason=None, at=None):
raise RuntimeError("graph store unavailable")
memory = _memory_with("customer-4471", 2)
receipt = ErasureCoordinator(graph=_RaisingGraph(), memory=memory).erase_entity(
"customer-4471"
)
self.assertEqual(receipt.stores["memory"]["status"], STATUS_ERASED)
self.assertEqual(receipt.stores["graph"]["status"], STATUS_FAILED)
self.assertFalse(receipt.complete)
class TestReceipt(unittest.TestCase):
def test_unconfigured_stores_are_reported_and_still_count_as_complete(self):
receipt = ErasureCoordinator(graph=_graph()).erase_entity("customer-4471")
self.assertEqual(receipt.stores["memory"]["status"], STATUS_NOT_CONFIGURED)
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_NOT_CONFIGURED)
self.assertTrue(receipt.complete)
def test_erasing_a_second_time_reports_nothing_left_rather_than_raising(self):
graph, memory = _graph(), _memory_with("customer-4471", 3)
coordinator = ErasureCoordinator(graph=graph, memory=memory)
coordinator.erase_entity("customer-4471")
second = coordinator.erase_entity("customer-4471")
self.assertEqual(second.stores["graph"]["status"], STATUS_NOT_FOUND)
self.assertEqual(second.stores["memory"]["status"], STATUS_NOT_FOUND)
self.assertTrue(second.complete)
def test_to_dict_round_trips_the_reported_shape(self):
graph = _graph()
receipt = ErasureCoordinator(graph=graph).erase_entity(
"customer-4471",
reason="GDPR Art. 17 request #882",
at="2026-08-16T00:00:00Z",
)
payload = receipt.to_dict()
self.assertEqual(payload["entity_id"], "customer-4471")
self.assertEqual(payload["reason"], "GDPR Art. 17 request #882")
self.assertEqual(payload["erased_at"], "2026-08-16T00:00:00")
self.assertTrue(payload["complete"])
self.assertEqual(set(payload["stores"]), {"graph", "memory", "vectors"})
def test_to_dict_copies_the_store_results(self):
receipt = ErasureCoordinator(graph=_graph()).erase_entity("customer-4471")
payload = receipt.to_dict()
payload["stores"]["graph"]["status"] = "tampered"
self.assertEqual(receipt.stores["graph"]["status"], STATUS_ERASED)
def test_receipt_and_tombstone_agree_on_when_the_erasure_happened(self):
graph = _graph()
receipt = ErasureCoordinator(graph=graph).erase_entity(
"customer-4471", at="2026-08-16T00:00:00Z"
)
tombstone = graph.get_tombstone("customer-4471", "node")
self.assertEqual(tombstone["purged_at"], "2026-08-16T00:00:00")
self.assertEqual(receipt.erased_at, tombstone["purged_at"])
def test_receipt_and_tombstone_agree_when_no_at_is_given(self):
"""The default path, where the drift actually happens.
With `at=None` the coordinator and `purge_node()` would each take their
own `now()`, so the receipt attested to a different instant than the
tombstone it points at. Passing an explicit `at` hides this, which is
why the test above passed while the common case was wrong.
"""
graph = _graph()
receipt = ErasureCoordinator(graph=graph).erase_entity("customer-4471")
tombstone = graph.get_tombstone("customer-4471", "node")
self.assertEqual(receipt.erased_at, tombstone["purged_at"])
def test_epoch_seconds_are_accepted_like_the_graph_accepts_them(self):
graph = _graph()
receipt = ErasureCoordinator(graph=graph).erase_entity(
"customer-4471", at=1755302400
)
tombstone = graph.get_tombstone("customer-4471", "node")
self.assertEqual(receipt.erased_at, tombstone["purged_at"])
self.assertTrue(receipt.erased_at.startswith("2025-"))
def test_an_unparseable_at_is_rejected_before_any_store_is_touched(self):
graph, memory = _graph(), _memory_with("customer-4471", 2)
with self.assertRaises(ValueError):
ErasureCoordinator(graph=graph, memory=memory).erase_entity(
"customer-4471", at="not-a-timestamp"
)
self.assertTrue(graph.has_node("customer-4471"))
self.assertEqual(len(memory.find_by_entity("customer-4471", limit=500)), 2)
def test_incomplete_stores_names_every_store_still_holding_data(self):
receipt = ErasureReceipt(
entity_id="e1",
stores={
"vectors": {"status": STATUS_UNSUPPORTED},
"memory": {"status": STATUS_FAILED},
"graph": {"status": STATUS_ERASED},
},
)
self.assertEqual(sorted(receipt.incomplete_stores), ["memory", "vectors"])
self.assertFalse(receipt.complete)
class TestRealVectorStoreBackend(unittest.TestCase):
"""The fakes above assert the shapes the coordinator expects; these assert
that a real backend actually has one of them.
This repo's recurring failure is a change verified only against the default
that reaches for internals and breaks on every other backend, so the fake
stores are worth exactly as much as the assumption that a real store looks
like them. ``VectorStore(backend="inmemory")`` is the one backend that runs
without external services, so it is the one that can hold that assumption
to account here.
"""
def _store(self):
return VectorStore(backend="inmemory", dimension=8)
def test_real_backend_erases_the_vector_ids_it_is_given(self):
store = self._store()
vector_ids = store.store_vectors(
vectors=[np.ones(8), np.zeros(8)], metadata=[{}, {}]
)
self.assertEqual(store.count(), 2)
receipt = ErasureCoordinator(vector_store=store).erase_entity(
"customer-4471", vector_ids=vector_ids
)
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_ERASED)
self.assertEqual(receipt.stores["vectors"]["backend"], "inmemory")
self.assertEqual(store.count(), 0)
def test_the_full_cascade_removes_a_real_memory_bound_embedding(self):
"""The end-to-end case the receipt actually attests to.
Real ``ContextGraph``, real ``AgentMemory``, real ``VectorStore`` --
the embedding is written by ``AgentMemory.store()`` and has to be gone
afterwards, which exercises the memory leg's own ``delete_memory()``
vector cascade rather than the coordinator's model of it.
"""
store, graph = self._store(), _graph()
memory = AgentMemory(vector_store=store)
memory.store(
"note about customer-4471",
entities=[{"id": "customer-4471", "name": "customer-4471"}],
skip_graph=True,
)
self.assertEqual(store.count(), 1)
receipt = ErasureCoordinator(graph=graph, memory=memory).erase_entity(
"customer-4471", reason="GDPR Art. 17 request #882"
)
self.assertTrue(receipt.complete)
self.assertEqual(receipt.stores["memory"]["status"], STATUS_ERASED)
self.assertEqual(receipt.stores["graph"]["status"], STATUS_ERASED)
self.assertEqual(store.count(), 0)
self.assertFalse(graph.has_node("customer-4471"))
self.assertEqual(memory.find_by_entity("customer-4471", limit=500), [])
def test_erased_means_the_store_accepted_the_delete_not_that_data_existed(self):
"""Pins a limit of the receipt worth knowing before trusting it.
The in-memory backend pops the ids and returns ``True`` whether or not
they were there, and no backend offers a portable "did this id exist"
check, so the vectors leg reports how many ids the store accepted --
not how many embeddings were really removed. ``erased`` on this leg is
therefore weaker than on the memory leg, which re-queries to confirm.
"""
store = self._store()
receipt = ErasureCoordinator(vector_store=store).erase_entity("never-embedded")
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_ERASED)
self.assertEqual(receipt.stores["vectors"]["vector_ids"], 1)
self.assertEqual(store.count(), 0)
class TestConstruction(unittest.TestCase):
def test_a_coordinator_with_no_stores_is_rejected(self):
with self.assertRaises(ValueError):
ErasureCoordinator()
def test_a_single_store_is_enough(self):
self.assertIsNotNone(ErasureCoordinator(graph=_graph()))
self.assertIsNotNone(ErasureCoordinator(memory=AgentMemory()))
self.assertIsNotNone(ErasureCoordinator(vector_store=_DeleteStore()))
def test_a_falsey_vector_store_is_still_a_store(self):
"""An empty store defining __len__ is falsey but perfectly valid."""
class _EmptyButReal(_DeleteVectorsStore):
def __len__(self):
return 0
store = _EmptyButReal()
coordinator = ErasureCoordinator(vector_store=store)
self.assertIs(coordinator.vector_store, store)
receipt = coordinator.erase_entity("customer-4471")
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_ERASED)
class TestBackendDeleteResults(unittest.TestCase):
"""Backends report deletes as dicts, not bools.
Qdrant returns ``{"status": <UpdateStatus>}`` and Pinecone
``{"deleted": True}``, so a bare ``result is False`` check calls every dict
a success and throws away the only account of the delete the caller gets.
"""
def _store_returning(self, value):
store = _DeleteVectorsStore(result=value)
return store, ErasureCoordinator(vector_store=store)
def test_qdrant_shaped_success_dict_is_erased_and_kept(self):
_, coordinator = self._store_returning({"status": "completed"})
vectors = coordinator.erase_entity("e1").stores["vectors"]
self.assertEqual(vectors["status"], STATUS_ERASED)
self.assertEqual(vectors["backend_result"], {"status": "completed"})
def test_pinecone_shaped_success_dict_is_erased(self):
_, coordinator = self._store_returning({"deleted": True})
self.assertEqual(
coordinator.erase_entity("e1").stores["vectors"]["status"], STATUS_ERASED
)
def test_explicit_failure_marker_in_a_dict_is_failed(self):
for payload in ({"deleted": False}, {"success": False}, {"status": "failed"}):
with self.subTest(payload=payload):
_, coordinator = self._store_returning(payload)
receipt = coordinator.erase_entity("e1")
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_FAILED)
self.assertFalse(receipt.complete)
def test_an_enum_like_failure_status_is_not_read_as_success(self):
class _UpdateStatus:
def __str__(self):
return "UpdateStatus.FAILED"
_, coordinator = self._store_returning({"status": _UpdateStatus()})
receipt = coordinator.erase_entity("e1")
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_FAILED)
# Rendered as a string so the receipt stays serializable as an audit record.
self.assertEqual(
receipt.stores["vectors"]["backend_result"],
{"status": "UpdateStatus.FAILED"},
)
json.dumps(receipt.to_dict())
def test_a_zero_count_return_is_not_mistaken_for_False(self):
"""`0 == False` in Python; a store reporting "0 rows" is not a failure."""
_, coordinator = self._store_returning({"deleted": 0})
self.assertEqual(
coordinator.erase_entity("e1").stores["vectors"]["status"], STATUS_ERASED
)
def test_a_void_delete_returning_None_is_accepted(self):
"""Reporting `failed` for a void method would be a false alarm."""
_, coordinator = self._store_returning(None)
self.assertEqual(
coordinator.erase_entity("e1").stores["vectors"]["status"], STATUS_ERASED
)
class _SelectiveDeleteStore:
"""Deletes some ids and refuses others, tracking what is still live.
Models the case that matters: the entity-keyed id deletes fine while the
embedding an ``AgentMemory`` item owns does not.
"""
backend = "qdrant"
def __init__(self, refuse=()):
self._refuse = set(refuse)
self.live = set()
self.attempts = []
def store_vectors(self, vectors, metadata=None, **options):
ids = [f"vec-{len(self.live) + index}" for index in range(len(vectors))]
self.live.update(ids)
return ids
def delete_vectors(self, vector_ids, **options):
self.attempts.append(list(vector_ids))
if any(vector_id in self._refuse for vector_id in vector_ids):
return False
self.live.difference_update(vector_ids)
return True
def _memory_with_embedding(entity_id, store):
memory = AgentMemory(vector_store=store)
memory.store(
f"note about {entity_id}",
entities=[{"id": entity_id, "name": entity_id}],
embedding=np.zeros(4),
skip_graph=True,
)
return memory
class TestSeparateVectorStoreHandling(unittest.TestCase):
"""Verify correct behavior when coordinator.vector_store != memory.vector_store.
AgentMemory.delete_memory() has its own best-effort vector cascade that
logs failures but returns True. When the coordinator's vector_store differs
from (or is disabled vs) memory.vector_store, a vector remaining in
memory.vector_store must not be hidden by the coordinator's receipt.
"""
def test_vector_store_false_disables_vector_leg_entirely(self):
"""vector_store=False must disable the vector leg, not try memory.vector_store."""
memory_store = _SelectiveDeleteStore()
memory = _memory_with_embedding("customer-4471", memory_store)
# Disable vector leg explicitly
receipt = ErasureCoordinator(
graph=_graph(), memory=memory, vector_store=False
).erase_entity("customer-4471")
# Vector leg should report not_configured, not attempt deletion
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_NOT_CONFIGURED)
# Memory's own cascade still runs, but coordinator doesn't track it
self.assertTrue(receipt.complete)
def test_separate_vector_store_only_handles_coordinator_store(self):
"""When coordinator has a different vector_store, it only handles that one.
If memory.vector_store contains a memory-owned vector and fails to delete
it, that's memory's problem -- the coordinator only reports on the store
it was given. This test verifies the coordinator correctly collects IDs
from memory items and attempts deletion on its own store, independent of
memory.vector_store.
"""
# Memory has its own store with a vector
memory_store = _SelectiveDeleteStore()
memory = _memory_with_embedding("customer-4471", memory_store)
memory_vector_id = list(memory_store.live)[0]
# Coordinator has a separate store that refuses to delete
coordinator_store = _SelectiveDeleteStore(refuse={memory_vector_id})
receipt = ErasureCoordinator(
graph=_graph(), memory=memory, vector_store=coordinator_store
).erase_entity("customer-4471")
# The coordinator's store should have been asked to delete the memory-owned vector
self.assertIn(memory_vector_id, coordinator_store.attempts[0])
# The coordinator's store refused, so receipt is incomplete
self.assertFalse(receipt.complete)
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_FAILED)
# Memory's own store was used by delete_memory()'s cascade (best-effort)
# but the coordinator's receipt only reflects the coordinator's store
self.assertNotIn(memory_vector_id, memory_store.live) # memory deleted it
def test_memory_vector_store_failure_is_not_reported_when_coordinator_has_separate_store(
self,
):
"""If memory.vector_store fails but coordinator.vector_store succeeds, receipt is complete.
The coordinator reports only on its own store. Memory's delete_memory()
cascade is best-effort and logs failures, but the coordinator doesn't
re-check memory.vector_store after deletion.
"""
# Memory's store will fail to delete (but delete_memory catches it)
memory_store = _SelectiveDeleteStore(refuse={"vec-0"})
memory = _memory_with_embedding("customer-4471", memory_store)
# Coordinator has a separate, cooperative store
coordinator_store = _SelectiveDeleteStore()
receipt = ErasureCoordinator(
graph=_graph(), memory=memory, vector_store=coordinator_store
).erase_entity("customer-4471")
# Coordinator's store succeeded
self.assertTrue(receipt.complete)
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_ERASED)
# But memory's store still has the vector (delete_memory logged it)
self.assertIn("vec-0", memory_store.live)
class TestMemoryOwnedVectorsAreReported(unittest.TestCase):
"""A memory item's embedding must not survive a `complete` receipt.
``AgentMemory.delete_memory()`` deletes an item's vectors best-effort: it
catches a vector-store failure, logs a warning, and still returns ``True``.
The coordinator therefore cannot learn from the memory leg whether those
embeddings actually went away, so it deletes them through its own vector
leg, which reports honestly.
"""
def test_refused_memory_owned_vector_makes_the_receipt_incomplete(self):
store = _SelectiveDeleteStore(refuse={"vec-0"})
memory = _memory_with_embedding("customer-4471", store)
self.assertEqual(
memory.vector_ids_for(next(iter(memory.memory_items))), ["vec-0"]
)
receipt = ErasureCoordinator(graph=_graph(), memory=memory).erase_entity(
"customer-4471"
)
# The embedding is demonstrably still there ...
self.assertIn("vec-0", store.live)
# ... so the receipt must not claim the erasure is done.
self.assertFalse(receipt.complete)
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_FAILED)
self.assertEqual(receipt.incomplete_stores, ["vectors"])
def test_memory_owned_vector_ids_are_sent_to_the_vector_store(self):
store = _SelectiveDeleteStore()
memory = _memory_with_embedding("customer-4471", store)
receipt = ErasureCoordinator(graph=_graph(), memory=memory).erase_entity(
"customer-4471"
)
# The coordinator's own leg must have attempted the memory-owned id,
# not just the entity-keyed one.
self.assertIn("vec-0", store.attempts[0])
self.assertIn("customer-4471", store.attempts[0])
self.assertNotIn("vec-0", store.live)
self.assertTrue(receipt.complete)
def test_explicit_vector_ids_do_not_displace_memory_owned_ids(self):
store = _SelectiveDeleteStore()
memory = _memory_with_embedding("customer-4471", store)
ErasureCoordinator(graph=_graph(), memory=memory).erase_entity(
"customer-4471", vector_ids=["extra-1"]
)
self.assertIn("extra-1", store.attempts[0])
self.assertIn("vec-0", store.attempts[0])
def test_vector_ids_for_falls_back_to_the_memory_id(self):
"""An item stored without tracked vector ids is keyed by its own id."""
memory = AgentMemory()
memory.store(
"note about customer-4471",
entities=[{"id": "customer-4471", "name": "customer-4471"}],
skip_graph=True,
)
memory_id = next(iter(memory.memory_items))
self.assertEqual(memory.vector_ids_for(memory_id), [memory_id])
self.assertEqual(memory.vector_ids_for("no-such-item"), [])
def test_pagination_collects_vectors_from_all_501_items(self):
"""Regression: _all_vector_ids must page to collect ALL vectors.
The original implementation called find_by_entity(limit=500) once,
collecting only the first 500 items' vectors, while _erase_memory()
continued paging and deleted all 501+ items. The vector belonging to
item 501 remained, yet the receipt reported complete=True -- the exact
failure mode the coordinator exists to prevent.
This test uses 51 items (crossing a 50-item batch boundary for testing)
to verify pagination logic without the performance cost of 501 real items.
The test would fail against the original bug with ANY batch size > 1.
"""
# Use batch size of 50 for this test (instead of production's 500)
# This keeps the test fast while still proving pagination across boundaries
TEST_BATCH_SIZE = 50
TEST_ITEM_COUNT = 51 # One more than batch size
store = _SelectiveDeleteStore(refuse={"vec-50"}) # 0-indexed: item 51
# Create a lightweight memory mock optimized for speed
class FastMemoryFor51Test:
"""Fast memory implementation for pagination test."""
def __init__(self, vector_store):
self.vector_store = vector_store
entity_id = "customer-with-many-memories"
self._items = {}
for i in range(TEST_ITEM_COUNT):
memory_id = f"mem-{i}"
self._items[memory_id] = {
"memory_id": memory_id,
"content": f"Memory {i}",
"entities": [{"id": entity_id}],
"metadata": {},
"timestamp": "2026-01-01T00:00:00",
"relationships": [],
}
def find_by_entity(self, entity_id, limit=None):
"""Return all remaining items, with limit."""
results = list(self._items.values())
if limit is not None:
return results[:limit]
return results
def batch_delete(self, memory_ids):
"""Fast deletion."""
deleted = 0
for memory_id in memory_ids:
if memory_id in self._items:
del self._items[memory_id]
deleted += 1
return deleted
def vector_ids_for(self, memory_id):
"""Return vector ID for this memory."""
idx = int(memory_id.split("-")[1])
return [f"vec-{idx}"]
memory = FastMemoryFor51Test(store)
# Pre-populate the vector store
for i in range(TEST_ITEM_COUNT):
store.live.add(f"vec-{i}")
# Temporarily patch the batch size constant for this test
from semantica.context import erasure
original_batch_size = erasure._MEMORY_SWEEP_BATCH
erasure._MEMORY_SWEEP_BATCH = TEST_BATCH_SIZE
try:
# Verify setup
self.assertEqual(len(memory.find_by_entity("customer-with-many-memories")), TEST_ITEM_COUNT)
self.assertIn("vec-50", store.live)
receipt = ErasureCoordinator(graph=_graph(), memory=memory).erase_entity(
"customer-with-many-memories"
)
# The 51st embedding is demonstrably still there...
self.assertIn("vec-50", store.live)
# ...so the receipt MUST NOT claim complete erasure
self.assertFalse(
receipt.complete,
f"Receipt claimed complete=True while vec-50 (item {TEST_ITEM_COUNT}) remains; "
"_all_vector_ids() only collected the first {TEST_BATCH_SIZE} items' vectors",
)
self.assertEqual(receipt.stores["vectors"]["status"], STATUS_FAILED)
self.assertIn("vectors", receipt.incomplete_stores)
# Verify all 51 memory-owned vector IDs were attempted (proving pagination worked)
all_attempted = set()
for batch in store.attempts:
all_attempted.update(batch)
# Should have attempted entity_id + all TEST_ITEM_COUNT memory-owned vectors
# (entity_id is always included by _all_vector_ids when vector_ids=None)
self.assertEqual(len(all_attempted), TEST_ITEM_COUNT + 1,
f"Expected {TEST_ITEM_COUNT + 1} vector deletion attempts "
f"(entity_id + {TEST_ITEM_COUNT} memory vectors), got {len(all_attempted)}")
# Specifically must have tried the 51st memory vector
self.assertIn("vec-50", all_attempted,
"Pagination failed: vec-50 (item 51) was never collected")
finally:
# Restore original batch size
erasure._MEMORY_SWEEP_BATCH = original_batch_size
if __name__ == "__main__":
unittest.main()
+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
+94
View File
@@ -714,6 +714,100 @@ class TestImportExport:
assert response.status_code == 200
assert "text/csv" in response.headers["content-type"].lower()
@pytest.mark.parametrize(
"fmt,rdflib_format",
[
("turtle", "turtle"),
("ttl", "turtle"),
("nt", "nt"),
("ntriples", "nt"),
("n-triples", "nt"),
("xml", "xml"),
("rdfxml", "xml"),
("rdf-xml", "xml"),
("jsonld", "json-ld"),
("json-ld", "json-ld"),
],
)
def test_export_rdf_formats(self, client, fmt, rdflib_format):
"""The Explorer used to answer 422 for every RDF format while the MCP
`export_graph` tool offered them, so a graph could be loaded as JSON-LD and never
exported back (#1131).
Parsed with a real RDF parser rather than asserted on strings: a response that
merely *looks* like Turtle is what makes this class of gap survive a test suite.
"""
rdflib = pytest.importorskip("rdflib")
response = client.post("/api/export", json={"format": fmt})
assert response.status_code == 200, response.text
graph = rdflib.Graph()
graph.parse(data=response.text, format=rdflib_format)
assert len(graph) > 0, f"{fmt} export parsed to zero triples"
def test_export_aliases_agree_with_mcp_tool_where_overlapping(self):
"""The two surfaces of one product should not disagree about what `ttl` means.
Explorer now maps to RDFExporter canonical formats (e.g., nt->ntriples),
while MCP maps to its own intermediates (e.g., nt->nt). This test verifies
that where MCP and Explorer overlap in alias names, they ultimately work
correctly even if the intermediate canonical form differs.
Canary: if either alias table drifts such that an alias becomes unsupported,
this test will catch it."""
from mcp.tools.export import _FORMAT_ALIASES as MCP_ALIASES
from semantica.explorer.routes.export_import import _RDF_FORMATS
# Verify all MCP aliases are present in Explorer
for alias in MCP_ALIASES.keys():
assert alias in _RDF_FORMATS, (
f"MCP alias {alias!r} not present in Explorer _RDF_FORMATS"
)
# Note: We don't require identical canonical forms because:
# - MCP maps to intermediates that RDFExporter then translates
# - Explorer now maps directly to RDFExporter canonical forms
# - Both ultimately work correctly
def test_export_graphml(self, client):
"""GraphML export should work using GraphExporter."""
response = client.post("/api/export", json={"format": "graphml"})
assert response.status_code == 200, response.text
assert "application/xml" in response.headers["content-type"].lower()
# Verify it's valid XML and contains GraphML structure
content = response.text
assert '<?xml version="1.0"' in content
assert '<graphml' in content
assert '</graphml>' in content
def test_export_empty_graph_rdf(self, client):
"""Empty graphs should export successfully in RDF formats."""
# First, clear the graph or use a clean client
# This test assumes test fixtures provide a graph; for empty graph
# we'd need to manipulate the session, which may not be straightforward
# in these integration tests. Keeping this as documentation.
pass
def test_export_rdf_validation_error_handling(self, client):
"""RDF validation errors should return HTTP 422, not 500."""
# This would require crafting malformed graph data that passes
# session.build_graph_dict() but fails RDF validation.
# Since build_graph_dict() returns valid structure, this is difficult
# to trigger in integration tests. Keeping as documentation.
pass
def test_unsupported_format_names_what_is_supported(self, client):
"""The old message said only that the format was unsupported, which reads as 'this
format does not exist' rather than 'this door does not open it'."""
response = client.post("/api/export", json={"format": "no-such-format"})
assert response.status_code == 422
detail = response.json()["detail"]
assert "turtle" in detail and "json" in detail
def test_import_json_with_edge_metadata(self, client):
payload = json.dumps(
{
@@ -0,0 +1,97 @@
"""
Test for GraphBuilder with GraphStore backend (Issue #1135).
This test verifies that GraphBuilder correctly works with the GraphStore
facade interface, not with raw backend stores like Neo4jStore.
"""
import unittest
from unittest.mock import MagicMock, patch
class TestGraphBuilderWithGraphStore(unittest.TestCase):
"""Test GraphBuilder integration with GraphStore facade."""
def setUp(self):
"""Set up test fixtures."""
# Mock progress tracker
self.mock_tracker_patcher = patch("semantica.utils.progress_tracker.get_progress_tracker")
self.mock_get_tracker = self.mock_tracker_patcher.start()
self.mock_tracker = MagicMock()
self.mock_get_tracker.return_value = self.mock_tracker
def tearDown(self):
"""Clean up after tests."""
self.mock_tracker_patcher.stop()
def test_graph_builder_with_graph_store_facade(self):
"""Test that GraphBuilder works with GraphStore facade (Issue #1135)."""
from semantica.kg.graph_builder import GraphBuilder
from semantica.graph_store import GraphStore
# Create a mock GraphStore facade
mock_store = MagicMock(spec=GraphStore)
mock_store.add_nodes.return_value = 2
mock_store.add_edges.return_value = 1
# Create GraphBuilder with the GraphStore facade
builder = GraphBuilder(
merge_entities=False,
resolve_conflicts=False,
graph_store=mock_store
)
# Build a simple graph
entities = [
{"id": "alice", "type": "Person"},
{"id": "bob", "type": "Person"},
]
relationships = [
{"source": "alice", "target": "bob", "type": "knows"},
]
graph = builder.build({
"entities": entities,
"relationships": relationships
})
# Verify the graph was built
self.assertEqual(len(graph["entities"]), 2)
self.assertEqual(len(graph["relationships"]), 1)
# Verify that add_nodes and add_edges were called on the GraphStore
mock_store.add_nodes.assert_called_once()
mock_store.add_edges.assert_called_once()
def test_graph_builder_without_graph_store_still_works(self):
"""Test that GraphBuilder still works without a graph_store parameter."""
from semantica.kg.graph_builder import GraphBuilder
# Create GraphBuilder without graph_store
builder = GraphBuilder(
merge_entities=False,
resolve_conflicts=False
)
# Build a simple graph
entities = [
{"id": "alice", "type": "Person"},
{"id": "bob", "type": "Person"},
]
relationships = [
{"source": "alice", "target": "bob", "type": "knows"},
]
graph = builder.build({
"entities": entities,
"relationships": relationships
})
# Verify the graph was built
self.assertEqual(len(graph["entities"]), 2)
self.assertEqual(len(graph["relationships"]), 1)
self.assertEqual(graph["metadata"]["num_entities"], 2)
self.assertEqual(graph["metadata"]["num_relationships"], 1)
if __name__ == "__main__":
unittest.main()
@@ -1,6 +1,9 @@
import pytest
from semantica.ontology.class_inferrer import ClassInferrer
from semantica.ontology.ontology_generator import OntologyGenerator
from semantica.ontology.property_generator import PropertyGenerator
from semantica.utils.exceptions import ValidationError
def _entities():
@@ -39,3 +42,15 @@ def test_ontology_pipeline_emits_data_properties_for_normalized_types():
email = next(prop for prop in ontology["properties"] if prop["name"] == "email")
assert email["domain"] == ["SoftwareEngineer"]
assert email["range"] == "xsd:string"
def test_class_inference_rejects_normalized_type_collisions():
entities = [
{"type": "Person", "name": "Alice"},
{"type": "Person", "name": "Bob"},
{"type": "person", "name": "Carol"},
{"type": "person", "name": "Dan"},
]
with pytest.raises(ValidationError, match="duplicate class names"):
ClassInferrer().infer_classes(entities)
@@ -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")
+50
View File
@@ -0,0 +1,50 @@
"""
Test for the Claude Code plugin manifest (Issue #1350).
Claude Code's plugin schema requires "agents" to be an array of .md file
paths (a bare directory string is rejected with "agents: Invalid input"),
while "skills" may be a directory string. This guards the manifest shape
so the bundled plugin stays installable.
"""
import json
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
MANIFEST = REPO_ROOT / "plugins" / ".claude-plugin" / "plugin.json"
class TestPluginManifest(unittest.TestCase):
"""Validate plugins/.claude-plugin/plugin.json against Claude Code's schema shape."""
@classmethod
def setUpClass(cls):
cls.manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
cls.plugin_root = MANIFEST.parent.parent
def test_agents_is_list_of_md_file_paths(self):
agents = self.manifest["agents"]
self.assertIsInstance(
agents, list,
'Claude Code rejects "agents" unless it is an array of .md file paths',
)
self.assertTrue(agents, "agents list should not be empty")
for entry in agents:
self.assertIsInstance(entry, str)
self.assertTrue(entry.endswith(".md"), f"{entry} is not a .md file path")
path = self.plugin_root / entry
self.assertTrue(path.is_file(), f"{entry} does not exist under plugins/")
def test_agents_list_covers_all_agent_files(self):
declared = {Path(entry).name for entry in self.manifest["agents"]}
on_disk = {p.name for p in (self.plugin_root / "agents").glob("*.md")}
self.assertEqual(declared, on_disk)
def test_skills_directory_exists(self):
skills = self.manifest["skills"]
self.assertIsInstance(skills, str)
self.assertTrue((self.plugin_root / skills).is_dir())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,142 @@
"""Facade-level contract tests for the cloud vector store backends.
Other tests here either mock a backend's internals or inject a fake into
``VectorStore._backend_store``. Both skip ``_init_backend_store``, which is
where the qdrant/pinecone/milvus/weaviate adapters are built, and that is how
#1316 shipped green while a qdrant-backed store could neither read nor write.
Gaps are recorded as strict xfail so they turn into XPASS once the wiring
lands, failing the suite until the stale marker is removed.
Related: #1265, #1019.
"""
from contextlib import ExitStack
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from semantica.vector_store import VectorStore
# Availability flag per backend, plus every symbol its connect/select path
# calls. The clients must be patched too: without the real SDK installed they
# are None, so a fixed _init_backend_store would still fail and these could
# never reach XPASS. Extend these if the wiring touches more symbols.
_AVAILABILITY_FLAG = {
"qdrant": "semantica.vector_store.qdrant_store.QDRANT_AVAILABLE",
"pinecone": "semantica.vector_store.pinecone_store.PINECONE_AVAILABLE",
"milvus": "semantica.vector_store.milvus_store.MILVUS_AVAILABLE",
"weaviate": "semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE",
}
_CLIENT_SYMBOLS = {
"qdrant": ("semantica.vector_store.qdrant_store.QdrantClientLib",),
"pinecone": ("semantica.vector_store.pinecone_store.PineconeClientLib",),
"milvus": (
"semantica.vector_store.milvus_store.connections",
"semantica.vector_store.milvus_store.Collection",
"semantica.vector_store.milvus_store.utility",
),
"weaviate": ("semantica.vector_store.weaviate_store.weaviate",),
}
# Pinecone refuses to connect without a key, so supply a dummy one rather than
# letting a missing credential masquerade as the wiring gap.
_EXTRA_CONFIG = {"pinecone": {"api_key": "test-key"}}
CLOUD_BACKENDS = sorted(_AVAILABILITY_FLAG)
# Backends that store locally and need no connection step.
_LOCAL_BACKENDS = {"inmemory", "faiss", "sqlite", "pgvector"}
# The facade dispatches store_vectors() to `add` or `add_vectors`. Milvus
# exposes add_vectors so it already resolves; the other three name their write
# method differently and fall through to NotImplementedError.
_NO_WRITE_DISPATCH = {"qdrant", "pinecone", "weaviate"}
def _construct(backend):
"""Build a VectorStore through the real _init_backend_store path."""
config = {"dimension": 3, **_EXTRA_CONFIG.get(backend, {})}
with ExitStack() as stack:
stack.enter_context(patch(_AVAILABILITY_FLAG[backend], True))
for symbol in _CLIENT_SYMBOLS[backend]:
stack.enter_context(patch(symbol, MagicMock()))
return VectorStore(backend=backend, config=config)
def _live_handle(backend_store):
"""The attribute each adapter holds its connected resource in.
Reaching into the adapter rather than asserting through the facade is
deliberate: the facade's read methods are exactly what is broken, so there
is no public call that distinguishes "not connected" from the other gaps.
"""
for name in ("collection", "index"):
if hasattr(backend_store, name):
return getattr(backend_store, name)
return None
def _param(backend, broken_for, reason):
marks = [pytest.mark.xfail(strict=True, reason=reason)] if backend in broken_for else []
return pytest.param(backend, marks=marks)
def test_roster_covers_every_supported_backend():
"""A new backend must be classified here rather than silently uncovered."""
assert set(CLOUD_BACKENDS) | _LOCAL_BACKENDS == VectorStore.SUPPORTED_BACKENDS
@pytest.mark.parametrize("backend", CLOUD_BACKENDS)
def test_facade_constructs_an_adapter(backend):
store = _construct(backend)
assert store._backend_store is not None
assert store.backend == backend
@pytest.mark.parametrize(
"backend",
[
_param(b, CLOUD_BACKENDS, "_init_backend_store never connects or selects a collection")
for b in CLOUD_BACKENDS
],
)
def test_backend_is_connected_after_construction(backend):
"""A constructed store should be usable without the caller reaching past
the facade to call connect() and get_collection() itself."""
store = _construct(backend)
assert _live_handle(store._backend_store) is not None
@pytest.mark.parametrize(
"backend",
[
_param(b, _NO_WRITE_DISPATCH, "facade dispatches only to add/add_vectors")
for b in CLOUD_BACKENDS
],
)
def test_store_vectors_dispatch_resolves(backend):
"""store_vectors() should reach the backend's write method."""
store = _construct(backend)
try:
store.store_vectors([np.zeros(3)], [{}], ids=["a"])
except NotImplementedError as exc:
pytest.fail(f"no write dispatch for {backend}: {exc}")
except Exception:
# Any other error means the facade found a write method and the failure
# came from below it, which is the connection gap the test above pins.
# Whether the write succeeds needs a live server, not this test.
pass
def test_milvus_write_dispatch_already_resolves():
"""Control for _NO_WRITE_DISPATCH: if milvus changes, the xfail list is
wrong rather than the feature being broken."""
store = _construct("milvus")
assert hasattr(store._backend_store, "add_vectors")
+382 -6
View File
@@ -1,9 +1,130 @@
import json
import uuid
from datetime import datetime, timezone
from unittest.mock import MagicMock
import numpy as np
import pytest
from semantica.vector_store.faiss_store import FAISSIndex, FAISSStore
from semantica.utils.exceptions import ProcessingError
from semantica.vector_store.faiss_store import (
FAISSIndex,
FAISSStore,
_metadata_path,
)
def test_faiss_index_save_load_non_json_serializable_metadata(tmp_path):
"""Save/load roundtrip works with non-JSON-serializable metadata types."""
faiss = pytest.importorskip("faiss")
vectors = np.array(
[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]],
dtype=np.float32,
)
ids = ["vec_a", "vec_b"]
now = datetime.now(timezone.utc)
uid = uuid.uuid4()
metadata = [
{
"timestamp": now,
"uuid": uid,
"numpy_int": np.int64(42),
"numpy_float": np.float64(3.14),
"a_set": {1, 2, 3},
},
{
"timestamp": now,
"uuid": uid,
"numpy_int": np.int64(99),
"numpy_float": np.float64(2.71),
"a_set": {4, 5, 6},
},
]
index = FAISSIndex(faiss.IndexFlatL2(3), dimension=3)
index.add_vectors(vectors, ids=ids)
for vec_id, meta in zip(ids, metadata):
index.metadata[vec_id] = meta
index_path = tmp_path / "test_index.faiss"
index.save(index_path)
assert _metadata_path(index_path).exists()
loaded_index = FAISSIndex.load(index_path, dimension=3)
assert loaded_index.vector_ids == ids
assert loaded_index.dimension == 3
assert loaded_index.index_type == "flat"
for i, vec_id in enumerate(ids):
np.testing.assert_allclose(
loaded_index.get_vector(vec_id), vectors[i], atol=1e-6
)
loaded_meta = loaded_index.get_metadata(vec_id)
# Verify lossless restoration of all types
assert loaded_meta["timestamp"] == now
assert isinstance(loaded_meta["timestamp"], datetime)
assert loaded_meta["uuid"] == uid
assert isinstance(loaded_meta["uuid"], uuid.UUID)
assert loaded_meta["numpy_int"] == metadata[i]["numpy_int"]
assert isinstance(loaded_meta["numpy_int"], int)
assert loaded_meta["numpy_float"] == float(metadata[i]["numpy_float"])
assert isinstance(loaded_meta["numpy_float"], float)
assert loaded_meta["a_set"] == metadata[i]["a_set"]
assert isinstance(loaded_meta["a_set"], set)
def test_faiss_index_save_load_bytes_roundtrip(tmp_path):
"""Save/load roundtrip preserves bytes metadata via base64 encoding."""
faiss = pytest.importorskip("faiss")
vectors = np.array(
[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]],
dtype=np.float32,
)
ids = ["doc_1", "doc_2"]
metadata = [
{"blob": b"raw-embedding-hash"},
{"blob": b"\x00\x01\x02\xff"},
]
index = FAISSIndex(faiss.IndexFlatL2(3), dimension=3)
index.add_vectors(vectors, ids=ids)
for vec_id, meta in zip(ids, metadata):
index.metadata[vec_id] = meta
index_path = tmp_path / "test_index.faiss"
index.save(index_path)
loaded = FAISSIndex.load(index_path, dimension=3)
assert loaded.metadata["doc_1"]["blob"] == b"raw-embedding-hash"
assert isinstance(loaded.metadata["doc_1"]["blob"], bytes)
assert loaded.metadata["doc_2"]["blob"] == b"\x00\x01\x02\xff"
assert isinstance(loaded.metadata["doc_2"]["blob"], bytes)
def test_faiss_index_load_raises_on_vector_count_mismatch(tmp_path):
"""Loading an index with mismatched vector_ids count vs index.ntotal raises ProcessingError."""
faiss = pytest.importorskip("faiss")
vectors = np.array(
[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]],
dtype=np.float32,
)
ids = ["vec_a", "vec_b", "vec_c"]
index = FAISSIndex(faiss.IndexFlatL2(3), dimension=3)
index.add_vectors(vectors, ids=ids)
index_path = tmp_path / "test_index.faiss"
index.save(index_path)
# Corrupt the metadata: remove one vector_id but keep the FAISS index intact
meta_path = _metadata_path(index_path)
data = json.loads(meta_path.read_text())
data["vector_ids"] = ["vec_a", "vec_b"] # Only 2 IDs, but index has 3 vectors
meta_path.write_text(json.dumps(data))
with pytest.raises(ProcessingError, match="Sidecar metadata vector count.*does not match"):
FAISSIndex.load(index_path, dimension=3)
def test_get_vector_reconstructs_from_flat_l2_index():
@@ -157,7 +278,9 @@ def test_scan_vectors_includes_vector_and_metadata():
assert len(page) == 1
assert page[0]["id"] == "a"
assert page[0]["metadata"] == {"tag": "only"}
np.testing.assert_array_equal(page[0]["vector"], np.array([0.0, 0.0, 0.0], dtype=np.float32))
np.testing.assert_array_equal(
page[0]["vector"], np.array([0.0, 0.0, 0.0], dtype=np.float32)
)
def test_scan_vectors_no_index_returns_empty_list():
@@ -183,7 +306,9 @@ def test_add_vectors_retry_with_same_ids_does_not_duplicate():
store = FAISSStore(dimension=3)
store.index = FAISSIndex(backend_index, dimension=3)
vectors = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], dtype=np.float32)
vectors = np.array(
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], dtype=np.float32
)
ids = ["a", "b", "c", "d"]
store.add_vectors(vectors, ids=ids, metadata=[{"i": i} for i in range(4)])
@@ -200,10 +325,261 @@ def test_add_vectors_retry_with_partial_overlap_only_adds_new_ids():
store = FAISSStore(dimension=3)
store.index = FAISSIndex(backend_index, dimension=3)
store.add_vectors(np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32), ids=["a", "b"])
store.add_vectors(np.array([[1, 2, 3], [7, 8, 9]], dtype=np.float32), ids=["a", "c"])
store.add_vectors(
np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32), ids=["a", "b"]
)
store.add_vectors(
np.array([[1, 2, 3], [7, 8, 9]], dtype=np.float32), ids=["a", "c"]
)
assert store.index.vector_ids == ["a", "b", "c"]
second_call_vectors = backend_index.add.call_args[0][0]
assert second_call_vectors.shape[0] == 1
np.testing.assert_array_equal(second_call_vectors[0], np.array([7, 8, 9], dtype=np.float32))
np.testing.assert_array_equal(
second_call_vectors[0], np.array([7, 8, 9], dtype=np.float32)
)
def test_faiss_index_save_load_roundtrip_with_metadata(tmp_path):
"""vector_ids and metadata persist across a FAISSIndex save/load round-trip."""
faiss = pytest.importorskip("faiss")
vectors = np.array(
[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]],
dtype=np.float32,
)
ids = ["vec_a", "vec_b", "vec_c"]
metadata = [
{"tag": "alpha", "value": 1},
{"tag": "beta", "value": 2},
{"tag": "gamma", "value": 3},
]
index = FAISSIndex(faiss.IndexFlatL2(3), dimension=3)
index.add_vectors(vectors, ids=ids)
for vec_id, meta in zip(ids, metadata):
index.metadata[vec_id] = meta
index_path = tmp_path / "test_index.faiss"
index.save(index_path)
assert _metadata_path(index_path).exists()
loaded_index = FAISSIndex.load(index_path, dimension=3)
assert loaded_index.vector_ids == ids
assert loaded_index.metadata == dict(zip(ids, metadata))
assert loaded_index.dimension == 3
assert loaded_index.index_type == "flat"
for i, vec_id in enumerate(ids):
np.testing.assert_allclose(
loaded_index.get_vector(vec_id), vectors[i], atol=1e-6
)
assert loaded_index.get_metadata(vec_id) == metadata[i]
def test_faiss_index_load_writes_companion_json_file(tmp_path):
"""save() writes a companion .meta.json file alongside the index."""
faiss = pytest.importorskip("faiss")
index = FAISSIndex(faiss.IndexFlatL2(3), dimension=3)
index.add_vectors(np.array([[1, 2, 3]], dtype=np.float32), ids=["x"])
index.metadata["x"] = {"source": "doc"}
index_path = tmp_path / "sub" / "dir" / "index.faiss"
index.save(index_path)
meta_path = _metadata_path(index_path)
assert meta_path.exists()
payload = json.loads(meta_path.read_text())
assert payload["vector_ids"] == ["x"]
assert payload["metadata"] == {"x": {"source": "doc"}}
assert payload["dimension"] == 3
assert payload["index_type"] == "flat"
def test_faiss_store_save_load_roundtrip_with_metadata(tmp_path):
"""FAISSStore save_index/load_index round-trip preserves IDs and metadata."""
_ = pytest.importorskip("faiss")
store = FAISSStore(dimension=3)
vectors = np.array(
[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]],
dtype=np.float32,
)
ids = ["store_vec_1", "store_vec_2"]
metadata = [{"source": "doc1"}, {"source": "doc2"}]
store.add_vectors(vectors, ids=ids, metadata=metadata)
index_path = tmp_path / "store_index.faiss"
store.save_index(index_path)
new_store = FAISSStore(dimension=3)
new_store.load_index(index_path)
assert new_store.index.vector_ids == ids
assert new_store.index.metadata == dict(zip(ids, metadata))
assert new_store.count() == 2
for i, vec_id in enumerate(ids):
np.testing.assert_allclose(new_store.get_vector(vec_id), vectors[i], atol=1e-6)
assert new_store.get_metadata(vec_id) == metadata[i]
results = new_store.search_similar(vectors[0], k=2)
assert len(results) == 2
assert results[0]["id"] == ids[0]
assert results[0]["metadata"] == metadata[0]
def test_roundtrip_load_respects_persisted_dimension_and_index_type(tmp_path):
"""load() uses persisted dimension/index_type over caller-supplied values."""
faiss = pytest.importorskip("faiss")
vectors = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32)
index = FAISSIndex(faiss.IndexFlatL2(3), dimension=3, index_type="flat")
index.add_vectors(vectors, ids=["a", "b"])
index_path = tmp_path / "index.faiss"
index.save(index_path)
loaded_index = FAISSIndex.load(index_path, dimension=999, index_type="hnsw")
assert loaded_index.dimension == 3
assert loaded_index.index_type == "flat"
assert loaded_index.vector_ids == ["a", "b"]
def test_roundtrip_filter_by_metadata_after_reload(tmp_path):
"""filter_by_metadata works correctly on a reloaded store."""
_ = pytest.importorskip("faiss")
store = FAISSStore(dimension=3)
vectors = np.array(
[
[0.1, 0.2, 0.3],
[0.4, 0.5, 0.6],
[0.7, 0.8, 0.9],
],
dtype=np.float32,
)
ids = ["d1", "d2", "d3"]
metadata = [
{"source": "alpha", "tier": 1},
{"source": "beta", "tier": 2},
{"source": "alpha", "tier": 3},
]
store.add_vectors(vectors, ids=ids, metadata=metadata)
index_path = tmp_path / "index.faiss"
store.save_index(index_path)
new_store = FAISSStore(dimension=3)
new_store.load_index(index_path)
alpha = new_store.filter_by_metadata({"source": "alpha"})
assert {r["id"] for r in alpha} == {"d1", "d3"}
for r in alpha:
assert r["metadata"]["source"] == "alpha"
np.testing.assert_allclose(r["vector"], store.get_vector(r["id"]), atol=1e-6)
tier = new_store.filter_by_metadata({"tier": {"min": 2}})
assert {r["id"] for r in tier} == {"d2", "d3"}
none_match = new_store.filter_by_metadata({"source": "gamma"})
assert none_match == []
def test_roundtrip_scan_vectors_on_loaded_store(tmp_path):
"""scan_vectors returns restored ids and metadata on a loaded store."""
_ = pytest.importorskip("faiss")
store = FAISSStore(dimension=3)
vectors = np.array(
[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]],
dtype=np.float32,
)
ids = ["a", "b", "c"]
metadata = [{"i": 0}, {"i": 1}, {"i": 2}]
store.add_vectors(vectors, ids=ids, metadata=metadata)
index_path = tmp_path / "index.faiss"
store.save_index(index_path)
new_store = FAISSStore(dimension=3)
new_store.load_index(index_path)
page = new_store.scan_vectors(offset=0, limit=10)
assert [p["id"] for p in page] == ids
for p, v, meta in zip(page, vectors, metadata):
np.testing.assert_allclose(p["vector"], v, atol=1e-6)
assert p["metadata"] == meta
assert [p["id"] for p in new_store.scan_vectors(offset=1, limit=2)] == ["b", "c"]
def test_roundtrip_duplicate_check_on_loaded_store(tmp_path):
"""Re-adding existing ids on a loaded store does not duplicate vectors."""
_ = pytest.importorskip("faiss")
store = FAISSStore(dimension=3)
vectors = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32)
ids = ["a", "b"]
store.add_vectors(vectors, ids=ids, metadata=[{"i": 0}, {"i": 1}])
index_path = tmp_path / "index.faiss"
store.save_index(index_path)
new_store = FAISSStore(dimension=3)
new_store.load_index(index_path)
assert new_store.count() == 2
new_store.add_vectors(vectors, ids=ids, metadata=[{"i": 0}, {"i": 1}])
assert new_store.count() == 2
assert new_store.index.vector_ids == ids
assert new_store.index.metadata == dict(zip(ids, [{"i": 0}, {"i": 1}]))
assert new_store.index.index.ntotal == 2
def test_faiss_store_save_load_scan_vectors_end_to_end(tmp_path):
"""End-to-end: save/load a store, then scan_vectors returns original ids and metadata."""
_ = pytest.importorskip("faiss")
store = FAISSStore(dimension=3)
vectors = np.array(
[
[0.1, 0.2, 0.3],
[0.4, 0.5, 0.6],
[0.7, 0.8, 0.9],
],
dtype=np.float32,
)
ids = ["doc_a", "doc_b", "doc_c"]
metadata = [
{"source": "alpha", "page": 1},
{"source": "beta", "page": 2},
{"source": "alpha", "page": 3},
]
store.add_vectors(vectors, ids=ids, metadata=metadata)
index_path = tmp_path / "index.faiss"
store.save_index(index_path)
new_store = FAISSStore(dimension=3)
new_store.load_index(index_path)
page = new_store.scan_vectors(offset=0, limit=10)
assert [p["id"] for p in page] == ids
for p, v, meta in zip(page, vectors, metadata):
np.testing.assert_allclose(p["vector"], v, atol=1e-6)
assert p["metadata"] == meta
def test_loading_index_without_meta_json_warns(tmp_path):
"""Loading an index with no companion .meta.json emits an explicit warning."""
faiss = pytest.importorskip("faiss")
index = FAISSIndex(faiss.IndexFlatL2(3), dimension=3)
index.add_vectors(np.array([[1, 2, 3]], dtype=np.float32), ids=["x"])
index_path = tmp_path / "index.faiss"
index.save(index_path)
_metadata_path(index_path).unlink()
with pytest.warns(RuntimeWarning, match="without ID mappings"):
loaded = FAISSIndex.load(index_path, dimension=3)
assert loaded.vector_ids == []
assert loaded.metadata == {}
@@ -0,0 +1,141 @@
"""Tests for MilvusStore.get_collection schema validation (#1331)."""
from unittest import TestCase
from unittest.mock import MagicMock, patch
from semantica.vector_store.milvus_store import MilvusStore
from semantica.utils.exceptions import ProcessingError
def _field(name, dtype_name, primary=False, auto_id=False):
f = MagicMock()
f.name = name
f.is_primary = primary
f.auto_id = auto_id
f.dtype.name = dtype_name
return f
class MilvusGetCollectionSchemaTest(TestCase):
def setUp(self):
self.patches = [
patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", True),
patch("semantica.vector_store.milvus_store.utility"),
patch("semantica.vector_store.milvus_store.Collection"),
]
for p in self.patches:
p.start()
# utility.has_collection() must return truthy
import semantica.vector_store.milvus_store as m
m.utility.has_collection.return_value = True
def tearDown(self):
for p in reversed(self.patches):
p.stop()
def _make_store(self, coll):
store = MilvusStore()
store.client = MagicMock() # skip real connect
import semantica.vector_store.milvus_store as m
m.Collection.return_value = coll
return store
def _assert_rejected(self, store, expected_msg):
with self.assertRaises(ProcessingError) as ctx:
store.get_collection("c")
self.assertIn(expected_msg, str(ctx.exception))
self.assertIsNone(store.collection)
def test_accepts_matching_schema(self):
coll = MagicMock()
coll.schema.fields = [
_field("id", "VARCHAR", primary=True),
_field("vector", "FLOAT_VECTOR"),
_field("metadata", "JSON"),
]
store = self._make_store(coll)
result = store.get_collection("c")
self.assertIsNotNone(result)
self.assertIsNotNone(store.collection)
self.assertIsNotNone(store.search_engine)
self.assertEqual(store.collection.collection_name, "c")
def test_rejects_non_varchar_primary_key(self):
coll = MagicMock()
coll.schema.fields = [
_field("id", "INT64", primary=True),
_field("vector", "FLOAT_VECTOR"),
_field("metadata", "JSON"),
]
store = self._make_store(coll)
self._assert_rejected(store, "has an invalid primary key")
def test_rejects_missing_primary_key(self):
coll = MagicMock()
coll.schema.fields = [
_field("id", "VARCHAR"),
_field("vector", "FLOAT_VECTOR"),
_field("metadata", "JSON"),
]
store = self._make_store(coll)
self._assert_rejected(store, "has an invalid primary key")
def test_rejects_wrongly_named_primary_key(self):
coll = MagicMock()
coll.schema.fields = [
_field("pk", "VARCHAR", primary=True),
_field("vector", "FLOAT_VECTOR"),
_field("metadata", "JSON"),
]
store = self._make_store(coll)
self._assert_rejected(store, "has an invalid primary key")
def test_rejects_missing_metadata_field(self):
coll = MagicMock()
coll.schema.fields = [
_field("id", "VARCHAR", primary=True),
_field("vector", "FLOAT_VECTOR"),
]
store = self._make_store(coll)
self._assert_rejected(store, "is missing required field 'metadata'")
def test_rejects_missing_vector_field(self):
coll = MagicMock()
coll.schema.fields = [
_field("id", "VARCHAR", primary=True),
_field("metadata", "JSON"),
]
store = self._make_store(coll)
self._assert_rejected(store, "is missing required field 'vector'")
def test_rejects_wrong_vector_dtype(self):
coll = MagicMock()
coll.schema.fields = [
_field("id", "VARCHAR", primary=True),
_field("vector", "BINARY_VECTOR"),
_field("metadata", "JSON"),
]
store = self._make_store(coll)
self._assert_rejected(store, "has an invalid vector field")
def test_rejects_auto_id_primary_key(self):
coll = MagicMock()
coll.schema.fields = [
_field("id", "VARCHAR", primary=True, auto_id=True),
_field("vector", "FLOAT_VECTOR"),
_field("metadata", "JSON"),
]
store = self._make_store(coll)
self._assert_rejected(store, "has an invalid primary key")
def test_rejects_non_json_metadata(self):
coll = MagicMock()
coll.schema.fields = [
_field("id", "VARCHAR", primary=True),
_field("vector", "FLOAT_VECTOR"),
_field("metadata", "STRING"),
]
store = self._make_store(coll)
self._assert_rejected(store, "has an invalid metadata field")
+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())