mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-03 04:00:18 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
195354d1ad |
@@ -26,17 +26,16 @@ each file's own autogenerated header comment for its exact command).
|
||||
|
||||
| File | Used by | Installs |
|
||||
| --- | --- | --- |
|
||||
| `bootstrap.txt` | security-scan.yml, benchmark.yml | pip, setuptools (upgrade before anything else) |
|
||||
| `bootstrap.txt` | security.yml, 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) |
|
||||
| `pgvector-extra.txt` | integration.yml | semantica's base deps + the `vectorstore-pgvector` extra, resolved for python 3.11 |
|
||||
| `pytest-tool.txt` | ci.yml, integration.yml | pytest, for the pre-all-extras deterministic test |
|
||||
| `pytest-tool.txt` | ci.yml | pytest, for the pre-all-extras deterministic test |
|
||||
| `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-scan.yml | pip-audit |
|
||||
| `security-scan-tools.txt` | security-scan.yml | bandit, semgrep, jq |
|
||||
| `pip-audit.txt` | security.yml | pip-audit |
|
||||
| `security-scan-tools.txt` | security-scan.yml | safety, 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.) |
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
safety==3.8.1
|
||||
bandit==1.9.4
|
||||
semgrep==1.175.0
|
||||
jq==1.12.0
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 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
|
||||
@@ -20,6 +24,10 @@ 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
|
||||
@@ -42,6 +50,7 @@ certifi==2026.7.22 \
|
||||
# httpcore
|
||||
# httpx
|
||||
# requests
|
||||
# safety
|
||||
cffi==2.1.1 \
|
||||
--hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \
|
||||
--hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \
|
||||
@@ -323,12 +332,19 @@ 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
|
||||
@@ -380,7 +396,20 @@ cryptography==50.0.1 \
|
||||
--hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \
|
||||
--hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \
|
||||
--hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef
|
||||
# via pyjwt
|
||||
# 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
|
||||
exceptiongroup==1.2.2 \
|
||||
--hash=sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b \
|
||||
--hash=sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc
|
||||
@@ -389,6 +418,10 @@ 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
|
||||
@@ -410,7 +443,9 @@ httpcore==1.0.9 \
|
||||
httpx==0.28.1 \
|
||||
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
|
||||
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
|
||||
# via mcp
|
||||
# via
|
||||
# mcp
|
||||
# safety
|
||||
httpx-sse==0.4.3 \
|
||||
--hash=sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc \
|
||||
--hash=sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d
|
||||
@@ -426,6 +461,18 @@ 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 \
|
||||
@@ -502,6 +549,101 @@ 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
|
||||
@@ -510,6 +652,10 @@ 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
|
||||
@@ -570,7 +716,10 @@ packaging==26.3 \
|
||||
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
|
||||
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
|
||||
# via
|
||||
# dparse
|
||||
# opentelemetry-instrumentation
|
||||
# safety
|
||||
# safety-schemas
|
||||
# semgrep
|
||||
peewee==3.19.0 \
|
||||
--hash=sha256:de220b94766e6008c466e00ce4ba5299b9a832117d9eb36d45d0062f3cfd7417 \
|
||||
@@ -600,6 +749,8 @@ pydantic==2.13.5 \
|
||||
# via
|
||||
# mcp
|
||||
# pydantic-settings
|
||||
# safety
|
||||
# safety-schemas
|
||||
pydantic-core==2.46.5 \
|
||||
--hash=sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942 \
|
||||
--hash=sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821 \
|
||||
@@ -825,6 +976,122 @@ 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
|
||||
@@ -837,6 +1104,7 @@ rich==15.0.0 \
|
||||
# via
|
||||
# bandit
|
||||
# semgrep
|
||||
# typer
|
||||
rpds-py==2026.6.3 \
|
||||
--hash=sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5 \
|
||||
--hash=sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680 \
|
||||
@@ -960,7 +1228,10 @@ rpds-py==2026.6.3 \
|
||||
ruamel-yaml==0.19.1 \
|
||||
--hash=sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93 \
|
||||
--hash=sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993
|
||||
# via semgrep
|
||||
# via
|
||||
# safety
|
||||
# safety-schemas
|
||||
# semgrep
|
||||
ruamel-yaml-clib==0.2.15 \
|
||||
--hash=sha256:014181cdec565c8745b7cbc4de3bf2cc8ced05183d986e6d1200168e5bb59490 \
|
||||
--hash=sha256:04d21dc9c57d9608225da28285900762befbb0165ae48482c15d8d4989d4af14 \
|
||||
@@ -1024,6 +1295,14 @@ 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
|
||||
@@ -1038,6 +1317,10 @@ 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
|
||||
@@ -1052,6 +1335,10 @@ 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 \
|
||||
@@ -1101,6 +1388,22 @@ 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
|
||||
@@ -1114,6 +1417,8 @@ typing-extensions==4.16.0 \
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
# referencing
|
||||
# safety
|
||||
# safety-schemas
|
||||
# semgrep
|
||||
# starlette
|
||||
# typing-inspection
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
name: Integration Tests
|
||||
|
||||
# Separate from ci.yml, which is a required check: a slow image pull or a
|
||||
# container flake must not block unrelated merges.
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'docs_check.py'
|
||||
- '**/*.md'
|
||||
schedule:
|
||||
- cron: '0 5 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
pgvector:
|
||||
name: pgvector (live PostgreSQL)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
services:
|
||||
postgres:
|
||||
# pgvector/pgvector:pg16 as published 2026-08-13. Pinned by digest like
|
||||
# the action pins, though verify-action-pins.sh does not check images.
|
||||
image: pgvector/pgvector@sha256:ccc6e83d6e35e931dc7c5def2022729d5a6c370318d099181995567ff1fb4d6b
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: test
|
||||
# Throwaway container reachable only from this job, so trust auth
|
||||
# avoids putting a credential in the workflow at all.
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
TEST_PGVECTOR_URL: postgresql://postgres@localhost:5432/test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install semantica with the pgvector extra
|
||||
# Hash-verified installs throughout, matching ci.yml/security.yml/etc
|
||||
# (OpenSSF Scorecard's Pinned-Dependencies check). --no-deps here
|
||||
# skips runtime dependency resolution for the editable install itself
|
||||
# (nothing to hash); pep517-build.txt + --no-build-isolation stops
|
||||
# its PEP 517 build from separately fetching an unhashed
|
||||
# setuptools/wheel via build isolation.
|
||||
run: |
|
||||
pip install -r .github/requirements/bootstrap.txt --require-hashes
|
||||
pip install -r .github/requirements/pep517-build.txt --require-hashes
|
||||
pip install --no-deps --no-build-isolation -e .
|
||||
pip install -r .github/requirements/pgvector-extra.txt --require-hashes
|
||||
pip install -r .github/requirements/pytest-tool.txt --require-hashes
|
||||
|
||||
- name: Create the vector extension
|
||||
# PgVectorStore._verify_pgvector_extension() requires it and refuses to
|
||||
# create it. Doubles as the connectivity gate.
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import os
|
||||
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(os.environ["TEST_PGVECTOR_URL"]) as conn:
|
||||
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
conn.commit()
|
||||
print("vector extension ready")
|
||||
PY
|
||||
|
||||
- name: Run the live pgvector suite
|
||||
# pg_available raises rather than skipping when TEST_PGVECTOR_URL was
|
||||
# set explicitly (which this job always does), so a service that's
|
||||
# actually unreachable fails this step instead of the suite quietly
|
||||
# reporting green having run nothing.
|
||||
run: |
|
||||
pytest tests/vector_store/test_pgvector_store.py -v -rs
|
||||
@@ -3,7 +3,6 @@ name: Security Scan
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * 1,4' # Mon/Thu 7 AM IST
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
@@ -46,100 +45,94 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r .github/requirements/bootstrap.txt --require-hashes
|
||||
# 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).
|
||||
# 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).
|
||||
pip install -r requirements-ci.txt --require-hashes
|
||||
# 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
|
||||
# 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.
|
||||
pip install -r .github/requirements/security-scan-tools.txt --require-hashes
|
||||
|
||||
- name: Run pip-audit (Package Vulnerabilities)
|
||||
continue-on-error: true
|
||||
- name: Run Safety Check (Package Vulnerabilities)
|
||||
run: |
|
||||
# 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"
|
||||
# NOTE: Safety 3.x repurposed --output to select a console format
|
||||
# (json/text/screen/...), not a file path. Writing JSON to a file
|
||||
# now requires --save-json; the previous `--output safety-report.json`
|
||||
# usage was silently invalid and never produced a report.
|
||||
#
|
||||
# Scan requirements-ci.txt directly instead of the installed environment
|
||||
# to avoid crashes from packages like cuda-toolkit that Safety cannot
|
||||
# parse. This also ensures we're auditing the declared dependency tree
|
||||
# rather than transitive dependencies of the security tooling itself.
|
||||
safety check --file requirements-ci.txt --save-json safety-report.json || true
|
||||
|
||||
# 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
|
||||
# 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
|
||||
# scanner indistinguishable from a clean scan.
|
||||
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."
|
||||
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."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Checking for package vulnerabilities..."
|
||||
|
||||
# Guard 2 above already confirmed pip-audit-report.json is valid
|
||||
# JSON with a well-shaped dependencies array, so this count is
|
||||
# always a plain non-negative integer.
|
||||
SKIPPED=$(jq '[.dependencies[] | select(has("skip_reason"))] | length' pip-audit-report.json)
|
||||
if [ "$SKIPPED" -gt 0 ]; then
|
||||
echo "⚠️ pip-audit could not audit $SKIPPED dependencies (see pip-audit-report.json for skip_reason):"
|
||||
jq -r '.dependencies[] | select(has("skip_reason")) | " - \(.name): \(.skip_reason)"' pip-audit-report.json
|
||||
fi
|
||||
|
||||
# Vulnerability IDs reviewed and accepted as non-actionable for this
|
||||
# project. 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=""
|
||||
# project. Filtered out here with jq rather than passed to Safety's
|
||||
# own --ignore flag: --ignore crashes ("Unhandled exception happened:
|
||||
# 'cuda-toolkit'") when it has to apply itself against a live-matched
|
||||
# vulnerability for cuda-toolkit, apparently the same class of
|
||||
# unguarded dependency-graph lookup that broke the plain environment
|
||||
# scan (see git history on this file). The un-ignored scan above is
|
||||
# the one path confirmed - by an actual CI run - not to crash even
|
||||
# with a live cuda-toolkit match, so all filtering happens after the
|
||||
# fact in jq instead of inside Safety.
|
||||
#
|
||||
# - SFTY-20260120-40557 (CVE-2025-33228): cuda-toolkit<13.1.0. torch
|
||||
# 2.13.0 (latest available; no newer release exists) hard-pins
|
||||
# cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,
|
||||
# cusparse,nvjitlink,nvrtc,nvtx]==13.0.3 on Linux - not a version we
|
||||
# control. The CVE is OS command injection in NVIDIA Nsight
|
||||
# Systems' gfx_hotspot recipe (process_nsys_rep_cli.py), requiring
|
||||
# manual invocation with an attacker-supplied string; unreachable
|
||||
# from Semantica, and Nsight Systems isn't among the extras torch
|
||||
# requests above. Re-evaluate once torch pins a patched
|
||||
# cuda-toolkit.
|
||||
#
|
||||
# - SFTY-20260723-60537 (CVE-2026-65918): torchvision<=0.28.0, an
|
||||
# out-of-bounds heap read in the GIF decoder's read_from_tensor
|
||||
# callback (unclamped length passed to memcpy while parsing GIF
|
||||
# metadata). torchvision isn't a direct Semantica dependency; it
|
||||
# comes in transitively via safetensors/sentence-transformers, and
|
||||
# nothing in this codebase decodes GIFs through torchvision or
|
||||
# calls into torchvision at all. Fixed upstream in commit 4e05dc2;
|
||||
# no released version has it yet. Re-evaluate once one does.
|
||||
IGNORED_VULN_IDS="SFTY-20260120-40557,SFTY-20260723-60537"
|
||||
|
||||
# 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.
|
||||
# safety-report.json independently in JS, so without this the PR
|
||||
# comment would show the accepted CVE as a live finding even though
|
||||
# this gate correctly treats it as non-actionable.
|
||||
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.
|
||||
# No []? / || echo "0" fallback on a missing/null "vulnerabilities"
|
||||
# key: iterating over null raises inside jq, leaving VULNS empty, so
|
||||
# guard 2 below catches it rather than silently treating a broken
|
||||
# report as zero.
|
||||
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)]
|
||||
($ignored | split(",")) as $ignore_list
|
||||
| [.vulnerabilities[] | select(.vulnerability_id as $id | ($ignore_list | index($id)) | not)]
|
||||
| length
|
||||
' pip-audit-report.json 2>/dev/null)
|
||||
' safety-report.json 2>/dev/null)
|
||||
|
||||
# Guard 3: ensure VULNS is a non-negative integer before the -gt
|
||||
# Guard 2: 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::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."
|
||||
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."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -149,17 +142,15 @@ jobs:
|
||||
echo ""
|
||||
echo "Vulnerability details:"
|
||||
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
|
||||
($ignored | split(",")) as $ignore_list
|
||||
| .vulnerabilities[] | select(.vulnerability_id as $id | ($ignore_list | index($id)) | not)
|
||||
| "- \(.package_name)==\(.analyzed_version): \(.vulnerability_id) (\(.CVE // "no CVE assigned"))"
|
||||
' safety-report.json || true
|
||||
exit 1
|
||||
else
|
||||
echo "✅ No actionable security vulnerabilities found${IGNORED_VULN_IDS:+ (ignored: $IGNORED_VULN_IDS)}"
|
||||
echo 'AUDIT_SCAN_STATUS=passed' >> "$GITHUB_ENV"
|
||||
echo "✅ No actionable security vulnerabilities found (ignored: $IGNORED_VULN_IDS)"
|
||||
fi
|
||||
|
||||
|
||||
- name: Run Bandit (Code Security Linter)
|
||||
run: |
|
||||
bandit -r semantica/ -f json -o bandit-report.json || true
|
||||
@@ -197,18 +188,17 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload Security Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: security-reports
|
||||
retention-days: 14
|
||||
path: |
|
||||
pip-audit-report.json
|
||||
safety-report.json
|
||||
bandit-report.json
|
||||
semgrep-report.json
|
||||
|
||||
- name: Comment PR with Security Results
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
@@ -231,12 +221,6 @@ 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');
|
||||
}
|
||||
@@ -256,60 +240,23 @@ jobs:
|
||||
// Mirrors the shell step's own IGNORED_VULN_IDS (passed through
|
||||
// $GITHUB_ENV) so an accepted, non-actionable CVE that the CI
|
||||
// gate already excluded doesn't reappear here as a live finding -
|
||||
// this reads the same raw, unfiltered pip-audit-report.json.
|
||||
// this reads the same raw, unfiltered safety-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(', ')})` : '')
|
||||
)
|
||||
);
|
||||
}
|
||||
const safetySection = renderSection(
|
||||
'Safety — dependency vulnerabilities',
|
||||
'safety-report.json',
|
||||
(data) => (data.vulnerabilities || [])
|
||||
.filter((v) => !ignoredVulnIds.includes(v.vulnerability_id))
|
||||
.map(
|
||||
(v) => `- \`${v.package_name}==${v.analyzed_version}\`: ${v.vulnerability_id}` +
|
||||
(v.CVE ? ` (${v.CVE})` : '') + ` — ${v.advisory || 'no advisory text'}`
|
||||
)
|
||||
) + (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(
|
||||
@@ -331,7 +278,7 @@ jobs:
|
||||
const comment = [
|
||||
'# 🔒 Security Scan Results',
|
||||
'',
|
||||
pipAuditSection,
|
||||
safetySection,
|
||||
'',
|
||||
banditSection,
|
||||
'',
|
||||
@@ -341,7 +288,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 pip-audit vulnerabilities and Bandit HIGH-severity findings. Semgrep findings above are informational and do not block merge.',
|
||||
'📊 **Security Policy**: CI fails on Safety vulnerabilities and Bandit HIGH-severity findings. Semgrep findings above are informational and do not block merge.',
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
@@ -356,11 +303,3 @@ 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
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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
Binary file not shown.
@@ -151,19 +151,6 @@ 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`)
|
||||
|
||||
+3
-2
@@ -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`, `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`, `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.
|
||||
- **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,7 +164,8 @@ 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.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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,338 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,115 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,21 +1,10 @@
|
||||
"""Semantica Evals — evaluation layer for decision intelligence outputs.
|
||||
"""
|
||||
Semantica Evals Module
|
||||
|
||||
Provides a small library of deterministic and model-backed evaluators plus a
|
||||
runner for measuring decision records, audit trails, and reasoning output.
|
||||
Coming Soon
|
||||
"""
|
||||
|
||||
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.1"
|
||||
__status__ = "coming_soon"
|
||||
__all__ = []
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = [
|
||||
"evaluate",
|
||||
"get_evaluator",
|
||||
"list_evaluators",
|
||||
"CaseResult",
|
||||
"EvalMetric",
|
||||
"EvalSummary",
|
||||
]
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -1,181 +0,0 @@
|
||||
"""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)})
|
||||
@@ -1,34 +0,0 @@
|
||||
"""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]
|
||||
@@ -1,211 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -1,37 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,176 +0,0 @@
|
||||
# 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.
|
||||
@@ -43,11 +43,6 @@ 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
|
||||
|
||||
|
||||
@@ -388,18 +383,56 @@ 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."""
|
||||
return build_entity_aliases(entities)
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def _get_relationship_endpoint(rel: Dict[str, Any], endpoint: str) -> Any:
|
||||
"""Return an endpoint value from either ID or legacy relationship fields."""
|
||||
return get_relationship_endpoint(rel, endpoint)
|
||||
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(
|
||||
self, rel: Dict[str, Any], endpoint: str, aliases: Dict[str, set]
|
||||
) -> Optional[str]:
|
||||
"""Resolve an endpoint type without treating missing fields as aliases."""
|
||||
return resolve_relationship_endpoint_type(rel, endpoint, 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
|
||||
|
||||
def _stage2_yaml_to_definition(
|
||||
self, semantic_network: Dict[str, Any], **options
|
||||
|
||||
@@ -34,7 +34,6 @@ 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:
|
||||
@@ -107,7 +106,7 @@ class PropertyGenerator:
|
||||
tracking_id, message="Inferring object properties from relationships..."
|
||||
)
|
||||
object_properties = self._infer_object_properties(
|
||||
relationships, classes, entities=entities, **options
|
||||
relationships, classes, **options
|
||||
)
|
||||
properties.extend(object_properties)
|
||||
|
||||
@@ -137,7 +136,6 @@ 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."""
|
||||
@@ -149,7 +147,6 @@ 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():
|
||||
@@ -159,12 +156,12 @@ class PropertyGenerator:
|
||||
ranges = set()
|
||||
|
||||
for rel in rels:
|
||||
source_type = resolve_relationship_endpoint_type(
|
||||
rel, "source", entity_aliases
|
||||
)
|
||||
target_type = resolve_relationship_endpoint_type(
|
||||
rel, "target", entity_aliases
|
||||
)
|
||||
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)
|
||||
|
||||
if source_type:
|
||||
domains.add(source_type)
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"""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
|
||||
@@ -661,15 +661,6 @@ 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]]:
|
||||
@@ -714,83 +705,21 @@ class MilvusStore:
|
||||
limit=limit,
|
||||
output_fields=["id", "vector", "metadata"],
|
||||
)
|
||||
return [self._record_to_result(item) for item in query_results]
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -299,44 +299,6 @@ 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.
|
||||
@@ -773,91 +735,6 @@ 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]:
|
||||
|
||||
@@ -590,77 +590,20 @@ class QdrantStore:
|
||||
with_payload=True,
|
||||
with_vectors=True,
|
||||
)
|
||||
return [self._record_to_result(rec) for rec in records]
|
||||
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
|
||||
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]:
|
||||
|
||||
@@ -867,22 +867,12 @@ 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 call
|
||||
batch_size: Number of vectors to fetch per underlying scan_vectors() 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)
|
||||
|
||||
@@ -488,28 +488,6 @@ 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]]:
|
||||
@@ -555,11 +533,22 @@ class WeaviateStore:
|
||||
try:
|
||||
objs = self.collection.query.fetch_objects(**kwargs)
|
||||
except TypeError:
|
||||
if "after" not in kwargs:
|
||||
raise
|
||||
objs, _ = self._fetch_objects_offset_or_plain(kwargs, scanned_count)
|
||||
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)
|
||||
elif "after" in kwargs:
|
||||
objs, _ = self._fetch_objects_offset_or_plain(kwargs, scanned_count)
|
||||
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)
|
||||
else:
|
||||
raise te
|
||||
except Exception as fe:
|
||||
@@ -622,122 +611,6 @@ 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,
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
"""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()}
|
||||
)
|
||||
@@ -1,81 +0,0 @@
|
||||
"""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
|
||||
@@ -1,67 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,32 +0,0 @@
|
||||
"""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
|
||||
@@ -1,36 +0,0 @@
|
||||
"""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
|
||||
@@ -1,459 +0,0 @@
|
||||
"""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 == []
|
||||
@@ -1,43 +0,0 @@
|
||||
"""Tests for evals result models."""
|
||||
import pytest
|
||||
|
||||
from semantica.evals.types import CaseResult, EvalMetric, EvalSummary
|
||||
|
||||
|
||||
class TestEvalMetric:
|
||||
def test_construction(self):
|
||||
m = EvalMetric(score=1.0, passed=True, meta={"threshold": 1.0})
|
||||
assert m.score == 1.0 and m.passed and m.meta["threshold"] == 1.0
|
||||
|
||||
def test_default_meta(self):
|
||||
m = EvalMetric(0.0, False)
|
||||
assert m.meta == {}
|
||||
|
||||
def test_default_meta_is_not_shared(self):
|
||||
m1 = EvalMetric(0.0, False)
|
||||
m2 = EvalMetric(0.0, False)
|
||||
m1.meta["mutated"] = True
|
||||
assert "mutated" not in m2.meta
|
||||
|
||||
|
||||
class TestCaseResult:
|
||||
def test_status_fail_on_any_failed_metric(self):
|
||||
r = CaseResult(
|
||||
case_id="c1",
|
||||
status="fail",
|
||||
metrics={"exact_match": EvalMetric(0.0, False)},
|
||||
details={},
|
||||
)
|
||||
assert r.status == "fail"
|
||||
assert r.metrics["exact_match"].passed is False
|
||||
|
||||
|
||||
class TestEvalSummary:
|
||||
def test_pass_rate(self):
|
||||
s = EvalSummary(total=10, passed=8, failed=1, errors=1, pass_rate=0.8)
|
||||
assert s.pass_rate == 0.8
|
||||
|
||||
def test_cases_are_mutable(self):
|
||||
s = EvalSummary(0, 0, 0, 0, 1.0)
|
||||
s.cases.append(CaseResult("c", "pass", {}, {}))
|
||||
assert len(s.cases) == 1
|
||||
@@ -96,29 +96,3 @@ 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"]
|
||||
|
||||
@@ -6,40 +6,54 @@ import os
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
# 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 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()
|
||||
|
||||
# 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):
|
||||
# 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
|
||||
)
|
||||
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()
|
||||
|
||||
# Configure mocks to return something iterable/valid
|
||||
self.mock_extract_entities_hf.return_value = [MagicMock(spec=Entity, confidence=0.9, text="Test Entity")]
|
||||
@@ -57,7 +71,7 @@ class TestExtractorsDispatch(unittest.TestCase):
|
||||
extractor.extract_entities(text, model="my-custom-ner-model")
|
||||
|
||||
# Verify get_entity_method was called with "huggingface"
|
||||
self.mock_methods.get_entity_method.assert_called_with("huggingface")
|
||||
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
|
||||
@@ -82,7 +96,7 @@ class TestExtractorsDispatch(unittest.TestCase):
|
||||
extractor.extract_relations(text, entities, model="my-relation-model")
|
||||
|
||||
# Verify dispatch
|
||||
self.mock_methods.get_relation_method.assert_called_with("huggingface")
|
||||
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")
|
||||
@@ -102,7 +116,7 @@ class TestExtractorsDispatch(unittest.TestCase):
|
||||
extractor.extract_triplets(text, model="my-triplet-model")
|
||||
|
||||
# Verify dispatch
|
||||
self.mock_methods.get_triplet_method.assert_called_with("huggingface")
|
||||
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")
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"""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
|
||||
@@ -10,7 +10,7 @@ To run these tests locally with Docker:
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=test \
|
||||
-p 5432:5432 \
|
||||
pgvector/pgvector:pg16
|
||||
ankane/pgvector:latest
|
||||
|
||||
pytest tests/vector_store/test_pgvector_store.py -v
|
||||
|
||||
@@ -63,37 +63,29 @@ TEST_CONNECTION_STRING = os.getenv(
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pg_available() -> bool:
|
||||
"""Check if PostgreSQL with pgvector is available.
|
||||
|
||||
A connection failure only means "skip" when TEST_PGVECTOR_URL wasn't set
|
||||
explicitly, i.e. this is a local run falling back to the documented
|
||||
default. CI sets it on purpose, so a failure there means the service is
|
||||
genuinely broken and the suite should fail loudly instead of skipping.
|
||||
"""
|
||||
"""Check if PostgreSQL with pgvector is available."""
|
||||
if not psycopg_available:
|
||||
return False
|
||||
|
||||
explicit_url = "TEST_PGVECTOR_URL" in os.environ
|
||||
|
||||
try:
|
||||
try:
|
||||
import psycopg
|
||||
if psycopg_available:
|
||||
try:
|
||||
import psycopg
|
||||
|
||||
conn = psycopg.connect(TEST_CONNECTION_STRING, connect_timeout=5)
|
||||
except ImportError:
|
||||
import psycopg2
|
||||
conn = psycopg.connect(TEST_CONNECTION_STRING, connect_timeout=5)
|
||||
except ImportError:
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(TEST_CONNECTION_STRING, connect_timeout=5)
|
||||
conn = psycopg2.connect(TEST_CONNECTION_STRING, connect_timeout=5)
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT 1")
|
||||
cur.close()
|
||||
conn.close()
|
||||
return True
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT 1")
|
||||
cur.close()
|
||||
conn.close()
|
||||
return True
|
||||
except Exception:
|
||||
if explicit_url:
|
||||
raise
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -199,13 +191,7 @@ class TestPgVectorStoreAdd:
|
||||
ids = store.add(vectors, metadata)
|
||||
|
||||
assert len(ids) == 5
|
||||
assert len(set(ids)) == 5
|
||||
# add() assigns uuid4 identifiers, not a "vec_" prefix
|
||||
for vector_id in ids:
|
||||
try:
|
||||
uuid.UUID(vector_id)
|
||||
except ValueError:
|
||||
pytest.fail(f"{vector_id!r} is not a valid uuid4 id")
|
||||
assert all(id.startswith("vec_") for id in ids)
|
||||
|
||||
def test_add_auto_generate_ids(self, store):
|
||||
"""Test that IDs are auto-generated if not provided."""
|
||||
@@ -304,40 +290,38 @@ class TestPgVectorStoreSearch:
|
||||
if not pg_available:
|
||||
pytest.skip("PostgreSQL not available")
|
||||
|
||||
from semantica.vector_store.pgvector_store import PgVectorStore, psycopg_sql
|
||||
from semantica.vector_store.pgvector_store import PgVectorStore
|
||||
|
||||
# setup_vectors is autouse and seeds unique_table_name, and fixtures are
|
||||
# cached per test, so this needs a table of its own to be empty at all.
|
||||
empty_table = f"{unique_table_name}_empty"
|
||||
empty_store = PgVectorStore(
|
||||
connection_string=TEST_CONNECTION_STRING,
|
||||
table_name=empty_table,
|
||||
table_name=unique_table_name,
|
||||
dimension=128,
|
||||
distance_metric="cosine",
|
||||
)
|
||||
|
||||
try:
|
||||
query = np.random.rand(128).astype(np.float32)
|
||||
results = empty_store.search(query, top_k=5)
|
||||
query = np.random.rand(128).astype(np.float32)
|
||||
results = empty_store.search(query, top_k=5)
|
||||
|
||||
assert len(results) == 0
|
||||
finally:
|
||||
try:
|
||||
with empty_store._get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
psycopg_sql.SQL("DROP TABLE IF EXISTS {}").format(
|
||||
psycopg_sql.Identifier(empty_table)
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
empty_store.close()
|
||||
except Exception:
|
||||
# Best-effort cleanup: PostgreSQL may be unavailable during
|
||||
# teardown. This is expected when tests are skipped or the
|
||||
# connection is lost.
|
||||
pass
|
||||
assert len(results) == 0
|
||||
|
||||
# Cleanup: Drop test table after test completes
|
||||
# Uses best-effort cleanup - failures are silently ignored since
|
||||
# this is teardown of optional test resources
|
||||
try:
|
||||
with empty_store._get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
from semantica.vector_store.pgvector_store import psycopg_sql
|
||||
drop_sql = psycopg_sql.SQL("DROP TABLE IF EXISTS {}").format(
|
||||
psycopg_sql.Identifier(unique_table_name)
|
||||
)
|
||||
cur.execute(drop_sql)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
empty_store.close()
|
||||
except Exception:
|
||||
# Best-effort cleanup: PostgreSQL may be unavailable during teardown
|
||||
# This is expected when tests are skipped or connection is lost
|
||||
pass
|
||||
|
||||
|
||||
class TestPgVectorStoreGet:
|
||||
|
||||
@@ -249,191 +249,6 @@ 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()
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
"""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,7 +26,6 @@ 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
|
||||
|
||||
|
||||
@@ -139,34 +138,6 @@ 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."""
|
||||
|
||||
@@ -221,72 +192,6 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user