From 6bfa334b53de75d5eda792e66b6dbed104aa3baa Mon Sep 17 00:00:00 2001 From: camerontjs-dot Date: Fri, 11 Sep 2026 09:31:45 -0400 Subject: [PATCH] feat(mindgraph): prepare public v0.4.0 candidate --- .github/workflows/public-core.yml | 10 +- README.md | 152 +- mindgraph/LICENSE | 21 + mindgraph/README.md | 489 ++++ mindgraph/examples/README.md | 5 + .../examples/example-vault/balancing-loops.md | 10 + .../example-vault/bounded-rationality.md | 10 + .../examples/example-vault/feedback-loops.md | 10 + .../example-vault/mental-models-overview.md | 10 + .../example-vault/reinforcing-loops.md | 10 + .../example-vault/systems-archetypes.md | 10 + .../examples/example-vault/unrelated-noise.md | 10 + mindgraph/pyproject.toml | 41 + mindgraph/scripts/run_example_smoke.py | 268 ++ mindgraph/src/mindgraph/__init__.py | 6 + mindgraph/src/mindgraph/cli.py | 1296 +++++++++ mindgraph/src/mindgraph/daemon.py | 195 ++ mindgraph/src/mindgraph/db.py | 633 +++++ mindgraph/src/mindgraph/embedders.py | 167 ++ mindgraph/src/mindgraph/exceptions.py | 26 + mindgraph/src/mindgraph/idle_lifecycle.py | 106 + mindgraph/src/mindgraph/intent.py | 1572 +++++++++++ mindgraph/src/mindgraph/mcp_proxy.py | 79 + mindgraph/src/mindgraph/mcp_server.py | 448 +++ mindgraph/src/mindgraph/models.py | 113 + mindgraph/src/mindgraph/parser.py | 486 ++++ mindgraph/src/mindgraph/query.py | 1187 ++++++++ mindgraph/src/mindgraph/routing.py | 901 ++++++ mindgraph/tests/__init__.py | 0 .../tests/fixtures/intent_graph_cases.yaml | 280 ++ .../tests/fixtures/phase3_routing_cases.yaml | 59 + mindgraph/tests/test_allocation_only.py | 320 +++ mindgraph/tests/test_associate.py | 128 + mindgraph/tests/test_citation_trust.py | 117 + mindgraph/tests/test_doctor.py | 162 ++ mindgraph/tests/test_embedders.py | 103 + mindgraph/tests/test_examples.py | 291 ++ mindgraph/tests/test_expand.py | 385 +++ mindgraph/tests/test_governance.py | 104 + mindgraph/tests/test_ingest.py | 709 +++++ mindgraph/tests/test_intent.py | 782 ++++++ mindgraph/tests/test_mcp.py | 353 +++ mindgraph/tests/test_neighbors.py | 157 ++ mindgraph/tests/test_parser.py | 480 ++++ mindgraph/tests/test_query.py | 903 ++++++ mindgraph/tests/test_routing.py | 684 +++++ mindgraph/tests/test_shared_mcp.py | 305 +++ mindgraph/uv.lock | 2412 +++++++++++++++++ 48 files changed, 16947 insertions(+), 58 deletions(-) create mode 100644 mindgraph/LICENSE create mode 100644 mindgraph/README.md create mode 100644 mindgraph/examples/README.md create mode 100644 mindgraph/examples/example-vault/balancing-loops.md create mode 100644 mindgraph/examples/example-vault/bounded-rationality.md create mode 100644 mindgraph/examples/example-vault/feedback-loops.md create mode 100644 mindgraph/examples/example-vault/mental-models-overview.md create mode 100644 mindgraph/examples/example-vault/reinforcing-loops.md create mode 100644 mindgraph/examples/example-vault/systems-archetypes.md create mode 100644 mindgraph/examples/example-vault/unrelated-noise.md create mode 100644 mindgraph/pyproject.toml create mode 100644 mindgraph/scripts/run_example_smoke.py create mode 100644 mindgraph/src/mindgraph/__init__.py create mode 100644 mindgraph/src/mindgraph/cli.py create mode 100644 mindgraph/src/mindgraph/daemon.py create mode 100644 mindgraph/src/mindgraph/db.py create mode 100644 mindgraph/src/mindgraph/embedders.py create mode 100644 mindgraph/src/mindgraph/exceptions.py create mode 100644 mindgraph/src/mindgraph/idle_lifecycle.py create mode 100644 mindgraph/src/mindgraph/intent.py create mode 100644 mindgraph/src/mindgraph/mcp_proxy.py create mode 100644 mindgraph/src/mindgraph/mcp_server.py create mode 100644 mindgraph/src/mindgraph/models.py create mode 100644 mindgraph/src/mindgraph/parser.py create mode 100644 mindgraph/src/mindgraph/query.py create mode 100644 mindgraph/src/mindgraph/routing.py create mode 100644 mindgraph/tests/__init__.py create mode 100644 mindgraph/tests/fixtures/intent_graph_cases.yaml create mode 100644 mindgraph/tests/fixtures/phase3_routing_cases.yaml create mode 100644 mindgraph/tests/test_allocation_only.py create mode 100644 mindgraph/tests/test_associate.py create mode 100644 mindgraph/tests/test_citation_trust.py create mode 100644 mindgraph/tests/test_doctor.py create mode 100644 mindgraph/tests/test_embedders.py create mode 100644 mindgraph/tests/test_examples.py create mode 100644 mindgraph/tests/test_expand.py create mode 100644 mindgraph/tests/test_governance.py create mode 100644 mindgraph/tests/test_ingest.py create mode 100644 mindgraph/tests/test_intent.py create mode 100644 mindgraph/tests/test_mcp.py create mode 100644 mindgraph/tests/test_neighbors.py create mode 100644 mindgraph/tests/test_parser.py create mode 100644 mindgraph/tests/test_query.py create mode 100644 mindgraph/tests/test_routing.py create mode 100644 mindgraph/tests/test_shared_mcp.py create mode 100644 mindgraph/uv.lock diff --git a/.github/workflows/public-core.yml b/.github/workflows/public-core.yml index 52fcb9a..b6fe28e 100644 --- a/.github/workflows/public-core.yml +++ b/.github/workflows/public-core.yml @@ -21,12 +21,16 @@ jobs: with: python-version: "3.12" - - name: Install pytest - run: python -m pip install --disable-pip-version-check pytest - + - name: Install pytest and public MindGraph core + run: | + python -m pip install --disable-pip-version-check pytest + python -m pip install --disable-pip-version-check -e 'mindgraph[dev]' - name: Run public tests run: python -m pytest tests 40_operations/mainframe-process-eval/tests -q + - name: Run MindGraph core tests + run: python -m pytest mindgraph/tests --ignore=mindgraph/tests/test_mcp.py --ignore=mindgraph/tests/test_shared_mcp.py -q + - name: Exercise public CLIs run: | ./bin/process-eval --help diff --git a/README.md b/README.md index f9e196d..67792f7 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,20 @@ # MainFrame -MainFrame is a local-first, Markdown-centered operating environment for agent-assisted knowledge work. It separates capture, durable knowledge, volatile state, bounded projects, standing operations, and archive so each can have different update and authority rules. +A local-first, Markdown-centered operating environment for agent-assisted +knowledge work. It gives files explicit lifecycle boundaries, deterministic +control surfaces, retrieval nominations, project/operation identity, and a +portable self-evaluation engine. -This repository is a **reference implementation**. It contains portable contracts, deterministic control surfaces, synthetic examples, and a bounded self-evaluation engine. It does not contain private knowledge, live state, project evidence, or evaluation history. - -## Run the public core - -```bash -git clone https://github.com/camerontjs-dot/MainFrame.git -cd MainFrame - -./bin/process-eval status --json -uvx --with pytest pytest tests 40_operations/mainframe-process-eval/tests -q -``` - -The test surface exercises lifecycle identity, fail-closed duplicate and missing authority behavior, session routing, the process-eval shim, and the bounded public MPE profile. `process-eval status` should resolve `40_operations/mainframe-process-eval` without requiring private evaluation output. +This repository is a **reference implementation**. It is not a backup of any +one person's installed MainFrame, and it does not contain private knowledge, +live state, project evidence, or evaluation receipts. ## Why it exists -Captures, durable notes, volatile status, bounded projects, and standing operations need different update rules. Mixing them in one pile makes recall and safe updates harder. MainFrame organizes work by **information lifecycle first, topic second**, with explicit authority and fail-closed tools around identity and publication. +Captures, durable notes, volatile status, bounded projects, and standing +operations need different update rules. Mixing them in one pile makes recall +and safe updates harder. MainFrame organizes work by **information lifecycle +first, topic second**, with fail-closed tools around identity and publication. ## Architecture @@ -31,7 +27,8 @@ projects, operations, runtime contracts, engines, tests +-----------------------------------------+ ``` -The public tree is cut from a private working tree through a positive allowlist into a fresh directory before publication. Private Git history does not cross that boundary. +The public tree is produced from a private working repository through a +deterministic allowlist. Private Git history does not cross that boundary. ## Lifecycle model @@ -40,78 +37,123 @@ The public tree is cut from a private working tree through a positive allowlist | `00_inbox/` | Fast capture | | `01_ingest/` | Normalization and routing | | `10_knowledge/` | Durable knowledge | -| `20_live/` | Volatile-state interfaces and templates, not someone else's live state | +| `20_live/` | Volatile-state **interfaces and templates**, not someone else's live state | | `30_projects/` | Bounded outcome work | | `40_operations/` | Standing systems and recurring programs | | `90_archive/` | Retired or preserved material | -Lifecycle zones may carry local `AGENTS.md` contracts that refine root policy. Location does not itself decide WIP, focus, health, or approval. Direct file authority outranks indexes and retrieval. - -## Projects vs operations +Lifecycle zones may carry local `AGENTS.md` contracts that refine root +policy. Location does not itself decide WIP, focus, health, or approval. +Direct file authority outranks indexes and retrieval. -Slugs are unique across `30_projects/` and `40_operations/`. A project is a bounded outcome. An operation is a standing loop. Duplicate slugs and missing README authority fail closed. +## Quick demo (synthetic) -The implementation and tests are in `scripts/lifecycle_identity.py` and `tests/test_lifecycle_identity.py`. +The tree under `examples/demo-mainframe/` is labelled synthetic. It is not a +claim that any private MainFrame passed an evaluation. -## MainFrame Process Evaluation +```bash +python3 -c "from pathlib import Path; import sys; sys.path.insert(0,'scripts'); from lifecycle_identity import resolve_record, DuplicateIdentity, MissingIdentity" +python3 bin/process-eval --help +python3 bin/process-eval status --json +``` -The portable evaluation engine lives at `40_operations/mainframe-process-eval/`. Generic lifecycle substrate stays at the repository root; evaluation evidence stays local. +`status` resolves the portable `mainframe-process-eval` operation in this +tree. Evaluation **outputs** are local-only and are not part of Git. -The public profile includes: +## Projects vs operations -- operation identity and status; -- process-evaluation methodology; -- the real loop-evaluation scorer; -- labelled synthetic evaluation cases; -- a bounded `close --write` path whose output remains local-only. +Slugs are unique across `30_projects/` and `40_operations/`. A project is a +bounded outcome. An operation is a standing loop. Duplicate slugs and missing +README authority fail closed. -Malformed synthetic catalogue input fails closed. The installed private MainFrame uses a larger `process-eval preflight` pack with additional operational tools. That full pack is not claimed as publicly reproducible here. +See `scripts/lifecycle_identity.py` and `tests/test_lifecycle_identity.py`. -## Agent and runtime integration +## Agent / runtime integration -Agents read contracts from `AGENTS.md`, `.context/workflows/`, and `.agents/skills/`. `bin/session-open` lists context; it does not establish that an agent understood it. Client-specific runtimes are optional. +Agents read contracts from `AGENTS.md`, `.context/workflows/`, and +`.agents/skills/`. `bin/session-open` lists context; it does not prove that +an agent understood it. Client-specific runtimes are optional. -## Authority boundaries +## Authority and fail-closed boundaries -- Direct README and contract files are authority. +- Direct README / contract files are authority. - Retrieval nominates evidence; it does not establish truth or lifecycle state. -- Deterministic tools prefer `--check` or dry-run behavior and fail closed on missing identity, duplicate slugs, or broken inputs. -- Publication uses a positive allowlist. A newly tracked private file does not become public by default. +- Deterministic tools prefer `--check` / dry-run and fail closed on missing + identity, duplicate slugs, or broken publication inputs. +- Publication uses a positive allowlist. A newly tracked private file does + not become public by default. ## MindGraph retrieval -MindGraph is an optional public component and is not included in this first vNext core. When the retrieval engine is present, returned chunks are nominations. Inspect the underlying note before treating a result as true. +This vNext slice includes a bounded MindGraph engine snapshot under +`mindgraph/`. Its base installation provides local Markdown parsing, FTS5 +lexical retrieval, provenance models, and graph traversal. Sentence-transformer +semantic retrieval and the MCP transports remain optional installation +profiles; they are not required for the MainFrame lifecycle core. -## Evidence in this tree +MindGraph returns nominations, not truth, verification, lifecycle authority, +approval, or factual adjudication. Inspect the underlying note and the +applicable MainFrame authority before relying on a result. Host-specific +absolute source roots are not serialized in CLI/MCP result rows. -The public tests and synthetic fixtures are meant to be inspected, not just counted: +## MainFrame Process Evaluation -- `tests/test_lifecycle_identity.py` exercises project/operation identity and failure cases. -- `tests/test_public_mpe_synthetic.py` exercises the bounded public evaluation profile. -- `40_operations/mainframe-process-eval/tests/test_process_eval.py` exercises operation-owned evaluator behavior. -- `tests/test_session_open.py` and `tests/test_session_open_routes.py` exercise session context and routing behavior. -- `examples/demo-mainframe/` provides synthetic project, operation, and evaluation fixtures. +The portable engine lives at `40_operations/mainframe-process-eval/`. Generic +lifecycle substrate stays at the repository root. Raw evaluation evidence +stays local. Public methodology, identity, and a bounded synthetic loop-eval +scoring path are included; private migration archaeology, outputs, receipts, +and traces are not. -The public branch is also exercised in GitHub Actions using the same public-core test and CLI surfaces. +The installed private MainFrame uses a larger `process-eval preflight` pack +involving additional optional operational tools. That full pack is not claimed +as publicly reproducible in this vNext slice. ## What is deliberately not included -- Private knowledge bases, inbox contents, and archives -- Live focus, handoff, or telemetry state +- Private knowledge bases, inboxes, and archives +- Live focus/handoff/telemetry state - Private project contents - Workstation UI - Real evaluation outputs, transcripts, and baselines - Private Git history -- The private-to-public exporter itself +- The private-to-public exporter itself (it stays on the private owner) + +## Demonstrated properties + +These are backed by inspectable tests in this tree: + +- Lifecycle zones have explicit contracts +- Project and operation types share one identity namespace +- Duplicate identity fails closed +- Missing authority fails closed +- `bin/process-eval` is a shim over operation-owned code +- Publication candidates are exact allowlisted file sets +- Synthetic examples are labelled synthetic ## Limitations - This is not an autonomous agent framework or enterprise orchestrator. -- Full process-eval `preflight` talks to adjacent MainFrame tools; some steps are machine-bound and are outside the bounded public profile. -- MindGraph, Workstation, and many operator CLIs are not in this first vNext core. -- `session-open` intentionally fails closed when local `STATE.md` authority is absent. -- No claim is made that a private MainFrame is healthy or that Git contains evaluation history. +- Full process-eval **preflight** talks to adjacent MainFrame tools; some + steps are machine-bound and will skip or fail closed without local setup. +- MindGraph semantic and MCP extras are optional and are not part of the + dependency-light MainFrame core installation. +- No claim is made that a private MainFrame is healthy or that Git contains + evaluation history. + +## How this public tree is produced -## Publication boundary +A private exporter (`bin/public-export` on `camerontjs-dot/mainframe-live`) +builds a fresh directory from an exact private SHA, a positive manifest, and +named transforms. It never copies `.git`. Publication remains a human PR +onto public `camerontjs-dot/MainFrame`. + +## Setup + +```bash +git clone https://github.com/camerontjs-dot/MainFrame.git +python3 bin/process-eval --help +uvx --with pytest pytest tests/test_lifecycle_identity.py tests/test_process_eval_shim.py -q +``` -The public repository keeps its own Git history. A private exporter builds a fresh candidate from an exact private source identity, a positive manifest, and curated public variants. Publication remains a deliberate PR into this repository rather than a mirror or history copy. +There is no required cloud account. Local notes you add under lifecycle +directories stay yours. diff --git a/mindgraph/LICENSE b/mindgraph/LICENSE new file mode 100644 index 0000000..911ba58 --- /dev/null +++ b/mindgraph/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 camerontjs-dot + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mindgraph/README.md b/mindgraph/README.md new file mode 100644 index 0000000..53ac49a --- /dev/null +++ b/mindgraph/README.md @@ -0,0 +1,489 @@ +# MindGraph + +MindGraph is a local, graph-augmented retrieval engine for personal Markdown knowledge bases. It ingests a directory of notes, extracts a typed `[[link]]` document graph, chunks the body text, and stores everything in one SQLite file. The core profile provides lexical search and graph traversal; an optional semantic profile adds vector similarity over the same store. + +This is the engine I run against Mainframe, my own Markdown knowledge base. Any vault of Markdown notes with `[[wikilink]]` syntax (Obsidian, Foam, Logseq with the right setting) works the same way. + +## Installation profiles + +The base package is deliberately local and dependency-light. It does not +install Torch, CUDA, or an MCP SDK. + +```bash +pip install . +pip install '.[semantic]' # add sentence-transformers for vector retrieval +pip install '.[mcp]' # add the supported MCP v1 SDK +pip install '.[full]' # semantic + MCP +``` + +The `dev` extra adds test dependencies without pulling either runtime profile. +Install the extra required by the operation you intend to run. + +## What this does + +- Parses Markdown files. Reads optional YAML frontmatter for a `title` and a `domain`. +- Splits each note into a Truth body and an optional Timeline section on a `---` rule followed by a `## Timeline` heading. +- Extracts `[[target]]` and `[[target]] (relationship)` links as typed graph edges, with link targets normalized to add `.md` when missing. +- Computes a stable document ID from `sha256(relative_path)` for ordinary single-root ingest, or from `index_id + namespace + source_path` for scoped multi-root ingest. +- Skips re-embedding when the content hash matches an existing row. +- Chunks the Truth body into paragraphs packed up to `max_chars`, keeping paragraphs whole. +- Removes fenced code, table structure, reference-only lists, metadata rows, and explicit Markdown alert/callout blocks from semantic chunks. Ordinary blockquotes remain because they may contain substantive evidence; the full Truth body remains in the lexical lane. +- Optionally embeds chunks with a selectable model (`--embedder`: `minilm`, `bge-small`, `e5-small`; default MiniLM) at 384 dimensions per DB. +- Optional `--embed-template mainframe` prefixes domain/type/title at ingest and `[intent=query]` at query time. +- Writes documents, chunks, optional embeddings, FTS5 rows, and edges to one SQLite file with `sqlite-vec` and FTS5 attached. + +## What it ranks + +`mindgraph query ` returns ranked chunks. Two signals run over the same SQLite store and fuse with Reciprocal Rank Fusion at the canonical `k = 60`: + +- Lexical: FTS5 BM25 over the chunked Truth text +- Semantic: `sqlite-vec` cosine over `vec_chunks` + +Each result carries a `signal` label (`lexical`, `semantic`, `fused`, `expanded`, or `associated`), a `rrf_score`, and the per-signal `lexical_rank` and `semantic_rank` integers, so the attribution is mechanically verifiable. Ties break by `(doc_id, chunk_index)` lexicographic for deterministic output. Free-text queries pass through an FTS5 sanitizer that strips operator characters and the uppercase keywords `AND`, `OR`, `NOT`, `NEAR`, then OR-joins the surviving tokens. + +Result rows also carry trust and provenance metadata for consumers that need to decide what to inspect next: `doc_type`, `domain`, `status`, `index_id`, `trust_profile`, `namespace`, `source_path`, `display_path`, `semantic_distance`, `weak_fit`, and `query_scope_warning`. The local `source_root` is retained for internal provenance but is omitted from serialized CLI/MCP results so host-specific absolute paths do not cross the boundary. `weak_fit` marks semantic-only rows beyond the current distance threshold. `query_scope_warning` appears when the query itself seems to ask for inbox, live/current, or project-status state that may belong in a different lifecycle database. + +### Tuning the scope warnings + +`query_scope_warning` fires on a keyword heuristic, and the shipped defaults +describe one vault's vocabulary. They are a starting point, not a claim about +how anyone else labels current state. If your notes say "unfiled" rather than +"inbox", or "standup" rather than "project status", retune it with a JSON file: + +```json +{ + "inbox_terms": ["unfiled", "to sort"], + "project_terms": ["sprint status", "standup"], + "live_state_terms": ["oncall", "deploy", "incident"] +} +``` + +```bash +export MINDGRAPH_SCOPE_VOCABULARY=~/.mindgraph/scope-vocabulary.json +``` + +Four keys are recognized: `inbox_terms`, `project_terms`, `freshness_terms`, and +`live_state_terms`. Any key you omit keeps its default, so you can retune one +branch without restating the rest. An empty list disables that warning entirely. + +Entries are regular-expression *fragments*, not literals, so `captures?` and +`state\.md` behave as written. They are joined with `|`, wrapped in `\b(...)\b`, +and matched case-insensitively. + +The `live_state` warning needs a hit in **both** `freshness_terms` and +`live_state_terms`, so "latest incident" warns and a bare "incident" does not. +That keeps ordinary durable-knowledge queries quiet. + +In Python, pass a vocabulary directly instead: + +```python +from mindgraph.query import ScopeVocabulary, classify_query_scope + +vocab = ScopeVocabulary(inbox_terms=("unfiled", "to sort")) +classify_query_scope("some unfiled notes", vocab) +``` + +`mindgraph query --expand` appends graph-walk results. After the fused list returns, the query walks outbound `[[link]]` edges from each fused result to a bounded depth (`--depth N`, default 1, cap 3) and appends walked documents with `signal = "expanded"` and an `expansion_depth` integer. The walk is outbound only, deduplicates against the fused set, terminates at dangling edges, and does not interact with the RRF math. `--expand-top-k N` (default 20) caps appended expanded rows. + +`mindgraph query --associate` appends semantic doc-neighbor results (ADR-034). From fused seeds (not expand results), embeds title + chunk excerpt per seed, runs vec kNN, and appends rows with `signal = "associated"`, `association_depth = 1`, and `semantic_distance`. `--associate-top-k` and `--associate-seed-k` cap output and seed count. + +`mindgraph neighbors ` lists outbound edges for a document, including dangling edges (links to files that do not exist as documents). + +## What this does not do yet + +- There is no LLM generation step. MindGraph retrieves and ranks. It does not write summaries, answers, or explanations. +- Renaming a file produces a new document ID. The old document remains in the database until a future cleanup pass prunes orphans. +- Only Markdown is a first-class input. PDFs and other formats are out of scope for this asset. +- Use a **separate SQLite file per embedder** (`--embedder` sets `vec_chunks` dimensions at init). Re-ingest the full scope into each eval DB; do not swap models in-place on one DB. +- Lexical-only results surface chunk index 0 because there is no semantic ranking to pick a better chunk from. Fused and semantic results surface the best-ranked chunk per document. +- A lexical-only index has no semantic vectors; use `--lexical-only` for both ingest and query, and create a fresh index when switching to semantic retrieval. +- Retrieval is nomination, not verification. A retrieved chunk is a candidate for a reader to read, not a verified source for any claim. +- Normal ingest and query never acquire models from the network. Run the explicit `mindgraph bootstrap-model` setup command once before semantic ingest/query. + +## Useful commands + +```bash +mindgraph init --db mindgraph.sqlite +mindgraph bootstrap-model --embedder minilm +mindgraph ingest path/to/your/vault --db mindgraph.sqlite +mindgraph ingest path/to/your/vault --db lexical.sqlite --lexical-only +mindgraph ingest path/to/your/vault --db mindgraph.sqlite --embedder bge-small --embed-template mainframe +mindgraph ingest path/to/your/vault --db mindgraph.sqlite --verbose +mindgraph ingest path/to/your/vault --db mindgraph.sqlite --index-id knowledge --trust-profile durable_knowledge --namespace knowledge --display-prefix knowledge +mindgraph ingest-many path/to/manifest.json --db mindgraph.sqlite +mindgraph query "what does this vault say about X" --db mindgraph.sqlite +mindgraph query "..." --db mindgraph.sqlite --top-k 5 --json +mindgraph query "..." --db mindgraph.sqlite --top-k 5 --json --envelope +mindgraph query "..." --db mindgraph.sqlite --expand +mindgraph query "..." --db mindgraph.sqlite --expand --depth 2 --expand-top-k 10 +mindgraph query "..." --db mindgraph.sqlite --associate --associate-top-k 10 +mindgraph query "..." --db mindgraph.sqlite --embedder e5-small --embed-template mainframe +mindgraph query "..." --db lexical.sqlite --lexical-only +mindgraph neighbors --db mindgraph.sqlite +mindgraph neighbors --db mindgraph.sqlite --json +mindgraph serve-mcp --db mindgraph.sqlite +mindgraph serve-mcp --db mindgraph.sqlite --verbose +``` + +The commands above are the compatibility path: one database, stdio transport, +and legacy list-shaped query/neighbor JSON by default. + +### Optional shared daemon + +The opt-in shared server loads one embedder and opens each declared index +read-only. You choose the scope names and their trust labels. It binds only to +loopback and requires every tool call to select exactly one of your scopes. Its +responses expose the selected scope and trust profile and never blend stores. + +```bash +mindgraph daemon-start --scope notes=~/.mindgraph/notes.sqlite \ + --scope archive=~/.mindgraph/archive.sqlite +mindgraph daemon-status +mindgraph daemon-health +mindgraph mcp-proxy --url http://127.0.0.1:8000/mcp +mindgraph daemon-stop +``` + +The proxy does not start the daemon for you. See the [MCP](#mcp) section for +declaring scopes, connecting clients, and what is not established. + +Semantic model acquisition is an explicit setup step. `bootstrap-model` may +use the configured model hub and caches the selected model; normal ingest and +query then load it cache-only. If the cache is missing, the command fails with +an instruction to install the semantic extra and run `bootstrap-model`. + +## Try it + +A small seven-file Markdown vault under `examples/example-vault/` exercises every retrieval path the engine exposes: lexical-only matches, semantic-only matches, fused matches, dangling graph edges, and graph expansion. Run the sequence below from the asset root after `pip install -e '.[full]'` and `mindgraph bootstrap-model` to reproduce the semantic captures. The captures regenerate from `scripts/run_example_smoke.py` against a fresh temporary database. + +### Bootstrap the semantic model + +``` +$ mindgraph bootstrap-model --embedder minilm +INFO mindgraph | Acquiring embedding model (all-MiniLM-L6-v2)... +Embedding model ready: all-MiniLM-L6-v2 (384 dimensions) +``` + +### Initialize the database + +``` +$ mindgraph init --db /tmp/mindgraph-example/db.sqlite +INFO mindgraph | Initialized database at /tmp/mindgraph-example/db.sqlite +``` + +### Ingest the example vault + +The model was acquired by the explicit bootstrap step before ingest. Ingest +only loads the local cache. + +``` +$ mindgraph ingest examples/example-vault --db /tmp/mindgraph-example/db.sqlite +INFO mindgraph | ingested: balancing-loops.md (1 chunks, 1 edges) +INFO mindgraph | ingested: bounded-rationality.md (1 chunks, 1 edges) +INFO mindgraph | ingested: feedback-loops.md (1 chunks, 2 edges) +INFO mindgraph | ingested: mental-models-overview.md (1 chunks, 1 edges) +INFO mindgraph | ingested: reinforcing-loops.md (1 chunks, 1 edges) +INFO mindgraph | ingested: systems-archetypes.md (1 chunks, 1 edges) +INFO mindgraph | ingested: unrelated-noise.md (1 chunks, 0 edges) +INFO mindgraph | Done. total=7 ingested=7 skipped=0 failed=0 +``` + +### Query a unique keyword + +`antinet` appears in only one note (`mental-models-overview.md`). It lands at rank 1 as a fused hit because the keyword also pulls the doc on the semantic side. The second and third results carry `signal=semantic` because they have no lexical match. + +``` +$ mindgraph query antinet --db /tmp/mindgraph-example/db.sqlite --top-k 3 +#1 signal=fused rrf_score=0.032787 lex_rank=1 sem_rank=1 + path: mental-models-overview.md + title: Mental models overview + chunk_index: 0 + excerpt: I keep a running file of mental models I have found useful, separate from the structural systems-thinking notes... +#2 signal=semantic rrf_score=0.016129 lex_rank=- sem_rank=2 + path: feedback-loops.md + title: Feedback loops + chunk_index: 0 + excerpt: A feedback loop is a circular causal structure where the output of a process becomes part of its own input on a later pass... +#3 signal=semantic rrf_score=0.015873 lex_rank=- sem_rank=3 + path: bounded-rationality.md + title: Bounded rationality + chunk_index: 0 + excerpt: Herbert Simon coined this term to describe how people decide when full information and unlimited compute are not available... +``` + +### Query a concept term + +`satisficing` is unique to `bounded-rationality.md`. The signal attribution shows the same pattern: a fused top hit plus two semantic-only neighbors that the embedder pulled in by topical similarity. + +``` +$ mindgraph query satisficing --db /tmp/mindgraph-example/db.sqlite --top-k 3 +#1 signal=fused rrf_score=0.032787 lex_rank=1 sem_rank=1 + path: bounded-rationality.md + title: Bounded rationality + chunk_index: 0 + excerpt: Herbert Simon coined this term to describe how people decide when full information and unlimited compute are not available... +#2 signal=semantic rrf_score=0.016129 lex_rank=- sem_rank=2 + path: mental-models-overview.md + title: Mental models overview + chunk_index: 0 + excerpt: I keep a running file of mental models I have found useful, separate from the structural systems-thinking notes... +#3 signal=semantic rrf_score=0.015873 lex_rank=- sem_rank=3 + path: balancing-loops.md + title: Balancing loops + chunk_index: 0 + excerpt: A balancing loop is a feedback structure that pushes a system back toward a setpoint... +``` + +### Query that fuses lexical and semantic signals + +`balancing feedback` matches multiple notes both ways. The top three all carry `signal=fused` with different `lex_rank` and `sem_rank` values, so the attribution is mechanically checkable. + +``` +$ mindgraph query "balancing feedback" --db /tmp/mindgraph-example/db.sqlite --top-k 3 +#1 signal=fused rrf_score=0.032522 lex_rank=1 sem_rank=2 + path: feedback-loops.md + title: Feedback loops + chunk_index: 0 + excerpt: A feedback loop is a circular causal structure where the output of a process becomes part of its own input on a later pass... +#2 signal=fused rrf_score=0.032522 lex_rank=2 sem_rank=1 + path: balancing-loops.md + title: Balancing loops + chunk_index: 0 + excerpt: A balancing loop is a feedback structure that pushes a system back toward a setpoint... +#3 signal=fused rrf_score=0.031258 lex_rank=5 sem_rank=3 + path: systems-archetypes.md + title: Systems archetypes + chunk_index: 0 + excerpt: A systems archetype is a recurring pattern of feedback structure that shows up across very different domains... +``` + +### Walk the graph from a seed + +With `--top-k 1` scoping the fused step to just the seed, `--expand --depth 2` walks the outbound `[[link]]` edges from `feedback-loops.md`. Depth-1 hits are the two `(illustrates)` targets; the depth-2 hit is `systems-archetypes.md`, reached through both one-hop docs and deduplicated. Walked rows carry `signal=expanded` and a `depth=N` suffix, with `lex_rank` and `sem_rank` blank because expansion is a labeled append, not a rerank. + +``` +$ mindgraph query "feedback loops" --db /tmp/mindgraph-example/db.sqlite --top-k 1 --expand --depth 2 +#1 signal=fused rrf_score=0.032787 lex_rank=1 sem_rank=1 + path: feedback-loops.md + title: Feedback loops + chunk_index: 0 + excerpt: A feedback loop is a circular causal structure where the output of a process becomes part of its own input on a later pass... +#2 signal=expanded rrf_score=0.000000 lex_rank=- sem_rank=- depth=1 + path: reinforcing-loops.md + title: Reinforcing loops + chunk_index: 0 + excerpt: A reinforcing loop is a feedback structure where a change in one direction produces more change in the same direction on the next cycle... +#3 signal=expanded rrf_score=0.000000 lex_rank=- sem_rank=- depth=1 + path: balancing-loops.md + title: Balancing loops + chunk_index: 0 + excerpt: A balancing loop is a feedback structure that pushes a system back toward a setpoint... +#4 signal=expanded rrf_score=0.000000 lex_rank=- sem_rank=- depth=2 + path: systems-archetypes.md + title: Systems archetypes + chunk_index: 0 + excerpt: A systems archetype is a recurring pattern of feedback structure that shows up across very different domains... +``` + +### Inspect outbound edges and surface a dangling target + +`systems-archetypes.md` links to `[[unicycle-mental-model]] (cites)`, but the target file does not exist in the vault. `mindgraph neighbors` returns the row with `target_path: (dangling)` so broken links surface during traversal rather than getting silently dropped. The `doc_id` argument is the hex ID from any earlier `--json` query output for the source document. + +``` +$ mindgraph neighbors c8a1be119b7ad0c3 --db /tmp/mindgraph-example/db.sqlite +#1 -> 885be168decf4005 rel=cites + target_path: (dangling) +``` + +## MCP + +MindGraph ships MCP transports around the same retrieval code used by the CLI. +It does not add ranking behavior, change the database schema, or turn retrieved +chunks into verified claims. + +Install the `mcp` extra for the transport surface. The MCP server's default +query path also needs the `semantic` extra and a bootstrapped model; the +dependency-light core remains usable independently through lexical-only CLI +ingest/query. + +Two transports ship. Pick by how many databases you need open at once. + +| Transport | Command | Use it when | +|---|---|---| +| Streamable HTTP daemon | `serve-daemon` / `daemon-start` | You want several named indexes, and several MCP clients sharing one loaded embedder | +| stdio server | `serve-mcp` | You want one database bound to one client, or you are debugging in isolation | + +### Recommended: shared daemon over Streamable HTTP + +Each stdio server process loads its own copy of the embedding model. If you run +several MCP clients at once, that is several copies of MiniLM resident at the +same time. The daemon exists to make that cost once instead of once per client. + +One loopback process opens each index read-only, holds a single embedder, and +serves them over the MCP Streamable HTTP transport. + +**You choose the scopes.** A scope is a name, a database, and a trust profile +label that rides along on every result from that store. Name them after whatever +distinction actually matters in your vault. Build one database per scope: + +```bash +mindgraph init --db ~/.mindgraph/recipes.sqlite +mindgraph ingest ~/vault/recipes --db ~/.mindgraph/recipes.sqlite \ + --index-id recipes --namespace recipes --display-prefix recipes + +mindgraph init --db ~/.mindgraph/journal.sqlite +mindgraph ingest ~/vault/journal --db ~/.mindgraph/journal.sqlite \ + --index-id journal --namespace journal --display-prefix journal +``` + +Then declare them with a repeatable `--scope`: + +```bash +mindgraph daemon-start \ + --scope recipes=~/.mindgraph/recipes.sqlite \ + --scope journal:personal_log=~/.mindgraph/journal.sqlite +mindgraph daemon-status +mindgraph daemon-health +mindgraph daemon-stop +``` + +The spec is `NAME=PATH`, or `NAME:TRUST_PROFILE=PATH` when you want the trust +label to differ from the scope name. Only the first `=` is a separator, so +database paths may contain `=` and `:`. Trust profile defaults to the scope +name. The health endpoint reports back exactly what you declared: + +```json +{"status":"ok","scopes":[{"scope":"journal","trust_profile":"personal_log"}, + {"scope":"recipes","trust_profile":"recipes"}]} +``` + +Every shared tool call must name one `scope`. There is no blended query: a call +with no scope fails validation, and an unrecognized scope is rejected with the +list of names you declared. Responses report the scope and trust profile they +were served from, so a caller can always tell which store an answer came from. + +Pass `--index-id`, `--namespace`, and `--display-prefix` at ingest if you want +provenance fields populated on result rows; without them those fields are null. + +`serve-daemon` runs the same server in the foreground if you would rather +supervise it yourself. Both accept `--host` (default `127.0.0.1`), `--port` +(default `8000`), `--path` (default `/mcp`), and `--embedder`. + +
+Two-index shorthand + +`--knowledge-db` and `--projects-db` are a fixed shorthand for a durable-notes +plus active-work split, kept for existing deployments: + +```bash +mindgraph daemon-start \ + --knowledge-db ~/.mindgraph/knowledge.sqlite \ + --projects-db ~/.mindgraph/projects.sqlite +``` + +That is equivalent to `--scope knowledge:durable_knowledge=...` plus +`--scope projects:project_status=...`. Any `--scope` you pass replaces this pair +entirely. Prefer `--scope` for new setups. + +
+ +### Connecting clients + +Clients that speak Streamable HTTP connect to the daemon directly: + +``` +http://127.0.0.1:8000/mcp +``` + +Clients that speak stdio connect through the bundled proxy, which is a thin +Streamable HTTP client that re-exposes the daemon's tools over stdio: + +```json +{ + "mcpServers": { + "mindgraph": { + "command": "mindgraph", + "args": ["mcp-proxy", "--url", "http://127.0.0.1:8000/mcp"] + } + } +} +``` + +Use the absolute path to the `mindgraph` entry point if it is not on your +client's `PATH` (for example `/path/to/.venv/bin/mindgraph`). The proxy does not +start the daemon for you. + +### Single-database stdio server + +For one index bound to one client, or for isolated debugging: + +```bash +mindgraph init --db /tmp/mindgraph-mcp/db.sqlite +mindgraph ingest examples/example-vault --db /tmp/mindgraph-mcp/db.sqlite +mindgraph serve-mcp --db /tmp/mindgraph-mcp/db.sqlite --verbose +``` + +Stdout is reserved for MCP protocol frames; logs go to stderr. + +### What is not established + +Re-ingesting does not hot-reload a running daemon; restart it to pick up new +content. The daemon binds to loopback and ships no authentication, so it is not +safe to expose beyond the local machine. Process supervision, concurrency +behavior under load, and RAM and latency figures are unmeasured. + +### Tools + +`query` runs the same retrieval path as `mindgraph query --json`. Parameters: `question`, `lexical_top_k`, `semantic_top_k`, `final_top_k`, `expand`, `expand_depth`, `expand_top_k`, `associate`, `associate_top_k`, `associate_seed_k`, and `envelope`. By default, the MCP response content is a JSON array of `QueryResult` records. With `envelope=true` (or CLI `--json --envelope`), it returns an object containing `schema_version`, `intent_resolution`, `routing`, and `results`; legacy list output remains unchanged when the flag is omitted. `routing` is single-database metadata for the bound index (not multi-index federation). In a smoke run against the example vault, the default list path matched the CLI JSON output exactly: + +```json +{ + "question": "feedback loops", + "final_top_k": 1, + "expand": true, + "expand_depth": 2 +} +``` + +Observed result paths from that smoke were `feedback-loops.md`, `reinforcing-loops.md`, `balancing-loops.md`, and `systems-archetypes.md`, with `expansion_depth` values `[0, 1, 1, 2]`. + +`graph_neighbors` runs the same lookup as `mindgraph neighbors --json`. Parameter: `doc_id`. The MCP response content is a JSON array of `NeighborResult` records, including dangling edges with `target_path = null`. In the same smoke, calling `graph_neighbors` with `doc_id = "c8a1be119b7ad0c3"` returned the single dangling edge the CLI lookup also returns. + +### claude.ai web + +The stdio transport does not connect directly to claude.ai web. The web product requires a remote transport such as Streamable HTTP or SSE over HTTPS. Exposing a local SQLite-backed knowledge base over the public internet would contradict MindGraph's local-first framing, so a remote transport is out of scope for now. + +## Architecture + +MindGraph runs entirely locally. There is no service to start and no remote dependency at retrieval time. + +1. **Ingestion.** `parser.parse_document` reads YAML frontmatter, splits Truth from Timeline, and returns a `ParsedDocument`. `parser.extract_document_graph_edges` collects `GraphEdge` records from both frontmatter `links:` and body `[[wikilinks]]` (ADR-033). `LinkResolver` resolves unique canonical trailing slugs (`…__slug`) as well as full stems and titles. `parser.chunk_truth` packs paragraphs into bounded chunks. +2. **Storage.** `db.init_db` creates the schema: `documents`, `documents_fts` (FTS5 over title and Truth content), `chunks`, `vec_chunks` (sqlite-vec, 384-dim), and `edges`. Foreign keys are on. +3. **Re-ingest.** `db.get_document_hash` compares the stored hash to the freshly computed one. Unchanged files exit before parsing or embedding. +4. **Retrieval.** `query.fetch_lexical_ranking` runs FTS5 BM25 over `documents_fts`. `query.fetch_semantic_ranking` runs a `sqlite-vec` KNN over `vec_chunks` and promotes to document granularity by keeping the best chunk per document. `query.rrf_fuse` combines the two ranked lists at the canonical `k = 60` and returns deterministic, signal-attributed results. +5. **Graph lookup.** `query.list_neighbors` resolves edges from a source document against the `documents` table, preserving dangling edges with `null` resolved paths. +6. **Graph expansion.** `query.expand_results` walks outbound edges from the fused result set in a deterministic BFS, deduplicating against the seed doc_ids and skipping dangling targets. Walked documents are appended with `signal = "expanded"`, `expansion_depth` set to the walk distance, and sorted by `(expansion_depth, doc_id, chunk_index)` before the `--expand-top-k` cut. + +## Test discipline + +The parser test suite (`tests/test_parser.py`) covers frontmatter parsing, the page-model split rule, internal `---` rules that must not split, the link extraction regex including nested-bracket rejection, chunk packing, and end-to-end `parse_document`. The ingest test suite (`tests/test_ingest.py`) covers the ingest happy path against a fixture vault. + +The query test suite (`tests/test_query.py`) covers FTS5 input sanitization, RRF fusion math, lexical and semantic ranking against a small ingested vault, end-to-end signal attribution, and the CLI surface. It uses a deterministic `KeywordEmbedder` stub so semantic similarity is reproducible without depending on the real MiniLM model. + +The neighbors test suite (`tests/test_neighbors.py`) covers outbound edge resolution, dangling edges, multiple relationship types per edge, sources with no edges, and the CLI surface. + +The expand test suite (`tests/test_expand.py`) covers one-hop walk, two-hop walk, no-expand and depth-0 equivalence to the un-expanded path, dedup of walked targets already in the fused set, the `--expand-top-k` cap, dangling-edge termination, and unreachable-doc absence. The CLI tests also assert the `expansion_depth` field in JSON output and the hard `--depth` cap of 3. + +The examples test suite (`tests/test_examples.py`) ingests the committed `examples/example-vault/` into a temp database with the same deterministic `KeywordEmbedder` and asserts each retrieval path: the unique lexical keyword lands on the expected doc, the semantic-only synonym path lands on `bounded-rationality.md` with `signal=semantic`, the fused query lands on `balancing-loops.md`, the dangling edge from `systems-archetypes.md` is preserved by `list_neighbors`, and the depth-2 expansion topology reaches the expected docs and skips the dangling target. + +The MCP test suite (`tests/test_mcp.py`) uses the official Python MCP SDK's in-memory client plus the deterministic `KeywordEmbedder` stub. It asserts server startup, missing-DB startup failure, `query` and `graph_neighbors` output shape parity with CLI JSON, opt-in envelope metadata, expansion parameter routing, dangling-edge preservation, and a clean tool error for unknown `doc_id` values. + +Run the full suite from the asset root: + +```bash +.venv/bin/python -m pytest +``` + +## Design notes + +`DECISIONS.md` is the architectural decision log. Each entry records what was decided, what was rejected, and why, so the reasoning is recoverable without reading the code or chasing planning files. diff --git a/mindgraph/examples/README.md b/mindgraph/examples/README.md new file mode 100644 index 0000000..07126db --- /dev/null +++ b/mindgraph/examples/README.md @@ -0,0 +1,5 @@ +# MindGraph example vault + +A seven-file Markdown vault under `example-vault/` that exercises every retrieval path MindGraph exposes: lexical-only matches, semantic-only matches, fused matches, dangling graph edges, and graph expansion from a seed document. The walkthrough that ingests this vault and shows the captured output for each path lives in the asset README's "Try it" section. + +The CI-safe smoke test under `../tests/test_examples.py` asserts the expected retrieval surface against this same vault using the deterministic `KeywordEmbedder` stub. The real-model captures in the README come from `../scripts/run_example_smoke.py`, which uses `sentence-transformers/all-MiniLM-L6-v2`. diff --git a/mindgraph/examples/example-vault/balancing-loops.md b/mindgraph/examples/example-vault/balancing-loops.md new file mode 100644 index 0000000..4544840 --- /dev/null +++ b/mindgraph/examples/example-vault/balancing-loops.md @@ -0,0 +1,10 @@ +--- +title: Balancing loops +domain: systems-thinking +--- + +A balancing loop is a feedback structure that pushes a system back toward a setpoint. A thermostat is the classic example: the room cools, the heater turns on, the room warms, the heater turns off. Predator-prey populations behave the same way at a longer time scale. + +The visible signature of a balancing loop is oscillation around the setpoint with delay. The delay matters. A balancing loop with no delay produces smooth correction. A balancing loop with a long delay can swing wildly, overshoot, and look like instability before it settles. + +Balancing loops paired with reinforcing loops produce most of the [[systems-archetypes]] (related) catalogued in the literature. diff --git a/mindgraph/examples/example-vault/bounded-rationality.md b/mindgraph/examples/example-vault/bounded-rationality.md new file mode 100644 index 0000000..a890f5d --- /dev/null +++ b/mindgraph/examples/example-vault/bounded-rationality.md @@ -0,0 +1,10 @@ +--- +title: Bounded rationality +domain: decision-science +--- + +Herbert Simon coined this term to describe how people decide when full information and unlimited compute are not available. Rather than searching for an optimal choice, decision-makers tend to pick the first option that clears an acceptance threshold. Simon called the strategy satisficing, a portmanteau of satisfying and sufficing. + +The behavior shows up across many domains. Where the textbook expects an optimizer, bounded rationality predicts a satisficer who stops as soon as good enough is found. Later work by Kahneman and Tversky on cognitive shortcuts built on the same observation from a different angle. + +A satisficer is not irrational. They are economizing on a resource the optimizer pretends is free. The connection to [[feedback-loops]] (related) is that real decision-makers operate inside a balancing loop where search cost grows with each candidate evaluated. diff --git a/mindgraph/examples/example-vault/feedback-loops.md b/mindgraph/examples/example-vault/feedback-loops.md new file mode 100644 index 0000000..58dcccf --- /dev/null +++ b/mindgraph/examples/example-vault/feedback-loops.md @@ -0,0 +1,10 @@ +--- +title: Feedback loops +domain: systems-thinking +--- + +A feedback loop is a circular causal structure where the output of a process becomes part of its own input on a later pass. The two basic shapes are reinforcing loops, which amplify a change in the same direction, and balancing loops, which push the system back toward a setpoint. + +Most real systems contain both kinds in interaction. A thermostat is a balancing loop on temperature. A viral product launch is a reinforcing loop on adoption. The interesting behavior usually comes from how these loops layer. + +See [[reinforcing-loops]] (illustrates) and [[balancing-loops]] (illustrates) for the two basic patterns. diff --git a/mindgraph/examples/example-vault/mental-models-overview.md b/mindgraph/examples/example-vault/mental-models-overview.md new file mode 100644 index 0000000..4af918a --- /dev/null +++ b/mindgraph/examples/example-vault/mental-models-overview.md @@ -0,0 +1,10 @@ +--- +title: Mental models overview +domain: epistemics +--- + +I keep a running file of mental models I have found useful, separate from the structural systems-thinking notes. The two categories overlap but the framings are different. A mental model is a portable lens. A systems pattern is a structural claim about how parts connect. + +The current shortlist includes inversion (Charlie Munger's "always invert"), the map-territory distinction, Chesterton's fence, and the Antinet method for note-taking that Scott Scheper revived from Niklas Luhmann's working style. Each one has paid for itself more than once. + +The Antinet entry is the longest because I am still figuring out how it interacts with [[feedback-loops]] (related) in my own thinking workflow. diff --git a/mindgraph/examples/example-vault/reinforcing-loops.md b/mindgraph/examples/example-vault/reinforcing-loops.md new file mode 100644 index 0000000..2480a19 --- /dev/null +++ b/mindgraph/examples/example-vault/reinforcing-loops.md @@ -0,0 +1,10 @@ +--- +title: Reinforcing loops +domain: systems-thinking +--- + +A reinforcing loop is a feedback structure where a change in one direction produces more change in the same direction on the next cycle. Compound interest is the textbook example. So is word-of-mouth growth, the rich-get-richer dynamic, and a fire that spreads faster as the heat dries out the fuel ahead of it. + +Reinforcing loops are not inherently good or bad. They are the engine behind virtuous cycles and vicious spirals alike. What matters is direction and what eventually stops them. Most reinforcing loops are bounded by a balancing loop somewhere downstream, even if the bound is far enough away to feel unlimited inside the run. + +A reinforcing loop in isolation is one of the [[systems-archetypes]] (related) that recur across domains. diff --git a/mindgraph/examples/example-vault/systems-archetypes.md b/mindgraph/examples/example-vault/systems-archetypes.md new file mode 100644 index 0000000..2b55307 --- /dev/null +++ b/mindgraph/examples/example-vault/systems-archetypes.md @@ -0,0 +1,10 @@ +--- +title: Systems archetypes +domain: systems-thinking +--- + +A systems archetype is a recurring pattern of feedback structure that shows up across very different domains. The catalogue includes shifting the burden, limits to growth, tragedy of the commons, escalation, success to the successful, and fixes that fail. Each archetype has a characteristic causal-loop diagram and a characteristic failure mode. + +The point of learning archetypes is not to label every system as one of them. It is to recognize the structural shape early enough to intervene before the failure mode plays out. Most archetypes have a documented high-leverage intervention point that beats fighting the symptoms. + +For the underlying mental model behind why these patterns generalize, see [[unicycle-mental-model]] (cites). diff --git a/mindgraph/examples/example-vault/unrelated-noise.md b/mindgraph/examples/example-vault/unrelated-noise.md new file mode 100644 index 0000000..7a2f4e1 --- /dev/null +++ b/mindgraph/examples/example-vault/unrelated-noise.md @@ -0,0 +1,10 @@ +--- +title: Saturday morning pancakes +domain: cooking +--- + +My current pancake recipe is one cup of flour, one cup of buttermilk, one egg, a tablespoon of melted butter, a teaspoon of baking powder, a half-teaspoon of baking soda, and a pinch of salt. Mix the wet and dry separately, then fold together until just combined. Lumps are fine. Lumps are good. + +Let the batter rest for at least ten minutes before cooking. This is the non-negotiable step. Cooked straight away the pancakes are dense; rested for ten minutes they puff up cleanly. I have no idea why the rest matters that much but it does. + +A medium pan on medium-low heat, a film of butter, and patience. Flip when the bubbles in the top stop being replaced. diff --git a/mindgraph/pyproject.toml b/mindgraph/pyproject.toml new file mode 100644 index 0000000..9a8ac8b --- /dev/null +++ b/mindgraph/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "mindgraph" +version = "0.2.0" +description = "A Graph-Augmented Personal Knowledge Engine" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" +dependencies = [ + "typer>=0.12.0", + "sqlite-vec>=0.1.6", + "pyyaml>=6.0.1", + "pydantic>=2.0.0", +] + +[project.optional-dependencies] +semantic = [ + "sentence-transformers>=3.0.0", +] +mcp = [ + "mcp>=1.0.0,<2", +] +full = [ + "sentence-transformers>=3.0.0", + "mcp>=1.0.0,<2", +] +dev = [ + "pytest>=8.0.0", + "numpy>=1.24.0", +] + +[project.scripts] +mindgraph = "mindgraph.cli:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] diff --git a/mindgraph/scripts/run_example_smoke.py b/mindgraph/scripts/run_example_smoke.py new file mode 100644 index 0000000..c768da1 --- /dev/null +++ b/mindgraph/scripts/run_example_smoke.py @@ -0,0 +1,268 @@ +"""Regenerates the asset README's "Try it" captures. + +Runs the handoff prompt's smoke sequence against the committed +examples/example-vault/ vault, using the real MiniLM model. Prints fenced +Markdown blocks formatted for direct paste into the asset README's "Try it" +section. + +Model acquisition is explicit: the script runs `mindgraph bootstrap-model` +before creating the index. Normal ingest/query then exercise the cache-only +runtime contract. + +Usage (from the asset root): + + .venv/bin/python scripts/run_example_smoke.py > /tmp/captures.md + +Inspect `/tmp/captures.md` and paste blocks into the README's "Try it" section. +""" + +import shutil +import subprocess +from pathlib import Path + +from mindgraph import parser + +ASSET_ROOT = Path(__file__).resolve().parent.parent +VAULT_PATH = ASSET_ROOT / "examples" / "example-vault" +DB_DIR = Path("/tmp/mindgraph-example") +DB_PATH = DB_DIR / "db.sqlite" +DB_PATH_STR = str(DB_PATH) + + +def reset_db_dir() -> None: + if DB_DIR.exists(): + shutil.rmtree(DB_DIR) + DB_DIR.mkdir(parents=True) + + +_NOISE_SUBSTRINGS = ( + "HTTP Request:", + "huggingface_hub.utils._http", + "sentence_transformers.base.model", + "Loading weights:", + "Batches:", + "Warning: You are sending unauthenticated", + "transformers_modules", +) + + +def _filter_noise(stderr: str) -> str: + """Drop HuggingFace and sentence-transformers chatter. + + Model acquisition is represented by the explicit bootstrap block, so + loader chatter is not needed in later captures. + """ + kept: list[str] = [] + for line in stderr.splitlines(): + stripped = line.strip() + if not stripped: + continue + if "Loading embedding model" in stripped: + continue + if any(noise in stripped for noise in _NOISE_SUBSTRINGS): + continue + kept.append(line) + return "\n".join(kept) + + +def run(cmd: list[str]) -> tuple[str, str]: + """Run a subprocess and capture stdout + stderr. + + Filters HuggingFace and sentence-transformers chatter from stderr so the + README captures stay readable. + """ + proc = subprocess.run(cmd, capture_output=True, text=True) + stdout = proc.stdout + stderr = _filter_noise(proc.stderr) + if proc.returncode != 0: + raise RuntimeError( + f"command exited with code {proc.returncode}: {' '.join(cmd)}\n" + f"{stderr}" + ) + return stdout, stderr + + +def emit_block( + heading: str, display_cmd: list[str], stdout: str, stderr: str +) -> None: + """Print a README-ready subsection: heading + fenced code block.""" + print(f"### {heading}") + print() + print("```") + print("$ " + " ".join(display_cmd)) + if stderr.strip(): + print(stderr.rstrip()) + if stdout.strip(): + print(stdout.rstrip()) + print("```") + print() + + +def main() -> None: + reset_db_dir() + mindgraph_bin = str(ASSET_ROOT / ".venv" / "bin" / "mindgraph") + + # 1. explicit model acquisition + out, err = run([mindgraph_bin, "bootstrap-model", "--embedder", "minilm"]) + emit_block( + "Bootstrap the semantic model", + ["mindgraph", "bootstrap-model", "--embedder", "minilm"], + out, + err, + ) + + # 2. init + out, err = run([mindgraph_bin, "init", "--db", DB_PATH_STR]) + emit_block( + "Initialize the database", + ["mindgraph", "init", "--db", DB_PATH_STR], + out, + err, + ) + + # 3. ingest — normal runtime is cache-only after bootstrap + out, err = run( + [mindgraph_bin, "ingest", str(VAULT_PATH), "--db", DB_PATH_STR] + ) + emit_block( + "Ingest the example vault", + [ + "mindgraph", + "ingest", + "examples/example-vault", + "--db", + DB_PATH_STR, + ], + out, + err, + ) + + # 4. lexical-heavy query (unique keyword 'antinet' in mental-models-overview) + out, err = run( + [ + mindgraph_bin, + "query", + "antinet", + "--db", + DB_PATH_STR, + "--top-k", + "3", + ] + ) + emit_block( + "Query a unique keyword", + [ + "mindgraph", + "query", + "antinet", + "--db", + DB_PATH_STR, + "--top-k", + "3", + ], + out, + err, + ) + + # 5. concept query (paraphrase-friendly term) + out, err = run( + [ + mindgraph_bin, + "query", + "satisficing", + "--db", + DB_PATH_STR, + "--top-k", + "3", + ] + ) + emit_block( + "Query a concept term", + [ + "mindgraph", + "query", + "satisficing", + "--db", + DB_PATH_STR, + "--top-k", + "3", + ], + out, + err, + ) + + # 6. fused query + out, err = run( + [ + mindgraph_bin, + "query", + "balancing feedback", + "--db", + DB_PATH_STR, + "--top-k", + "3", + ] + ) + emit_block( + "Query that fuses lexical and semantic signals", + [ + "mindgraph", + "query", + "balancing feedback", + "--db", + DB_PATH_STR, + "--top-k", + "3", + ], + out, + err, + ) + + # 7. expansion query — walk two hops from the seed + out, err = run( + [ + mindgraph_bin, + "query", + "feedback loops", + "--db", + DB_PATH_STR, + "--top-k", + "1", + "--expand", + "--depth", + "2", + ] + ) + emit_block( + "Walk the graph from a seed", + [ + "mindgraph", + "query", + "feedback loops", + "--db", + DB_PATH_STR, + "--top-k", + "1", + "--expand", + "--depth", + "2", + ], + out, + err, + ) + + # 8. neighbors lookup — surfaces the dangling unicycle-mental-model target + archetypes_id = parser.compute_doc_id("systems-archetypes.md") + out, err = run( + [mindgraph_bin, "neighbors", archetypes_id, "--db", DB_PATH_STR] + ) + emit_block( + "Inspect outbound edges and surface a dangling target", + ["mindgraph", "neighbors", archetypes_id, "--db", DB_PATH_STR], + out, + err, + ) + + +if __name__ == "__main__": + main() diff --git a/mindgraph/src/mindgraph/__init__.py b/mindgraph/src/mindgraph/__init__.py new file mode 100644 index 0000000..c82131a --- /dev/null +++ b/mindgraph/src/mindgraph/__init__.py @@ -0,0 +1,6 @@ +"""MindGraph: A Graph-Augmented Personal Knowledge Engine.""" + +__version__ = "0.2.0" + +from . import intent +from . import routing diff --git a/mindgraph/src/mindgraph/cli.py b/mindgraph/src/mindgraph/cli.py new file mode 100644 index 0000000..c7bec81 --- /dev/null +++ b/mindgraph/src/mindgraph/cli.py @@ -0,0 +1,1296 @@ +import json +import logging +import os +import sys +from dataclasses import dataclass, field +from fnmatch import fnmatch +from pathlib import Path + +import typer + +from mindgraph import daemon, db, embedders, idle_lifecycle, parser +from mindgraph import query as query_mod +from mindgraph.exceptions import EmbeddingError, IngestionError, MindgraphError +from mindgraph import intent as intent_mod +from mindgraph import routing as routing_mod + +app = typer.Typer( + name="mindgraph", + help="A Graph-Augmented Personal Knowledge Engine", + add_completion=False, +) + +logger = logging.getLogger("mindgraph") + + +_NOISY_LOGGERS = ( + "httpx", + "httpcore", + "huggingface_hub", + "huggingface_hub.utils._http", + "sentence_transformers", + "sentence_transformers.base.model", + "transformers", +) + + +def _configure_logging(verbose: bool) -> None: + os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1") + os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") + logging.basicConfig( + level=logging.WARNING, + format="%(asctime)s %(levelname)-7s %(name)s | %(message)s", + datefmt="%H:%M:%S", + ) + logger.setLevel(logging.DEBUG if verbose else logging.INFO) + for noisy_logger in _NOISY_LOGGERS: + logging.getLogger(noisy_logger).setLevel( + logging.WARNING if verbose else logging.ERROR + ) + + +def _load_embedder(embedder: str | None = None): + spec = embedders.resolve_embedder(embedder) + logger.info("Loading embedding model (%s)...", spec.model_id) + try: + return embedders.load_sentence_embedder(spec) + except EmbeddingError: + raise + except Exception as exc: + raise EmbeddingError( + f"Failed to load embedding model {spec.model_id!r}: " + f"{type(exc).__name__}: {exc}" + ) from exc + + +def _require_mcp_server(): + try: + from mindgraph import mcp_server + except ModuleNotFoundError as exc: + raise MindgraphError( + "MCP support is not installed. Install it with " + "`pip install 'mindgraph[mcp]'` (or `mindgraph[full]`)." + ) from exc + return mcp_server + + +def _require_mcp_proxy(): + try: + from mindgraph import mcp_proxy + except ModuleNotFoundError as exc: + raise MindgraphError( + "MCP proxy support is not installed. Install it with " + "`pip install 'mindgraph[mcp]'` (or `mindgraph[full]`)." + ) from exc + return mcp_proxy + + +def _encode_without_progress(embedder, texts): + """Encode text while suppressing sentence-transformers progress output.""" + try: + try: + return embedder.encode( + texts, convert_to_numpy=True, show_progress_bar=False + ) + except TypeError: + return embedder.encode(texts, convert_to_numpy=True) + except Exception as exc: + raise EmbeddingError( + f"Embedding model failed to encode input: {type(exc).__name__}: {exc}" + ) from exc + + +@dataclass(frozen=True) +class IngestScope: + root: Path + index_id: str | None = None + trust_profile: str | None = None + namespace: str | None = None + source_root: Path | None = None + display_prefix: str | None = None + include_globs: tuple[str, ...] = field(default_factory=tuple) + exclude_globs: tuple[str, ...] = field(default_factory=tuple) + + +def _matches_any(path: str, patterns: tuple[str, ...]) -> bool: + return any(fnmatch(path, pattern) for pattern in patterns) + + +def _markdown_files_for_scope(scope: IngestScope) -> list[Path]: + all_md = sorted(scope.root.rglob("*.md")) + selected: list[Path] = [] + for md_file in all_md: + rel_path = md_file.relative_to(scope.root).as_posix() + if scope.include_globs and not _matches_any(rel_path, scope.include_globs): + continue + if scope.exclude_globs and _matches_any(rel_path, scope.exclude_globs): + continue + selected.append(md_file) + return selected + + +def _display_path(prefix: str | None, source_path: str) -> str: + if not prefix: + return source_path + return f"{prefix.rstrip('/')}/{source_path}" + + +def _apply_scope_provenance( + parsed: parser.ParsedDocument, scope: IngestScope +) -> parser.ParsedDocument: + has_provenance = any( + ( + scope.index_id, + scope.trust_profile, + scope.namespace, + scope.source_root, + scope.display_prefix, + ) + ) + if not has_provenance: + return parsed + + source_path = parsed.path + namespace = scope.namespace or "" + doc_id = parsed.id + if scope.index_id and namespace: + doc_id = parser.compute_scoped_doc_id(scope.index_id, namespace, source_path) + display_path = _display_path(scope.display_prefix, source_path) + source_root = scope.source_root or scope.root + return parsed.model_copy( + update={ + "id": doc_id, + "path": display_path, + "index_id": scope.index_id, + "trust_profile": scope.trust_profile, + "namespace": namespace or None, + "source_root": str(source_root), + "source_path": source_path, + "display_path": display_path, + } + ) + + +def _doc_id_for_scope_file(scope: IngestScope, source_path: str) -> str: + namespace = scope.namespace or "" + if scope.index_id and namespace: + return parser.compute_scoped_doc_id(scope.index_id, namespace, source_path) + return parser.compute_doc_id(source_path) + + +def _ingest_scopes( + scopes: list[IngestScope], + db_path: str, + *, + embedder: str | None = None, + embed_template: str | None = None, + lexical_only: bool = False, +) -> dict[str, int]: + stats = {"total": 0, "ingested": 0, "skipped": 0, "pruned": 0, "failed": 0} + scope_files: list[tuple[IngestScope, Path]] = [] + for scope in scopes: + md_files = _markdown_files_for_scope(scope) + scope_files.extend((scope, md_file) for md_file in md_files) + md_files = [md_file for _, md_file in scope_files] + stats["total"] = len(md_files) + + if not md_files: + logger.warning( + "No markdown files found in authoritative ingest scope(s); " + "pruning stale indexed documents" + ) + + spec = embedders.resolve_embedder(embedder) + template = embedders.resolve_embed_template(embed_template) + conn = db.init_db( + db_path, + embedding_dims=spec.dimensions, + semantic_enabled=not lexical_only, + ) + model = None + parsed_docs: list[tuple[Path, parser.ParsedDocument]] = [] + + try: + for scope, md_file in scope_files: + relative_path = md_file.relative_to(scope.root).as_posix() + try: + body_bytes = md_file.read_bytes() + parsed = parser.parse_document(relative_path, body_bytes) + parsed_docs.append( + (md_file, _apply_scope_provenance(parsed, scope)) + ) + except MindgraphError as e: + logger.error("failed: %s — %s", relative_path, e) + stats["failed"] += 1 + except Exception: + logger.exception("unexpected failure: %s", relative_path) + stats["failed"] += 1 + + link_resolver = parser.LinkResolver.from_documents( + parsed for _, parsed in parsed_docs + ) + + for md_file, parsed in parsed_docs: + relative_path = parsed.path + try: + edges = parser.extract_document_graph_edges( + parsed, + link_resolver=link_resolver, + ) + + existing_hash = db.get_document_hash(conn, parsed.id) + if existing_hash == parsed.content_hash: + logger.debug("skipped (unchanged): %s", relative_path) + # Re-resolve edges (a newly-added note can resolve a target + # that was previously dangling), but only write when the + # resolved edge set actually differs from what is stored. + # This keeps the common no-op refresh read-only instead of + # running a DELETE+INSERT transaction per unchanged document. + new_edge_keys = { + (e.target_id, e.relationship_type) for e in edges + } + if new_edge_keys != db.get_outbound_edge_keys(conn, parsed.id): + with conn: + db.replace_edges(conn, parsed.id, edges) + stats["skipped"] += 1 + continue + + chunks = parser.chunk_truth(parsed.truth_text) + embeddings: list[list[float]] | None = None if lexical_only else [] + if chunks and not lexical_only: + if model is None: + model = _load_embedder(embedder) + encode_chunks = [ + embedders.format_passage_text( + spec, + chunk, + template=template, + title=parsed.title, + domain=parsed.metadata.get("domain"), + doc_type=parsed.metadata.get("type"), + ) + for chunk in chunks + ] + raw = _encode_without_progress(model, encode_chunks) + embeddings = [row.tolist() for row in raw] + + with conn: + db.upsert_document(conn, parsed) + db.insert_chunks_and_embeddings( + conn, parsed.id, chunks, embeddings + ) + db.insert_edges(conn, edges) + + logger.info( + "ingested: %s (%d chunks, %d edges)", + relative_path, + len(chunks), + len(edges), + ) + stats["ingested"] += 1 + except MindgraphError as e: + logger.error("failed: %s — %s", relative_path, e) + stats["failed"] += 1 + except Exception: + logger.exception("unexpected failure: %s", relative_path) + stats["failed"] += 1 + + # Prune documents whose source file no longer exists. The selected file + # walk is authoritative for this ingest root, including files that failed + # to parse in this run; a partial refresh should not delete still-present + # rows just because one source is temporarily malformed. + # Materialize the id list before deleting so we don't mutate a live + # cursor. Inbound edges are left dangling by design (see delete_document). + on_disk_ids = { + _doc_id_for_scope_file(scope, md_file.relative_to(scope.root).as_posix()) + for scope, md_file in scope_files + } + db_ids = [row["id"] for row in conn.execute("SELECT id FROM documents")] + orphans = [doc_id for doc_id in db_ids if doc_id not in on_disk_ids] + if orphans: + with conn: + for doc_id in orphans: + db.delete_document(conn, doc_id) + stats["pruned"] += 1 + logger.info("pruned %d orphaned document(s)", stats["pruned"]) + finally: + conn.close() + + return stats + + +def _ingest_directory( + directory: Path, + db_path: str, + *, + index_id: str | None = None, + trust_profile: str | None = None, + namespace: str | None = None, + source_root: Path | None = None, + display_prefix: str | None = None, + include_globs: tuple[str, ...] | None = None, + exclude_globs: tuple[str, ...] | None = None, + embedder: str | None = None, + embed_template: str | None = None, + lexical_only: bool = False, +) -> dict[str, int]: + return _ingest_scopes( + [ + IngestScope( + root=directory, + index_id=index_id, + trust_profile=trust_profile, + namespace=namespace, + source_root=source_root, + display_prefix=display_prefix, + include_globs=include_globs or (), + exclude_globs=exclude_globs or (), + ) + ], + db_path, + embedder=embedder, + embed_template=embed_template, + lexical_only=lexical_only, + ) + + +def _coerce_globs(value, field_name: str) -> tuple[str, ...]: + if value is None: + return () + if not isinstance(value, list): + raise IngestionError(f"manifest field {field_name!r} must be a list") + globs: list[str] = [] + for item in value: + if not isinstance(item, str) or not item.strip(): + raise IngestionError( + f"manifest field {field_name!r} must contain non-empty strings" + ) + globs.append(item.strip()) + return tuple(globs) + + +def _resolve_manifest_path(value: str, manifest_dir: Path) -> Path: + path = Path(value).expanduser() + if not path.is_absolute(): + path = manifest_dir / path + return path.resolve() + + +def _load_ingest_manifest(manifest_path: Path) -> list[IngestScope]: + try: + payload = json.loads(manifest_path.read_text()) + except json.JSONDecodeError as e: + raise IngestionError(f"manifest is not valid JSON: {e}") from e + if not isinstance(payload, dict): + raise IngestionError("manifest must be a JSON object") + + scopes_payload = payload.get("scopes") + if not isinstance(scopes_payload, list) or not scopes_payload: + raise IngestionError("manifest must include a non-empty 'scopes' list") + + manifest_dir = manifest_path.resolve().parent + scopes: list[IngestScope] = [] + for idx, entry in enumerate(scopes_payload, start=1): + if not isinstance(entry, dict): + raise IngestionError(f"manifest scope #{idx} must be an object") + root_value = entry.get("root") or entry.get("path") + if not isinstance(root_value, str) or not root_value.strip(): + raise IngestionError(f"manifest scope #{idx} requires a root path") + root = _resolve_manifest_path(root_value, manifest_dir) + if not root.is_dir(): + raise IngestionError(f"manifest scope #{idx} root is not a directory: {root}") + source_root_value = entry.get("source_root") + source_root = ( + _resolve_manifest_path(source_root_value, manifest_dir) + if isinstance(source_root_value, str) and source_root_value.strip() + else root + ) + scopes.append( + IngestScope( + root=root, + index_id=entry.get("index_id") or payload.get("index_id"), + trust_profile=entry.get("trust_profile") + or payload.get("trust_profile"), + namespace=entry.get("namespace"), + source_root=source_root, + display_prefix=entry.get("display_prefix"), + include_globs=_coerce_globs( + entry.get("include") or payload.get("include"), "include" + ), + exclude_globs=_coerce_globs( + entry.get("exclude") or payload.get("exclude"), "exclude" + ), + ) + ) + return scopes + + +@app.command() +def init( + db_path: str = typer.Option("mindgraph.sqlite", "--db", help="Path to SQLite DB."), + embedder: str | None = typer.Option( + None, + "--embedder", + help="Embedder key (minilm, bge-small, e5-small). Sets vec_chunks dimensions.", + ), + verbose: bool = typer.Option(False, "--verbose", "-v"), +): + """Initialize the MindGraph database.""" + _configure_logging(verbose) + try: + spec = embedders.resolve_embedder(embedder) + db.init_db(db_path, embedding_dims=spec.dimensions).close() + logger.info( + "Initialized database at %s (embedding_dims=%d, embedder=%s)", + db_path, + spec.dimensions, + spec.key, + ) + except MindgraphError as e: + logger.error(str(e)) + raise typer.Exit(code=1) + + +@app.command("bootstrap-model") +def bootstrap_model( + embedder: str | None = typer.Option( + None, + "--embedder", + help="Embedder key (minilm, bge-small, e5-small) or MINDGRAPH_EMBEDDER env.", + ), + verbose: bool = typer.Option(False, "--verbose", "-v"), +): + """Explicitly acquire and cache a semantic embedding model.""" + _configure_logging(verbose) + try: + spec = embedders.resolve_embedder(embedder) + logger.info("Acquiring embedding model (%s)...", spec.model_id) + embedders.bootstrap_sentence_embedder(spec) + typer.echo( + f"Embedding model ready: {spec.model_id} ({spec.dimensions} dimensions)" + ) + except MindgraphError as e: + logger.error(str(e)) + raise typer.Exit(code=1) + + +def _format_db_doctor_block(report: dict) -> str: + status = "OK" if report.get("ok") else "FAIL" + role = report.get("role") or "db" + trust = report.get("trust_profile") or "-" + lines = [ + f"## {role} [{status}]", + f" path: {report.get('path')}", + f" trust_profile: {trust}", + f" exists: {report.get('exists')}", + ] + if report.get("size_bytes") is not None: + size = report["size_bytes"] + if size >= 1_048_576: + size_h = f"{size / 1_048_576:.1f} MiB" + elif size >= 1024: + size_h = f"{size / 1024:.1f} KiB" + else: + size_h = f"{size} B" + lines.append(f" size: {size_h} ({size} bytes)") + if report.get("mtime_iso"): + lines.append(f" mtime: {report['mtime_iso']}") + if report.get("embedding_dims") is not None: + lines.append(f" embedding_dims: {report['embedding_dims']}") + if report.get("semantic_enabled") is not None: + lines.append(f" semantic_enabled: {report['semantic_enabled']}") + missing = report.get("tables_missing") or [] + if missing: + lines.append(f" tables_missing: {', '.join(missing)}") + else: + lines.append(" tables_missing: (none)") + counts = report.get("counts") or {} + if counts: + parts = [ + f"{k}={v}" for k, v in counts.items() if v is not None + ] + if parts: + lines.append(f" counts: {', '.join(parts)}") + for issue in report.get("issues") or []: + lines.append(f" issue: {issue}") + for warn in report.get("warnings") or []: + lines.append(f" warning: {warn}") + return "\n".join(lines) + + +@app.command("doctor") +@app.command("status") +def doctor( + db_path: str | None = typer.Option( + None, + "--db", + help="Inspect a single DB. Default: dual MainFrame indexes under ~/.mindgraph/.", + ), + workspace: Path | None = typer.Option( + None, + "--workspace", + help="Also scan this directory for tiny stub mainframe*.sqlite files (default: cwd).", + ), + as_json: bool = typer.Option(False, "--json", help="Emit JSON diagnostics."), + verbose: bool = typer.Option(False, "--verbose", "-v"), +): + """First-contact diagnostics for MindGraph indexes (MH01). + + Reports authoritative DB paths, sizes, required tables (documents_fts, + vec_chunks, …), row counts, and workspace stub traps — without loading the + embedding model. + """ + _configure_logging(verbose) + reports: list[dict] = [] + if db_path: + reports.append(db.inspect_database(db_path)) + else: + for spec in db.default_dual_db_specs(): + report = db.inspect_database( + spec["path"], + role=spec["role"], + trust_profile=spec["trust_profile"], + ) + report["refresh_hint"] = spec.get("refresh_hint") + reports.append(report) + + scan_root = workspace if workspace is not None else Path.cwd() + stubs = db.find_workspace_stub_sqlite([scan_root]) + + # Exit non-zero only when a checked index is unusable. Workspace stubs are + # loud warnings (common on MainFrame checkouts) but not hard failures when + # ~/.mindgraph indexes are healthy. + dbs_ok = all(r.get("ok") for r in reports) + payload = { + "ok": dbs_ok, + "databases": reports, + "workspace_stubs": stubs, + "hints": [ + "Authoritative DBs: ~/.mindgraph/mainframe.sqlite (durable_knowledge)", + "Authoritative DBs: ~/.mindgraph/mainframe-projects.sqlite (project_status)", + "Never query workspace-root mainframe*.sqlite stubs", + "Refresh: bin/mindgraph-refresh && bin/mindgraph-refresh-projects", + ], + } + + if as_json: + typer.echo(json.dumps(payload, indent=2)) + else: + typer.echo("# MindGraph doctor") + typer.echo("") + for report in reports: + typer.echo(_format_db_doctor_block(report)) + if report.get("refresh_hint") and not report.get("ok"): + typer.echo(f" refresh_hint: {report['refresh_hint']}") + typer.echo("") + if stubs: + typer.echo("## workspace stubs (do not query these)") + for stub in stubs: + typer.echo(f" path: {stub['path']} size={stub['size_bytes']} B") + typer.echo(f" warning: {stub['warning']}") + typer.echo("") + if dbs_ok: + msg = "Overall: OK — dual indexes look query-ready." + if stubs: + msg += " (workspace stubs present — ignore them; use ~/.mindgraph paths)" + typer.echo(msg) + else: + typer.echo("Overall: FAIL — fix database issues above before planning queries.") + typer.echo("Hints:") + for h in payload["hints"]: + typer.echo(f" - {h}") + + if not dbs_ok: + raise typer.Exit(code=1) + + +@app.command() +def ingest( + directory: Path = typer.Argument( + ..., exists=True, file_okay=False, dir_okay=True, readable=True + ), + db_path: str = typer.Option("mindgraph.sqlite", "--db", help="Path to SQLite DB."), + index_id: str | None = typer.Option( + None, "--index-id", help="Optional lifecycle/index identifier." + ), + trust_profile: str | None = typer.Option( + None, "--trust-profile", help="Optional trust profile for every document." + ), + namespace: str | None = typer.Option( + None, "--namespace", help="Optional namespace for scoped document IDs." + ), + source_root: Path | None = typer.Option( + None, "--source-root", help="Absolute source root stored as provenance." + ), + display_prefix: str | None = typer.Option( + None, "--display-prefix", help="Path prefix shown to query clients." + ), + embedder: str | None = typer.Option( + None, + "--embedder", + help="Embedder key (minilm, bge-small, e5-small) or MINDGRAPH_EMBEDDER env.", + ), + embed_template: str | None = typer.Option( + None, + "--embed-template", + help="Optional passage/query template (none, mainframe).", + ), + lexical_only: bool = typer.Option( + False, + "--lexical-only", + help="Build an FTS5/graph index without loading or storing semantic vectors.", + ), + verbose: bool = typer.Option(False, "--verbose", "-v"), +): + """Ingest a directory of markdown files.""" + _configure_logging(verbose) + try: + stats = _ingest_directory( + directory, + db_path, + index_id=index_id, + trust_profile=trust_profile, + namespace=namespace, + source_root=source_root, + display_prefix=display_prefix, + embedder=embedder, + embed_template=embed_template, + lexical_only=lexical_only, + ) + logger.info( + "Done. total=%d ingested=%d skipped=%d pruned=%d failed=%d", + stats["total"], + stats["ingested"], + stats["skipped"], + stats["pruned"], + stats["failed"], + ) + if stats["failed"]: + raise typer.Exit(code=1) + except MindgraphError as e: + logger.error(str(e)) + raise typer.Exit(code=1) + + +@app.command("ingest-many") +def ingest_many( + manifest: Path = typer.Argument( + ..., exists=True, file_okay=True, dir_okay=False, readable=True + ), + db_path: str = typer.Option("mindgraph.sqlite", "--db", help="Path to SQLite DB."), + allow_failures: bool = typer.Option( + False, + "--allow-failures", + help="Keep a partial index when some files fail to parse or ingest.", + ), + embedder: str | None = typer.Option( + None, + "--embedder", + help="Embedder key (minilm, bge-small, e5-small) or MINDGRAPH_EMBEDDER env.", + ), + embed_template: str | None = typer.Option( + None, + "--embed-template", + help="Optional passage/query template (none, mainframe).", + ), + lexical_only: bool = typer.Option( + False, + "--lexical-only", + help="Build an FTS5/graph index without loading or storing semantic vectors.", + ), + verbose: bool = typer.Option(False, "--verbose", "-v"), +): + """Ingest multiple markdown roots from a JSON manifest as one index.""" + _configure_logging(verbose) + try: + scopes = _load_ingest_manifest(manifest) + stats = _ingest_scopes( + scopes, + db_path, + embedder=embedder, + embed_template=embed_template, + lexical_only=lexical_only, + ) + logger.info( + "Done. scopes=%d total=%d ingested=%d skipped=%d pruned=%d failed=%d", + len(scopes), + stats["total"], + stats["ingested"], + stats["skipped"], + stats["pruned"], + stats["failed"], + ) + if stats["failed"] and not allow_failures: + raise typer.Exit(code=1) + except MindgraphError as e: + logger.error(str(e)) + raise typer.Exit(code=1) + + +def _format_query_result_block(idx: int, result) -> str: + lex = result.lexical_rank if result.lexical_rank is not None else "-" + sem = result.semantic_rank if result.semantic_rank is not None else "-" + header = ( + f"#{idx} signal={result.signal} rrf_score={result.rrf_score:.6f} " + f"lex_rank={lex} sem_rank={sem}" + ) + if result.expansion_depth > 0: + header = f"{header} depth={result.expansion_depth}" + if result.semantic_distance is not None: + header = f"{header} dist={result.semantic_distance:.4f}" + if result.weak_fit: + header = f"{header} [weak-fit]" + excerpt = result.chunk_text.strip().replace("\n", " ") + if len(excerpt) > 280: + excerpt = excerpt[:277] + "..." + meta_parts = [ + f"{label}={value}" + for label, value in ( + ("type", result.doc_type), + ("domain", result.domain), + ("status", result.status), + ) + if value + ] + meta_line = f" meta: {' '.join(meta_parts)}\n" if meta_parts else "" + provenance_parts = [ + f"{label}={value}" + for label, value in ( + ("index", result.index_id), + ("trust", result.trust_profile), + ("namespace", result.namespace), + ) + if value + ] + provenance_line = ( + f" provenance: {' '.join(provenance_parts)}\n" + if provenance_parts + else "" + ) + return ( + f"{header}\n" + f" path: {result.path}\n" + f" title: {result.title}\n" + f"{meta_line}" + f"{provenance_line}" + f" chunk_index: {result.chunk_index}\n" + f" excerpt: {excerpt}" + ) + + +def _format_scope_warning(warning) -> str: + return ( + "scope warning: " + f"{warning.message} " + f"recommended_trust_profile={warning.recommended_trust_profile}" + ) + + +def _format_neighbor_block(idx: int, neighbor) -> str: + rel = neighbor.relationship_type or "(no relationship)" + target_path = neighbor.target_path or "(dangling)" + return ( + f"#{idx} -> {neighbor.target_id} rel={rel}\n" + f" target_path: {target_path}" + ) + + +@app.command() +def query( + question: str = typer.Argument(..., help="The free-text query."), + db_path: str = typer.Option("mindgraph.sqlite", "--db", help="Path to SQLite DB."), + lexical_top_k: int = typer.Option( + query_mod.DEFAULT_LEXICAL_TOP_K, + "--lexical-top-k", + help="Top-k for the FTS5 ranking before fusion.", + ), + semantic_top_k: int = typer.Option( + query_mod.DEFAULT_SEMANTIC_TOP_K, + "--semantic-top-k", + help="Top-k for the vec_chunks ranking before fusion.", + ), + lexical_only: bool = typer.Option( + False, + "--lexical-only", + help="Use only FTS5 and graph data; do not load an embedding model.", + ), + final_top_k: int = typer.Option( + query_mod.DEFAULT_FINAL_TOP_K, + "--top-k", + help="Top-k for the fused output.", + ), + expand: bool = typer.Option( + False, + "--expand", + help="Walk outbound graph edges from Phase 2 results and append expanded matches.", + ), + expand_depth: int = typer.Option( + query_mod.DEFAULT_EXPAND_DEPTH, + "--depth", + min=1, + max=3, + help="Walk depth when --expand is set. Default 1, hard cap 3.", + ), + expand_top_k: int = typer.Option( + query_mod.DEFAULT_EXPAND_TOP_K, + "--expand-top-k", + help="Cap on the number of appended expanded results.", + ), + associate: bool = typer.Option( + False, + "--associate", + help="Append semantic doc-neighbor matches from fused seeds (ADR-034).", + ), + associate_top_k: int = typer.Option( + query_mod.DEFAULT_ASSOCIATE_TOP_K, + "--associate-top-k", + help="Cap on appended associated results.", + ), + associate_seed_k: int = typer.Option( + query_mod.DEFAULT_ASSOCIATE_SEED_K, + "--associate-seed-k", + help="How many fused rows seed association (default min(5, top-k)).", + ), + embedder: str | None = typer.Option( + None, + "--embedder", + help="Embedder key (minilm, bge-small, e5-small) or MINDGRAPH_EMBEDDER env.", + ), + embed_template: str | None = typer.Option( + None, + "--embed-template", + help="Optional query template (none, mainframe).", + ), + as_json: bool = typer.Option( + False, "--json", help="Emit machine-readable JSON instead of text." + ), + envelope: bool = typer.Option( + False, + "--envelope", + help="With --json, emit intent metadata plus results instead of the legacy result list.", + ), + citable_only: bool = typer.Option( + False, + "--citable-only", + help=( + "Drop quarantined, retracted, and superseded documents from the " + "legacy --json list. --envelope always separates them instead." + ), + ), + no_intent: bool = typer.Option( + False, "--no-intent", help="Skip intent graph resolution." + ), + intent_db: str = typer.Option( + "~/.mindgraph/mainframe-intent.sqlite", "--intent-db", help="Path to intent graph DB for resolution." + ), + verbose: bool = typer.Option(False, "--verbose", "-v"), +): + """Run a fused lexical + semantic query against an ingested database. + + Pass --expand for graph BFS matches; --associate for semantic doc neighbors. + Pass --lexical-only for the dependency-light FTS5/graph profile. + Pass --envelope with --json to include intent graph resolution metadata. + Plain --json preserves the legacy result-list contract for existing callers. + Text output still shows intent resolution by default. Use --no-intent to skip. + """ + _configure_logging(verbose) + try: + conn = db.get_db(db_path, read_only=True) + # MH01: fail fast on stub/incomplete DBs instead of soft FTS degradation. + db.validate_query_schema(conn, db_path) + except MindgraphError as e: + logger.error(str(e)) + raise typer.Exit(code=1) + + try: + try: + if lexical_only and associate: + raise EmbeddingError( + "--associate requires semantic retrieval; remove --lexical-only" + ) + effective_semantic_top_k = 0 if lexical_only else semantic_top_k + template = embedders.resolve_embed_template(embed_template) + spec = None + model = None + if effective_semantic_top_k > 0 or associate: + spec = embedders.resolve_embedder(embedder) + if db.get_semantic_enabled(conn) is False: + raise EmbeddingError( + "This database is lexical-only. Re-ingest it without " + "--lexical-only to enable semantic retrieval." + ) + model = _load_embedder(spec.key) + formatted_question = embedders.format_query_text( + spec, question, template=template + ) + else: + formatted_question = question + results = query_mod.run_query( + conn, + formatted_question, + model, + lexical_top_k=lexical_top_k, + semantic_top_k=effective_semantic_top_k, + final_top_k=final_top_k, + expand=expand, + expand_depth=expand_depth, + expand_top_k=expand_top_k, + associate=associate, + associate_top_k=associate_top_k, + associate_seed_k=associate_seed_k, + embedder_spec=spec, + embed_template=template, + ) + except MindgraphError as e: + logger.error(str(e)) + raise typer.Exit(code=1) + + resolution = None + if not no_intent and (envelope or not as_json): + intent_path = os.path.expanduser(intent_db) + intent_conn = None + if os.path.exists(intent_path): + try: + intent_conn = intent_mod.open_intent_store(intent_path) + except Exception as exc: + logger.warning("Failed to open intent DB: %s", exc) + if intent_conn: + try: + resolution = intent_mod.resolve_intent( + intent_conn, + formatted_question, + limits=intent_mod.TraversalLimits(max_depth=2, max_nodes=32), + ) + finally: + try: + intent_conn.close() + except Exception: + pass + + if as_json: + if envelope: + # Match MCP envelope shape so CLI and tool callers share one parser. + if resolution is not None: + resolution_payload = resolution.as_transport_payload() + reason = "intent_resolved" + warnings = list(resolution.warnings) + else: + intent_path = Path(os.path.expanduser(intent_db)) + if intent_path.exists(): + reason = "intent_resolution_skipped_or_failed" + warnings = ["intent_resolution_unavailable"] + else: + reason = "intent_store_missing" + warnings = [] + resolution_payload = None + out = { + "schema_version": routing_mod.SCHEMA_VERSION, + "intent_resolution": resolution_payload, + "routing": { + "mode": "single_database", + "selected_retrievers": ["cli-bound-db"], + "reason_codes": [reason], + "warnings": warnings, + }, + **query_mod.citation_partition_payload(results), + } + else: + emitted = results + if citable_only: + emitted, _ = query_mod.partition_by_citation(results) + out = [r.model_dump() for r in emitted] + typer.echo(json.dumps(out, indent=2, default=str)) + return + + if resolution: + typer.echo("=== Intent Resolution from graph ===") + typer.echo( + f"graph: {resolution.graph_id}@{resolution.graph_version}" + ) + typer.echo( + f"outcome: {resolution.outcome} " + f"(method: {resolution.resolution_method})" + ) + if resolution.matched_goal_ids: + typer.echo(f"matched goals: {list(resolution.matched_goal_ids)}") + if resolution.capability_hints: + typer.echo(f"hints: {list(resolution.capability_hints)}") + if resolution.warnings: + typer.echo(f"warnings: {list(resolution.warnings)}") + typer.echo("--- results below ---") + elif not no_intent: + typer.echo("(no intent DB or resolution; legacy results)") + + warning = query_mod.classify_query_scope(question) + if warning is not None: + typer.echo(_format_scope_warning(warning)) + + if not results: + typer.echo("(no candidate found)") + return + + for idx, result in enumerate(results, start=1): + typer.echo(_format_query_result_block(idx, result)) + finally: + try: + conn.close() + except Exception: # noqa: BLE001 + pass + + +@app.command() +def neighbors( + doc_id: str = typer.Argument(..., help="The source document ID."), + db_path: str = typer.Option("mindgraph.sqlite", "--db", help="Path to SQLite DB."), + as_json: bool = typer.Option( + False, "--json", help="Emit machine-readable JSON instead of text." + ), + verbose: bool = typer.Option(False, "--verbose", "-v"), +): + """List outbound edges from a document. Preserves dangling edges.""" + _configure_logging(verbose) + try: + conn = db.get_db(db_path, read_only=True) + except MindgraphError as e: + logger.error(str(e)) + raise typer.Exit(code=1) + try: + results = query_mod.list_neighbors(conn, doc_id) + except MindgraphError as e: + logger.error(str(e)) + conn.close() + raise typer.Exit(code=1) + finally: + try: + conn.close() + except Exception: # noqa: BLE001 + pass + + if as_json: + typer.echo(json.dumps([n.model_dump() for n in results], indent=2)) + return + + if not results: + typer.echo("(no outbound edges)") + return + + for idx, neighbor in enumerate(results, start=1): + typer.echo(_format_neighbor_block(idx, neighbor)) + + +@app.command("serve-mcp") +def serve_mcp( + db_path: str = typer.Option("mindgraph.sqlite", "--db", help="Path to SQLite DB."), + embedder: str | None = typer.Option( + None, + "--embedder", + help="Embedder key (minilm, bge-small, e5-small) or MINDGRAPH_EMBEDDER env.", + ), + embed_template: str | None = typer.Option( + None, + "--embed-template", + help="Optional query template (none, mainframe).", + ), + intent_db: str = typer.Option( + "~/.mindgraph/mainframe-intent.sqlite", + "--intent-db", + help="Path to intent graph DB used for MCP query envelope metadata.", + ), + verbose: bool = typer.Option(False, "--verbose", "-v"), +): + """Start a stdio MCP server for one ingested MindGraph database.""" + _configure_logging(verbose) + conn = None + try: + mcp_server = _require_mcp_server() + conn = mcp_server.open_database(db_path) + spec = embedders.resolve_embedder(embedder) + template = embedders.resolve_embed_template(embed_template) + model = _load_embedder(spec.key) + server = mcp_server.create_server( + conn, + model, + embedder_spec=spec, + embed_template=template, + intent_db_path=intent_db, + log_level="DEBUG" if verbose else "INFO", + ) + mcp_server.run_stdio(server) + except MindgraphError as e: + logger.error(str(e)) + typer.echo(str(e), err=True) + raise typer.Exit(code=1) + finally: + if conn is not None: + conn.close() + + +SCOPE_SPEC_HELP = ( + "Serve an arbitrary named scope: NAME=PATH or NAME:TRUST_PROFILE=PATH. " + "Repeatable. Trust profile defaults to NAME. When any --scope is given it " + "replaces the default knowledge/projects pair." +) + + +def parse_scope_specs(values: list[str] | None) -> dict[str, tuple[str, str]]: + """Parse `NAME=PATH` / `NAME:TRUST=PATH` specs into {name: (path, trust)}. + + Split on the first `=` only, so database paths may contain any character. + """ + scopes: dict[str, tuple[str, str]] = {} + for raw in values or []: + name_part, sep, db_path = raw.partition("=") + if not sep or not name_part.strip() or not db_path.strip(): + raise MindgraphError( + f"invalid --scope {raw!r}; expected NAME=PATH or NAME:TRUST_PROFILE=PATH" + ) + name, _, trust = name_part.partition(":") + name, trust = name.strip(), trust.strip() + if not name: + raise MindgraphError(f"invalid --scope {raw!r}; scope name is empty") + if name in scopes: + raise MindgraphError(f"duplicate --scope name: {name}") + scopes[name] = (db_path.strip(), trust or name) + return scopes + + +@app.command("serve-daemon") +def serve_daemon( + knowledge_db: str = typer.Option("~/.mindgraph/mainframe.sqlite", "--knowledge-db"), + projects_db: str = typer.Option("~/.mindgraph/mainframe-projects.sqlite", "--projects-db"), + scope: list[str] = typer.Option(None, "--scope", help=SCOPE_SPEC_HELP), + host: str = typer.Option("127.0.0.1", "--host"), + port: int = typer.Option(8000, "--port"), + path: str = typer.Option("/mcp", "--path"), + embedder: str | None = typer.Option(None, "--embedder"), + idle_seconds: float | None = typer.Option(None, "--idle-seconds", min=60.0), + lease_ttl: float = typer.Option(90.0, "--lease-ttl", min=30.0), +): + """Run the explicit-scope shared MCP server in the foreground.""" + conns = [] + try: + mcp_server = _require_mcp_server() + requested = parse_scope_specs(scope) or { + "knowledge": (knowledge_db, "durable_knowledge"), + "projects": (projects_db, "project_status"), + } + scopes = {} + for name, (db_path, trust_profile) in requested.items(): + conn = mcp_server.open_database_readonly(db_path) + conns.append(conn) + scopes[name] = (conn, trust_profile) + spec = embedders.resolve_embedder(embedder) + lifecycle = ( + idle_lifecycle.IdleLifecycle(idle_seconds, lease_ttl=lease_ttl) + if idle_seconds is not None else None + ) + server = mcp_server.create_shared_server( + scopes, + _load_embedder(spec.key), host=host, port=port, path=path, + embedder_spec=spec, lifecycle=lifecycle, + ) + if lifecycle: + lifecycle.start() + try: + mcp_server.run_streamable_http(server) + finally: + if lifecycle: + lifecycle.stop() + except MindgraphError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) + finally: + for conn in conns: + conn.close() + + +@app.command("mcp-proxy") +def mcp_proxy_command( + url: str = typer.Option("http://127.0.0.1:8000/mcp", "--url"), + lease: bool = typer.Option(False, "--lease"), + auto_start: bool | None = typer.Option(None, "--auto-start/--no-auto-start"), + state_dir: Path = typer.Option(Path("~/.mindgraph/run").expanduser(), "--state-dir"), + idle_seconds: float = typer.Option(900.0, "--idle-seconds", min=60.0), +): + """Proxy stdio MCP to an already-running shared daemon.""" + try: + mcp_proxy = _require_mcp_proxy() + activated_grace = daemon.idle_opt_in(state_dir) + if auto_start is None: + auto_start = activated_grace is not None + if activated_grace is not None and idle_seconds == 900.0: + idle_seconds = activated_grace + if auto_start: + health_url = daemon.health_url_for_mcp(url) + daemon_host, daemon_port, daemon_path = daemon.daemon_endpoint_args(url) + command = [ + sys.executable, "-m", "mindgraph.cli", "serve-daemon", + "--idle-seconds", str(idle_seconds), + "--host", daemon_host, "--port", str(daemon_port), + "--path", daemon_path, + ] + result = daemon.start(state_dir, command, health_url=health_url) + if result["status"] == "start_failed": + raise MindgraphError(f"shared daemon failed to start: {result}") + mcp_proxy.run_proxy_sync(url, lease=lease or auto_start) + except Exception as exc: + typer.echo(f"MCP proxy failed: {exc}", err=True) + raise typer.Exit(1) + + +@app.command("daemon-start") +def daemon_start( + state_dir: Path = typer.Option(Path("~/.mindgraph/run").expanduser(), "--state-dir"), + knowledge_db: str = typer.Option("~/.mindgraph/mainframe.sqlite", "--knowledge-db"), + projects_db: str = typer.Option("~/.mindgraph/mainframe-projects.sqlite", "--projects-db"), + scope: list[str] = typer.Option(None, "--scope", help=SCOPE_SPEC_HELP), + host: str = typer.Option("127.0.0.1", "--host"), + port: int = typer.Option(8000, "--port"), + path: str = typer.Option("/mcp", "--path"), + idle_seconds: float | None = typer.Option(None, "--idle-seconds", min=60.0), +): + try: + parse_scope_specs(scope) + except MindgraphError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) + command = [sys.executable, "-m", "mindgraph.cli", "serve-daemon", + "--host", host, "--port", str(port), "--path", path] + if scope: + for spec in scope: + command.extend(["--scope", spec]) + else: + command.extend(["--knowledge-db", knowledge_db, + "--projects-db", projects_db]) + if idle_seconds is not None: + command.extend(["--idle-seconds", str(idle_seconds)]) + health_endpoint = daemon.health_url(host, port) + typer.echo(json.dumps(daemon.start(state_dir, command, health_url=health_endpoint))) + + +@app.command("daemon-status") +def daemon_status( + state_dir: Path = typer.Option(Path("~/.mindgraph/run").expanduser(), "--state-dir"), + url: str = typer.Option("http://127.0.0.1:8000/health", "--url"), +): + typer.echo(json.dumps(daemon.status(state_dir, url))) + + +@app.command("daemon-health") +def daemon_health(url: str = typer.Option("http://127.0.0.1:8000/health", "--url")): + result = daemon.health(url) + typer.echo(json.dumps(result)) + if result.get("status") != "ok": + raise typer.Exit(1) + + +@app.command("daemon-stop") +def daemon_stop( + state_dir: Path = typer.Option(Path("~/.mindgraph/run").expanduser(), "--state-dir"), + url: str = typer.Option("http://127.0.0.1:8000/health", "--url"), +): + typer.echo(json.dumps(daemon.stop(state_dir, health_url=url))) + + +if __name__ == "__main__": + app() diff --git a/mindgraph/src/mindgraph/daemon.py b/mindgraph/src/mindgraph/daemon.py new file mode 100644 index 0000000..bb1a7f8 --- /dev/null +++ b/mindgraph/src/mindgraph/daemon.py @@ -0,0 +1,195 @@ +"""Explicit process lifecycle for the opt-in shared MCP daemon.""" + +import json +import fcntl +import os +import signal +import subprocess +import time +import urllib.request +import urllib.parse +from pathlib import Path + + +def paths(state_dir: Path) -> tuple[Path, Path]: + return state_dir / "mindgraph-daemon.pid", state_dir / "mindgraph-daemon.log" + + +def lock_path(state_dir: Path) -> Path: + return state_dir / "mindgraph-daemon.start.lock" + + +def identity_path(state_dir: Path) -> Path: + return state_dir / "mindgraph-daemon.identity.json" + + +def read_identity(state_dir: Path) -> dict | None: + try: + payload = json.loads(identity_path(state_dir).read_text()) + except (FileNotFoundError, json.JSONDecodeError): + return None + return payload if isinstance(payload, dict) else None + + +def read_pid(state_dir: Path) -> int | None: + try: + return int(paths(state_dir)[0].read_text().strip()) + except (FileNotFoundError, ValueError): + return None + + +def alive(pid: int | None) -> bool: + if pid is None: + return False + try: + waited, _status = os.waitpid(pid, os.WNOHANG) + if waited == pid: + return False + except ChildProcessError: + pass + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + + +def is_mindgraph_health(payload: dict | None) -> bool: + return bool( + payload + and payload.get("status") == "ok" + and isinstance(payload.get("scopes"), list) + ) + + +def status(state_dir: Path, health_url: str | None = None) -> dict: + pid = read_pid(state_dir) + tracked = alive(pid) + observed = health(health_url) if health_url else None + if is_mindgraph_health(observed): + observed_pid = observed.get("pid") + return { + "status": "running", + "pid": observed_pid, + "supervision": "pid_file" if tracked and observed_pid == pid else "external", + "pid_file_pid": pid, + "health": observed, + } + result = {"status": "running" if tracked else "stopped", "pid": pid} + if health_url: + result["health"] = observed + return result + + +def start( + state_dir: Path, + command: list[str], + *, + health_url: str | None = None, + startup_timeout: float = 20.0, +) -> dict: + state_dir.mkdir(parents=True, exist_ok=True) + with lock_path(state_dir).open("a+") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + current = status(state_dir, health_url) + if current["status"] == "running": + return current + pid_file, log_file = paths(state_dir) + with log_file.open("ab") as log: + proc = subprocess.Popen( + command, stdin=subprocess.DEVNULL, stdout=log, stderr=log, + start_new_session=True, + ) + pid_file.write_text(f"{proc.pid}\n") + identity_path(state_dir).write_text(json.dumps({ + "pid": proc.pid, + "command": command, + }) + "\n") + if not health_url: + return {"status": "started", "pid": proc.pid} + deadline = time.monotonic() + startup_timeout + while time.monotonic() < deadline: + observed = health(health_url, timeout=0.25) + if is_mindgraph_health(observed): + return {"status": "started", "pid": proc.pid, "health": observed} + if not alive(proc.pid): + break + time.sleep(0.1) + return { + "status": "start_failed", + "pid": proc.pid, + "health": health(health_url, timeout=0.25), + } + + +def stop( + state_dir: Path, + timeout: float = 5.0, + *, + health_url: str | None = None, +) -> dict: + pid = read_pid(state_dir) + if health_url: + observed = health(health_url) + if is_mindgraph_health(observed) and observed.get("pid") is None: + return {"status": "refused_unverified_listener", "pid": pid} + observed_pid = observed.get("pid") if is_mindgraph_health(observed) else None + if observed_pid is not None and observed_pid != pid: + return { + "status": "refused_pid_mismatch", + "pid": pid, + "observed_pid": observed_pid, + } + if not alive(pid): + paths(state_dir)[0].unlink(missing_ok=True) + identity_path(state_dir).unlink(missing_ok=True) + return {"status": "stopped", "pid": pid} + identity = read_identity(state_dir) + if identity is None or identity.get("pid") != pid: + return {"status": "refused_unverified_pid", "pid": pid} + os.kill(pid, signal.SIGTERM) + deadline = time.monotonic() + timeout + while alive(pid) and time.monotonic() < deadline: + time.sleep(0.05) + if alive(pid): + return {"status": "stop_timeout", "pid": pid} + paths(state_dir)[0].unlink(missing_ok=True) + identity_path(state_dir).unlink(missing_ok=True) + return {"status": "stopped", "pid": pid} + + +def health(url: str, timeout: float = 1.0) -> dict: + try: + with urllib.request.urlopen(url, timeout=timeout) as response: + return json.loads(response.read()) + except Exception as exc: + return {"status": "unhealthy", "error": str(exc)} + + +def health_url_for_mcp(url: str) -> str: + parsed = urllib.parse.urlsplit(url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError(f"invalid MCP URL: {url}") + return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "/health", "", "")) + + +def daemon_endpoint_args(url: str) -> tuple[str, int, str]: + parsed = urllib.parse.urlsplit(url) + if parsed.scheme != "http" or parsed.hostname not in {"127.0.0.1", "localhost", "::1"}: + raise ValueError("auto-start requires a loopback http MCP URL") + return parsed.hostname, parsed.port or 80, parsed.path or "/mcp" + + +def health_url(host: str, port: int) -> str: + rendered_host = f"[{host}]" if ":" in host else host + return f"http://{rendered_host}:{port}/health" + + +def idle_opt_in(state_dir: Path) -> float | None: + """Return the explicitly activated idle grace, or None when disabled.""" + marker = state_dir / "idle-lifecycle.enabled" + try: + value = float(marker.read_text().strip()) + except (FileNotFoundError, ValueError): + return None + return value if value >= 60 else None diff --git a/mindgraph/src/mindgraph/db.py b/mindgraph/src/mindgraph/db.py new file mode 100644 index 0000000..1e7ec3f --- /dev/null +++ b/mindgraph/src/mindgraph/db.py @@ -0,0 +1,633 @@ +import os +import sqlite3 +import struct +import time +from collections.abc import Iterable +from pathlib import Path +from typing import Any +from urllib.parse import quote + +import sqlite_vec + +from mindgraph.exceptions import DatabaseError +from mindgraph.models import GraphEdge, ParsedDocument + +# Tables required for fused lexical + semantic query (MH01 first-contact contract). +REQUIRED_QUERY_TABLES = frozenset( + { + "documents", + "documents_fts", + "chunks", + "vec_chunks", + "edges", + } +) + +# Heuristic: workspace-root stub SQLite files from early scaffolding are tiny. +STUB_SIZE_BYTES = 16_384 # 16 KiB + + +def _configure_connection( + conn: sqlite3.Connection, *, read_only: bool +) -> sqlite3.Connection: + conn.enable_load_extension(True) + sqlite_vec.load(conn) + conn.enable_load_extension(False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + if read_only: + conn.execute("PRAGMA query_only = ON") + # Force SQLite to read the schema now. A WAL database can connect in + # mode=ro and then fail on its first query when the containing directory + # cannot host a shared-memory file; callers need that failure here so + # the immutable snapshot fallback can be selected deterministically. + conn.execute("SELECT 1 FROM sqlite_master LIMIT 1").fetchone() + else: + # WAL lets the long-lived MCP reader and the ingest/refresh writer + # coexist without `database is locked` errors. Journal mode is a + # persistent property of the file, so issuing it on every writable + # connection is idempotent and migrates pre-existing rollback journals. + conn.execute("PRAGMA journal_mode = WAL") + return conn + + +def _open_read_only(db_path: str) -> sqlite3.Connection: + """Open a query-only database, falling back to an immutable snapshot. + + Normal ``mode=ro`` remains preferred because it observes a live WAL. The + immutable fallback is only used when SQLite cannot create/read WAL shared + memory in a non-writable runtime (for example, a sandbox-mounted index). + """ + if db_path == ":memory:": + raise DatabaseError(":memory: cannot be opened read-only") + + resolved = Path(db_path).expanduser().resolve() + base_uri = f"file:{quote(str(resolved), safe='/')}?mode=ro" + last_error: Exception | None = None + for immutable in (False, True): + conn: sqlite3.Connection | None = None + uri = f"{base_uri}&immutable=1" if immutable else base_uri + try: + conn = sqlite3.connect(uri, uri=True, timeout=30.0) + return _configure_connection(conn, read_only=True) + except (sqlite3.Error, RuntimeError) as exc: + last_error = exc + if conn is not None: + try: + conn.close() + except sqlite3.Error: + pass + assert last_error is not None + raise DatabaseError(f"Failed to open database at {db_path}: {last_error}") + + +def get_db( + db_path: str = "mindgraph.sqlite", *, read_only: bool = False +) -> sqlite3.Connection: + """Connect to SQLite, load sqlite-vec, and apply the requested access mode.""" + if read_only: + return _open_read_only(db_path) + + try: + conn = sqlite3.connect(db_path, timeout=30.0) + _configure_connection(conn, read_only=False) + if conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'documents'" + ).fetchone(): + with conn: + _ensure_document_provenance_columns(conn) + return conn + except (sqlite3.Error, RuntimeError) as e: + raise DatabaseError(f"Failed to open database at {db_path}: {e}") from e + + +DEFAULT_EMBEDDING_DIMS = 384 + + +def init_db( + db_path: str = "mindgraph.sqlite", + *, + embedding_dims: int = DEFAULT_EMBEDDING_DIMS, + semantic_enabled: bool | None = None, +) -> sqlite3.Connection: + """Initialize the database schema for MindGraph. + + ``semantic_enabled=False`` creates a lexical-only index with no vector + rows. ``None`` preserves the legacy behavior for existing databases and + leaves the mode unspecified until an ingest declares it. + """ + conn = get_db(db_path) + + with conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS index_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + title TEXT, + path TEXT, + domain TEXT, + content_hash TEXT NOT NULL, + index_id TEXT, + trust_profile TEXT, + namespace TEXT, + source_root TEXT, + source_path TEXT, + display_path TEXT, + timeline_text TEXT, + metadata_json TEXT, + created_at TEXT, + updated_at TEXT + ) + """) + _ensure_document_provenance_columns(conn) + + conn.execute(""" + CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5( + id UNINDEXED, + title, + content + ) + """) + + conn.execute(""" + CREATE TABLE IF NOT EXISTS chunks ( + rowid INTEGER PRIMARY KEY AUTOINCREMENT, + doc_id TEXT, + chunk_index INTEGER, + text TEXT, + FOREIGN KEY (doc_id) REFERENCES documents(id) ON DELETE CASCADE + ) + """) + + vec_exists = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'vec_chunks'" + ).fetchone() + stored_dims = get_embedding_dims(conn) + if stored_dims is None: + if vec_exists: + stored_dims = DEFAULT_EMBEDDING_DIMS + else: + stored_dims = embedding_dims + set_embedding_dims(conn, stored_dims) + elif stored_dims != embedding_dims: + raise DatabaseError( + f"Database {db_path} was initialized with embedding_dims=" + f"{stored_dims}; requested {embedding_dims}. Use a separate DB " + "per embedder dimension." + ) + + if not vec_exists: + conn.execute( + f""" + CREATE VIRTUAL TABLE vec_chunks USING vec0( + embedding float[{stored_dims}] + ) + """ + ) + + stored_semantic = get_semantic_enabled(conn) + if semantic_enabled is not None: + if stored_semantic is not None and stored_semantic != semantic_enabled: + raise DatabaseError( + f"Database {db_path} has semantic_enabled={stored_semantic}; " + f"requested {semantic_enabled}. Use a separate DB for a " + "different retrieval profile." + ) + if stored_semantic is None: + existing_vectors = conn.execute( + "SELECT COUNT(*) FROM vec_chunks" + ).fetchone()[0] + if not semantic_enabled and existing_vectors: + raise DatabaseError( + f"Database {db_path} already contains semantic vectors; " + "create a fresh database for --lexical-only ingest." + ) + set_semantic_enabled(conn, semantic_enabled) + + conn.execute(""" + CREATE TABLE IF NOT EXISTS edges ( + source_id TEXT, + target_id TEXT, + relationship_type TEXT, + PRIMARY KEY (source_id, target_id, relationship_type) + ) + """) + + return conn + + +def list_table_names(conn: sqlite3.Connection) -> set[str]: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type IN ('table', 'virtual table')" + ).fetchall() + return {row["name"] if isinstance(row, sqlite3.Row) else row[0] for row in rows} + + +def missing_required_tables( + conn: sqlite3.Connection, required: frozenset[str] = REQUIRED_QUERY_TABLES +) -> list[str]: + existing = list_table_names(conn) + return sorted(required - existing) + + +def validate_query_schema( + conn: sqlite3.Connection, + db_path: str, + *, + required: frozenset[str] = REQUIRED_QUERY_TABLES, +) -> None: + """Fail fast when a DB cannot support fused query (MH01 doctor contract). + + Raises DatabaseError with an actionable message instead of soft-degrading + to semantic-only / empty FTS results on stub databases. + """ + missing = missing_required_tables(conn, required) + if missing: + raise DatabaseError( + f"Database at {db_path} is not a usable MindGraph index; " + f"missing tables: {', '.join(missing)}. " + "Authoritative DBs live under ~/.mindgraph/ " + "(not workspace-root stub *.sqlite files). " + "Run: bin/mindgraph doctor && bin/mindgraph-refresh" + ) + + +def _count(conn: sqlite3.Connection, sql: str) -> int | None: + try: + row = conn.execute(sql).fetchone() + if row is None: + return None + return int(row[0]) + except sqlite3.Error: + return None + + +def inspect_database( + db_path: str, + *, + role: str | None = None, + trust_profile: str | None = None, +) -> dict[str, Any]: + """Return a doctor diagnostic payload for one database path. + + Does not load the embedding model. Safe for first-contact preflight. + """ + expanded = os.path.expanduser(db_path) + path = Path(expanded) + report: dict[str, Any] = { + "path": str(path), + "role": role, + "trust_profile": trust_profile, + "exists": path.exists(), + "size_bytes": None, + "mtime_iso": None, + "ok": False, + "issues": [], + "warnings": [], + "tables_present": [], + "tables_missing": sorted(REQUIRED_QUERY_TABLES), + "counts": {}, + "embedding_dims": None, + "semantic_enabled": None, + "likely_stub": False, + } + + if not path.exists(): + report["issues"].append("file_missing") + return report + + try: + stat = path.stat() + except OSError as e: + report["issues"].append(f"stat_failed:{e}") + return report + + report["size_bytes"] = stat.st_size + report["mtime_iso"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(stat.st_mtime)) + if stat.st_size <= STUB_SIZE_BYTES: + report["likely_stub"] = True + report["warnings"].append( + f"file is very small ({stat.st_size} bytes); may be a workspace stub, " + "not an authoritative index" + ) + + conn: sqlite3.Connection | None = None + try: + conn = get_db(str(path), read_only=True) + tables = list_table_names(conn) + report["tables_present"] = sorted(tables) + missing = missing_required_tables(conn) + report["tables_missing"] = missing + if missing: + report["issues"].append("missing_required_tables") + report["embedding_dims"] = get_embedding_dims(conn) + report["semantic_enabled"] = get_semantic_enabled(conn) + report["counts"] = { + "documents": _count(conn, "SELECT COUNT(*) FROM documents") + if "documents" in tables + else None, + "chunks": _count(conn, "SELECT COUNT(*) FROM chunks") + if "chunks" in tables + else None, + "edges": _count(conn, "SELECT COUNT(*) FROM edges") + if "edges" in tables + else None, + "vec_chunks": _count(conn, "SELECT COUNT(*) FROM vec_chunks") + if "vec_chunks" in tables + else None, + } + docs = report["counts"].get("documents") + if docs == 0 and not missing: + report["warnings"].append("index has zero documents — run mindgraph-refresh") + report["ok"] = not report["issues"] + except DatabaseError as e: + report["issues"].append("open_failed") + report["warnings"].append(str(e)) + except sqlite3.Error as e: + report["issues"].append("sqlite_error") + report["warnings"].append(str(e)) + finally: + if conn is not None: + try: + conn.close() + except Exception: # noqa: BLE001 + pass + + return report + + +def default_dual_db_specs() -> list[dict[str, str]]: + """Authoritative MainFrame dual-index locations (MH01 / HARNESS).""" + home = Path.home() / ".mindgraph" + return [ + { + "role": "knowledge", + "trust_profile": "durable_knowledge", + "path": str(home / "mainframe.sqlite"), + "refresh_hint": "bin/mindgraph-refresh", + }, + { + "role": "projects", + "trust_profile": "project_status", + "path": str(home / "mainframe-projects.sqlite"), + "refresh_hint": "bin/mindgraph-refresh-projects", + }, + ] + + +def find_workspace_stub_sqlite(search_roots: list[Path] | None = None) -> list[dict[str, Any]]: + """Detect tiny workspace-root *.sqlite files that look authoritative but are not.""" + roots = search_roots or [Path.cwd()] + hits: list[dict[str, Any]] = [] + for root in roots: + if not root.is_dir(): + continue + for name in ("mainframe.sqlite", "mainframe-projects.sqlite"): + p = root / name + if not p.is_file(): + continue + try: + size = p.stat().st_size + except OSError: + continue + if size <= STUB_SIZE_BYTES: + hits.append( + { + "path": str(p.resolve()), + "size_bytes": size, + "warning": ( + "Workspace-root sqlite looks like a stub. " + "Query ~/.mindgraph/*.sqlite instead." + ), + } + ) + return hits + + +def get_embedding_dims(conn: sqlite3.Connection) -> int | None: + row = conn.execute( + "SELECT value FROM index_meta WHERE key = 'embedding_dims'" + ).fetchone() + if row is None: + return None + try: + return int(row["value"]) + except (TypeError, ValueError): + return None + + +def set_embedding_dims(conn: sqlite3.Connection, dims: int) -> None: + conn.execute( + """ + INSERT INTO index_meta (key, value) VALUES ('embedding_dims', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + """, + (str(dims),), + ) + + +def get_semantic_enabled(conn: sqlite3.Connection) -> bool | None: + """Return the declared semantic mode, or None for legacy databases.""" + row = conn.execute( + "SELECT value FROM index_meta WHERE key = 'semantic_enabled'" + ).fetchone() + if row is None: + return None + return row["value"] == "1" + + +def set_semantic_enabled(conn: sqlite3.Connection, enabled: bool) -> None: + conn.execute( + """ + INSERT INTO index_meta (key, value) VALUES ('semantic_enabled', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + """, + ("1" if enabled else "0",), + ) + + +def _ensure_document_provenance_columns(conn: sqlite3.Connection) -> None: + """Add provenance columns to older databases without requiring a rebuild.""" + existing = { + row["name"] + for row in conn.execute("PRAGMA table_info(documents)").fetchall() + } + for column in ( + "index_id", + "trust_profile", + "namespace", + "source_root", + "source_path", + "display_path", + ): + if column not in existing: + conn.execute(f"ALTER TABLE documents ADD COLUMN {column} TEXT") + + +def _serialize_embedding(vec: list[float]) -> bytes: + """Pack a float vector into the bytes format sqlite-vec expects.""" + return struct.pack(f"{len(vec)}f", *vec) + + +def get_document_hash(conn: sqlite3.Connection, doc_id: str) -> str | None: + row = conn.execute( + "SELECT content_hash FROM documents WHERE id = ?", (doc_id,) + ).fetchone() + return row["content_hash"] if row else None + + +def _delete_document_artifacts(conn: sqlite3.Connection, doc_id: str) -> None: + """Remove chunks, vec_chunks, FTS rows, and outgoing edges for a doc.""" + chunk_rowids = [ + row["rowid"] + for row in conn.execute( + "SELECT rowid FROM chunks WHERE doc_id = ?", (doc_id,) + ) + ] + if chunk_rowids: + placeholders = ",".join("?" for _ in chunk_rowids) + conn.execute( + f"DELETE FROM vec_chunks WHERE rowid IN ({placeholders})", + chunk_rowids, + ) + conn.execute("DELETE FROM chunks WHERE doc_id = ?", (doc_id,)) + conn.execute("DELETE FROM documents_fts WHERE id = ?", (doc_id,)) + conn.execute("DELETE FROM edges WHERE source_id = ?", (doc_id,)) + + +def delete_document(conn: sqlite3.Connection, doc_id: str) -> None: + """Fully remove a document from the index, including the documents row. + + Drops the chunks, vec rows, FTS row, and outbound edges (via + `_delete_document_artifacts`) and then the `documents` row itself. Inbound + edges (where this doc is the *target*) are intentionally left in place so + they become dangling, matching the engine's link-resolution model where an + unresolved target stays dangling rather than being guessed. Used by ingest + to prune deleted/renamed source files. + """ + _delete_document_artifacts(conn, doc_id) + conn.execute("DELETE FROM documents WHERE id = ?", (doc_id,)) + + +def upsert_document(conn: sqlite3.Connection, doc: ParsedDocument) -> None: + """Insert or replace a document and clear any prior chunks/edges/FTS rows.""" + import json + from datetime import datetime, timezone + + _delete_document_artifacts(conn, doc.id) + + now = datetime.now(timezone.utc).isoformat() + existing = conn.execute( + "SELECT created_at FROM documents WHERE id = ?", (doc.id,) + ).fetchone() + created_at = existing["created_at"] if existing else now + + conn.execute( + """ + INSERT OR REPLACE INTO documents + ( + id, title, path, domain, content_hash, index_id, trust_profile, + namespace, source_root, source_path, display_path, + timeline_text, metadata_json, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + doc.id, + doc.title, + doc.path, + doc.metadata.get("domain"), + doc.content_hash, + doc.index_id, + doc.trust_profile, + doc.namespace, + doc.source_root, + doc.source_path, + doc.display_path, + doc.timeline_text, + json.dumps(doc.metadata, default=str), + created_at, + now, + ), + ) + + conn.execute( + "INSERT INTO documents_fts (id, title, content) VALUES (?, ?, ?)", + (doc.id, doc.title, doc.truth_text), + ) + + +def insert_chunks_and_embeddings( + conn: sqlite3.Connection, + doc_id: str, + chunks: list[str], + embeddings: list[list[float]] | None, +) -> None: + if embeddings is None: + for idx, text in enumerate(chunks): + conn.execute( + "INSERT INTO chunks (doc_id, chunk_index, text) VALUES (?, ?, ?)", + (doc_id, idx, text), + ) + return + if len(chunks) != len(embeddings): + raise DatabaseError( + f"chunk/embedding count mismatch for {doc_id}: " + f"{len(chunks)} chunks vs {len(embeddings)} embeddings" + ) + for idx, (text, embedding) in enumerate(zip(chunks, embeddings)): + cursor = conn.execute( + "INSERT INTO chunks (doc_id, chunk_index, text) VALUES (?, ?, ?)", + (doc_id, idx, text), + ) + rowid = cursor.lastrowid + conn.execute( + "INSERT INTO vec_chunks (rowid, embedding) VALUES (?, ?)", + (rowid, _serialize_embedding(embedding)), + ) + + +def insert_edges(conn: sqlite3.Connection, edges: Iterable[GraphEdge]) -> None: + for edge in edges: + conn.execute( + """ + INSERT OR IGNORE INTO edges (source_id, target_id, relationship_type) + VALUES (?, ?, ?) + """, + (edge.source_id, edge.target_id, edge.relationship_type), + ) + + +def get_outbound_edge_keys( + conn: sqlite3.Connection, source_id: str +) -> set[tuple[str, str | None]]: + """Return the set of (target_id, relationship_type) for a source's edges. + + Lets the ingest skip-path detect when a re-resolved edge set is identical to + what is already stored, so it can avoid a redundant DELETE+INSERT write. + """ + return { + (row["target_id"], row["relationship_type"]) + for row in conn.execute( + "SELECT target_id, relationship_type FROM edges WHERE source_id = ?", + (source_id,), + ) + } + + +def replace_edges( + conn: sqlite3.Connection, source_id: str, edges: Iterable[GraphEdge] +) -> None: + """Replace all outbound edges for one source document.""" + conn.execute("DELETE FROM edges WHERE source_id = ?", (source_id,)) + insert_edges(conn, edges) + + +if __name__ == "__main__": + init_db() + print("Database schema initialized successfully.") diff --git a/mindgraph/src/mindgraph/embedders.py b/mindgraph/src/mindgraph/embedders.py new file mode 100644 index 0000000..30caa64 --- /dev/null +++ b/mindgraph/src/mindgraph/embedders.py @@ -0,0 +1,167 @@ +"""Embedding model registry and text formatting for ingest/query.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Literal + +from mindgraph.exceptions import EmbeddingError + +EmbedTemplate = Literal["none", "mainframe"] + + +@dataclass(frozen=True) +class EmbedderSpec: + key: str + model_id: str + dimensions: int + query_prefix: str = "" + passage_prefix: str = "" + + +_REGISTRY: dict[str, EmbedderSpec] = { + "minilm": EmbedderSpec("minilm", "all-MiniLM-L6-v2", 384), + "all-minilm-l6-v2": EmbedderSpec( + "all-minilm-l6-v2", "all-MiniLM-L6-v2", 384 + ), + "bge-small": EmbedderSpec( + "bge-small", + "BAAI/bge-small-en-v1.5", + 384, + query_prefix="query: ", + passage_prefix="", + ), + "bge-small-en-v1.5": EmbedderSpec( + "bge-small-en-v1.5", + "BAAI/bge-small-en-v1.5", + 384, + query_prefix="query: ", + passage_prefix="", + ), + "e5-small": EmbedderSpec( + "e5-small", + "intfloat/e5-small-v2", + 384, + query_prefix="query: ", + passage_prefix="passage: ", + ), + "e5-small-v2": EmbedderSpec( + "e5-small-v2", + "intfloat/e5-small-v2", + 384, + query_prefix="query: ", + passage_prefix="passage: ", + ), +} + +DEFAULT_EMBEDDER_KEY = "minilm" + + +def resolve_embedder(name: str | None = None) -> EmbedderSpec: + """Resolve an embedder key from CLI, env, or default.""" + raw = (name or os.environ.get("MINDGRAPH_EMBEDDER") or DEFAULT_EMBEDDER_KEY).strip() + key = raw.lower() + if key not in _REGISTRY: + known = ", ".join(sorted(_REGISTRY)) + raise EmbeddingError(f"Unknown embedder {raw!r}; known keys: {known}") + return _REGISTRY[key] + + +def resolve_embed_template(name: str | None = None) -> EmbedTemplate: + raw = ( + name or os.environ.get("MINDGRAPH_EMBED_TEMPLATE") or "none" + ).strip().lower() + if raw in ("none", ""): + return "none" + if raw == "mainframe": + return "mainframe" + raise EmbeddingError( + f"Unknown embed template {raw!r}; known: none, mainframe" + ) + + +def load_sentence_embedder(spec: EmbedderSpec): + """Load a SentenceTransformer from the local Hugging Face cache only. + + MindGraph expects models to already be cached on disk. Resolving via the + hub client can fail with ``RuntimeError: Cannot send a request, as the + client has been closed`` even when the cache is warm, so we never open a + network metadata request at query/ingest time. + """ + try: + from sentence_transformers import SentenceTransformer + except ModuleNotFoundError as exc: + raise EmbeddingError( + "Semantic support is not installed. Install it with " + "`pip install 'mindgraph[semantic]'` before semantic ingest/query." + ) from exc + + try: + return SentenceTransformer(spec.model_id, local_files_only=True) + except Exception as exc: + raise EmbeddingError( + f"Failed to load cached embedding model {spec.model_id!r} with " + f"local_files_only=True ({type(exc).__name__}: {exc}). " + "Cache the model once while online, for example:\n" + " python -c \"from sentence_transformers import SentenceTransformer; " + f"SentenceTransformer({spec.model_id!r})\"" + ) from exc + + +def bootstrap_sentence_embedder(spec: EmbedderSpec): + """Acquire and load a model during an explicit setup operation. + + Normal ingest/query uses :func:`load_sentence_embedder`, which is + cache-only. This function is the only package-level path that permits the + SentenceTransformer loader to resolve a model from its configured hub. + """ + try: + from sentence_transformers import SentenceTransformer + except ModuleNotFoundError as exc: + raise EmbeddingError( + "Semantic support is not installed. Install it with " + "`pip install 'mindgraph[semantic]'` before model bootstrap." + ) from exc + + try: + return SentenceTransformer(spec.model_id) + except Exception as exc: + raise EmbeddingError( + f"Failed to acquire embedding model {spec.model_id!r}: " + f"{type(exc).__name__}: {exc}." + ) from exc + + +def format_query_text(spec: EmbedderSpec, text: str, *, template: EmbedTemplate) -> str: + body = text.strip() + if template == "mainframe": + body = f"[intent=query] {body}" + if spec.query_prefix: + body = f"{spec.query_prefix}{body}" + return body + + +def format_passage_text( + spec: EmbedderSpec, + text: str, + *, + template: EmbedTemplate, + title: str | None = None, + domain: str | None = None, + doc_type: str | None = None, +) -> str: + body = text.strip() + if template == "mainframe": + parts: list[str] = [] + if domain: + parts.append(f"[domain={domain}]") + if doc_type: + parts.append(f"[type={doc_type}]") + if title: + parts.append(title) + prefix = " ".join(parts) + body = f"{prefix} — {body}" if prefix else body + if spec.passage_prefix: + body = f"{spec.passage_prefix}{body}" + return body diff --git a/mindgraph/src/mindgraph/exceptions.py b/mindgraph/src/mindgraph/exceptions.py new file mode 100644 index 0000000..1fe91e6 --- /dev/null +++ b/mindgraph/src/mindgraph/exceptions.py @@ -0,0 +1,26 @@ +class MindgraphError(Exception): + """Base exception for all MindGraph errors.""" + + +class DatabaseError(MindgraphError): + """Raised when a database connection, schema, or write operation fails.""" + + +class EmbeddingError(MindgraphError, ValueError): + """Raised when an embedding configuration or runtime cannot be used.""" + + +class IngestionError(MindgraphError): + """Raised when ingesting a file fails. Carries the offending path.""" + + def __init__(self, message: str, path: str | None = None): + super().__init__(message) + self.path = path + + def __str__(self) -> str: + base = super().__str__() + return f"{base} (path={self.path})" if self.path else base + + +class ParseError(IngestionError): + """Raised when a file cannot be parsed (frontmatter, structure, etc.).""" diff --git a/mindgraph/src/mindgraph/idle_lifecycle.py b/mindgraph/src/mindgraph/idle_lifecycle.py new file mode 100644 index 0000000..23b5843 --- /dev/null +++ b/mindgraph/src/mindgraph/idle_lifecycle.py @@ -0,0 +1,106 @@ +"""Conservative, opt-in idle lifecycle for the shared HTTP daemon.""" + +from __future__ import annotations + +from contextlib import contextmanager +import os +import signal +import threading +import time +import uuid +from collections.abc import Callable, Iterator + + +class IdleLifecycle: + """Track requests and renewable client leases before allowing idle exit.""" + + def __init__( + self, + idle_seconds: float, + *, + lease_ttl: float = 90.0, + clock: Callable[[], float] = time.monotonic, + request_shutdown: Callable[[], None] | None = None, + ) -> None: + if idle_seconds <= 0 or lease_ttl <= 0: + raise ValueError("idle and lease durations must be positive") + self.idle_seconds = idle_seconds + self.lease_ttl = lease_ttl + self._clock = clock + self._request_shutdown = request_shutdown or ( + lambda: os.kill(os.getpid(), signal.SIGTERM) + ) + self._lock = threading.Lock() + self._in_flight = 0 + self._last_activity = clock() + self._leases: dict[str, float] = {} + self._stopped = threading.Event() + self._thread: threading.Thread | None = None + + @contextmanager + def request(self) -> Iterator[None]: + with self._lock: + self._in_flight += 1 + self._last_activity = self._clock() + try: + yield + finally: + with self._lock: + self._in_flight -= 1 + self._last_activity = self._clock() + + def acquire_lease(self) -> str: + token = uuid.uuid4().hex + self.renew_lease(token) + return token + + def renew_lease(self, token: str) -> bool: + if not token: + return False + with self._lock: + self._leases[token] = self._clock() + self.lease_ttl + self._last_activity = self._clock() + return True + + def release_lease(self, token: str) -> None: + with self._lock: + self._leases.pop(token, None) + self._last_activity = self._clock() + + def snapshot(self) -> dict: + now = self._clock() + with self._lock: + self._leases = {key: expiry for key, expiry in self._leases.items() if expiry > now} + return { + "enabled": True, + "idle_seconds": self.idle_seconds, + "in_flight": self._in_flight, + "active_leases": len(self._leases), + "idle_for_seconds": max(0.0, now - self._last_activity), + } + + def should_shutdown(self) -> bool: + state = self.snapshot() + return ( + state["in_flight"] == 0 + and state["active_leases"] == 0 + and state["idle_for_seconds"] >= self.idle_seconds + ) + + def start(self) -> None: + if self._thread is not None: + return + self._thread = threading.Thread(target=self._monitor, daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stopped.set() + if self._thread is not None: + self._thread.join(timeout=2) + + def _monitor(self) -> None: + interval = min(5.0, max(0.1, self.idle_seconds / 10)) + while not self._stopped.wait(interval): + if self.should_shutdown(): + self._request_shutdown() + return diff --git a/mindgraph/src/mindgraph/intent.py b/mindgraph/src/mindgraph/intent.py new file mode 100644 index 0000000..bbb3649 --- /dev/null +++ b/mindgraph/src/mindgraph/intent.py @@ -0,0 +1,1572 @@ +"""Deterministic compilation and read-only traversal for V1 intent graphs. + +Reviewed YAML is the source of truth. SQLite files produced here are generated +control-plane artifacts; they are deliberately separate from MindGraph's +document indexes and contain no embeddings or retrieved content. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import sqlite3 +import tempfile +import unicodedata +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal +from urllib.parse import quote, urlsplit + +import yaml +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator + +from mindgraph.exceptions import MindgraphError + + +SCHEMA_VERSION = "1" +NODE_KINDS = frozenset({"goal", "capability", "constraint"}) +RELATIONS = frozenset( + {"decomposes_to", "requires", "next_step", "blocked_by", "routes_to"} +) +ACYCLIC_RELATIONS = frozenset({"decomposes_to", "requires", "next_step"}) +BINDING_SCHEMES = frozenset( + {"knowledge", "project", "retriever", "capability", "policy"} +) +ID_RE = re.compile(r"^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$") +VERSION_RE = re.compile(r"^[a-z0-9][a-z0-9]*(?:[.-][a-z0-9]+)*$") +TOKEN_RE = re.compile(r"\w+", re.UNICODE) +REQUIRED_TABLES = frozenset( + { + "schema_meta", + "graph_versions", + "intent_nodes", + "intent_aliases", + "intent_edges", + "intent_bindings", + "intent_rules", + } +) + + +class IntentGraphError(MindgraphError): + """Base intent error with a stable machine-readable code.""" + + def __init__( + self, + code: str, + message: str, + *, + location: str | None = None, + witness: tuple[str, ...] = (), + graph_id: str | None = None, + graph_version: str | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.location = location + self.witness = witness + self.graph_id = graph_id + self.graph_version = graph_version + + def __str__(self) -> str: + details = [] + if self.location: + details.append(f"location={self.location}") + if self.witness: + details.append(f"witness={' -> '.join(self.witness)}") + suffix = f" ({', '.join(details)})" if details else "" + return f"{self.code}: {super().__str__()}{suffix}" + + +class IntentGraphValidationError(IntentGraphError): + """Raised when reviewed source violates the V1 graph contract.""" + + +class IntentVersionConflict(IntentGraphError): + """Raised when compilation would mutate approved version history.""" + + +class IntentStoreError(IntentGraphError): + """Raised when a compiled store is missing or violates its schema.""" + + +class _StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +def _require_id(value: str) -> str: + value = value.strip() + if not ID_RE.fullmatch(value): + raise ValueError("must be a lowercase dot/hyphen-separated stable ID") + return value + + +def _require_nonempty(value: str) -> str: + value = value.strip() + if not value: + raise ValueError("must not be empty") + return value + + +def _require_version(value: str) -> str: + value = value.strip() + if not VERSION_RE.fullmatch(value): + raise ValueError("must be a lowercase dot/hyphen-separated version") + return value + + +def _require_reference(value: str) -> str: + value = value.strip() + parsed = urlsplit(value) + if not parsed.scheme or not parsed.netloc: + raise ValueError("must be a stable scheme://reference") + return value + + +class GraphMetadata(_StrictModel): + id: str + version: str + status: Literal["approved"] + created_at: str + reviewed_at: str + reviewed_by: tuple[str, ...] + source_refs: tuple[str, ...] + supersedes: str | None = None + + _id = field_validator("id")(_require_id) + _version = field_validator("version")(_require_version) + _times = field_validator("created_at", "reviewed_at")(_require_nonempty) + + @field_validator("reviewed_by") + @classmethod + def validate_reviewers(cls, value: tuple[str, ...]) -> tuple[str, ...]: + cleaned = tuple(sorted({_require_nonempty(item) for item in value})) + if not cleaned: + raise ValueError("must contain at least one reviewer") + return cleaned + + @field_validator("source_refs") + @classmethod + def validate_source_refs(cls, value: tuple[str, ...]) -> tuple[str, ...]: + cleaned = tuple(sorted({_require_reference(item) for item in value})) + if not cleaned: + raise ValueError("must contain at least one source reference") + return cleaned + + @field_validator("supersedes") + @classmethod + def validate_supersedes(cls, value: str | None) -> str | None: + return _require_version(value) if value is not None else None + + +class IntentNodeSource(_StrictModel): + id: str + kind: Literal["goal", "capability", "constraint"] + label: str + aliases: tuple[str, ...] = () + status: Literal["active", "deprecated"] = "active" + source_refs: tuple[str, ...] + + _id = field_validator("id")(_require_id) + _label = field_validator("label")(_require_nonempty) + + @field_validator("aliases") + @classmethod + def validate_aliases(cls, value: tuple[str, ...]) -> tuple[str, ...]: + return tuple(_require_nonempty(item) for item in value) + + @field_validator("source_refs") + @classmethod + def validate_source_refs(cls, value: tuple[str, ...]) -> tuple[str, ...]: + cleaned = tuple(sorted({_require_reference(item) for item in value})) + if not cleaned: + raise ValueError("must contain at least one source reference") + return cleaned + + +class IntentEdgeSource(_StrictModel): + source_id: str + target_id: str + relation: str + status: Literal["active", "deprecated"] = "active" + source_refs: tuple[str, ...] + + _ids = field_validator("source_id", "target_id")(_require_id) + _relation = field_validator("relation")(_require_nonempty) + + @field_validator("source_refs") + @classmethod + def validate_source_refs(cls, value: tuple[str, ...]) -> tuple[str, ...]: + cleaned = tuple(sorted({_require_reference(item) for item in value})) + if not cleaned: + raise ValueError("must contain at least one source reference") + return cleaned + + +class IntentBindingSource(_StrictModel): + id: str + node_id: str + ref: str + required: bool = False + availability: Literal["available", "unavailable"] = "available" + source_refs: tuple[str, ...] + + _ids = field_validator("id", "node_id")(_require_id) + + @field_validator("ref") + @classmethod + def validate_ref(cls, value: str) -> str: + value = _require_reference(value) + if urlsplit(value).scheme not in BINDING_SCHEMES: + raise ValueError( + "binding scheme must be one of " + ", ".join(sorted(BINDING_SCHEMES)) + ) + return value + + @field_validator("source_refs") + @classmethod + def validate_source_refs(cls, value: tuple[str, ...]) -> tuple[str, ...]: + cleaned = tuple(sorted({_require_reference(item) for item in value})) + if not cleaned: + raise ValueError("must contain at least one source reference") + return cleaned + + +class IntentRuleMatch(_StrictModel): + scope: str | None = None + all_terms: tuple[str, ...] = () + any_terms: tuple[str, ...] = () + + @field_validator("scope") + @classmethod + def validate_scope(cls, value: str | None) -> str | None: + return normalize_text(value) if value is not None else None + + @field_validator("all_terms", "any_terms") + @classmethod + def validate_terms(cls, value: tuple[str, ...]) -> tuple[str, ...]: + cleaned = tuple(sorted({normalize_text(item) for item in value})) + if any(not item for item in cleaned): + raise ValueError("rule terms must not be empty") + return cleaned + + +class IntentRuleSource(_StrictModel): + id: str + priority: int = Field(ge=0, le=1000) + goal_id: str + match: IntentRuleMatch + source_refs: tuple[str, ...] + + _ids = field_validator("id", "goal_id")(_require_id) + + @field_validator("source_refs") + @classmethod + def validate_source_refs(cls, value: tuple[str, ...]) -> tuple[str, ...]: + cleaned = tuple(sorted({_require_reference(item) for item in value})) + if not cleaned: + raise ValueError("must contain at least one source reference") + return cleaned + + +class IntentGraphDocument(_StrictModel): + schema_version: Literal["1"] + graph: GraphMetadata + nodes: tuple[IntentNodeSource, ...] + edges: tuple[IntentEdgeSource, ...] = () + bindings: tuple[IntentBindingSource, ...] = () + rules: tuple[IntentRuleSource, ...] = () + + +class VersionHash(_StrictModel): + graph_id: str + version: str + source_hash: str + + +class CompileResult(_StrictModel): + destination: Path + graph_id: str + current_version: str + corpus_hash: str + version_hashes: tuple[VersionHash, ...] + version_count: int + node_count: int + edge_count: int + binding_count: int + rule_count: int + replaced: bool + + +class TraversalLimits(_StrictModel): + max_depth: int = Field(default=2, ge=0, le=32) + max_nodes: int = Field(default=64, ge=1, le=1000) + + +class IntentTraceEdge(_StrictModel): + source_id: str + target_id: str + relation: str + + +class IntentResolution(_StrictModel): + schema_version: Literal["1"] = "1" + graph_id: str + graph_version: str + source_hash: str + outcome: Literal["resolved", "fallback", "refusal"] + resolution_method: Literal["explicit", "alias", "rule", "none"] + matched_goal_ids: tuple[str, ...] = () + prerequisite_goal_ids: tuple[str, ...] = () + intent_path: tuple[str, ...] = () + edge_path: tuple[IntentTraceEdge, ...] = () + capability_hints: tuple[str, ...] = () + rejected_capability_hints: tuple[str, ...] = () + constraint_ids: tuple[str, ...] = () + warnings: tuple[str, ...] = () + truncation_reason: Literal["max_depth", "max_nodes"] | None = None + refusal_reason: str | None = None + + def as_transport_payload(self) -> dict[str, Any]: + """Return the stable field names used by CLI and MCP envelopes.""" + payload = self.model_dump() + payload["method"] = payload.pop("resolution_method") + payload["matched_goals"] = payload.pop("matched_goal_ids") + payload["prerequisite_goals"] = payload.pop("prerequisite_goal_ids") + payload["path"] = payload.pop("intent_path") + return payload + + def as_contract_result( + self, result_id: str, behaviors: tuple[str, ...] = () + ) -> dict[str, Any]: + """Return the additive candidate shape used by the Phase 1 evaluator.""" + behavior_set = set(behaviors) + if self.refusal_reason == "intent_no_match": + behavior_set.update({"require explicit scope", "do not query all stores"}) + if self.truncation_reason: + behavior_set.update({"return truncation warning", "preserve visited path"}) + if self.rejected_capability_hints: + behavior_set.add("policy rejected untrusted capability hint") + + payload: dict[str, Any] = { + "id": result_id, + "outcome": self.outcome, + "resolution_method": self.resolution_method, + "matched_goal_ids": list(self.matched_goal_ids), + "prerequisite_goal_ids": list(self.prerequisite_goal_ids), + "intent_path": list(self.intent_path), + "capability_hints": list(self.capability_hints), + "behaviors": sorted(behavior_set), + "fields": sorted(type(self).model_fields), + "graph_id": self.graph_id, + "graph_version": self.graph_version, + } + reason = self.refusal_reason + if self.truncation_reason: + reason = "intent_traversal_truncated" + if reason: + payload["reason"] = reason + return payload + + +@dataclass(frozen=True) +class _ValidatedVersion: + path: Path + document: IntentGraphDocument + canonical_bytes: bytes + source_hash: str + effective_status: str = "approved" + + +@dataclass(frozen=True) +class _ValidatedCorpus: + graph_id: str + current_version: str + corpus_hash: str + versions: tuple[_ValidatedVersion, ...] + + +def normalize_text(value: str) -> str: + """Normalize exact-match text without discarding its word content.""" + normalized = unicodedata.normalize("NFKC", value) + return " ".join(normalized.strip().split()).casefold() + + +def _tokens(value: str) -> frozenset[str]: + return frozenset(TOKEN_RE.findall(normalize_text(value))) + + +def _json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def _canonical_payload(document: IntentGraphDocument) -> dict[str, Any]: + payload = document.model_dump(mode="json") + payload["graph"]["reviewed_by"] = sorted(payload["graph"]["reviewed_by"]) + payload["graph"]["source_refs"] = sorted(payload["graph"]["source_refs"]) + for node in payload["nodes"]: + node["aliases"] = sorted(node["aliases"], key=normalize_text) + node["source_refs"] = sorted(node["source_refs"]) + for edge in payload["edges"]: + edge["source_refs"] = sorted(edge["source_refs"]) + for binding in payload["bindings"]: + binding["source_refs"] = sorted(binding["source_refs"]) + for rule in payload["rules"]: + rule["match"]["all_terms"] = sorted(rule["match"]["all_terms"]) + rule["match"]["any_terms"] = sorted(rule["match"]["any_terms"]) + rule["source_refs"] = sorted(rule["source_refs"]) + payload["nodes"] = sorted(payload["nodes"], key=lambda item: item["id"]) + payload["edges"] = sorted( + payload["edges"], + key=lambda item: (item["source_id"], item["target_id"], item["relation"]), + ) + payload["bindings"] = sorted(payload["bindings"], key=lambda item: item["id"]) + payload["rules"] = sorted(payload["rules"], key=lambda item: item["id"]) + return payload + + +def _canonical_bytes(document: IntentGraphDocument) -> bytes: + return (_json(_canonical_payload(document)) + "\n").encode("utf-8") + + +def _validation_error( + code: str, + message: str, + *, + document: IntentGraphDocument | None = None, + location: str | None = None, + witness: tuple[str, ...] = (), +) -> IntentGraphValidationError: + return IntentGraphValidationError( + code, + message, + location=location, + witness=witness, + graph_id=document.graph.id if document else None, + graph_version=document.graph.version if document else None, + ) + + +def _load_document(path: Path) -> IntentGraphDocument: + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError) as exc: + raise IntentGraphValidationError( + "intent_schema_invalid", f"cannot read YAML source: {exc}", location=path.name + ) from exc + if not isinstance(raw, dict): + raise IntentGraphValidationError( + "intent_schema_invalid", "source must be a YAML mapping", location=path.name + ) + try: + return IntentGraphDocument.model_validate(raw) + except ValidationError as exc: + errors = sorted( + exc.errors(), + key=lambda item: (tuple(str(part) for part in item["loc"]), item["type"]), + ) + first = errors[0] + field = ".".join(str(part) for part in first["loc"]) + raise IntentGraphValidationError( + "intent_schema_invalid", + first["msg"], + location=f"{path.name}:{field}", + ) from exc + + +def _duplicate(values: list[str]) -> str | None: + seen: set[str] = set() + for value in values: + if value in seen: + return value + seen.add(value) + return None + + +def _cycle_witness(adjacency: dict[str, tuple[str, ...]]) -> tuple[str, ...] | None: + visited: set[str] = set() + active: list[str] = [] + active_set: set[str] = set() + + def visit(node: str) -> tuple[str, ...] | None: + visited.add(node) + active.append(node) + active_set.add(node) + for target in adjacency.get(node, ()): + if target in active_set: + start = active.index(target) + return tuple(active[start:] + [target]) + if target not in visited: + witness = visit(target) + if witness: + return witness + active.pop() + active_set.remove(node) + return None + + for node in sorted(adjacency): + if node not in visited: + witness = visit(node) + if witness: + return witness + return None + + +def _validate_document(document: IntentGraphDocument, path: Path) -> None: + node_ids = [node.id for node in sorted(document.nodes, key=lambda item: item.id)] + duplicate = _duplicate(node_ids) + if duplicate: + raise _validation_error( + "intent_duplicate_id", + f"duplicate node ID {duplicate}", + document=document, + location=path.name, + witness=(duplicate,), + ) + if not node_ids: + raise _validation_error( + "intent_schema_invalid", + "graph version must contain at least one node", + document=document, + location=path.name, + ) + + node_map = {node.id: node for node in document.nodes} + for node in sorted(document.nodes, key=lambda item: item.id): + if not node.id.startswith(f"{node.kind}."): + raise _validation_error( + "intent_kind_mismatch", + f"node {node.id} must use the {node.kind}. prefix", + document=document, + location=f"{path.name}:nodes.{node.id}", + witness=(node.id,), + ) + if node.kind != "goal" and node.aliases: + raise _validation_error( + "intent_kind_mismatch", + f"only goals may declare aliases: {node.id}", + document=document, + location=f"{path.name}:nodes.{node.id}.aliases", + witness=(node.id,), + ) + + alias_owners: dict[str, str] = {} + for node in sorted(document.nodes, key=lambda item: item.id): + if node.kind != "goal" or node.status != "active": + continue + for alias in (node.label, *node.aliases): + normalized = normalize_text(alias) + owner = alias_owners.get(normalized) + if owner and owner != node.id: + raise _validation_error( + "intent_alias_ambiguous", + f"normalized label/alias {normalized!r} identifies multiple goals", + document=document, + location=f"{path.name}:nodes", + witness=(owner, node.id), + ) + if owner == node.id: + raise _validation_error( + "intent_alias_ambiguous", + f"goal {node.id} repeats normalized label/alias {normalized!r}", + document=document, + location=f"{path.name}:nodes.{node.id}.aliases", + witness=(node.id,), + ) + alias_owners[normalized] = node.id + + edge_keys = [ + (edge.source_id, edge.target_id, edge.relation) + for edge in sorted( + document.edges, + key=lambda item: (item.source_id, item.target_id, item.relation), + ) + ] + duplicate_edge = _duplicate(["\x1f".join(key) for key in edge_keys]) + if duplicate_edge: + raise _validation_error( + "intent_duplicate_id", + "duplicate edge", + document=document, + location=f"{path.name}:edges", + witness=tuple(duplicate_edge.split("\x1f")), + ) + + expected_kinds = { + "decomposes_to": ("goal", "goal"), + "requires": ("goal", "goal"), + "next_step": ("goal", "goal"), + "blocked_by": ("goal", "constraint"), + "routes_to": ("goal", "capability"), + } + for edge in sorted( + document.edges, + key=lambda item: (item.source_id, item.target_id, item.relation), + ): + if edge.relation not in RELATIONS: + raise _validation_error( + "intent_relation_invalid", + f"unknown relation {edge.relation}", + document=document, + location=f"{path.name}:edges", + witness=(edge.source_id, edge.target_id), + ) + missing = [item for item in (edge.source_id, edge.target_id) if item not in node_map] + if missing: + raise _validation_error( + "intent_missing_target", + f"edge references missing node {missing[0]}", + document=document, + location=f"{path.name}:edges", + witness=(edge.source_id, edge.target_id), + ) + actual = (node_map[edge.source_id].kind, node_map[edge.target_id].kind) + if actual != expected_kinds[edge.relation]: + raise _validation_error( + "intent_kind_mismatch", + f"{edge.relation} requires {expected_kinds[edge.relation]}, got {actual}", + document=document, + location=f"{path.name}:edges", + witness=(edge.source_id, edge.target_id), + ) + + for relation in sorted(ACYCLIC_RELATIONS): + adjacency: dict[str, list[str]] = {} + for edge in document.edges: + if edge.status == "active" and edge.relation == relation: + adjacency.setdefault(edge.source_id, []).append(edge.target_id) + ordered = {key: tuple(sorted(value)) for key, value in adjacency.items()} + witness = _cycle_witness(ordered) + if witness: + raise _validation_error( + "intent_cycle_detected", + f"{relation} contains a cycle", + document=document, + location=f"{path.name}:edges", + witness=witness, + ) + + binding_ids = [item.id for item in sorted(document.bindings, key=lambda item: item.id)] + duplicate = _duplicate(binding_ids) + if duplicate: + raise _validation_error( + "intent_duplicate_id", + f"duplicate binding ID {duplicate}", + document=document, + location=f"{path.name}:bindings", + witness=(duplicate,), + ) + for binding in sorted(document.bindings, key=lambda item: item.id): + if binding.node_id not in node_map: + raise _validation_error( + "intent_missing_target", + f"binding references missing node {binding.node_id}", + document=document, + location=f"{path.name}:bindings.{binding.id}", + witness=(binding.node_id,), + ) + if binding.required and binding.availability == "unavailable": + raise _validation_error( + "intent_binding_unavailable", + f"required binding {binding.ref} is unavailable", + document=document, + location=f"{path.name}:bindings.{binding.id}", + witness=(binding.node_id, binding.ref), + ) + + rule_ids = [item.id for item in sorted(document.rules, key=lambda item: item.id)] + duplicate = _duplicate(rule_ids) + if duplicate: + raise _validation_error( + "intent_duplicate_id", + f"duplicate rule ID {duplicate}", + document=document, + location=f"{path.name}:rules", + witness=(duplicate,), + ) + for rule in sorted(document.rules, key=lambda item: item.id): + goal = node_map.get(rule.goal_id) + if goal is None: + raise _validation_error( + "intent_missing_target", + f"rule references missing goal {rule.goal_id}", + document=document, + location=f"{path.name}:rules.{rule.id}", + witness=(rule.goal_id,), + ) + if goal.kind != "goal": + raise _validation_error( + "intent_kind_mismatch", + f"rule target {rule.goal_id} is not a goal", + document=document, + location=f"{path.name}:rules.{rule.id}", + witness=(rule.goal_id,), + ) + match = rule.match + if not match.scope and not match.all_terms and not match.any_terms: + raise _validation_error( + "intent_schema_invalid", + f"rule {rule.id} has no match condition", + document=document, + location=f"{path.name}:rules.{rule.id}.match", + witness=(rule.id,), + ) + + +def validate_intent_corpus(source_dir: Path) -> _ValidatedCorpus: + """Load and validate one append-only graph-family corpus.""" + source_dir = Path(source_dir) + if not source_dir.is_dir(): + raise IntentGraphValidationError( + "intent_schema_invalid", + "source directory does not exist", + location=str(source_dir), + ) + paths = sorted(path for path in source_dir.glob("*.yaml") if path.is_file()) + if not paths: + raise IntentGraphValidationError( + "intent_schema_invalid", + "source corpus contains no direct *.yaml files", + location=str(source_dir), + ) + + versions: list[_ValidatedVersion] = [] + for path in paths: + document = _load_document(path) + _validate_document(document, path) + canonical = _canonical_bytes(document) + versions.append( + _ValidatedVersion( + path=path, + document=document, + canonical_bytes=canonical, + source_hash=hashlib.sha256(canonical).hexdigest(), + ) + ) + + graph_ids = sorted({version.document.graph.id for version in versions}) + if len(graph_ids) != 1: + raise IntentGraphValidationError( + "intent_version_fork", + "a source corpus must contain exactly one graph family", + witness=tuple(graph_ids), + ) + graph_id = graph_ids[0] + + by_version: dict[str, _ValidatedVersion] = {} + for item in sorted(versions, key=lambda version: version.document.graph.version): + version = item.document.graph.version + if version in by_version: + raise _validation_error( + "intent_duplicate_id", + f"duplicate graph version {version}", + document=item.document, + location=item.path.name, + witness=(version,), + ) + by_version[version] = item + + successors: dict[str, list[str]] = {version: [] for version in by_version} + roots: list[str] = [] + for version, item in sorted(by_version.items()): + predecessor = item.document.graph.supersedes + if predecessor is None: + roots.append(version) + continue + if predecessor not in by_version: + raise _validation_error( + "intent_version_missing", + f"version {version} supersedes missing version {predecessor}", + document=item.document, + location=item.path.name, + witness=(predecessor, version), + ) + successors[predecessor].append(version) + + forks = sorted(version for version, items in successors.items() if len(items) > 1) + if len(roots) != 1 or forks: + witness = tuple(sorted(roots + forks)) + raise IntentGraphValidationError( + "intent_version_fork", + "version lineage must be one unbranched chain", + graph_id=graph_id, + witness=witness, + ) + + ordered_names: list[str] = [] + current = roots[0] + seen: set[str] = set() + while current not in seen: + seen.add(current) + ordered_names.append(current) + next_versions = successors[current] + if not next_versions: + break + current = next_versions[0] + if len(seen) != len(by_version): + raise IntentGraphValidationError( + "intent_version_fork", + "version lineage contains a disconnected chain or cycle", + graph_id=graph_id, + witness=tuple(sorted(set(by_version) - seen)), + ) + + ordered: list[_ValidatedVersion] = [] + for name in ordered_names: + item = by_version[name] + ordered.append( + _ValidatedVersion( + path=item.path, + document=item.document, + canonical_bytes=item.canonical_bytes, + source_hash=item.source_hash, + effective_status="approved" if name == ordered_names[-1] else "superseded", + ) + ) + corpus_payload = [ + { + "graph_id": item.document.graph.id, + "version": item.document.graph.version, + "source_hash": item.source_hash, + } + for item in ordered + ] + corpus_hash = hashlib.sha256((_json(corpus_payload) + "\n").encode("utf-8")).hexdigest() + return _ValidatedCorpus( + graph_id=graph_id, + current_version=ordered_names[-1], + corpus_hash=corpus_hash, + versions=tuple(ordered), + ) + + +def _existing_version_hashes(destination: Path) -> tuple[str, dict[str, str]]: + try: + conn = _connect_read_only(destination) + try: + _validate_store(conn) + graph_row = conn.execute( + "SELECT value FROM schema_meta WHERE key = 'graph_id'" + ).fetchone() + rows = conn.execute( + "SELECT version, source_hash FROM graph_versions ORDER BY version" + ).fetchall() + finally: + conn.close() + except (sqlite3.Error, IntentStoreError) as exc: + raise IntentStoreError( + "intent_store_invalid", + f"cannot inspect existing destination: {exc}", + location=str(destination), + ) from exc + if graph_row is None: + raise IntentStoreError( + "intent_store_invalid", + "existing destination has no graph_id", + location=str(destination), + ) + return graph_row["value"], {row["version"]: row["source_hash"] for row in rows} + + +def _check_existing_history(destination: Path, corpus: _ValidatedCorpus) -> None: + if not destination.exists(): + return + graph_id, existing = _existing_version_hashes(destination) + if graph_id != corpus.graph_id: + raise IntentVersionConflict( + "intent_version_conflict", + f"existing graph {graph_id} cannot be replaced by {corpus.graph_id}", + location=str(destination), + graph_id=corpus.graph_id, + ) + proposed = { + item.document.graph.version: item.source_hash for item in corpus.versions + } + for version, source_hash in sorted(existing.items()): + if version not in proposed: + raise IntentVersionConflict( + "intent_version_missing", + f"approved version {version} was removed from the source corpus", + location=str(destination), + witness=(version,), + graph_id=graph_id, + graph_version=version, + ) + if proposed[version] != source_hash: + raise IntentVersionConflict( + "intent_version_conflict", + f"approved version {version} changed source hash", + location=str(destination), + witness=(version,), + graph_id=graph_id, + graph_version=version, + ) + + +SCHEMA_SQL = """ +CREATE TABLE schema_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +) WITHOUT ROWID; +CREATE TABLE graph_versions ( + graph_id TEXT NOT NULL, + version TEXT NOT NULL, + effective_status TEXT NOT NULL, + schema_version TEXT NOT NULL, + source_hash TEXT NOT NULL, + source_path TEXT NOT NULL, + created_at TEXT NOT NULL, + reviewed_at TEXT NOT NULL, + reviewed_by_json TEXT NOT NULL, + supersedes_version TEXT, + PRIMARY KEY (graph_id, version) +) WITHOUT ROWID; +CREATE TABLE intent_nodes ( + graph_id TEXT NOT NULL, + version TEXT NOT NULL, + node_id TEXT NOT NULL, + kind TEXT NOT NULL, + label TEXT NOT NULL, + normalized_label TEXT NOT NULL, + aliases_json TEXT NOT NULL, + status TEXT NOT NULL, + source_refs_json TEXT NOT NULL, + PRIMARY KEY (graph_id, version, node_id), + FOREIGN KEY (graph_id, version) REFERENCES graph_versions(graph_id, version) +) WITHOUT ROWID; +CREATE TABLE intent_aliases ( + graph_id TEXT NOT NULL, + version TEXT NOT NULL, + normalized_alias TEXT NOT NULL, + node_id TEXT NOT NULL, + alias TEXT NOT NULL, + PRIMARY KEY (graph_id, version, normalized_alias), + FOREIGN KEY (graph_id, version, node_id) + REFERENCES intent_nodes(graph_id, version, node_id) +) WITHOUT ROWID; +CREATE TABLE intent_edges ( + graph_id TEXT NOT NULL, + version TEXT NOT NULL, + source_id TEXT NOT NULL, + target_id TEXT NOT NULL, + relation TEXT NOT NULL, + status TEXT NOT NULL, + source_refs_json TEXT NOT NULL, + PRIMARY KEY (graph_id, version, source_id, target_id, relation), + FOREIGN KEY (graph_id, version, source_id) + REFERENCES intent_nodes(graph_id, version, node_id), + FOREIGN KEY (graph_id, version, target_id) + REFERENCES intent_nodes(graph_id, version, node_id) +) WITHOUT ROWID; +CREATE TABLE intent_bindings ( + graph_id TEXT NOT NULL, + version TEXT NOT NULL, + binding_id TEXT NOT NULL, + node_id TEXT NOT NULL, + ref TEXT NOT NULL, + required INTEGER NOT NULL, + availability TEXT NOT NULL, + source_refs_json TEXT NOT NULL, + PRIMARY KEY (graph_id, version, binding_id), + FOREIGN KEY (graph_id, version, node_id) + REFERENCES intent_nodes(graph_id, version, node_id) +) WITHOUT ROWID; +CREATE TABLE intent_rules ( + graph_id TEXT NOT NULL, + version TEXT NOT NULL, + rule_id TEXT NOT NULL, + priority INTEGER NOT NULL, + goal_id TEXT NOT NULL, + scope TEXT, + all_terms_json TEXT NOT NULL, + any_terms_json TEXT NOT NULL, + source_refs_json TEXT NOT NULL, + PRIMARY KEY (graph_id, version, rule_id), + FOREIGN KEY (graph_id, version, goal_id) + REFERENCES intent_nodes(graph_id, version, node_id) +) WITHOUT ROWID; +""" + + +def _build_database(path: Path, corpus: _ValidatedCorpus) -> None: + conn = sqlite3.connect(str(path)) + conn.row_factory = sqlite3.Row + try: + conn.execute("PRAGMA page_size = 4096") + conn.execute("PRAGMA encoding = 'UTF-8'") + conn.execute("PRAGMA auto_vacuum = NONE") + conn.execute("PRAGMA journal_mode = OFF") + conn.execute("PRAGMA synchronous = OFF") + conn.execute("PRAGMA foreign_keys = ON") + conn.executescript(SCHEMA_SQL) + meta = { + "schema_version": SCHEMA_VERSION, + "graph_id": corpus.graph_id, + "current_version": corpus.current_version, + "corpus_hash": corpus.corpus_hash, + } + conn.executemany( + "INSERT INTO schema_meta(key, value) VALUES (?, ?)", sorted(meta.items()) + ) + for item in corpus.versions: + graph = item.document.graph + conn.execute( + """ + INSERT INTO graph_versions VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + graph.id, + graph.version, + item.effective_status, + SCHEMA_VERSION, + item.source_hash, + item.path.name, + graph.created_at, + graph.reviewed_at, + _json(list(graph.reviewed_by)), + graph.supersedes, + ), + ) + for node in sorted(item.document.nodes, key=lambda value: value.id): + aliases = sorted(node.aliases, key=normalize_text) + conn.execute( + "INSERT INTO intent_nodes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + graph.id, + graph.version, + node.id, + node.kind, + node.label, + normalize_text(node.label), + _json(aliases), + node.status, + _json(list(node.source_refs)), + ), + ) + if node.kind == "goal" and node.status == "active": + for alias in (node.label, *aliases): + conn.execute( + "INSERT INTO intent_aliases VALUES (?, ?, ?, ?, ?)", + (graph.id, graph.version, normalize_text(alias), node.id, alias), + ) + for edge in sorted( + item.document.edges, + key=lambda value: (value.source_id, value.target_id, value.relation), + ): + conn.execute( + "INSERT INTO intent_edges VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + graph.id, + graph.version, + edge.source_id, + edge.target_id, + edge.relation, + edge.status, + _json(list(edge.source_refs)), + ), + ) + for binding in sorted(item.document.bindings, key=lambda value: value.id): + conn.execute( + "INSERT INTO intent_bindings VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + graph.id, + graph.version, + binding.id, + binding.node_id, + binding.ref, + int(binding.required), + binding.availability, + _json(list(binding.source_refs)), + ), + ) + for rule in sorted(item.document.rules, key=lambda value: value.id): + conn.execute( + "INSERT INTO intent_rules VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + graph.id, + graph.version, + rule.id, + rule.priority, + rule.goal_id, + rule.match.scope, + _json(list(rule.match.all_terms)), + _json(list(rule.match.any_terms)), + _json(list(rule.source_refs)), + ), + ) + conn.commit() + if conn.execute("PRAGMA foreign_key_check").fetchall(): + raise IntentStoreError( + "intent_store_invalid", "compiled store failed foreign_key_check" + ) + integrity = conn.execute("PRAGMA integrity_check").fetchone()[0] + if integrity != "ok": + raise IntentStoreError( + "intent_store_invalid", f"compiled store failed integrity_check: {integrity}" + ) + conn.execute("VACUUM") + integrity = conn.execute("PRAGMA integrity_check").fetchone()[0] + if integrity != "ok": + raise IntentStoreError( + "intent_store_invalid", f"vacuumed store failed integrity_check: {integrity}" + ) + except Exception: + conn.close() + raise + else: + conn.close() + + +def _fsync(path: Path) -> None: + with path.open("rb") as handle: + os.fsync(handle.fileno()) + try: + directory_fd = os.open(path.parent, os.O_RDONLY) + except OSError: + return + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + + +def compile_intent_corpus(source_dir: Path, destination: Path) -> CompileResult: + """Validate and atomically compile a reviewed intent corpus.""" + source_dir = Path(source_dir) + destination = Path(destination) + corpus = validate_intent_corpus(source_dir) + _check_existing_history(destination, corpus) + destination.parent.mkdir(parents=True, exist_ok=True) + replaced = destination.exists() + fd, temp_name = tempfile.mkstemp( + prefix=f".{destination.name}.", suffix=".tmp", dir=destination.parent + ) + os.close(fd) + temp_path = Path(temp_name) + temp_path.unlink() + try: + _build_database(temp_path, corpus) + os.chmod(temp_path, 0o600) + check = open_intent_store(temp_path) + check.close() + _fsync(temp_path) + os.replace(temp_path, destination) + _fsync(destination) + finally: + if temp_path.exists(): + temp_path.unlink() + + return CompileResult( + destination=destination, + graph_id=corpus.graph_id, + current_version=corpus.current_version, + corpus_hash=corpus.corpus_hash, + version_hashes=tuple( + VersionHash( + graph_id=item.document.graph.id, + version=item.document.graph.version, + source_hash=item.source_hash, + ) + for item in corpus.versions + ), + version_count=len(corpus.versions), + node_count=sum(len(item.document.nodes) for item in corpus.versions), + edge_count=sum(len(item.document.edges) for item in corpus.versions), + binding_count=sum(len(item.document.bindings) for item in corpus.versions), + rule_count=sum(len(item.document.rules) for item in corpus.versions), + replaced=replaced, + ) + + +def _connect_read_only(path: Path) -> sqlite3.Connection: + resolved = Path(path).expanduser().resolve() + if not resolved.is_file(): + raise IntentStoreError( + "intent_store_invalid", "intent store does not exist", location=str(resolved) + ) + uri = f"file:{quote(str(resolved), safe='/')}?mode=ro&immutable=1" + conn = sqlite3.connect(uri, uri=True) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA query_only = ON") + return conn + + +def _validate_store(conn: sqlite3.Connection) -> None: + tables = { + row["name"] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ).fetchall() + } + missing = sorted(REQUIRED_TABLES - tables) + if missing: + raise IntentStoreError( + "intent_store_invalid", f"intent store is missing tables: {', '.join(missing)}" + ) + meta = { + row["key"]: row["value"] + for row in conn.execute("SELECT key, value FROM schema_meta").fetchall() + } + required_meta = {"schema_version", "graph_id", "current_version", "corpus_hash"} + if required_meta - set(meta) or meta.get("schema_version") != SCHEMA_VERSION: + raise IntentStoreError( + "intent_store_invalid", "intent store metadata is missing or incompatible" + ) + integrity = conn.execute("PRAGMA integrity_check").fetchone()[0] + if integrity != "ok": + raise IntentStoreError( + "intent_store_invalid", f"intent store failed integrity_check: {integrity}" + ) + if conn.execute("PRAGMA foreign_key_check").fetchall(): + raise IntentStoreError( + "intent_store_invalid", "intent store failed foreign_key_check" + ) + heads = conn.execute( + "SELECT version FROM graph_versions WHERE effective_status = 'approved'" + ).fetchall() + if len(heads) != 1 or heads[0]["version"] != meta["current_version"]: + raise IntentStoreError( + "intent_store_invalid", "intent store must contain exactly one current version" + ) + + +def open_intent_store(path: Path) -> sqlite3.Connection: + """Open and validate a compiled intent store without creating or writing it.""" + conn = _connect_read_only(Path(path)) + try: + _validate_store(conn) + except Exception: + conn.close() + raise + return conn + + +def _metadata(conn: sqlite3.Connection) -> tuple[str, str, str]: + meta = { + row["key"]: row["value"] + for row in conn.execute("SELECT key, value FROM schema_meta").fetchall() + } + version = meta["current_version"] + row = conn.execute( + "SELECT source_hash FROM graph_versions WHERE graph_id = ? AND version = ?", + (meta["graph_id"], version), + ).fetchone() + if row is None: + raise IntentStoreError( + "intent_store_invalid", "current graph version is missing" + ) + return meta["graph_id"], version, row["source_hash"] + + +def _base_resolution( + graph_id: str, + graph_version: str, + source_hash: str, + *, + outcome: Literal["resolved", "fallback", "refusal"], + method: Literal["explicit", "alias", "rule", "none"], + goal_id: str | None = None, + refusal_reason: str | None = None, + path: tuple[str, ...] = (), +) -> IntentResolution: + return IntentResolution( + graph_id=graph_id, + graph_version=graph_version, + source_hash=source_hash, + outcome=outcome, + resolution_method=method, + matched_goal_ids=(goal_id,) if goal_id else (), + intent_path=path, + refusal_reason=refusal_reason, + ) + + +def _resolve_rule( + conn: sqlite3.Connection, + graph_id: str, + version: str, + query_text: str, + scope: str | None, +) -> tuple[str | None, bool]: + query_tokens = _tokens(query_text) + normalized_scope = normalize_text(scope) if scope is not None else None + matches: list[tuple[int, str, str]] = [] + rows = conn.execute( + """ + SELECT rule_id, priority, goal_id, scope, all_terms_json, any_terms_json + FROM intent_rules WHERE graph_id = ? AND version = ? + ORDER BY priority DESC, rule_id ASC + """, + (graph_id, version), + ).fetchall() + for row in rows: + if row["scope"] is not None and row["scope"] != normalized_scope: + continue + all_terms = json.loads(row["all_terms_json"]) + any_terms = json.loads(row["any_terms_json"]) + if any(not _tokens(term).issubset(query_tokens) for term in all_terms): + continue + if any_terms and not any(_tokens(term).issubset(query_tokens) for term in any_terms): + continue + matches.append((row["priority"], row["rule_id"], row["goal_id"])) + if not matches: + return None, False + top_priority = matches[0][0] + top = [item for item in matches if item[0] == top_priority] + goals = sorted({item[2] for item in top}) + if len(goals) > 1: + return None, True + return goals[0], False + + +def _runtime_cycle( + conn: sqlite3.Connection, graph_id: str, version: str, relation: str +) -> tuple[str, ...] | None: + rows = conn.execute( + """ + SELECT source_id, target_id FROM intent_edges + WHERE graph_id = ? AND version = ? AND relation = ? AND status = 'active' + ORDER BY source_id, target_id + """, + (graph_id, version, relation), + ).fetchall() + adjacency: dict[str, list[str]] = {} + for row in rows: + adjacency.setdefault(row["source_id"], []).append(row["target_id"]) + return _cycle_witness( + {source: tuple(sorted(targets)) for source, targets in adjacency.items()} + ) + + +def _traverse_relation( + conn: sqlite3.Connection, + graph_id: str, + version: str, + root: str, + relation: Literal["decomposes_to", "requires", "next_step"], + limits: TraversalLimits, +) -> tuple[ + tuple[str, ...], + tuple[str, ...], + tuple[IntentTraceEdge, ...], + Literal["max_depth", "max_nodes"] | None, +]: + path = [root] + prerequisites: list[str] = [] + edge_path: list[IntentTraceEdge] = [] + visited = {root} + truncation: Literal["max_depth", "max_nodes"] | None = None + + def visit(node: str, depth: int) -> None: + nonlocal truncation + rows = conn.execute( + """ + SELECT target_id FROM intent_edges + WHERE graph_id = ? AND version = ? AND source_id = ? + AND relation = ? AND status = 'active' + ORDER BY target_id + """, + (graph_id, version, node, relation), + ).fetchall() + for row in rows: + target = row["target_id"] + if target in visited: + continue + if depth >= limits.max_depth: + truncation = truncation or "max_depth" + continue + if len(visited) >= limits.max_nodes: + truncation = truncation or "max_nodes" + continue + visited.add(target) + path.append(target) + prerequisites.append(target) + edge_path.append( + IntentTraceEdge(source_id=node, target_id=target, relation=relation) + ) + visit(target, depth + 1) + + visit(root, 0) + return tuple(path), tuple(prerequisites), tuple(edge_path), truncation + + +def _collect_constraints( + conn: sqlite3.Connection, graph_id: str, version: str, goals: tuple[str, ...] +) -> tuple[str, ...]: + constraints: set[str] = set() + for goal in goals: + rows = conn.execute( + """ + SELECT target_id FROM intent_edges + WHERE graph_id = ? AND version = ? AND source_id = ? + AND relation = 'blocked_by' AND status = 'active' + ORDER BY target_id + """, + (graph_id, version, goal), + ).fetchall() + constraints.update(row["target_id"] for row in rows) + return tuple(sorted(constraints)) + + +def _collect_capabilities( + conn: sqlite3.Connection, + graph_id: str, + version: str, + goals: tuple[str, ...], + allowed: frozenset[str], +) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...]]: + hints: set[str] = set() + rejected: set[str] = set() + warnings: set[str] = set() + capability_nodes: set[str] = set() + for goal in goals: + rows = conn.execute( + """ + SELECT target_id FROM intent_edges + WHERE graph_id = ? AND version = ? AND source_id = ? + AND relation = 'routes_to' AND status = 'active' + ORDER BY target_id + """, + (graph_id, version, goal), + ).fetchall() + capability_nodes.update(row["target_id"] for row in rows) + for node in sorted(capability_nodes): + rows = conn.execute( + """ + SELECT ref, availability FROM intent_bindings + WHERE graph_id = ? AND version = ? AND node_id = ? + ORDER BY ref + """, + (graph_id, version, node), + ).fetchall() + for row in rows: + ref = row["ref"] + if row["availability"] != "available": + rejected.add(ref) + warnings.add(f"binding_unavailable:{ref}") + elif ref not in allowed: + rejected.add(ref) + warnings.add(f"capability_not_allowed:{ref}") + else: + hints.add(ref) + return tuple(sorted(hints)), tuple(sorted(rejected)), tuple(sorted(warnings)) + + +def resolve_intent( + conn: sqlite3.Connection, + query_text: str, + *, + intent_id: str | None = None, + scope: str | None = None, + allowed_capability_refs: frozenset[str] = frozenset(), + limits: TraversalLimits = TraversalLimits(), +) -> IntentResolution: + """Resolve one reviewed root goal and return bounded prerequisite context.""" + graph_id, version, source_hash = _metadata(conn) + method: Literal["explicit", "alias", "rule", "none"] = "none" + goal_id: str | None = None + + if intent_id is not None: + method = "explicit" + row = conn.execute( + """ + SELECT node_id FROM intent_nodes + WHERE graph_id = ? AND version = ? AND node_id = ? + AND kind = 'goal' AND status = 'active' + """, + (graph_id, version, intent_id), + ).fetchone() + goal_id = row["node_id"] if row else None + else: + normalized = normalize_text(query_text) + rows = conn.execute( + """ + SELECT node_id FROM intent_aliases + WHERE graph_id = ? AND version = ? AND normalized_alias = ? + ORDER BY node_id + """, + (graph_id, version, normalized), + ).fetchall() + alias_goals = sorted({row["node_id"] for row in rows}) + if len(alias_goals) > 1: + return _base_resolution( + graph_id, + version, + source_hash, + outcome="refusal", + method="none", + refusal_reason="intent_alias_ambiguous", + ) + if alias_goals: + method = "alias" + goal_id = alias_goals[0] + else: + goal_id, ambiguous = _resolve_rule( + conn, graph_id, version, query_text, scope + ) + if ambiguous: + return _base_resolution( + graph_id, + version, + source_hash, + outcome="refusal", + method="rule", + refusal_reason="intent_rule_ambiguous", + ) + if goal_id: + method = "rule" + + if goal_id is None: + return _base_resolution( + graph_id, + version, + source_hash, + outcome="fallback", + method="none" if intent_id is None else "explicit", + refusal_reason="intent_no_match", + ) + + for relation in sorted(ACYCLIC_RELATIONS): + cycle = _runtime_cycle(conn, graph_id, version, relation) + if cycle: + return _base_resolution( + graph_id, + version, + source_hash, + outcome="refusal", + method=method, + goal_id=goal_id, + refusal_reason="intent_cycle_detected", + path=cycle, + ) + + path, prerequisites, edge_path, truncation = _traverse_relation( + conn, graph_id, version, goal_id, "requires", limits + ) + constraints = _collect_constraints(conn, graph_id, version, path) + hints, rejected, capability_warnings = _collect_capabilities( + conn, graph_id, version, path, allowed_capability_refs + ) + warnings = set(capability_warnings) + warnings.update(f"blocked_by:{item}" for item in constraints) + if truncation: + warnings.add("intent_traversal_truncated") + return IntentResolution( + graph_id=graph_id, + graph_version=version, + source_hash=source_hash, + outcome="resolved", + resolution_method=method, + matched_goal_ids=(goal_id,), + prerequisite_goal_ids=prerequisites, + intent_path=path, + edge_path=edge_path, + capability_hints=hints, + rejected_capability_hints=rejected, + constraint_ids=constraints, + warnings=tuple(sorted(warnings)), + truncation_reason=truncation, + ) diff --git a/mindgraph/src/mindgraph/mcp_proxy.py b/mindgraph/src/mindgraph/mcp_proxy.py new file mode 100644 index 0000000..f4193fb --- /dev/null +++ b/mindgraph/src/mindgraph/mcp_proxy.py @@ -0,0 +1,79 @@ +"""Official-SDK stdio server proxying to a Streamable HTTP MCP server.""" + +from contextlib import asynccontextmanager +import anyio +import json +import urllib.parse +import urllib.request +from mcp import ClientSession, types +from mcp.client.streamable_http import streamablehttp_client +from mcp.server import Server +from mcp.server.stdio import stdio_server + + +def create_proxy_server(remote: ClientSession) -> Server: + server = Server("mindgraph-mcp-proxy") + + @server.list_tools() + async def list_tools() -> list[types.Tool]: + return (await remote.list_tools()).tools + + @server.call_tool() + async def call_tool(name: str, arguments: dict | None): + return await remote.call_tool(name, arguments) + return server + + +@asynccontextmanager +async def remote_session(url: str): + async with streamablehttp_client(url) as (read, write, _session_id): + async with ClientSession(read, write) as session: + await session.initialize() + yield session + + +def _lease_url(url: str) -> str: + parsed = urllib.parse.urlsplit(url) + return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "/lifecycle/lease", "", "")) + + +def _lease_request(url: str, token: str = "", method: str = "POST") -> dict: + request = urllib.request.Request( + _lease_url(url), method=method, + headers={"X-MindGraph-Lease": token} if token else {}, + ) + with urllib.request.urlopen(request, timeout=2) as response: + return json.loads(response.read()) + + +async def _renew_lease(url: str, token: str, interval: float) -> None: + while True: + await anyio.sleep(interval) + await anyio.to_thread.run_sync(_lease_request, url, token) + + +async def run_proxy(url: str, *, lease: bool = False) -> None: + token = "" + if lease: + response = await anyio.to_thread.run_sync(_lease_request, url) + token = response["lease"] + interval = max(1.0, float(response["ttl_seconds"]) / 3) + try: + async with anyio.create_task_group() as tasks: + if token: + tasks.start_soon(_renew_lease, url, token, interval) + async with remote_session(url) as remote: + proxy = create_proxy_server(remote) + async with stdio_server() as (read, write): + await proxy.run(read, write, proxy.create_initialization_options()) + tasks.cancel_scope.cancel() + finally: + if token: + try: + await anyio.to_thread.run_sync(_lease_request, url, token, "DELETE") + except Exception: + pass + + +def run_proxy_sync(url: str, *, lease: bool = False) -> None: + anyio.run(lambda: run_proxy(url, lease=lease)) diff --git a/mindgraph/src/mindgraph/mcp_server.py b/mindgraph/src/mindgraph/mcp_server.py new file mode 100644 index 0000000..2b976c9 --- /dev/null +++ b/mindgraph/src/mindgraph/mcp_server.py @@ -0,0 +1,448 @@ +"""Stdio MCP transport for MindGraph. + +Phase 5 is a transport wrap only. Query behavior stays in `mindgraph.query`. +""" + +import json +import logging +import os +import sqlite3 +import time +from contextlib import contextmanager +from pathlib import Path +from typing import Literal + +from mcp.server.fastmcp import FastMCP +from mcp.types import CallToolResult, TextContent +from starlette.responses import JSONResponse +from starlette.requests import Request + +from mindgraph import db +from mindgraph import query as query_mod +from mindgraph.embedders import EmbedTemplate, EmbedderSpec, format_query_text +from mindgraph import intent as intent_mod +from mindgraph import routing as routing_mod +from mindgraph.exceptions import MindgraphError +from mindgraph.models import QueryResult +from mindgraph.idle_lifecycle import IdleLifecycle + +REQUIRED_TABLES = { + "documents", + "documents_fts", + "chunks", + "vec_chunks", + "edges", +} + +logger = logging.getLogger("mindgraph") + + +class MCPServerStartupError(MindgraphError): + """Raised when the MCP server cannot start cleanly.""" + + +def _find_locked_operational_error(exc: BaseException) -> sqlite3.OperationalError | None: + """Find a SQLite lock error even when a query layer wrapped it.""" + current: BaseException | None = exc + seen: set[int] = set() + while current is not None and id(current) not in seen: + seen.add(id(current)) + if isinstance(current, sqlite3.OperationalError) and "locked" in str( + current + ).lower(): + return current + current = current.__cause__ or current.__context__ + return None + + +def _run_with_lock_retry(operation, *, attempts: int = 5, base_delay: float = 0.5): + """Retry transient SQLite lock errors, including wrapped query errors.""" + for attempt in range(attempts): + try: + return operation() + except Exception as exc: + if _find_locked_operational_error(exc) is None or attempt + 1 >= attempts: + raise + time.sleep(base_delay * (attempt + 1)) + raise AssertionError("unreachable") + + +def open_database(db_path: str) -> sqlite3.Connection: + """Open and validate the single database used by the MCP server.""" + if db_path != ":memory:" and not Path(db_path).exists(): + raise MCPServerStartupError(f"Database does not exist: {db_path}") + + try: + conn = db.get_db(db_path, read_only=True) + _validate_schema(conn, db_path) + return conn + except MindgraphError: + raise + except sqlite3.Error as e: + raise MCPServerStartupError(f"Failed to open database at {db_path}: {e}") from e + + +def open_database_readonly(db_path: str) -> sqlite3.Connection: + """Open and validate one index without allowing persistent writes.""" + expanded = Path(db_path).expanduser().resolve() + if not expanded.exists(): + raise MCPServerStartupError(f"Database does not exist: {expanded}") + try: + conn = db.get_db(str(expanded), read_only=True) + _validate_schema(conn, str(expanded)) + return conn + except MindgraphError: + raise + except sqlite3.Error as exc: + raise MCPServerStartupError( + f"Failed to open database at {expanded}: {exc}" + ) from exc + + +def _validate_schema(conn: sqlite3.Connection, db_path: str) -> None: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type IN ('table', 'virtual table')" + ).fetchall() + existing = {row["name"] for row in rows} + missing = sorted(REQUIRED_TABLES - existing) + if missing: + conn.close() + raise MCPServerStartupError( + f"Database at {db_path} is not a MindGraph database; " + f"missing tables: {', '.join(missing)}" + ) + + +def create_server( + conn: sqlite3.Connection, + embedder: query_mod.Embedder, + *, + embedder_spec: EmbedderSpec | None = None, + embed_template: EmbedTemplate = "none", + intent_db_path: str | os.PathLike[str] = "~/.mindgraph/mainframe-intent.sqlite", + log_level: Literal["DEBUG", "INFO"] = "INFO", +) -> FastMCP: + """Create a FastMCP server bound to one DB connection and one embedder.""" + server = FastMCP( + "mindgraph", + instructions=( + "MindGraph retrieves candidate chunks from a local Markdown vault. " + "It does not verify claims. Each result carries semantic_distance " + "(lower is a closer match) and a weak_fit flag: when weak_fit is " + "true, this index has no strong answer for the query and the chunk " + "should be treated as a low-confidence nomination, not an answer. " + "Rows may also carry query_scope_warning when the query appears to " + "ask for inbox, live, or project-status state that belongs in a " + "different lifecycle scope." + ), + log_level=log_level, + ) + + @server.tool( + name="query", + description=( + "Run the MindGraph lexical plus semantic query path, optionally " + "appending outbound graph expansion or association results. " + "Default response is a JSON array of QueryResult nominations. " + "With envelope=true, returns " + "{schema_version, intent_resolution, routing, results} where " + "routing is single-database metadata for this MCP-bound index " + "(not multi-index federation)." + ), + ) + def query_tool( + question: str, + lexical_top_k: int = query_mod.DEFAULT_LEXICAL_TOP_K, + semantic_top_k: int = query_mod.DEFAULT_SEMANTIC_TOP_K, + final_top_k: int = query_mod.DEFAULT_FINAL_TOP_K, + expand: bool = False, + expand_depth: int = query_mod.DEFAULT_EXPAND_DEPTH, + expand_top_k: int = query_mod.DEFAULT_EXPAND_TOP_K, + associate: bool = False, + associate_top_k: int = query_mod.DEFAULT_ASSOCIATE_TOP_K, + associate_seed_k: int = query_mod.DEFAULT_ASSOCIATE_SEED_K, + envelope: bool = False, + ) -> CallToolResult: + formatted_question = question + if embedder_spec is not None: + formatted_question = format_query_text( + embedder_spec, question, template=embed_template + ) + try: + if db.get_semantic_enabled(conn) is False and ( + semantic_top_k > 0 or associate + ): + return _tool_error( + "This database is lexical-only; re-ingest it without " + "--lexical-only before using semantic MCP queries." + ) + results = _run_with_lock_retry( + lambda: query_mod.run_query( + conn, + formatted_question, + embedder, + lexical_top_k=lexical_top_k, + semantic_top_k=semantic_top_k, + final_top_k=final_top_k, + expand=expand, + expand_depth=expand_depth, + expand_top_k=expand_top_k, + associate=associate, + associate_top_k=associate_top_k, + associate_seed_k=associate_seed_k, + embedder_spec=embedder_spec, + embed_template=embed_template, + ) + ) + if envelope: + payload = _query_envelope( + formatted_question, + results, + intent_db_path=intent_db_path, + ) + return _json_result(payload) + return _json_result([result.model_dump() for result in results]) + except sqlite3.OperationalError as e: + return _tool_error(f"Database error: {e}") + except MindgraphError as e: + return _tool_error(str(e)) + except Exception: + logger.exception("unexpected MCP query tool failure") + raise + + @server.tool( + name="graph_neighbors", + description=( + "List outbound MindGraph edges for a document ID, preserving " + "dangling targets as null target_path values." + ), + ) + def graph_neighbors_tool(doc_id: str) -> CallToolResult: + def lookup_neighbors(): + _ensure_document_exists(conn, doc_id) + return query_mod.list_neighbors(conn, doc_id) + + try: + results = _run_with_lock_retry(lookup_neighbors) + return _json_result([result.model_dump() for result in results]) + except sqlite3.OperationalError as e: + return _tool_error(f"Database error: {e}") + except MindgraphError as e: + return _tool_error(str(e)) + except Exception: + logger.exception("unexpected MCP graph_neighbors tool failure") + raise + + return server + + +def run_stdio(server: FastMCP) -> None: + """Run the server on stdio. Stdout is reserved for MCP protocol frames.""" + server.run("stdio") + + +def create_shared_server( + scopes: dict[str, tuple[sqlite3.Connection, str]], + embedder: query_mod.Embedder, + *, + host: str = "127.0.0.1", + port: int = 8000, + path: str = "/mcp", + embedder_spec: EmbedderSpec | None = None, + embed_template: EmbedTemplate = "none", + lifecycle: IdleLifecycle | None = None, +) -> FastMCP: + """Create a loopback shared server with one explicit scope per call.""" + if host not in {"127.0.0.1", "localhost", "::1"}: + raise MCPServerStartupError("shared MCP daemon host must be loopback") + if not path.startswith("/"): + raise MCPServerStartupError("MCP path must start with '/'") + server = FastMCP( + "mindgraph-shared", + instructions="Select one lifecycle scope. Results are nominations, not verified claims.", + host=host, + port=port, + streamable_http_path=path, + ) + + def selected(scope: str) -> tuple[sqlite3.Connection, str]: + try: + return scopes[scope] + except KeyError as exc: + raise query_mod.QueryError( + f"unknown scope: {scope}; choose one of: {', '.join(sorted(scopes))}" + ) from exc + + @server.tool(name="query") + def scoped_query(question: str, scope: str, final_top_k: int = query_mod.DEFAULT_FINAL_TOP_K) -> CallToolResult: + try: + activity = lifecycle.request() if lifecycle else _null_context() + with activity: + conn, trust_profile = selected(scope) + if db.get_semantic_enabled(conn) is False: + return _tool_error( + "This database is lexical-only; re-ingest it without " + "--lexical-only before using semantic MCP queries." + ) + formatted = ( + format_query_text(embedder_spec, question, template=embed_template) + if embedder_spec is not None else question + ) + rows = _run_with_lock_retry( + lambda: query_mod.run_query(conn, formatted, embedder, final_top_k=final_top_k) + ) + return _json_result({"scope": scope, "trust_profile": trust_profile, + "results": [row.model_dump() for row in rows]}) + except sqlite3.OperationalError as exc: + return _tool_error(f"Database error: {exc}") + except MindgraphError as exc: + return _tool_error(str(exc)) + + @server.tool(name="graph_neighbors") + def scoped_neighbors(doc_id: str, scope: str) -> CallToolResult: + try: + activity = lifecycle.request() if lifecycle else _null_context() + with activity: + conn, trust_profile = selected(scope) + def lookup(): + _ensure_document_exists(conn, doc_id) + return query_mod.list_neighbors(conn, doc_id) + rows = _run_with_lock_retry(lookup) + return _json_result({"scope": scope, "trust_profile": trust_profile, + "results": [row.model_dump() for row in rows]}) + except sqlite3.OperationalError as exc: + return _tool_error(f"Database error: {exc}") + except MindgraphError as exc: + return _tool_error(str(exc)) + + @server.custom_route("/health", methods=["GET"]) + async def health(_request): + return JSONResponse({"status": "ok", "pid": os.getpid(), "scopes": [ + {"scope": name, "trust_profile": trust} + for name, (_conn, trust) in sorted(scopes.items()) + ], "idle_lifecycle": lifecycle.snapshot() if lifecycle else {"enabled": False}}) + if lifecycle: + @server.custom_route("/lifecycle/lease", methods=["POST", "DELETE"]) + async def lease(request: Request): + token = request.headers.get("x-mindgraph-lease", "") + if request.method == "DELETE": + lifecycle.release_lease(token) + return JSONResponse({"status": "released"}) + if token: + lifecycle.renew_lease(token) + else: + token = lifecycle.acquire_lease() + return JSONResponse({"status": "ok", "lease": token, + "ttl_seconds": lifecycle.lease_ttl}) + return server + + +def run_streamable_http(server: FastMCP) -> None: + server.run("streamable-http") + + +@contextmanager +def _null_context(): + yield + + +def _ensure_document_exists(conn: sqlite3.Connection, doc_id: str) -> None: + try: + row = conn.execute( + "SELECT 1 FROM documents WHERE id = ? LIMIT 1", (doc_id,) + ).fetchone() + except sqlite3.Error as e: + raise query_mod.QueryError(f"document lookup failed: {e}") from e + if row is None: + raise query_mod.QueryError(f"unknown doc_id: {doc_id}") + + +def _intent_resolution_payload( + resolution: intent_mod.IntentResolution, +) -> dict: + return resolution.as_transport_payload() + + +def _resolve_intent_payload( + question: str, intent_db_path: str | os.PathLike[str] +) -> tuple[dict | None, str, tuple[str, ...]]: + expanded = Path(os.path.expanduser(os.fspath(intent_db_path))) + if not expanded.exists(): + return None, "intent_store_missing", () + + intent_conn = None + try: + intent_conn = intent_mod.open_intent_store(expanded) + resolution = intent_mod.resolve_intent( + intent_conn, + question, + limits=intent_mod.TraversalLimits(max_depth=2, max_nodes=32), + ) + return ( + _intent_resolution_payload(resolution), + "intent_resolved", + tuple(resolution.warnings), + ) + except Exception as exc: # noqa: BLE001 - MCP envelope reports resolution failures. + logger.warning("MCP intent resolution failed: %s", exc) + return ( + { + "outcome": "fallback", + "method": "none", + "refusal_reason": "intent_resolution_failed", + "error_type": type(exc).__name__, + "message": str(exc), + }, + "intent_resolution_failed", + ("intent_resolution_failed",), + ) + finally: + if intent_conn is not None: + intent_conn.close() + + +def _query_envelope( + question: str, + results: list[QueryResult], + *, + intent_db_path: str | os.PathLike[str], +) -> dict: + intent_payload, reason, warnings = _resolve_intent_payload(question, intent_db_path) + return { + "schema_version": routing_mod.SCHEMA_VERSION, + "intent_resolution": intent_payload, + "routing": { + "mode": "single_database", + "selected_retrievers": ["mcp-bound-db"], + "reason_codes": [reason], + "warnings": list(warnings), + }, + **_citation_partition(results), + } + + +def _citation_partition(results) -> dict: + """Payload fields that keep non-citable results out of the ranked set. + + Ranking cannot protect a caller here: a fabricated document is written to + be on topic and scores like any other, so on live queries quarantined + documents have taken the top rank. Separating them is what makes the + warning act rather than merely appear. + """ + return query_mod.citation_partition_payload(results) + + +def _json_result(payload) -> CallToolResult: + return CallToolResult( + content=[ + TextContent(type="text", text=json.dumps(payload, indent=2)), + ], + isError=False, + ) + + +def _tool_error(message: str) -> CallToolResult: + return CallToolResult( + content=[TextContent(type="text", text=message)], + isError=True, + ) diff --git a/mindgraph/src/mindgraph/models.py b/mindgraph/src/mindgraph/models.py new file mode 100644 index 0000000..7cbec69 --- /dev/null +++ b/mindgraph/src/mindgraph/models.py @@ -0,0 +1,113 @@ +from typing import Literal + +from pydantic import BaseModel, Field + +Signal = Literal["lexical", "semantic", "fused", "expanded", "associated"] +#: How far a document may be trusted as a source, independent of how well its +#: text matches a query. See `query._citation_assessment`. +CitationClass = Literal["citable", "unverified", "not_citable"] +QueryScopeIntent = Literal["inbox_state", "live_state", "project_status"] + + +class GraphEdge(BaseModel): + source_id: str + target_id: str + relationship_type: str | None = None + + +class ParsedDocument(BaseModel): + id: str + title: str + path: str + content_hash: str + index_id: str | None = None + trust_profile: str | None = None + namespace: str | None = None + source_root: str | None = None + source_path: str | None = None + display_path: str | None = None + metadata: dict = Field(default_factory=dict) + truth_text: str + timeline_text: str | None = None + + +class QueryScopeWarning(BaseModel): + """Query-level warning copied onto result rows. + + The warning means the query appears to ask for lifecycle state that may not + belong in the current DB. It is advisory metadata, not a ranking signal. + """ + + intent: QueryScopeIntent + recommended_trust_profile: str + message: str + + +class QueryResult(BaseModel): + """One ranked retrieval result. See DECISIONS.md § Phase 2 query path. + + `doc_type`/`domain`/`status` are surfaced from the document's frontmatter + (`type`/`domain`/`status`) so a consumer can tell a raw capture from a + curated note without a second lookup. They are null when the source has no + such frontmatter key. They do not affect ranking — see the Phase 7 ADR. + """ + + doc_id: str + chunk_index: int + path: str + title: str + doc_type: str | None = None + domain: str | None = None + status: str | None = None + index_id: str | None = None + trust_profile: str | None = None + namespace: str | None = None + #: Absolute ingest root on the indexing machine, e.g. `/home/me/notes`. + #: Excluded from serialization on purpose: it is an internal join key, and + #: it is the only field on this model that is definitionally a host path. + #: `path`, `source_path`, and `display_path` are all relative and already + #: tell a consumer where the document lives, so nothing downstream needs + #: the root. Query-time governance reads it in-process as an attribute + #: (see `_result_source_path`), which `exclude` does not affect. + #: Excluding it here rather than at each `model_dump()` call site means a + #: serialization path added later cannot reintroduce the leak. + source_root: str | None = Field(default=None, exclude=True) + source_path: str | None = None + display_path: str | None = None + content_hash: str | None = None + eligibility_run_id: str | None = None + signal: Signal + rrf_score: float + lexical_rank: int | None + semantic_rank: int | None + semantic_distance: float | None = None + weak_fit: bool = False + #: Machine-readable citability, so a consumer can partition on trust + #: without parsing `provenance_warning`. Ranking is trust-blind by design — + #: a fabricated document is written to be on topic and scores accordingly — + #: so this is the field that keeps a quarantined top hit out of a citable + #: result set. `unverified` is a nomination, not a bar. + citation_class: CitationClass = "citable" + query_scope_warning: QueryScopeWarning | None = None + #: Set when the source document is quarantined or otherwise not citable. + #: Travels on EVERY chunk, which is the whole point: on 2026-08-09, 103 + #: captures with fabricated citations were found in `10_knowledge/`. Each + #: carried a `needs-audit` tag and later a body banner — but a banner only + #: appears in chunk 0, and a frontmatter tag never appears in chunk text at + #: all. A query landing on chunk 3 returned authoritative-looking prose with + #: no indication the source did not exist. A trust flag that is invisible at + #: query time is not a control. + provenance_warning: str | None = None + chunk_text: str + expansion_depth: int = 0 + association_depth: int = 0 + + +class NeighborResult(BaseModel): + """One outbound edge from a source document. Dangling edges have null paths.""" + + source_id: str + target_id: str + relationship_type: str | None + source_path: str | None + target_path: str | None diff --git a/mindgraph/src/mindgraph/parser.py b/mindgraph/src/mindgraph/parser.py new file mode 100644 index 0000000..5d3af8b --- /dev/null +++ b/mindgraph/src/mindgraph/parser.py @@ -0,0 +1,486 @@ +import hashlib +import re +from collections.abc import Callable, Iterable +from dataclasses import dataclass, field +from pathlib import Path + +import yaml + +from mindgraph.exceptions import ParseError +from mindgraph.models import GraphEdge, ParsedDocument + +LINK_PATTERN = re.compile(r"\[\[([^\[\]]+?)\]\](?:\s*\(([^)]+)\))?") + +# `---` on its own line followed (possibly across blank lines) by a `## Timeline` heading. +TIMELINE_SPLIT_PATTERN = re.compile( + r"^[ \t]*---[ \t]*\n(?:[ \t]*\n)*[ \t]*##[ \t]+Timeline[ \t]*$", + re.MULTILINE | re.IGNORECASE, +) + +FRONTMATTER_PATTERN = re.compile(r"\A---\s*\n(.*?)\n---\s*\n(.*)\Z", re.DOTALL) +CANONICAL_FILENAME_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}__") +WIKILINK_WRAPPER_PATTERN = re.compile(r"^\[\[(.+?)\]\]$") + + +def compute_doc_id(relative_path: str) -> str: + """Stable short hash of a path string. Same input → same ID.""" + return hashlib.sha256(relative_path.encode("utf-8")).hexdigest()[:16] + + +def compute_scoped_doc_id(index_id: str, namespace: str, source_path: str) -> str: + """Stable short hash for a document inside a named index namespace.""" + scoped = f"{index_id}\0{namespace}\0{source_path}" + return compute_doc_id(scoped) + + +def compute_content_hash(body_bytes: bytes) -> str: + return hashlib.sha256(body_bytes).hexdigest() + + +def _normalize_link_target(target: str) -> str: + target = target.strip() + if not target.endswith(".md"): + target = target + ".md" + return target + + +def _normalize_lookup_key(value: str) -> str: + return value.strip().casefold() + + +def canonical_trailing_slug(stem: str) -> str | None: + """Return the trailing slug from a canonical MainFrame filename stem. + + Canonical shape: ``YYYY-MM-DD__domain__type__slug`` (see ADR-033). + """ + if not CANONICAL_FILENAME_PREFIX.match(stem): + return None + parts = stem.split("__") + if len(parts) < 4: + return None + slug = parts[-1].strip() + return slug or None + + +@dataclass +class LinkResolver: + """Resolve wikilink labels against documents in one ingest scope.""" + + paths: set[str] = field(default_factory=set) + ids_by_path: dict[str, str] = field(default_factory=dict) + stems: dict[str, set[str]] = field(default_factory=dict) + titles: dict[str, set[str]] = field(default_factory=dict) + slug_suffixes: dict[str, set[str]] = field(default_factory=dict) + + @classmethod + def from_documents(cls, documents: Iterable[ParsedDocument]) -> "LinkResolver": + resolver = cls() + for doc in documents: + resolver.add_document(doc) + return resolver + + def add_document(self, doc: ParsedDocument) -> None: + self.paths.add(doc.path) + self.ids_by_path[doc.path] = doc.id + stem = Path(doc.path).stem + self.stems.setdefault(_normalize_lookup_key(stem), set()).add(doc.path) + self.titles.setdefault(_normalize_lookup_key(doc.title), set()).add(doc.path) + trailing_slug = canonical_trailing_slug(stem) + if trailing_slug is not None: + self.slug_suffixes.setdefault( + _normalize_lookup_key(trailing_slug), set() + ).add(doc.path) + + def doc_id_for_path(self, path: str) -> str | None: + return self.ids_by_path.get(path) + + def resolve(self, target: str, source_path: str | None = None) -> str | None: + normalized = _normalize_link_target(target) + if normalized in self.paths: + return normalized + + if source_path is not None: + sibling = str(Path(source_path).parent / normalized) + if sibling in self.paths: + return sibling + + if "/" not in normalized: + stem_key = _normalize_lookup_key(Path(normalized).stem) + stem_matches = self.stems.get(stem_key, set()) + if len(stem_matches) == 1: + return next(iter(stem_matches)) + + raw_key = _normalize_lookup_key(target) + title_matches = self.titles.get(raw_key, set()) + if len(title_matches) == 1: + return next(iter(title_matches)) + + if "/" not in normalized: + suffix_key = _normalize_lookup_key(Path(normalized).stem) + suffix_matches = self.slug_suffixes.get(suffix_key, set()) + if len(suffix_matches) == 1: + return next(iter(suffix_matches)) + + return None + + +def parse_frontmatter(text: str) -> tuple[dict, str]: + """Extract YAML frontmatter if present. Returns (metadata, body).""" + match = FRONTMATTER_PATTERN.match(text) + if not match: + return {}, text + raw_yaml, body = match.groups() + try: + metadata = yaml.safe_load(raw_yaml) or {} + except yaml.YAMLError as e: + raise ParseError(f"Malformed YAML frontmatter: {e}") from e + if not isinstance(metadata, dict): + raise ParseError( + f"Frontmatter must be a YAML mapping, got {type(metadata).__name__}" + ) + return metadata, body + + +def split_page_model(body: str) -> tuple[str, str | None]: + """Split body into (truth, timeline) on `---` followed by `## Timeline`. + + Plain `---` horizontal rules elsewhere in the body do not trigger the split. + Returns (body, None) if no Timeline section is present. + """ + match = TIMELINE_SPLIT_PATTERN.search(body) + if not match: + return body.strip(), None + truth = body[: match.start()].strip() + timeline = body[match.end():].strip() + return truth, (timeline or None) + + +def normalize_link_label(raw: str) -> str: + """Normalize a link label from frontmatter or wikilink syntax.""" + target = raw.strip() + if not target: + return "" + wrapper = WIKILINK_WRAPPER_PATTERN.match(target) + if wrapper: + target = wrapper.group(1).strip() + if "|" in target: + target = target.split("|", 1)[0].strip() + return target + + +def extract_metadata_link_targets(metadata: dict) -> list[str]: + """Return deduplicated link targets from frontmatter ``links:``.""" + raw_links = metadata.get("links") + if raw_links is None: + return [] + if isinstance(raw_links, str): + candidates = [raw_links] + elif isinstance(raw_links, list): + candidates = [str(item) for item in raw_links if item is not None] + else: + return [] + + out: list[str] = [] + seen: set[str] = set() + for raw in candidates: + target = normalize_link_label(raw) + if not target: + continue + key = _normalize_lookup_key(target) + if key in seen: + continue + seen.add(key) + out.append(target) + return out + + +def _edge_for_target( + target_raw: str, + source_id: str, + *, + link_resolver: Callable[[str, str | None], str | None] | LinkResolver | None, + source_path: str | None, + relationship_type: str | None = None, +) -> GraphEdge: + resolved_path = None + resolved_id = None + if isinstance(link_resolver, LinkResolver): + resolved_path = link_resolver.resolve(target_raw, source_path) + if resolved_path is not None: + resolved_id = link_resolver.doc_id_for_path(resolved_path) + elif link_resolver is not None: + resolved_path = link_resolver(target_raw, source_path) + target_id = resolved_id or compute_doc_id( + resolved_path or _normalize_link_target(target_raw) + ) + return GraphEdge( + source_id=source_id, + target_id=target_id, + relationship_type=relationship_type, + ) + + +def extract_graph_edges( + text: str, + source_id: str, + *, + link_resolver: Callable[[str, str | None], str | None] | LinkResolver | None = None, + source_path: str | None = None, +) -> list[GraphEdge]: + """Find `[[link]]` and `[[link]] (relationship)` patterns and return edges.""" + edges: list[GraphEdge] = [] + for match in LINK_PATTERN.finditer(text): + target_raw, relationship = match.groups() + edges.append( + _edge_for_target( + target_raw, + source_id, + link_resolver=link_resolver, + source_path=source_path, + relationship_type=relationship.strip() if relationship else None, + ) + ) + return edges + + +def extract_document_graph_edges( + parsed: ParsedDocument, + *, + link_resolver: Callable[[str, str | None], str | None] | LinkResolver | None = None, +) -> list[GraphEdge]: + """Collect graph edges from frontmatter ``links:`` and body wikilinks.""" + by_target: dict[str, GraphEdge] = {} + + for target in extract_metadata_link_targets(parsed.metadata): + edge = _edge_for_target( + target, + parsed.id, + link_resolver=link_resolver, + source_path=parsed.path, + ) + by_target.setdefault(edge.target_id, edge) + + for edge in extract_graph_edges( + parsed.truth_text, + parsed.id, + link_resolver=link_resolver, + source_path=parsed.path, + ): + existing = by_target.get(edge.target_id) + if existing is None: + by_target[edge.target_id] = edge + elif existing.relationship_type is None and edge.relationship_type is not None: + by_target[edge.target_id] = edge + + return list(by_target.values()) + + +# Sentence boundary: whitespace that follows `.`, `!`, or `?`. +_SENTENCE_BOUNDARY = re.compile(r"(?<=[.!?])\s+") + +# --------------------------------------------------------------------------- +# Apparatus stripping (semantic lane only) +# +# Chunks feed the embedder; `documents_fts` is populated from the full +# `truth_text` (db.py). This keeps paths, commands, and filenames searchable +# in the lexical lane while preventing common document apparatus from being +# embedded as if it were an assertion. +# --------------------------------------------------------------------------- + +_FENCED_CODE = re.compile(r"^[ \t]*(?:```|~~~).*?(?:^[ \t]*(?:```|~~~)[ \t]*$|\Z)", re.M | re.S) +_HEADING_MARKER = re.compile(r"^[ \t]*#{1,6}[ \t]+") +_BLOCKQUOTE_LINE = re.compile(r"^[ \t]*>") +_CALLOUT_LABEL = r"(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION|DANGER|ERROR|INFO|SUCCESS|ATTENTION)" +_CALLOUT_START = re.compile( + rf"^[ \t]*>[ \t]*(?:\[!{_CALLOUT_LABEL}\]|\*\*{_CALLOUT_LABEL}(?:[.:!])?\*\*)(?=[ \t]|$)", + re.IGNORECASE, +) +_BOLD_KEY = re.compile(r"\*\*[^*\n]{1,40}:\*\*") +_MD_LINK = re.compile(r"\[[^\]]*\]\([^)]*\)") +_WIKILINK = re.compile(r"\[\[[^\]]*\]\]") +_INLINE_CODE = re.compile(r"`[^`\n]*`") +_URLISH = re.compile(r"\S*(?:https?://|[\w.-]+/[\w./-]+|\.(?:md|py|json|jsonl|ya?ml|sqlite|sh|plist|html))\S*") +_LIST_MARKER = re.compile(r"^[ \t]*(?:[-*+]|\d+\.)[ \t]+") + +_MIN_PROSE_WORDS = 5 + + +def _residual_prose_words(line: str) -> int: + """Words left once links, paths, inline code, and bold keys are removed. + + A reference line ("- [Title](path/to/note.md)") drops to nearly nothing; + a sentence that merely cites a path keeps its argument. + """ + stripped = _MD_LINK.sub(" ", line) + stripped = _WIKILINK.sub(" ", stripped) + stripped = _INLINE_CODE.sub(" ", stripped) + stripped = _URLISH.sub(" ", stripped) + stripped = _BOLD_KEY.sub(" ", stripped) + stripped = re.sub(r"[^\w\s]", " ", stripped) + return len([w for w in stripped.split() if any(c.isalpha() for c in w)]) + + +def _is_apparatus_line(line: str) -> bool: + """True when the line is structure or reference rather than an assertion.""" + s = line.strip() + if not s: + return False + if s.startswith("|") or re.fullmatch(r"[|\s:-]+", s): + return True # table rows and rules + if len(_BOLD_KEY.findall(s)) >= 2: + return True # metadata-like rows + has_reference = bool( + _MD_LINK.search(s) or _WIKILINK.search(s) or _URLISH.search(s) or _INLINE_CODE.search(s) + ) + if has_reference and _residual_prose_words(s) < _MIN_PROSE_WORDS: + return True # link lists and command dumps + if _LIST_MARKER.match(s) and _residual_prose_words(s) < 3: + return True # bare enumerations + return False + + +def _is_callout_start(line: str) -> bool: + """Return True only for an explicit Markdown alert/callout marker.""" + return bool(_CALLOUT_START.match(line)) + + +def strip_apparatus(text: str) -> str: + """Remove non-assertional markup so the embedder sees prose. + + Headings keep their words and lose their markers: `## Retrieval floor` is a + topical anchor, `##` is not. Fenced code, tables, recognized Markdown + alert/callout blocks, metadata rows, and reference lists are dropped + outright. Ordinary blockquotes are retained because a quoted passage can + be substantive evidence. + + Fail-safe: a document that is entirely apparatus (an index page, a pure + table) returns unchanged rather than becoming unindexable. Losing the + semantic lane for a document is worse than embedding its structure. + """ + if not text.strip(): + return text + + without_code = _FENCED_CODE.sub("\n", text) + kept: list[str] = [] + lines = without_code.splitlines() + line_index = 0 + while line_index < len(lines): + line = lines[line_index] + if _is_callout_start(line): + # Drop the whole contiguous blockquote only after an explicit + # alert marker. A later ordinary blockquote is not swallowed. + line_index += 1 + while line_index < len(lines) and _BLOCKQUOTE_LINE.match(lines[line_index]): + line_index += 1 + continue + if _is_apparatus_line(line): + line_index += 1 + continue + kept.append(_HEADING_MARKER.sub("", line)) + line_index += 1 + + cleaned = re.sub(r"\n{3,}", "\n\n", "\n".join(kept)).strip() + return cleaned if cleaned else text + + + +def _hard_split(text: str, max_chars: int) -> list[str]: + """Last-resort fixed-width cut for a unit with no usable boundary.""" + return [text[i : i + max_chars] for i in range(0, len(text), max_chars)] + + +def _split_oversized(para: str, max_chars: int) -> list[str]: + """Break a paragraph longer than max_chars into pieces each <= max_chars. + + Prefers line boundaries, then sentence boundaries, and finally a hard + character cut for a single unit that still exceeds max_chars (e.g. a giant + blob with no whitespace, like a raw arXiv capture). Surviving units are + greedily re-packed up to max_chars. + """ + if len(para) <= max_chars: + return [para] + + units: list[str] = [] + for line in para.split("\n"): + if len(line) <= max_chars: + units.append(line) + continue + for sentence in _SENTENCE_BOUNDARY.split(line): + if len(sentence) <= max_chars: + units.append(sentence) + else: + units.extend(_hard_split(sentence, max_chars)) + + pieces: list[str] = [] + current = "" + for unit in units: + if not unit: + continue + if not current: + current = unit + elif len(current) + 1 + len(unit) <= max_chars: + current = current + " " + unit + else: + pieces.append(current) + current = unit + if current: + pieces.append(current) + return pieces + + +def chunk_truth(truth_text: str, max_chars: int = 1000) -> list[str]: + """Pack paragraphs into chunks bounded by max_chars. + + Paragraphs are the natural chunk unit, but a single paragraph longer than + max_chars is first hard-split on sentence/line boundaries so no chunk can + exceed the bound. This protects semantic recall (MiniLM truncates at ~256 + tokens, so an unsplit megachunk is mostly invisible to the embedder) and + avoids dumping an oversized chunk into a `--json`/MCP response. + + Apparatus is stripped first (see `strip_apparatus`): chunks feed the + embedder, while the lexical lane retains the original Truth body. + """ + if not truth_text.strip(): + return [] + truth_text = strip_apparatus(truth_text) + paragraphs: list[str] = [] + for raw in re.split(r"\n\s*\n", truth_text): + para = raw.strip() + if para: + paragraphs.extend(_split_oversized(para, max_chars)) + chunks: list[str] = [] + current = "" + for para in paragraphs: + if not current: + current = para + elif len(current) + 2 + len(para) <= max_chars: + current = current + "\n\n" + para + else: + chunks.append(current) + current = para + if current: + chunks.append(current) + return chunks + + +def parse_document(relative_path: str, body_bytes: bytes) -> ParsedDocument: + """Parse a Markdown file's bytes into a validated ParsedDocument.""" + try: + text = body_bytes.decode("utf-8") + except UnicodeDecodeError as e: + raise ParseError(f"File is not valid UTF-8: {e}", path=relative_path) from e + + metadata, body = parse_frontmatter(text) + truth, timeline = split_page_model(body) + + title = metadata.get("title") or Path(relative_path).stem + + return ParsedDocument( + id=compute_doc_id(relative_path), + title=str(title), + path=relative_path, + content_hash=compute_content_hash(body_bytes), + metadata=metadata, + truth_text=truth, + timeline_text=timeline, + ) diff --git a/mindgraph/src/mindgraph/query.py b/mindgraph/src/mindgraph/query.py new file mode 100644 index 0000000..865ce2d --- /dev/null +++ b/mindgraph/src/mindgraph/query.py @@ -0,0 +1,1187 @@ +"""Query path: lexical plus semantic retrieval fused with Reciprocal Rank Fusion. + +Design is locked in DECISIONS.md § 2026-05-19 — Phase 2 query path. The fusion +constant RRF_K is canonical (Cormack, Clarke, Buettcher 2009) and not configurable +at runtime by design. +""" + +import json +import logging +import os +import re +import sqlite3 +import struct +from collections.abc import Sequence +from dataclasses import dataclass +from functools import cached_property, lru_cache +from pathlib import Path +from typing import Any, Literal, Protocol + +from mindgraph.embedders import EmbedTemplate, EmbedderSpec, format_query_text +from mindgraph.exceptions import DatabaseError, MindgraphError +from mindgraph.models import NeighborResult, QueryResult, QueryScopeWarning + +logger = logging.getLogger(__name__) + +RRF_K = 60 +DEFAULT_LEXICAL_TOP_K = 20 +DEFAULT_SEMANTIC_TOP_K = 20 +DEFAULT_FINAL_TOP_K = 10 +MAX_QUERY_TOP_K = 1000 +MAX_EXPAND_DEPTH = 3 + +# Best-semantic-distance cutoff above which a semantic-only result is flagged +# weak_fit ("this index probably has no answer"). Calibrated against the live +# MainFrame index (Phase 7 ADR): strong hits land near 0.71 (top-5 <= 0.82), +# out-of-scope queries near 1.10, nonsense near 1.22, so 1.0 sits in the gap. +WEAK_FIT_DISTANCE_THRESHOLD = 1.0 + +# Scope-warning vocabulary. +# +# Each entry is a regular-expression *fragment*, not a literal, so `captures?` +# and `state\.md` behave as written. Fragments are joined with `|` and wrapped +# in `\b(...)\b`, matched case-insensitively. +# +# The defaults below describe one vault's lifecycle vocabulary. They are a +# starting point, not a claim about anyone else's notes. Override them with a +# JSON file (see `load_scope_vocabulary`) when your own material uses different +# words for "this is current state, not durable knowledge". + +DEFAULT_INBOX_TERMS = ( + "inbox", "captures?", "routing queue", "ready queue", + "waiting for routing", "00_inbox", "01_ingest", +) +DEFAULT_PROJECT_TERMS = ( + "30_projects", "project status", "active project", "project_state", + "next_action", "next action", "project readme", r"state\.md", + "handoff", "next gate", +) +DEFAULT_FRESHNESS_TERMS = ( + "current", "latest", "today", "this week", "this month", "right now", + "now", "recent", "live", "as of", +) +DEFAULT_LIVE_STATE_TERMS = ( + "job hunt", "finance", "calendar", "workflow metrics", "telemetry", + "live state", "status", "blocked?", "blockers?", "remaining", "next", +) + + +def _compile_terms(terms: Sequence[str]) -> re.Pattern[str] | None: + """Compile alternation fragments into `\\b(a|b|c)\\b`, or None if empty.""" + kept = [t for t in terms if t] + if not kept: + return None + return re.compile(r"\b(" + "|".join(kept) + r")\b", re.IGNORECASE) + + +@dataclass(frozen=True) +class ScopeVocabulary: + """Term fragments driving `classify_query_scope`. + + An empty term list disables that warning branch entirely. + """ + + inbox_terms: tuple[str, ...] = DEFAULT_INBOX_TERMS + project_terms: tuple[str, ...] = DEFAULT_PROJECT_TERMS + freshness_terms: tuple[str, ...] = DEFAULT_FRESHNESS_TERMS + live_state_terms: tuple[str, ...] = DEFAULT_LIVE_STATE_TERMS + + @cached_property + def _patterns(self) -> tuple[re.Pattern[str] | None, ...]: + return ( + _compile_terms(self.inbox_terms), + _compile_terms(self.project_terms), + _compile_terms(self.freshness_terms), + _compile_terms(self.live_state_terms), + ) + + @classmethod + def from_mapping(cls, data: dict) -> "ScopeVocabulary": + """Build from a mapping. Absent keys keep their defaults.""" + known = {f: getattr(cls, f, None) for f in cls.__dataclass_fields__} + unknown = sorted(set(data) - set(known)) + if unknown: + raise QueryError( + f"unknown scope vocabulary key(s): {', '.join(unknown)}; " + f"expected any of: {', '.join(sorted(known))}" + ) + kwargs = {} + for field in cls.__dataclass_fields__: + if field in data: + value = data[field] + if isinstance(value, str) or not isinstance(value, Sequence): + raise QueryError( + f"scope vocabulary key {field!r} must be a list of strings" + ) + kwargs[field] = tuple(str(v) for v in value) + return cls(**kwargs) + + +DEFAULT_SCOPE_VOCABULARY = ScopeVocabulary() + +# Environment variable naming a JSON file that overrides the defaults. +SCOPE_VOCABULARY_ENV = "MINDGRAPH_SCOPE_VOCABULARY" + + +def load_scope_vocabulary(path: str | os.PathLike[str]) -> ScopeVocabulary: + """Load a scope vocabulary from a JSON file. Absent keys keep defaults.""" + resolved = Path(path).expanduser() + try: + data = json.loads(resolved.read_text()) + except FileNotFoundError: + raise QueryError(f"scope vocabulary file not found: {resolved}") + except json.JSONDecodeError as exc: + raise QueryError(f"scope vocabulary file is not valid JSON: {resolved} ({exc})") + if not isinstance(data, dict): + raise QueryError(f"scope vocabulary file must contain a JSON object: {resolved}") + return ScopeVocabulary.from_mapping(data) + + +@lru_cache(maxsize=1) +def _vocabulary_from_env(raw: str | None) -> ScopeVocabulary: + return load_scope_vocabulary(raw) if raw else DEFAULT_SCOPE_VOCABULARY + + +def active_scope_vocabulary() -> ScopeVocabulary: + """The vocabulary in force: the env-var override, else the defaults.""" + return _vocabulary_from_env(os.environ.get(SCOPE_VOCABULARY_ENV)) + +# A word-character run. Everything else — apostrophes, question marks, and the +# rest of `.,;/=%[]<>\|~@#$&!` plus the FTS5 operator symbols `"*():^-` — is a +# token boundary. `\w` is unicode-aware for str patterns, and every `\w+` run is +# a valid FTS5 bareword (ASCII alphanumerics/underscore and/or codepoints >=128), +# so an OR-join of these tokens can never produce a MATCH syntax error. +_FTS5_TOKEN = re.compile(r"\w+") + +# FTS5 reserved operator keywords. FTS5 treats lowercase `and`, `or`, `not`, +# `near` as ordinary tokens, so only the uppercase forms are dropped. +_FTS5_OPERATOR_KEYWORDS = frozenset({"AND", "OR", "NOT", "NEAR"}) +_SHA256_HEX = re.compile(r"^[0-9a-f]{64}$") +_SHA256_TAGGED = re.compile(r"^sha256:[0-9a-f]{64}$") + + +class QueryError(MindgraphError): + """Raised when a query execution fails.""" + + +def _validate_limit(name: str, value: int, *, maximum: int) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise QueryError(f"{name} must be an integer") + if value < 0 or value > maximum: + raise QueryError(f"{name} must be between 0 and {maximum}") + return value + + +class Embedder(Protocol): + """The minimal interface the query path needs from a sentence embedder. + + Matches the surface of `sentence_transformers.SentenceTransformer.encode` + that the ingest path already depends on. A test embedder can fulfill this + by implementing `encode(texts, convert_to_numpy=True)` returning a 2D array. + """ + + def encode(self, texts, convert_to_numpy=True): ... # pragma: no cover + + +def _encode_without_progress(embedder: Embedder, texts): + """Encode text while suppressing sentence-transformers progress output.""" + try: + return embedder.encode( + texts, convert_to_numpy=True, show_progress_bar=False + ) + except TypeError: + return embedder.encode(texts, convert_to_numpy=True) + + +def sanitize_fts5_query(text: str) -> str: + """Reduce free text to a safe OR-joined FTS5 MATCH expression. + + Uses an allowlist: only word-character runs survive as tokens, so arbitrary + punctuation (apostrophes, question marks, etc.) can never reach the MATCH + parser, and the uppercase operator keywords AND/OR/NOT/NEAR are dropped so + they are not interpreted as operators. The result is an implicit OR over + surviving tokens, per the ADR. Returns an empty string when no tokens + survive (e.g. operator- or punctuation-only input). + """ + tokens = [ + token + for token in _FTS5_TOKEN.findall(text) + if token not in _FTS5_OPERATOR_KEYWORDS + ] + if not tokens: + return "" + return " OR ".join(tokens) + + +def classify_query_scope( + query_text: str, vocabulary: ScopeVocabulary | None = None +) -> QueryScopeWarning | None: + """Return an advisory lifecycle-scope warning for current-state queries. + + This is deliberately a query-intent guardrail, not a no-answer classifier. + A query can have strong lexical and semantic matches while still asking the + wrong database for inbox, live, or project-status state. + + The terms driving it are configurable; see `ScopeVocabulary`. Passing None + uses `active_scope_vocabulary()`, which honours the + `MINDGRAPH_SCOPE_VOCABULARY` environment variable. + """ + vocab = vocabulary if vocabulary is not None else active_scope_vocabulary() + inbox_re, project_re, freshness_re, live_state_re = vocab._patterns + if inbox_re and inbox_re.search(query_text): + return QueryScopeWarning( + intent="inbox_state", + recommended_trust_profile="inbox_or_ingest_queue", + message=( + "Query appears to ask for inbox or routing-queue state. " + "Route it to the live inbox/ingest surface before treating " + "ranked chunks as current-state nominations." + ), + ) + if project_re and project_re.search(query_text): + return QueryScopeWarning( + intent="project_status", + recommended_trust_profile="project_status", + message=( + "Query appears to ask for project status. Route it to a " + "project-status database or inspect the project records before " + "treating ranked chunks as current-state nominations." + ), + ) + if ( + freshness_re + and live_state_re + and freshness_re.search(query_text) + and live_state_re.search(query_text) + ): + return QueryScopeWarning( + intent="live_state", + recommended_trust_profile="time_bound_live_state", + message=( + "Query appears to ask for current or live state. A durable " + "knowledge database can return relevant background notes that " + "are not current-status nominations." + ), + ) + return None + + +def _serialize_embedding(vec) -> bytes: + """Pack a float vector into the bytes format sqlite-vec expects.""" + return struct.pack(f"{len(vec)}f", *vec) + + +def fetch_lexical_ranking( + conn: sqlite3.Connection, + query_text: str, + top_k: int = DEFAULT_LEXICAL_TOP_K, +) -> list[tuple[str, int]]: + """Return (doc_id, rank) pairs from FTS5 BM25 ranking. + + Ranks start at 1. Returns an empty list when sanitization strips all tokens. + """ + _validate_limit("lexical_top_k", top_k, maximum=MAX_QUERY_TOP_K) + if top_k == 0: + return [] + match_expr = sanitize_fts5_query(query_text) + if not match_expr: + return [] + try: + cursor = conn.execute( + """ + SELECT id + FROM documents_fts + WHERE documents_fts MATCH ? + ORDER BY bm25(documents_fts) ASC, id ASC + LIMIT ? + """, + (match_expr, top_k), + ) + return [(row["id"], rank + 1) for rank, row in enumerate(cursor)] + except sqlite3.Error as e: + raise QueryError(f"FTS5 query failed: {e}") from e + + +def fetch_semantic_ranking( + conn: sqlite3.Connection, + query_embedding: list[float], + top_k: int = DEFAULT_SEMANTIC_TOP_K, +) -> list[tuple[str, int, int, float]]: + """Return (doc_id, chunk_index, rank, distance) ordered by vec_chunks distance. + + Promotes to document granularity by keeping the best-ranked chunk per + document, per ADR § retrieval pipeline. Ranks start at 1 and reflect the + chunk-level KNN position before dedup. A document's rank and distance are + those of its first (closest) chunk in the chunk-level ranking. The distance + is carried through fusion for the Phase 7 weak-fit signal. + """ + _validate_limit("semantic_top_k", top_k, maximum=MAX_QUERY_TOP_K) + if top_k == 0: + return [] + try: + # sqlite-vec requires the LIMIT or `k = ?` constraint to be visible on + # the vec0 virtual-table query itself. Wrapping the KNN in a subquery + # keeps the constraint local to vec_chunks, then we join chunks for + # the (doc_id, chunk_index) tuple. + cursor = conn.execute( + """ + SELECT c.doc_id, c.chunk_index, knn.distance + FROM ( + SELECT rowid, distance + FROM vec_chunks + WHERE embedding MATCH ? + AND k = ? + ) AS knn + JOIN chunks c ON c.rowid = knn.rowid + ORDER BY knn.distance ASC, c.doc_id ASC, c.chunk_index ASC + """, + (_serialize_embedding(query_embedding), top_k), + ) + rows = cursor.fetchall() + except sqlite3.Error as e: + raise QueryError(f"vec_chunks query failed: {e}") from e + + seen: dict[str, tuple[int, int, float]] = {} + for rank, row in enumerate(rows, start=1): + doc_id = row["doc_id"] + if doc_id not in seen: + seen[doc_id] = (row["chunk_index"], rank, row["distance"]) + return [ + (doc_id, chunk_index, rank, distance) + for doc_id, (chunk_index, rank, distance) in seen.items() + ] + + +def rrf_fuse( + lexical: list[tuple[str, int]], + semantic: list[tuple[str, int, int, float]], + top_k: int = DEFAULT_FINAL_TOP_K, +) -> list[tuple[str, int | None, float, int | None, int | None, float | None]]: + """Fuse lexical and semantic rankings with RRF at the canonical k = 60. + + Returns (doc_id, chunk_index, rrf_score, lexical_rank, semantic_rank, + semantic_distance). The chunk_index and distance come from the semantic + ranking when present; lexical-only results carry None for both and the + chunk is filled in by the caller with chunk 0 (see ADR § Phase 2 § + lexical-only chunk choice). The distance rides along untouched — it does not + enter the RRF math, which stays rank-based per the locked Phase 2 ADR. Sort + order: rrf_score descending, then doc_id ascending. Deterministic. + """ + _validate_limit("final_top_k", top_k, maximum=MAX_QUERY_TOP_K) + if top_k == 0: + return [] + lex_map = {doc_id: rank for doc_id, rank in lexical} + sem_map = { + doc_id: (chunk_index, rank, distance) + for doc_id, chunk_index, rank, distance in semantic + } + doc_ids = set(lex_map) | set(sem_map) + + scored: list[ + tuple[str, int | None, float, int | None, int | None, float | None] + ] = [] + for doc_id in doc_ids: + lex_rank = lex_map.get(doc_id) + sem_entry = sem_map.get(doc_id) + sem_rank = sem_entry[1] if sem_entry else None + chunk_index = sem_entry[0] if sem_entry else None + sem_distance = sem_entry[2] if sem_entry else None + + score = 0.0 + if lex_rank is not None: + score += 1.0 / (RRF_K + lex_rank) + if sem_rank is not None: + score += 1.0 / (RRF_K + sem_rank) + + scored.append((doc_id, chunk_index, score, lex_rank, sem_rank, sem_distance)) + + scored.sort(key=lambda row: (-row[2], row[0])) + return scored[:top_k] + + +def _attribute_signal(lex_rank: int | None, sem_rank: int | None): + if lex_rank is not None and sem_rank is not None: + return "fused" + if lex_rank is not None: + return "lexical" + return "semantic" + + +def _is_weak_fit( + lex_rank: int | None, sem_rank: int | None, distance: float | None +) -> bool: + """Flag a result the index probably has no real answer for. + + Per the Phase 7 ADR: a result is weak only when it was nominated purely by + semantics (no lexical/fused overlap to corroborate it) and its best chunk + distance is worse than the calibrated threshold. A lexical or fused hit + always carries independent evidence, so it is never weak; expanded graph + results carry no distance and stay False. + """ + return ( + lex_rank is None + and sem_rank is not None + and distance is not None + and distance > WEAK_FIT_DISTANCE_THRESHOLD + ) + + +def _resolve_chunk_text( + conn: sqlite3.Connection, doc_id: str, chunk_index: int +) -> str: + row = conn.execute( + "SELECT text FROM chunks WHERE doc_id = ? AND chunk_index = ?", + (doc_id, chunk_index), + ).fetchone() + return row["text"] if row else "" + + +def _coerce_optional_str(value) -> str | None: + """Normalize a frontmatter scalar to a string, or None when absent/empty.""" + if value is None: + return None + text = str(value).strip() + return text or None + + +#: Frontmatter states that make a document non-citable regardless of how well +#: its text matches a query. +_NON_CITABLE_STATUS = {"quarantined", "retracted", "superseded"} +_NON_CITABLE_TAGS = {"fabricated-citation", "quarantined", "needs-resourcing"} + + +def _citation_assessment(metadata: dict) -> tuple[str, str | None]: + """Classify a document's citability and build its trust warning. + + Returns `(citation_class, warning)` where citation_class is one of + `citable`, `unverified`, or `not_citable`. + + The two travel together deliberately. Before 2026-08-20 only the prose + warning existed, so the sole thing standing between a caller and a + fabricated source was that the caller read a sentence and chose to obey it. + Ranking is trust-blind — a fabricated document is written to be on topic and + so scores like any other — and on live queries quarantined documents from + the 2026-08-09 incident took the top rank. A machine-readable class lets a + consumer partition on trust without parsing English. + + `unverified` is deliberately not `not_citable`: a needs-audit capture is a + legitimate retrieval nomination, and collapsing the two would either hide + real candidates or launder fabricated ones into the same bucket. + """ + status = str(metadata.get("status") or "").strip().lower() + raw_tags = metadata.get("tags") or [] + if isinstance(raw_tags, str): + raw_tags = [t.strip(" '\"[]") for t in raw_tags.split(",")] + tags = {str(t).strip().lower() for t in raw_tags} + + if status in _NON_CITABLE_STATUS or (tags & _NON_CITABLE_TAGS): + reason = metadata.get("citation_status") or metadata.get("provenance") + detail = f" ({reason})" if reason else "" + return "not_citable", ( + f"NOT CITABLE — this document is {status or 'flagged'}{detail}. " + "Its content may be correct but has no verified source. " + "Do not cite it; re-source any claim before use." + ) + if "needs-audit" in tags or "full-text-pending" in tags: + return "unverified", ( + "UNVERIFIED — this capture is flagged needs-audit / full-text-pending. " + "Treat as a nomination, not as evidence." + ) + return "citable", None + + +def _provenance_warning(metadata: dict) -> str | None: + """Trust warning only. See `_citation_assessment` for the classification.""" + return _citation_assessment(metadata)[1] + + +def partition_by_citation( + results: list["QueryResult"], +) -> tuple[list["QueryResult"], list["QueryResult"]]: + """Split ranked results into usable candidates and non-citable ones. + + Order within each list is preserved, so ranking is unchanged — this + separates, it does not re-rank and it does not drop. A caller that wants + everything can concatenate the two back together. + + `unverified` stays with the candidates. It means "nomination, not evidence", + which is a caveat on use, not a bar on it. Only `not_citable` — quarantined, + retracted, superseded, or tagged as a fabricated citation — is separated, + because that is the class a caller must not cite at all. + """ + candidates = [r for r in results if r.citation_class != "not_citable"] + excluded = [r for r in results if r.citation_class == "not_citable"] + return candidates, excluded + + +def citation_counts(results: list["QueryResult"]) -> dict[str, int]: + """Count results by citation class, always reporting all three keys.""" + counts = {"citable": 0, "unverified": 0, "not_citable": 0} + for result in results: + counts[result.citation_class] = counts.get(result.citation_class, 0) + 1 + return counts + + +def citation_partition_payload(results: list["QueryResult"]) -> dict[str, Any]: + """Serialize ranked rows while keeping non-citable rows separate. + + This transport-neutral helper is shared by the core CLI and optional MCP + adapters. Retrieval remains nomination-only; partitioning is a consumer + safety boundary, not factual adjudication. + """ + candidates, excluded = partition_by_citation(results) + return { + "results": [r.model_dump() for r in candidates], + "not_citable": [r.model_dump() for r in excluded], + "citation_counts": citation_counts(results), + } + + +def _resolve_document(conn: sqlite3.Connection, doc_id: str) -> dict | None: + """Resolve a document's display + frontmatter fields, or None if missing. + + Returns path, title, provenance, and the `type`/`domain`/`status` + frontmatter values. `domain` is read from its dedicated column; + `doc_type`/`status` come from the stored `metadata_json`. All three are + null when the source omits them. + """ + row = conn.execute( + """ + SELECT + path, title, domain, content_hash, metadata_json, index_id, trust_profile, + namespace, source_root, source_path, display_path + FROM documents + WHERE id = ? + """, + (doc_id,), + ).fetchone() + if row is None: + return None + metadata = {} + if row["metadata_json"]: + try: + parsed = json.loads(row["metadata_json"]) + if isinstance(parsed, dict): + metadata = parsed + except (ValueError, TypeError): + metadata = {} + return { + "path": row["path"], + "title": row["title"], + "content_hash": _coerce_optional_str(row["content_hash"]), + "doc_type": _coerce_optional_str(metadata.get("type")), + "domain": _coerce_optional_str(row["domain"]), + "status": _coerce_optional_str(metadata.get("status")), + "provenance_warning": _citation_assessment(metadata)[1], + "citation_class": _citation_assessment(metadata)[0], + "index_id": _coerce_optional_str(row["index_id"]), + "trust_profile": _coerce_optional_str(row["trust_profile"]), + "namespace": _coerce_optional_str(row["namespace"]), + "source_root": _coerce_optional_str(row["source_root"]), + "source_path": _coerce_optional_str(row["source_path"]), + "display_path": _coerce_optional_str(row["display_path"]), + } + + +DEFAULT_EXPAND_DEPTH = 1 +DEFAULT_EXPAND_TOP_K = 20 +DEFAULT_ASSOCIATE_TOP_K = 10 +DEFAULT_ASSOCIATE_SEED_K = 5 + + +def run_query( + conn: sqlite3.Connection, + query_text: str, + embedder: Embedder | None, + *, + lexical_top_k: int = DEFAULT_LEXICAL_TOP_K, + semantic_top_k: int = DEFAULT_SEMANTIC_TOP_K, + final_top_k: int = DEFAULT_FINAL_TOP_K, + expand: bool = False, + expand_depth: int = DEFAULT_EXPAND_DEPTH, + expand_top_k: int = DEFAULT_EXPAND_TOP_K, + associate: bool = False, + associate_top_k: int = DEFAULT_ASSOCIATE_TOP_K, + associate_seed_k: int = DEFAULT_ASSOCIATE_SEED_K, + embedder_spec: EmbedderSpec | None = None, + embed_template: EmbedTemplate = "none", +) -> list[QueryResult]: + """Run the Phase 2 query pipeline end-to-end and return QueryResult rows. + + Lexical-only results surface chunk 0 by default because there is no semantic + ranking to pick a better chunk from. Pass ``semantic_top_k=0`` and + ``embedder=None`` for a core-profile lexical query. + + When `expand` is True, walks outbound `[[link]]` edges from the Phase 2 + results to `expand_depth` hops and appends the walked documents with + `signal="expanded"`. See DECISIONS.md § 2026-05-20 — Phase 3 graph expansion. + """ + for name, value in ( + ("lexical_top_k", lexical_top_k), + ("semantic_top_k", semantic_top_k), + ("final_top_k", final_top_k), + ("expand_top_k", expand_top_k), + ("associate_top_k", associate_top_k), + ("associate_seed_k", associate_seed_k), + ): + _validate_limit(name, value, maximum=MAX_QUERY_TOP_K) + _validate_limit("expand_depth", expand_depth, maximum=MAX_EXPAND_DEPTH) + + query_scope_warning = classify_query_scope(query_text) + try: + lexical = fetch_lexical_ranking(conn, query_text, top_k=lexical_top_k) + except QueryError as exc: + # A lexical failure must not sink the whole query: fall back to + # semantic-only retrieval with a logged warning. The sanitizer makes + # this path unreachable for ordinary punctuation, but it guards against + # a malformed FTS index or any future MATCH edge case. + logger.warning( + "lexical ranking failed; degrading to semantic-only: %s", exc + ) + lexical = [] + + if semantic_top_k > 0: + if embedder is None: + raise QueryError( + "semantic retrieval requires an embedding model; install " + "mindgraph[semantic] and run `mindgraph bootstrap-model`, or " + "use --lexical-only" + ) + raw = _encode_without_progress(embedder, [query_text]) + query_embedding = [float(x) for x in raw[0]] + semantic = fetch_semantic_ranking( + conn, query_embedding, top_k=semantic_top_k + ) + else: + semantic = [] + + fused = rrf_fuse(lexical, semantic, top_k=final_top_k) + + results: list[QueryResult] = [] + for doc_id, chunk_index, rrf_score, lex_rank, sem_rank, sem_distance in fused: + resolved = _resolve_document(conn, doc_id) + if resolved is None: + # FTS5 row exists but documents row was deleted out from under us. + # Treat as a data-integrity failure rather than silently dropping. + raise QueryError( + f"FTS5 hit for doc_id={doc_id} has no matching documents row" + ) + effective_chunk_index = chunk_index if chunk_index is not None else 0 + chunk_text = _resolve_chunk_text(conn, doc_id, effective_chunk_index) + results.append( + QueryResult( + doc_id=doc_id, + chunk_index=effective_chunk_index, + path=resolved["path"], + title=resolved["title"], + doc_type=resolved["doc_type"], + domain=resolved["domain"], + status=resolved["status"], + provenance_warning=resolved["provenance_warning"], + citation_class=resolved["citation_class"], + index_id=resolved["index_id"], + trust_profile=resolved["trust_profile"], + namespace=resolved["namespace"], + source_root=resolved["source_root"], + source_path=resolved["source_path"], + display_path=resolved["display_path"], + content_hash=resolved["content_hash"], + signal=_attribute_signal(lex_rank, sem_rank), + rrf_score=round(rrf_score, 6), + lexical_rank=lex_rank, + semantic_rank=sem_rank, + semantic_distance=( + round(sem_distance, 6) if sem_distance is not None else None + ), + weak_fit=_is_weak_fit(lex_rank, sem_rank, sem_distance), + query_scope_warning=query_scope_warning, + chunk_text=chunk_text, + expansion_depth=0, + ) + ) + + output = list(results) + + if expand: + output.extend( + expand_results( + conn, + results, + depth=expand_depth, + expand_top_k=expand_top_k, + query_scope_warning=query_scope_warning, + ) + ) + + if associate: + if embedder is None: + raise QueryError( + "semantic association requires an embedding model; install " + "mindgraph[semantic] and run `mindgraph bootstrap-model`" + ) + output.extend( + associate_results( + conn, + results, + embedder, + top_k=associate_top_k, + seed_k=associate_seed_k, + embedder_spec=embedder_spec, + embed_template=embed_template, + query_scope_warning=query_scope_warning, + ) + ) + + return output + + +def _association_seed_text(conn: sqlite3.Connection, seed: QueryResult) -> str: + chunk = _resolve_chunk_text(conn, seed.doc_id, seed.chunk_index) + title = (seed.title or "").strip() + excerpt = chunk[:500].strip() + if title and excerpt: + return f"{title}\n{excerpt}" + return title or excerpt + + +def associate_results( + conn: sqlite3.Connection, + phase_2_results: list[QueryResult], + embedder: Embedder, + *, + top_k: int = DEFAULT_ASSOCIATE_TOP_K, + seed_k: int = DEFAULT_ASSOCIATE_SEED_K, + embedder_spec: EmbedderSpec | None = None, + embed_template: EmbedTemplate = "none", + query_scope_warning: QueryScopeWarning | None = None, +) -> list[QueryResult]: + """Find embedding-neighbor documents from Phase 2 fused seeds. + + Seeds are the pre-expand Phase 2 rows only (ADR-034). Association is + append-only: rows use signal=associated, rrf_score=0, association_depth=1. + """ + _validate_limit("associate_top_k", top_k, maximum=MAX_QUERY_TOP_K) + _validate_limit("associate_seed_k", seed_k, maximum=MAX_QUERY_TOP_K) + if top_k == 0 or seed_k == 0 or not phase_2_results: + return [] + if query_scope_warning is None: + query_scope_warning = phase_2_results[0].query_scope_warning + + spec = embedder_spec + effective_seed_k = min(seed_k, len(phase_2_results)) if seed_k > 0 else 0 + if effective_seed_k <= 0: + return [] + + seen: set[str] = {r.doc_id for r in phase_2_results} + associated: list[QueryResult] = [] + + for seed in phase_2_results[:effective_seed_k]: + seed_text = _association_seed_text(conn, seed) + if not seed_text: + continue + if spec is not None: + seed_text = format_query_text(spec, seed_text, template=embed_template) + raw = _encode_without_progress(embedder, [seed_text]) + query_embedding = [float(x) for x in raw[0]] + candidate_k = min(MAX_QUERY_TOP_K, top_k + len(seen)) + semantic = fetch_semantic_ranking(conn, query_embedding, top_k=candidate_k) + for doc_id, chunk_index, _rank, distance in semantic: + if doc_id in seen: + continue + resolved = _resolve_document(conn, doc_id) + if resolved is None: + continue + chunk_text = _resolve_chunk_text(conn, doc_id, chunk_index) + associated.append( + QueryResult( + doc_id=doc_id, + chunk_index=chunk_index, + path=resolved["path"], + title=resolved["title"], + doc_type=resolved["doc_type"], + domain=resolved["domain"], + status=resolved["status"], + provenance_warning=resolved["provenance_warning"], + citation_class=resolved["citation_class"], + index_id=resolved["index_id"], + trust_profile=resolved["trust_profile"], + namespace=resolved["namespace"], + source_root=resolved["source_root"], + source_path=resolved["source_path"], + display_path=resolved["display_path"], + content_hash=resolved["content_hash"], + signal="associated", + rrf_score=0.0, + lexical_rank=None, + semantic_rank=None, + semantic_distance=round(distance, 6), + weak_fit=_is_weak_fit(None, 1, distance), + query_scope_warning=query_scope_warning, + chunk_text=chunk_text, + expansion_depth=0, + association_depth=1, + ) + ) + seen.add(doc_id) + if len(associated) >= top_k: + break + if len(associated) >= top_k: + break + + associated.sort( + key=lambda r: (r.association_depth, r.semantic_distance or 0.0, r.doc_id) + ) + return associated[:top_k] + + +def expand_results( + conn: sqlite3.Connection, + phase_2_results: list[QueryResult], + *, + depth: int, + expand_top_k: int, + query_scope_warning: QueryScopeWarning | None = None, +) -> list[QueryResult]: + """Walk outbound graph edges from the Phase 2 seeds and return expanded rows. + + Deterministic BFS per DECISIONS.md § 2026-05-20 — Phase 3 graph expansion. + Dangling targets terminate the walk at their depth. Documents already in the + seed set are not re-emitted. Final sort: (expansion_depth, doc_id, chunk_index). + """ + _validate_limit("expand_depth", depth, maximum=MAX_EXPAND_DEPTH) + _validate_limit("expand_top_k", expand_top_k, maximum=MAX_QUERY_TOP_K) + if depth == 0 or expand_top_k == 0 or not phase_2_results: + return [] + if query_scope_warning is None: + query_scope_warning = phase_2_results[0].query_scope_warning + + seen: set[str] = {r.doc_id for r in phase_2_results} + frontier: list[str] = list(seen) + expanded: list[QueryResult] = [] + + for d in range(1, depth + 1): + next_frontier: list[str] = [] + for source_id in frontier: + for edge in list_neighbors(conn, source_id): + target_id = edge.target_id + if edge.target_path is None: + continue + if target_id in seen: + continue + resolved = _resolve_document(conn, target_id) + if resolved is None: + # Defensive: list_neighbors already filtered dangling via + # target_path, so a missing documents row here is a data + # integrity issue worth surfacing. + continue + chunk_text = _resolve_chunk_text(conn, target_id, 0) + expanded.append( + QueryResult( + doc_id=target_id, + chunk_index=0, + path=resolved["path"], + title=resolved["title"], + doc_type=resolved["doc_type"], + domain=resolved["domain"], + status=resolved["status"], + provenance_warning=resolved["provenance_warning"], + citation_class=resolved["citation_class"], + index_id=resolved["index_id"], + trust_profile=resolved["trust_profile"], + namespace=resolved["namespace"], + source_root=resolved["source_root"], + source_path=resolved["source_path"], + display_path=resolved["display_path"], + content_hash=resolved["content_hash"], + signal="expanded", + rrf_score=0.0, + lexical_rank=None, + semantic_rank=None, + query_scope_warning=query_scope_warning, + chunk_text=chunk_text, + expansion_depth=d, + ) + ) + seen.add(target_id) + next_frontier.append(target_id) + if not next_frontier: + break + frontier = next_frontier + + expanded.sort(key=lambda r: (r.expansion_depth, r.doc_id, r.chunk_index)) + return expanded[:expand_top_k] + + +def list_neighbors( + conn: sqlite3.Connection, + doc_id: str, +) -> list[NeighborResult]: + """List outbound edges from a document, preserving dangling edges. + + Sort order: target_id ascending, then relationship_type ascending (with + NULL relationship_type sorting first per SQLite default). + """ + try: + cursor = conn.execute( + """ + SELECT + e.source_id, + e.target_id, + e.relationship_type, + src.path AS source_path, + tgt.path AS target_path + FROM edges e + LEFT JOIN documents src ON src.id = e.source_id + LEFT JOIN documents tgt ON tgt.id = e.target_id + WHERE e.source_id = ? + ORDER BY e.target_id ASC, e.relationship_type ASC + """, + (doc_id,), + ) + return [ + NeighborResult( + source_id=row["source_id"], + target_id=row["target_id"], + relationship_type=row["relationship_type"], + source_path=row["source_path"], + target_path=row["target_path"], + ) + for row in cursor + ] + except sqlite3.Error as e: + raise QueryError(f"neighbors lookup failed: {e}") from e + + +def _filter_by_c0_eligibility( + results: list[QueryResult], + eligibility_manifest: dict[str, Any] | None, +) -> list[QueryResult]: + """Keep only results with exact C-0 manifest membership; stamp the run id. + + Validates the manifest contract, then admits a result only when its + ``doc_id``, normalized source path, and indexed content hash all match one + approved record. Admitted rows are copies carrying the consumed + ``eligibility_run_id``. This applies no seat or character limit — see + ``_allocate_context_budget`` for that half. Behaviour is identical to the + filtering half of ``apply_dual_gate_governance`` before the split + (DECISIONS.md § 2026-07-27, MainFrame ADR-047). + """ + manifest_run_id, approved = _approved_manifest_records(eligibility_manifest) + + eligible: list[QueryResult] = [] + for result in results: + approved_record = approved.get(result.doc_id) + if approved_record is None: + continue + source_path = _result_source_path(result) + content_hash = _indexed_hash(result.content_hash) + if source_path != approved_record["path"]: + continue + if content_hash != approved_record["sha256"]: + continue + eligible.append(result.model_copy(update={"eligibility_run_id": manifest_run_id})) + return eligible + + +def _allocate_context_budget( + results: list[QueryResult], + max_seats: int, + max_chars: int, + quiet_keywords: list[str] | None, +) -> list[QueryResult]: + """Shortlist seats, apply the ``quiet_keywords`` swap, then the char budget. + + Allocation is subtractive at document granularity: every returned row comes + from ``results`` in input order, though the trailing row may be a truncated + copy. This makes no eligibility judgement and stamps no provenance. The + logic is the allocation half of ``apply_dual_gate_governance`` before the + split, preserved verbatim (DECISIONS.md § 2026-07-27, MainFrame ADR-047). + """ + if max_seats < 0 or max_chars < 0: + raise QueryError("max_seats and max_chars must be non-negative") + if max_seats == 0: + return [] + + if quiet_keywords and len(results) > max_seats: + shortlisted = results[:max_seats] + for candidate in results[max_seats:]: + candidate_text = (candidate.chunk_text or "").lower() + if any(keyword.lower() in candidate_text for keyword in quiet_keywords): + shortlisted[-1] = candidate + break + else: + shortlisted = results[:max_seats] + + allocated: list[QueryResult] = [] + current_chars = 0 + for item in shortlisted: + passage = item.chunk_text or "" + if current_chars + len(passage) <= max_chars: + allocated.append(item) + current_chars += len(passage) + continue + remaining = max_chars - current_chars + if remaining > 100: + allocated.append(item.model_copy(update={"chunk_text": passage[:remaining] + "..."})) + break + return allocated + + +def apply_dual_gate_governance( + results: list[QueryResult], + max_seats: int = 3, + max_chars: int = 4000, + quiet_keywords: list[str] | None = None, + *, + eligibility_manifest: dict[str, Any] | None, +) -> list[QueryResult]: + """Return manifest-approved, seat-bounded context without an ungated fallback. + + This is a consumer-side boundary over already-ranked retrieval nominations. + It does not alter ``run_query`` ranking or make a truth/compliance claim. + ``quiet_keywords`` remains a caller-directed ordering input, never a way to + admit a result that lacks C-0 membership. + + Composition order is load-bearing: filtering runs first, so manifest + validation still raises before the negative-argument check and the + ``max_seats == 0`` early return, exactly as it did before the split. + """ + return _allocate_context_budget( + _filter_by_c0_eligibility(results, eligibility_manifest), + max_seats, + max_chars, + quiet_keywords, + ) + + +def filter_by_c0_eligibility( + results: list[QueryResult], + *, + eligibility_manifest: dict[str, Any] | None, +) -> list[QueryResult]: + """C-0 filtering with no allocation step — the C-0-only evaluation arm. + + Returns every manifest-approved result, with no seat count and no character + budget applied. This exists so the C-0-only arm of the dual-gate 2x2 differs + from the governed arm by an absent step rather than by limits set high + enough to be inert. Not exported, not on the CLI or MCP surface. + """ + return _filter_by_c0_eligibility(results, eligibility_manifest) + + +def allocate_ungoverned_context( + results: list[QueryResult], + max_seats: int = 3, + max_chars: int = 4000, + quiet_keywords: list[str] | None = None, + *, + evaluation_use_only: Literal[True], + **rejected_keywords: Any, +) -> list[QueryResult]: + """Allocate seats and characters with NO C-0 gate — evaluation instrument only. + + The returned context is **not** C-0 governed. Nothing here checks source + eligibility, so this must not feed a governed answer path; it exists solely + so the Speaker-only arm of the dual-gate 2x2 can run allocation without + filtering. Allocation is subtractive, so this reaches no source that ungated + ``run_query`` did not already return. + + ``evaluation_use_only=True`` is keyword-only and has no default, so every + call site carries a visible acknowledgement. The function takes no manifest, + and never emits governance provenance: ``eligibility_run_id`` is cleared on + the way in and asserted null on the way out, so a governed row fed back in + cannot launder its run identity through this path. + """ + if "eligibility_manifest" in rejected_keywords: + raise QueryError( + "allocate_ungoverned_context takes no eligibility manifest; " + "use apply_dual_gate_governance for governed context" + ) + if rejected_keywords: + unexpected = next(iter(rejected_keywords)) + raise TypeError( + "allocate_ungoverned_context() got an unexpected keyword argument " + f"'{unexpected}'" + ) + if evaluation_use_only is not True: + raise QueryError( + "allocate_ungoverned_context requires evaluation_use_only=True; " + "ungoverned allocation is an evaluation instrument, not a governed path" + ) + + ungoverned = [ + result.model_copy(update={"eligibility_run_id": None}) for result in results + ] + allocated = _allocate_context_budget(ungoverned, max_seats, max_chars, quiet_keywords) + assert all(item.eligibility_run_id is None for item in allocated), ( + "ungoverned allocation must never emit C-0 eligibility provenance" + ) + return allocated + + +def _normalized_path(path: str) -> str: + """Normalize a source path for manifest membership comparison only.""" + normalized = path.replace("\\", "/") + while "//" in normalized: + normalized = normalized.replace("//", "/") + return normalized.rstrip("/") + + +def _result_source_path(result: QueryResult) -> str | None: + if result.source_root and result.source_path: + return _normalized_path(f"{result.source_root}/{result.source_path}") + if result.path: + return _normalized_path(result.path) + return None + + +def _indexed_hash(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip().lower() + if normalized.startswith("sha256:"): + normalized = normalized.removeprefix("sha256:") + return normalized if _SHA256_HEX.fullmatch(normalized) else None + + +def _approved_manifest_records( + eligibility_manifest: dict[str, Any] | None, +) -> tuple[str, dict[str, dict[str, str]]]: + """Validate the C-0 public boundary contract and index it by document id.""" + if not isinstance(eligibility_manifest, dict): + raise QueryError("governed context requires a C-0 eligibility manifest") + run_id = eligibility_manifest.get("eligibility_run_id") + if not isinstance(run_id, str) or not run_id.strip(): + raise QueryError("C-0 eligibility manifest has no eligibility_run_id") + inventory = eligibility_manifest.get("approved_inventory") + if not isinstance(inventory, list) or not inventory: + raise QueryError("C-0 eligibility manifest approved_inventory is empty") + + records: dict[str, dict[str, str]] = {} + for index, raw_record in enumerate(inventory): + if not isinstance(raw_record, dict): + raise QueryError(f"C-0 approved record {index} is not an object") + doc_id = raw_record.get("doc_id") + path = raw_record.get("path") + sha256 = raw_record.get("sha256") + status = raw_record.get("status") + if not isinstance(doc_id, str) or not doc_id: + raise QueryError(f"C-0 approved record {index} has no doc_id") + if doc_id in records: + raise QueryError(f"C-0 approved inventory has duplicate doc_id '{doc_id}'") + if not isinstance(path, str) or not path: + raise QueryError(f"C-0 approved record '{doc_id}' has no source path") + if not isinstance(sha256, str) or not _SHA256_TAGGED.fullmatch(sha256): + raise QueryError( + f"C-0 approved record '{doc_id}' has malformed sha256; " + "require sha256:<64 lowercase hex>" + ) + if status not in ("Approved", "Effective"): + raise QueryError( + f"C-0 approved record '{doc_id}' has invalid status '{status}'" + ) + records[doc_id] = { + "path": _normalized_path(path), + "sha256": sha256.removeprefix("sha256:"), + } + return run_id.strip(), records diff --git a/mindgraph/src/mindgraph/routing.py b/mindgraph/src/mindgraph/routing.py new file mode 100644 index 0000000..ba8dffc --- /dev/null +++ b/mindgraph/src/mindgraph/routing.py @@ -0,0 +1,901 @@ +"""Deterministic intent-aware routing over separately ranked retrievers. + +This module adds an orchestration surface without changing the legacy query or +MCP result lists. MainFrame-specific paths and policy stay with the caller. +""" + +from __future__ import annotations + +import re +import sqlite3 +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Any, Literal, Protocol +from urllib.parse import quote, urlsplit + +import sqlite_vec +from pydantic import ( + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) + +from mindgraph.embedders import EmbedTemplate, EmbedderSpec, format_query_text +from mindgraph.intent import ( + IntentGraphError, + IntentResolution, + TraversalLimits, + normalize_text, + resolve_intent, +) +from mindgraph.models import QueryResult +from mindgraph import db as db_mod +from mindgraph import query as query_mod + + +SCHEMA_VERSION = "1" +STABLE_ID_RE = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$") +TOKEN_RE = re.compile(r"\w+", re.UNICODE) + +RouteMode = Literal["auto", "durable", "projects", "federated"] +DecisionOutcome = Literal["selected", "fallback", "refusal"] +EnvelopeOutcome = Literal["success", "partial", "refusal", "fallback"] + + +class RoutingError(ValueError): + """Invalid routing configuration with a stable machine-readable code.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + def __str__(self) -> str: + return f"{self.code}: {super().__str__()}" + + +class _StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +def _nonempty(value: str) -> str: + value = value.strip() + if not value: + raise ValueError("must not be empty") + return value + + +def _stable_id(value: str) -> str: + value = value.strip() + if not STABLE_ID_RE.fullmatch(value): + raise ValueError( + "must be a lowercase dot/underscore/hyphen-separated stable ID" + ) + return value + + +def _capability_ref(value: str) -> str: + value = value.strip() + parsed = urlsplit(value) + if parsed.scheme not in {"capability", "retriever"} or not parsed.netloc: + raise ValueError("must be a capability:// or retriever:// reference") + return value + + +def _unique_tuple(value: tuple[str, ...], *, label: str) -> tuple[str, ...]: + if len(value) != len(set(value)): + raise ValueError(f"{label} may not contain duplicates") + return value + + +class RouteLimits(_StrictModel): + final_top_k: int = Field(default=10, ge=1, le=1000) + total_timeout_ms: int = Field(default=30000, ge=1, le=600000) + + +class RouteRequest(_StrictModel): + schema_version: Literal["1"] = "1" + query_id: str + query_text: str + mode: RouteMode = "auto" + intent_id: str | None = None + scope: str | None = None + allowed_capability_refs: tuple[str, ...] = () + limits: RouteLimits = RouteLimits() + + _query_id = field_validator("query_id")(_stable_id) + _query_text = field_validator("query_text")(_nonempty) + + @field_validator("intent_id", "scope") + @classmethod + def validate_optional_text(cls, value: str | None) -> str | None: + return _nonempty(value) if value is not None else None + + @field_validator("allowed_capability_refs") + @classmethod + def validate_capabilities(cls, value: tuple[str, ...]) -> tuple[str, ...]: + checked = tuple(_capability_ref(item) for item in value) + return _unique_tuple(checked, label="allowed_capability_refs") + + +class RefusalRule(_StrictModel): + rule_id: str + reason_code: str + all_terms: tuple[str, ...] = () + any_terms: tuple[str, ...] = () + phrases: tuple[str, ...] = () + + _rule_id = field_validator("rule_id")(_stable_id) + _reason_code = field_validator("reason_code")(_stable_id) + + @field_validator("all_terms", "any_terms", "phrases") + @classmethod + def validate_match_values(cls, value: tuple[str, ...]) -> tuple[str, ...]: + normalized = tuple(normalize_text(_nonempty(item)) for item in value) + return _unique_tuple(normalized, label="refusal rule match values") + + @model_validator(mode="after") + def validate_has_match(self) -> RefusalRule: + if not (self.all_terms or self.any_terms or self.phrases): + raise ValueError("refusal rule must declare a match condition") + return self + + def matches(self, query_text: str) -> bool: + normalized = normalize_text(query_text) + token_text = " ".join(TOKEN_RE.findall(normalized)) + + def contains(value: str) -> bool: + needle = " ".join(TOKEN_RE.findall(value)) + return bool(needle) and f" {needle} " in f" {token_text} " + + if any(not contains(term) for term in self.all_terms): + return False + if self.any_terms and not any(contains(term) for term in self.any_terms): + return False + if self.phrases and not any(contains(phrase) for phrase in self.phrases): + return False + return True + + +class RouterPolicy(_StrictModel): + policy_version: str + durable_retriever_id: str + project_retriever_id: str + allow_federated: bool = False + safe_default_retriever_id: str | None = None + refusal_rules: tuple[RefusalRule, ...] = () + + _policy_version = field_validator("policy_version")(_stable_id) + _retriever_ids = field_validator( + "durable_retriever_id", "project_retriever_id" + )(_stable_id) + + @field_validator("safe_default_retriever_id") + @classmethod + def validate_safe_default(cls, value: str | None) -> str | None: + return _stable_id(value) if value is not None else None + + @model_validator(mode="after") + def validate_rules(self) -> RouterPolicy: + rule_ids = tuple(rule.rule_id for rule in self.refusal_rules) + _unique_tuple(rule_ids, label="refusal rule IDs") + return self + + +class RetrieverDescriptor(_StrictModel): + retriever_id: str + capability_ref: str + trust_profile: str + source_surface: str + available: bool = True + unavailable_reason: str | None = None + + _retriever_id = field_validator("retriever_id")(_stable_id) + _capability = field_validator("capability_ref")(_capability_ref) + _labels = field_validator("trust_profile", "source_surface")(_nonempty) + + @field_validator("unavailable_reason") + @classmethod + def validate_unavailable_reason(cls, value: str | None) -> str | None: + return _stable_id(value) if value is not None else None + + @model_validator(mode="after") + def validate_availability(self) -> RetrieverDescriptor: + if self.available and self.unavailable_reason is not None: + raise ValueError("available retriever may not declare unavailable_reason") + if not self.available and self.unavailable_reason is None: + raise ValueError("unavailable retriever must declare unavailable_reason") + return self + + +class RetrieverExecutor(Protocol): + def retrieve(self, query_text: str, *, final_top_k: int) -> list[QueryResult]: ... + + +@dataclass(frozen=True) +class RetrieverRegistration: + descriptor: RetrieverDescriptor + executor: RetrieverExecutor | None = None + + def __post_init__(self) -> None: + if self.descriptor.available and self.executor is None: + raise RoutingError( + "routing_registry_invalid", + f"available retriever {self.descriptor.retriever_id} has no executor", + ) + if not self.descriptor.available and self.executor is not None: + raise RoutingError( + "routing_registry_invalid", + f"unavailable retriever {self.descriptor.retriever_id} has an executor", + ) + + +@dataclass(frozen=True, init=False) +class CapabilityRegistry: + """Immutable deterministic lookup for caller-provided retrievers.""" + + _registrations: tuple[RetrieverRegistration, ...] + _by_id: Mapping[str, RetrieverRegistration] + _by_capability: Mapping[str, RetrieverRegistration] + + def __init__(self, registrations: tuple[RetrieverRegistration, ...]) -> None: + ordered = tuple( + sorted(registrations, key=lambda item: item.descriptor.retriever_id) + ) + ids = tuple(item.descriptor.retriever_id for item in ordered) + capabilities = tuple(item.descriptor.capability_ref for item in ordered) + if len(ids) != len(set(ids)): + raise RoutingError( + "routing_registry_invalid", "duplicate retriever ID in registry" + ) + if len(capabilities) != len(set(capabilities)): + raise RoutingError( + "routing_registry_invalid", "duplicate capability ref in registry" + ) + object.__setattr__(self, "_registrations", ordered) + object.__setattr__( + self, + "_by_id", + MappingProxyType( + {item.descriptor.retriever_id: item for item in ordered} + ), + ) + object.__setattr__( + self, + "_by_capability", + MappingProxyType( + {item.descriptor.capability_ref: item for item in ordered} + ), + ) + + @property + def registrations(self) -> tuple[RetrieverRegistration, ...]: + return self._registrations + + @property + def descriptors(self) -> tuple[RetrieverDescriptor, ...]: + return tuple(item.descriptor for item in self._registrations) + + def by_id(self, retriever_id: str) -> RetrieverRegistration | None: + return self._by_id.get(retriever_id) + + def by_capability(self, capability_ref: str) -> RetrieverRegistration | None: + return self._by_capability.get(capability_ref) + + +class RouteRejection(_StrictModel): + target_id: str + reason_code: str + + _target = field_validator("target_id")(_nonempty) + _reason = field_validator("reason_code")(_stable_id) + + +class RouteDecision(_StrictModel): + schema_version: Literal["1"] = "1" + policy_version: str + outcome: DecisionOutcome + selected_retriever_ids: tuple[str, ...] = () + rejections: tuple[RouteRejection, ...] = () + reason_codes: tuple[str, ...] = () + intent_resolution: IntentResolution + effective_limits: RouteLimits + + _policy_version = field_validator("policy_version")(_stable_id) + + @field_validator("selected_retriever_ids", "reason_codes") + @classmethod + def validate_stable_values(cls, value: tuple[str, ...]) -> tuple[str, ...]: + checked = tuple(_stable_id(item) for item in value) + return _unique_tuple(checked, label="route decision values") + + @model_validator(mode="after") + def validate_decision_state(self) -> RouteDecision: + if not self.reason_codes: + raise ValueError("route decision must contain a reason code") + if self.selected_retriever_ids != tuple(sorted(self.selected_retriever_ids)): + raise ValueError("selected retriever IDs must be sorted") + if self.outcome == "refusal" and self.selected_retriever_ids: + raise ValueError("refusal may not select retrievers") + if self.outcome == "selected" and not self.selected_retriever_ids: + raise ValueError("selected outcome requires at least one retriever") + if self.outcome == "fallback" and len(self.selected_retriever_ids) != 1: + raise ValueError("fallback must select exactly one safe retriever") + rejection_keys = tuple( + (item.target_id, item.reason_code) for item in self.rejections + ) + if len(rejection_keys) != len(set(rejection_keys)): + raise ValueError("route rejections may not contain duplicates") + if rejection_keys != tuple(sorted(rejection_keys)): + raise ValueError("route rejections must be sorted") + return self + + +class RankedNomination(_StrictModel): + local_rank: int = Field(ge=1) + result: QueryResult + + +class RetrieverBatch(_StrictModel): + retriever_id: str + trust_profile: str + source_surface: str + assembly_reason: str + rows: tuple[RankedNomination, ...] = () + + _retriever_id = field_validator("retriever_id")(_stable_id) + _labels = field_validator( + "trust_profile", "source_surface", "assembly_reason" + )(_nonempty) + + @model_validator(mode="after") + def validate_local_ranks(self) -> RetrieverBatch: + ranks = tuple(row.local_rank for row in self.rows) + if ranks != tuple(range(1, len(self.rows) + 1)): + raise ValueError("batch local ranks must be contiguous and one-based") + return self + + +class RetrieverFailure(_StrictModel): + retriever_id: str + reason_code: Literal["retriever_failed"] = "retriever_failed" + error_type: str + message: str + + _retriever_id = field_validator("retriever_id")(_stable_id) + _error = field_validator("error_type", "message")(_nonempty) + + +class RetrievalEnvelope(_StrictModel): + schema_version: Literal["1"] = "1" + query_id: str + outcome: EnvelopeOutcome + decision: RouteDecision + batches: tuple[RetrieverBatch, ...] = () + failures: tuple[RetrieverFailure, ...] = () + partial: bool = False + warnings: tuple[str, ...] = () + + _query_id = field_validator("query_id")(_stable_id) + + @model_validator(mode="after") + def validate_partial_state(self) -> RetrievalEnvelope: + if self.partial != bool(self.failures): + raise ValueError("partial must exactly reflect whether failures exist") + if self.outcome == "partial" and not self.failures: + raise ValueError("partial outcome requires at least one failure") + if self.failures and self.outcome != "partial": + raise ValueError("retriever failures require partial outcome") + batch_ids = tuple(batch.retriever_id for batch in self.batches) + failure_ids = tuple(failure.retriever_id for failure in self.failures) + if batch_ids != tuple(sorted(batch_ids)): + raise ValueError("retriever batches must be sorted") + if failure_ids != tuple(sorted(failure_ids)): + raise ValueError("retriever failures must be sorted") + if set(batch_ids) & set(failure_ids): + raise ValueError("one retriever may not both succeed and fail") + selected = set(self.decision.selected_retriever_ids) + if set(batch_ids) | set(failure_ids) != selected: + raise ValueError("batches and failures must account for selected retrievers") + if self.decision.outcome == "refusal" and self.outcome != "refusal": + raise ValueError("refusal decision requires refusal envelope") + if self.outcome == "success" and self.decision.outcome != "selected": + raise ValueError("success envelope requires selected decision") + if self.outcome == "fallback" and self.decision.outcome != "fallback": + raise ValueError("fallback envelope requires fallback decision") + return self + + def as_contract_result(self, result_id: str) -> dict[str, Any]: + """Return the additive candidate shape used by the Phase 1 evaluator.""" + selected = list(self.decision.selected_retriever_ids) + trust_profiles = sorted({batch.trust_profile for batch in self.batches}) + source_paths: set[str] = set() + for batch in self.batches: + for row in batch.rows: + path = row.result.display_path or row.result.path or row.result.source_path + if path: + source_paths.add(path) + + behaviors: set[str] = set() + if len(self.batches) > 1: + behaviors.update( + { + "group results by retriever and trust profile", + "preserve per-retriever ranks", + "explain why each retriever was called", + } + ) + outcome = self.outcome + if outcome in {"success", "partial"}: + contract_outcome = "success" + else: + contract_outcome = outcome + if ( + contract_outcome == "refusal" + and self.decision.reason_codes + and self.decision.reason_codes[0] + == "raw_transcripts_not_in_default_retrieval" + ): + contract_outcome = "refusal_or_explicit_consent_gate" + fields = ( + ["retriever_id", "trust_profile", "source_surface", "assembly_reason"] + if contract_outcome + not in {"refusal", "refusal_or_explicit_consent_gate", "fallback"} + else [] + ) + payload: dict[str, Any] = { + "id": result_id, + "outcome": contract_outcome, + "selected_retrievers": selected, + "trust_profiles": trust_profiles, + "source_paths": sorted(source_paths), + "behaviors": sorted(behaviors), + "fields": fields, + } + if self.decision.reason_codes: + payload["reason"] = self.decision.reason_codes[0] + return payload + + +def _intent_unavailable_resolution(reason: str = "intent_unavailable") -> IntentResolution: + return IntentResolution( + graph_id="unavailable", + graph_version="unavailable", + source_hash="unavailable", + outcome="fallback", + resolution_method="none", + refusal_reason=reason, + warnings=(reason,), + ) + + +def _validate_policy_registry( + registry: CapabilityRegistry, policy: RouterPolicy +) -> None: + required = {policy.durable_retriever_id, policy.project_retriever_id} + if policy.safe_default_retriever_id is not None: + required.add(policy.safe_default_retriever_id) + missing = sorted(item for item in required if registry.by_id(item) is None) + if missing: + raise RoutingError( + "routing_policy_invalid", + "policy references missing retrievers: " + ", ".join(missing), + ) + + +def _make_decision( + *, + policy: RouterPolicy, + request: RouteRequest, + resolution: IntentResolution, + outcome: DecisionOutcome, + selected: tuple[str, ...] = (), + rejections: tuple[RouteRejection, ...] = (), + reasons: tuple[str, ...] = (), +) -> RouteDecision: + return RouteDecision( + policy_version=policy.policy_version, + outcome=outcome, + selected_retriever_ids=tuple(sorted(selected)), + rejections=tuple(sorted(rejections, key=lambda item: (item.target_id, item.reason_code))), + reason_codes=_unique_tuple(reasons, label="route reason codes"), + intent_resolution=resolution, + effective_limits=request.limits, + ) + + +def _explicit_decision( + request: RouteRequest, + resolution: IntentResolution, + registry: CapabilityRegistry, + policy: RouterPolicy, +) -> RouteDecision: + if request.mode == "federated" and not policy.allow_federated: + return _make_decision( + policy=policy, + request=request, + resolution=resolution, + outcome="refusal", + reasons=("federation_not_allowed",), + ) + mode_ids = { + "durable": (policy.durable_retriever_id,), + "projects": (policy.project_retriever_id,), + "federated": tuple( + sorted({policy.durable_retriever_id, policy.project_retriever_id}) + ), + } + reasons = { + "durable": ("explicit_durable_scope",), + "projects": ("explicit_project_scope",), + "federated": ("explicit_federated_scope",), + } + requested = mode_ids[request.mode] + selected: list[str] = [] + rejected: list[RouteRejection] = [] + for retriever_id in requested: + registration = registry.by_id(retriever_id) + assert registration is not None # checked by _validate_policy_registry + if registration.descriptor.available: + selected.append(retriever_id) + else: + rejected.append( + RouteRejection( + target_id=retriever_id, reason_code="retriever_unavailable" + ) + ) + if rejected: + return _make_decision( + policy=policy, + request=request, + resolution=resolution, + outcome="refusal", + rejections=tuple(rejected), + reasons=("retriever_unavailable",), + ) + return _make_decision( + policy=policy, + request=request, + resolution=resolution, + outcome="selected", + selected=tuple(selected), + reasons=reasons[request.mode], + ) + + +def _safe_default_decision( + request: RouteRequest, + resolution: IntentResolution, + registry: CapabilityRegistry, + policy: RouterPolicy, +) -> RouteDecision | None: + retriever_id = policy.safe_default_retriever_id + if retriever_id is None: + return None + registration = registry.by_id(retriever_id) + assert registration is not None # checked by _validate_policy_registry + if not registration.descriptor.available: + return _make_decision( + policy=policy, + request=request, + resolution=resolution, + outcome="refusal", + rejections=( + RouteRejection( + target_id=retriever_id, reason_code="retriever_unavailable" + ), + ), + reasons=("retriever_unavailable",), + ) + return _make_decision( + policy=policy, + request=request, + resolution=resolution, + outcome="fallback", + selected=(retriever_id,), + reasons=("safe_default",), + ) + + +def decide_route( + request: RouteRequest, + resolution: IntentResolution, + registry: CapabilityRegistry, + policy: RouterPolicy, +) -> RouteDecision: + """Select retrievers deterministically without executing them.""" + _validate_policy_registry(registry, policy) + for rule in policy.refusal_rules: + if rule.matches(request.query_text): + return _make_decision( + policy=policy, + request=request, + resolution=resolution, + outcome="refusal", + reasons=(rule.reason_code,), + ) + + if request.mode != "auto": + return _explicit_decision(request, resolution, registry, policy) + + if resolution.outcome == "refusal": + reason = resolution.refusal_reason or "intent_invalid" + if reason in {"intent_alias_ambiguous", "intent_rule_ambiguous"}: + reason = "intent_ambiguous" + elif reason != "intent_ambiguous": + reason = "intent_invalid" + return _make_decision( + policy=policy, + request=request, + resolution=resolution, + outcome="refusal", + reasons=(reason,), + ) + + if resolution.outcome == "fallback": + default = _safe_default_decision(request, resolution, registry, policy) + if default is not None: + return default + reason = ( + "intent_unavailable" + if resolution.refusal_reason == "intent_unavailable" + else "intent_no_match" + ) + return _make_decision( + policy=policy, + request=request, + resolution=resolution, + outcome="refusal", + reasons=(reason,), + ) + + allowed = frozenset(request.allowed_capability_refs) + selected: set[str] = set() + rejected: list[RouteRejection] = [] + for capability_ref in resolution.rejected_capability_hints: + reason = "capability_not_allowed" + if f"binding_unavailable:{capability_ref}" in resolution.warnings: + reason = "capability_unavailable" + rejected.append(RouteRejection(target_id=capability_ref, reason_code=reason)) + for capability_ref in resolution.capability_hints: + if capability_ref not in allowed: + rejected.append( + RouteRejection( + target_id=capability_ref, reason_code="capability_not_allowed" + ) + ) + continue + registration = registry.by_capability(capability_ref) + if registration is None: + rejected.append( + RouteRejection( + target_id=capability_ref, reason_code="capability_unavailable" + ) + ) + continue + if not registration.descriptor.available: + rejected.append( + RouteRejection( + target_id=registration.descriptor.retriever_id, + reason_code="retriever_unavailable", + ) + ) + continue + selected.add(registration.descriptor.retriever_id) + + if len(selected) > 1 and not policy.allow_federated: + return _make_decision( + policy=policy, + request=request, + resolution=resolution, + outcome="refusal", + rejections=tuple(rejected), + reasons=("federation_not_allowed",), + ) + if selected: + return _make_decision( + policy=policy, + request=request, + resolution=resolution, + outcome="selected", + selected=tuple(selected), + rejections=tuple(rejected), + reasons=("intent_capability_hint",), + ) + if rejected: + reason_codes = {item.reason_code for item in rejected} + reason = ( + "capability_unavailable" + if reason_codes & {"capability_unavailable", "retriever_unavailable"} + else "capability_not_allowed" + ) + return _make_decision( + policy=policy, + request=request, + resolution=resolution, + outcome="refusal", + rejections=tuple(rejected), + reasons=(reason,), + ) + default = _safe_default_decision(request, resolution, registry, policy) + if default is not None: + return default + return _make_decision( + policy=policy, + request=request, + resolution=resolution, + outcome="refusal", + reasons=("intent_no_match",), + ) + + +def open_query_store_read_only(path: Path) -> sqlite3.Connection: + """Open an existing document index without persisting connection changes.""" + resolved = Path(path).expanduser().resolve() + if not resolved.is_file(): + raise RoutingError( + "retriever_unavailable", f"query store does not exist: {resolved}" + ) + try: + return db_mod.get_db(str(resolved), read_only=True) + except (db_mod.DatabaseError, sqlite3.Error, RuntimeError) as exc: + raise RoutingError( + "retriever_unavailable", f"failed to open query store: {exc}" + ) from exc + + +@dataclass(frozen=True) +class MindGraphQueryRetriever: + descriptor: RetrieverDescriptor + db_path: Path + embedder: Any + embedder_spec: EmbedderSpec | None = None + embed_template: EmbedTemplate = "none" + lexical_top_k: int = query_mod.DEFAULT_LEXICAL_TOP_K + semantic_top_k: int = query_mod.DEFAULT_SEMANTIC_TOP_K + expand: bool = False + expand_depth: int = query_mod.DEFAULT_EXPAND_DEPTH + expand_top_k: int = query_mod.DEFAULT_EXPAND_TOP_K + associate: bool = False + associate_top_k: int = query_mod.DEFAULT_ASSOCIATE_TOP_K + associate_seed_k: int = query_mod.DEFAULT_ASSOCIATE_SEED_K + + def __post_init__(self) -> None: + if not self.descriptor.available: + raise RoutingError( + "routing_registry_invalid", + "MindGraphQueryRetriever requires an available descriptor", + ) + + def retrieve(self, query_text: str, *, final_top_k: int) -> list[QueryResult]: + conn = open_query_store_read_only(self.db_path) + try: + formatted = ( + format_query_text( + self.embedder_spec, query_text, template=self.embed_template + ) + if self.embedder_spec is not None + else query_text + ) + return query_mod.run_query( + conn, + formatted, + self.embedder, + lexical_top_k=self.lexical_top_k, + semantic_top_k=self.semantic_top_k, + final_top_k=final_top_k, + expand=self.expand, + expand_depth=self.expand_depth, + expand_top_k=self.expand_top_k, + associate=self.associate, + associate_top_k=self.associate_top_k, + associate_seed_k=self.associate_seed_k, + embedder_spec=self.embedder_spec, + embed_template=self.embed_template, + ) + finally: + conn.close() + + def registration(self) -> RetrieverRegistration: + return RetrieverRegistration(descriptor=self.descriptor, executor=self) + + +def orchestrate( + request: RouteRequest, + *, + intent_conn: sqlite3.Connection | None, + registry: CapabilityRegistry, + policy: RouterPolicy, +) -> RetrievalEnvelope: + """Resolve intent, decide a route, and return separately ranked batches.""" + if intent_conn is None: + resolution = _intent_unavailable_resolution() + else: + try: + resolution = resolve_intent( + intent_conn, + request.query_text, + intent_id=request.intent_id, + scope=request.scope, + allowed_capability_refs=frozenset(request.allowed_capability_refs), + limits=TraversalLimits(), + ) + except (IntentGraphError, sqlite3.Error): + resolution = _intent_unavailable_resolution() + + decision = decide_route(request, resolution, registry, policy) + if not decision.selected_retriever_ids: + return RetrievalEnvelope( + query_id=request.query_id, + outcome=decision.outcome, + decision=decision, + warnings=tuple(sorted(set(resolution.warnings))), + ) + + batches: list[RetrieverBatch] = [] + failures: list[RetrieverFailure] = [] + for retriever_id in decision.selected_retriever_ids: + registration = registry.by_id(retriever_id) + assert registration is not None and registration.executor is not None + try: + results = registration.executor.retrieve( + request.query_text, final_top_k=request.limits.final_top_k + ) + mismatched = sorted( + { + result.trust_profile + for result in results + if result.trust_profile is not None + and result.trust_profile + != registration.descriptor.trust_profile + } + ) + if mismatched: + raise RoutingError( + "retriever_provenance_mismatch", + f"{retriever_id} returned unexpected trust profiles: " + + ", ".join(mismatched), + ) + rows = tuple( + RankedNomination(local_rank=index, result=result) + for index, result in enumerate(results, start=1) + ) + batches.append( + RetrieverBatch( + retriever_id=retriever_id, + trust_profile=registration.descriptor.trust_profile, + source_surface=registration.descriptor.source_surface, + assembly_reason=decision.reason_codes[0], + rows=rows, + ) + ) + except Exception as exc: # noqa: BLE001 - failures belong in the envelope + failures.append( + RetrieverFailure( + retriever_id=retriever_id, + error_type=type(exc).__name__, + message=str(exc), + ) + ) + + if failures: + outcome: EnvelopeOutcome = "partial" + elif decision.outcome == "fallback": + outcome = "fallback" + else: + outcome = "success" + warnings = set(resolution.warnings) + if failures: + warnings.add("retriever_failed") + return RetrievalEnvelope( + query_id=request.query_id, + outcome=outcome, + decision=decision, + batches=tuple(batches), + failures=tuple(failures), + partial=bool(failures), + warnings=tuple(sorted(warnings)), + ) diff --git a/mindgraph/tests/__init__.py b/mindgraph/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mindgraph/tests/fixtures/intent_graph_cases.yaml b/mindgraph/tests/fixtures/intent_graph_cases.yaml new file mode 100644 index 0000000..6fbad8a --- /dev/null +++ b/mindgraph/tests/fixtures/intent_graph_cases.yaml @@ -0,0 +1,280 @@ +source_ref: &source_ref "plan://plans/retrieval-remodel.md" +reviewer: &reviewer "operator:example" + +documents: + phase1: + schema_version: "1" + graph: + id: "mainframe.core" + version: "2026-06-29.1" + status: "approved" + created_at: "2026-06-29T00:00:00Z" + reviewed_at: "2026-06-29T00:00:00Z" + reviewed_by: [*reviewer] + source_refs: [*source_ref] + supersedes: null + nodes: + - id: "goal.mindgraph-remodel" + kind: "goal" + label: "MindGraph remodel" + aliases: [] + status: "active" + source_refs: [*source_ref] + - id: "goal.route-contract-evaluation" + kind: "goal" + label: "Route contract evaluation" + aliases: ["Run the route contract checks."] + status: "active" + source_refs: [*source_ref] + - id: "goal.intent-schema-validation" + kind: "goal" + label: "Intent schema validation" + aliases: [] + status: "active" + source_refs: [*source_ref] + - id: "goal.project-status-review" + kind: "goal" + label: "Project status review" + aliases: [] + status: "active" + source_refs: [*source_ref] + - id: "goal.deep-root" + kind: "goal" + label: "Deep root" + aliases: [] + status: "active" + source_refs: [*source_ref] + - id: "goal.deep-1" + kind: "goal" + label: "Deep one" + aliases: [] + status: "active" + source_refs: [*source_ref] + - id: "goal.deep-2" + kind: "goal" + label: "Deep two" + aliases: [] + status: "active" + source_refs: [*source_ref] + - id: "goal.deep-3" + kind: "goal" + label: "Deep three" + aliases: [] + status: "active" + source_refs: [*source_ref] + - id: "capability.fixture-evaluator" + kind: "capability" + label: "Fixture evaluator" + aliases: [] + status: "active" + source_refs: [*source_ref] + - id: "capability.mindgraph-projects" + kind: "capability" + label: "MindGraph projects retriever" + aliases: [] + status: "active" + source_refs: [*source_ref] + - id: "capability.gmail" + kind: "capability" + label: "Gmail retriever" + aliases: [] + status: "active" + source_refs: [*source_ref] + - id: "capability.optional" + kind: "capability" + label: "Optional unavailable capability" + aliases: [] + status: "active" + source_refs: [*source_ref] + - id: "constraint.review-required" + kind: "constraint" + label: "Review required" + aliases: [] + status: "active" + source_refs: [*source_ref] + edges: + - source_id: "goal.mindgraph-remodel" + target_id: "goal.route-contract-evaluation" + relation: "requires" + status: "active" + source_refs: [*source_ref] + - source_id: "goal.route-contract-evaluation" + target_id: "goal.intent-schema-validation" + relation: "requires" + status: "active" + source_refs: [*source_ref] + - source_id: "goal.mindgraph-remodel" + target_id: "capability.mindgraph-projects" + relation: "routes_to" + status: "active" + source_refs: [*source_ref] + - source_id: "goal.mindgraph-remodel" + target_id: "capability.fixture-evaluator" + relation: "routes_to" + status: "active" + source_refs: [*source_ref] + - source_id: "goal.route-contract-evaluation" + target_id: "capability.fixture-evaluator" + relation: "routes_to" + status: "active" + source_refs: [*source_ref] + - source_id: "goal.project-status-review" + target_id: "capability.mindgraph-projects" + relation: "routes_to" + status: "active" + source_refs: [*source_ref] + - source_id: "goal.project-status-review" + target_id: "capability.gmail" + relation: "routes_to" + status: "active" + source_refs: [*source_ref] + - source_id: "goal.project-status-review" + target_id: "capability.optional" + relation: "routes_to" + status: "active" + source_refs: [*source_ref] + - source_id: "goal.mindgraph-remodel" + target_id: "constraint.review-required" + relation: "blocked_by" + status: "active" + source_refs: [*source_ref] + - source_id: "goal.deep-root" + target_id: "goal.deep-1" + relation: "requires" + status: "active" + source_refs: [*source_ref] + - source_id: "goal.deep-1" + target_id: "goal.deep-2" + relation: "requires" + status: "active" + source_refs: [*source_ref] + - source_id: "goal.deep-2" + target_id: "goal.deep-3" + relation: "requires" + status: "active" + source_refs: [*source_ref] + bindings: + - id: "binding.fixture-evaluator" + node_id: "capability.fixture-evaluator" + ref: "capability://fixture-evaluator" + required: true + availability: "available" + source_refs: ["project://mindgraph-eval"] + - id: "binding.mindgraph-projects" + node_id: "capability.mindgraph-projects" + ref: "retriever://mindgraph-projects" + required: true + availability: "available" + source_refs: ["project://mindgraph"] + - id: "binding.gmail" + node_id: "capability.gmail" + ref: "retriever://gmail" + required: false + availability: "available" + source_refs: ["project://mindgraph"] + - id: "binding.optional" + node_id: "capability.optional" + ref: "capability://optional-unavailable" + required: false + availability: "unavailable" + source_refs: ["project://mindgraph"] + rules: + - id: "rule.project-status-review" + priority: 100 + goal_id: "goal.project-status-review" + match: + scope: "project_status" + all_terms: ["project", "status"] + any_terms: ["review", "next"] + source_refs: ["decision://DECISIONS.md"] + + predecessor: + schema_version: "1" + graph: + id: "mainframe.core" + version: "2026-06-28.1" + status: "approved" + created_at: "2026-06-28T00:00:00Z" + reviewed_at: "2026-06-28T00:00:00Z" + reviewed_by: [*reviewer] + source_refs: [*source_ref] + supersedes: null + nodes: + - id: "goal.baseline" + kind: "goal" + label: "Baseline" + aliases: [] + status: "active" + source_refs: [*source_ref] + edges: [] + bindings: [] + rules: [] + +invalid_cases: + duplicate_id: + expected_error: "intent_duplicate_id" + alias_collision: + expected_error: "intent_alias_ambiguous" + missing_target: + expected_error: "intent_missing_target" + invalid_relation: + expected_error: "intent_relation_invalid" + kind_mismatch: + expected_error: "intent_kind_mismatch" + requires_cycle: + expected_error: "intent_cycle_detected" + expected_witness: ["goal.cyclic-a", "goal.cyclic-b", "goal.cyclic-a"] + decomposes_cycle: + expected_error: "intent_cycle_detected" + next_step_cycle: + expected_error: "intent_cycle_detected" + missing_predecessor: + expected_error: "intent_version_missing" + version_fork: + expected_error: "intent_version_fork" + changed_approved_version: + expected_error: "intent_version_conflict" + required_unavailable_binding: + expected_error: "intent_binding_unavailable" + ambiguous_rule: + expected_error: "intent_rule_ambiguous" + +phase1_expected: + intent01_explicit_goal_prerequisites: + outcome: "resolved" + resolution_method: "explicit" + matched_goal_ids: ["goal.mindgraph-remodel"] + prerequisite_goal_ids: + - "goal.route-contract-evaluation" + - "goal.intent-schema-validation" + intent_path: + - "goal.mindgraph-remodel" + - "goal.route-contract-evaluation" + - "goal.intent-schema-validation" + capability_hints: + - "capability://fixture-evaluator" + - "retriever://mindgraph-projects" + intent02_deterministic_alias_resolution: + outcome: "resolved" + resolution_method: "alias" + matched_goal_ids: ["goal.route-contract-evaluation"] + prerequisite_goal_ids: [] + intent_path: ["goal.route-contract-evaluation"] + capability_hints: ["capability://fixture-evaluator"] + intent03_ambiguous_alias_refusal: + outcome: "refusal" + reason: "intent_alias_ambiguous" + intent04_cycle_refusal: + outcome: "refusal" + reason: "intent_cycle_detected" + intent05_missing_goal_fallback: + outcome: "fallback" + reason: "intent_no_match" + intent06_untrusted_route_hint_rejected: + outcome: "resolved" + capability_hints: ["retriever://mindgraph-projects"] + forbidden_capability_hints: ["retriever://gmail"] + intent07_bounded_traversal_truncation: + outcome: "resolved" + reason: "intent_traversal_truncated" + intent_path: ["goal.deep-root", "goal.deep-1", "goal.deep-2"] diff --git a/mindgraph/tests/fixtures/phase3_routing_cases.yaml b/mindgraph/tests/fixtures/phase3_routing_cases.yaml new file mode 100644 index 0000000..0e4447d --- /dev/null +++ b/mindgraph/tests/fixtures/phase3_routing_cases.yaml @@ -0,0 +1,59 @@ +policy: + policy_version: "mainframe.router-v1" + durable_retriever_id: "mindgraph-durable" + project_retriever_id: "mindgraph-projects" + allow_federated: true + safe_default_retriever_id: "mindgraph-durable" + refusal_rules: + - rule_id: "privacy.raw-transcript" + reason_code: "raw_transcripts_not_in_default_retrieval" + phrases: + - "raw session transcript" + - "raw transcript" + +requests: + explicit_durable: + request: + query_id: "route.explicit-durable" + query_text: "Explain option exercise and assignment." + mode: "durable" + expected_outcome: "selected" + expected_retrievers: ["mindgraph-durable"] + expected_reason: "explicit_durable_scope" + + explicit_projects: + request: + query_id: "route.explicit-projects" + query_text: "What is the next approved implementation slice?" + mode: "projects" + expected_outcome: "selected" + expected_retrievers: ["mindgraph-projects"] + expected_reason: "explicit_project_scope" + + explicit_federated: + request: + query_id: "route.explicit-federated" + query_text: "Plan using durable methods and project status." + mode: "federated" + expected_outcome: "selected" + expected_retrievers: ["mindgraph-durable", "mindgraph-projects"] + expected_reason: "explicit_federated_scope" + + automatic_project_hint: + request: + query_id: "route.auto-project" + query_text: "Project status review" + mode: "auto" + allowed_capability_refs: ["retriever://mindgraph-projects"] + expected_outcome: "selected" + expected_retrievers: ["mindgraph-projects"] + expected_reason: "intent_capability_hint" + + raw_transcript_refusal: + request: + query_id: "route.raw-transcript" + query_text: "Search every raw session transcript for the exact prompt." + mode: "durable" + expected_outcome: "refusal" + expected_retrievers: [] + expected_reason: "raw_transcripts_not_in_default_retrieval" diff --git a/mindgraph/tests/test_allocation_only.py b/mindgraph/tests/test_allocation_only.py new file mode 100644 index 0000000..41fc941 --- /dev/null +++ b/mindgraph/tests/test_allocation_only.py @@ -0,0 +1,320 @@ +"""Separable C-0 filtering and context allocation (DECISIONS.md § 2026-07-27). + +These cover the two extracted primitives and the two evaluation-only entry +points. They prove behaviour parity and the ungoverned arm's containment; they +make no claim that either apparatus improves retrieval, and no held-out +evaluation is run here. ``tests/test_governance.py`` remains the untouched +regression proof for the governed path. +""" + +from __future__ import annotations + +import pytest + +from mindgraph.models import QueryResult +from mindgraph.query import ( + QueryError, + _allocate_context_budget, + _filter_by_c0_eligibility, + allocate_ungoverned_context, + apply_dual_gate_governance, + filter_by_c0_eligibility, +) + + +def _result(doc_id: str, *, path: str, content_hash: str, text: str = "passage") -> QueryResult: + return QueryResult( + doc_id=doc_id, + chunk_index=0, + path=path, + title=doc_id, + signal="fused", + rrf_score=1.0, + lexical_rank=1, + semantic_rank=1, + chunk_text=text, + content_hash=content_hash, + ) + + +def _manifest(*records: dict) -> dict: + return {"eligibility_run_id": "c0-test-run", "approved_inventory": list(records)} + + +def _approved(doc_id: str, path: str, content_hash: str) -> dict: + return { + "doc_id": doc_id, + "path": path, + "sha256": f"sha256:{content_hash}", + "status": "Approved", + } + + +def _series(count: int, *, texts: list[str] | None = None) -> list[QueryResult]: + """``count`` distinct results, doc-1..doc-N, each with a valid hex hash.""" + return [ + _result( + f"doc-{index}", + path=f"/vault/10_knowledge/{index}.md", + content_hash=f"{index:x}" * 64, + text="passage" if texts is None else texts[index - 1], + ) + for index in range(1, count + 1) + ] + + +def _inventory_for(results: list[QueryResult]) -> list[dict]: + return [ + _approved(result.doc_id, result.path, result.content_hash or "") + for result in results + ] + + +# --- composition equivalence ------------------------------------------------- + + +@pytest.mark.parametrize( + ("max_seats", "max_chars", "quiet_keywords"), + [ + (3, 4000, None), + (2, 4000, ["signal"]), + (5, 4000, ["absent-keyword"]), + (3, 12, None), + (0, 4000, None), + ], +) +def test_governed_helper_equals_filter_then_allocate( + max_seats: int, max_chars: int, quiet_keywords: list[str] | None +) -> None: + results = _series(4, texts=["alpha", "beta", "gamma", "delta signal"]) + manifest = _manifest(*_inventory_for(results)) + + composed = _allocate_context_budget( + _filter_by_c0_eligibility(results, manifest), + max_seats, + max_chars, + quiet_keywords, + ) + governed = apply_dual_gate_governance( + results, + max_seats, + max_chars, + quiet_keywords, + eligibility_manifest=manifest, + ) + assert governed == composed + + +def test_manifest_validation_still_precedes_argument_validation() -> None: + """Error precedence is unchanged by the split: manifest errors win.""" + results = _series(1) + with pytest.raises(QueryError, match="requires a C-0"): + apply_dual_gate_governance(results, -1, -1, eligibility_manifest=None) + with pytest.raises(QueryError, match="requires a C-0"): + apply_dual_gate_governance(results, 0, 4000, eligibility_manifest=None) + # With a valid manifest the negative-argument check is what raises. + with pytest.raises(QueryError, match="must be non-negative"): + apply_dual_gate_governance( + results, -1, 4000, eligibility_manifest=_manifest(*_inventory_for(results)) + ) + + +# --- C-0-only arm ------------------------------------------------------------ + + +def test_filter_applies_no_seat_or_character_cap() -> None: + results = _series(5, texts=["x" * 5000] * 5) + filtered = filter_by_c0_eligibility( + results, eligibility_manifest=_manifest(*_inventory_for(results)) + ) + assert [result.doc_id for result in filtered] == [ + "doc-1", + "doc-2", + "doc-3", + "doc-4", + "doc-5", + ] + assert all(result.chunk_text == "x" * 5000 for result in filtered) + assert all(result.eligibility_run_id == "c0-test-run" for result in filtered) + + +def test_public_filter_matches_private_filter() -> None: + results = _series(3) + manifest = _manifest(*_inventory_for(results[:2])) + assert filter_by_c0_eligibility( + results, eligibility_manifest=manifest + ) == _filter_by_c0_eligibility(results, manifest) + + +# --- Speaker-only arm -------------------------------------------------------- + + +def test_ungoverned_admits_a_result_with_no_manifest_record() -> None: + results = _series(2) + manifest_without_doc_2 = _manifest(*_inventory_for(results[:1])) + + governed = apply_dual_gate_governance( + results, eligibility_manifest=manifest_without_doc_2 + ) + assert [result.doc_id for result in governed] == ["doc-1"] + + ungoverned = allocate_ungoverned_context(results, evaluation_use_only=True) + assert [result.doc_id for result in ungoverned] == ["doc-1", "doc-2"] + + +def test_ungoverned_never_emits_eligibility_provenance() -> None: + results = _series(2) + already_governed = [ + result.model_copy(update={"eligibility_run_id": "c0-earlier-run"}) + for result in results + ] + ungoverned = allocate_ungoverned_context(already_governed, evaluation_use_only=True) + assert [result.doc_id for result in ungoverned] == ["doc-1", "doc-2"] + assert all(result.eligibility_run_id is None for result in ungoverned) + # The inputs are not mutated on the way through. + assert all(result.eligibility_run_id == "c0-earlier-run" for result in already_governed) + + +def test_ungoverned_requires_an_explicit_acknowledgement() -> None: + results = _series(1) + with pytest.raises(TypeError): + allocate_ungoverned_context(results) # type: ignore[call-arg] + for value in (False, None, 1, "True"): + with pytest.raises(QueryError, match="evaluation_use_only=True"): + allocate_ungoverned_context(results, evaluation_use_only=value) # type: ignore[arg-type] + + +def test_ungoverned_refuses_a_manifest_keyword() -> None: + results = _series(1) + with pytest.raises(QueryError, match="takes no eligibility manifest"): + allocate_ungoverned_context( + results, + evaluation_use_only=True, + eligibility_manifest=_manifest(*_inventory_for(results)), + ) + + +def test_ungoverned_rejects_other_unexpected_keywords() -> None: + results = _series(1) + with pytest.raises(TypeError, match="unexpected keyword argument"): + allocate_ungoverned_context( + results, evaluation_use_only=True, not_a_parameter=True + ) + + +# --- allocator parity between the arms --------------------------------------- + + +_SHORT = "a" * 100 +_LONG = "b" * 300 + + +@pytest.mark.parametrize( + ("texts", "max_seats", "max_chars", "quiet_keywords"), + [ + # plain seat cap + (["alpha", "beta", "gamma", "delta"], 3, 4000, None), + # seat cap wider than the input + (["alpha", "beta"], 5, 4000, None), + # swap: last shortlisted item replaced by the first later keyword match + (["alpha", "beta", "gamma", "delta signal", "epsilon signal"], 3, 4000, ["signal"]), + # swap needs len(results) > max_seats, so this leaves the shortlist alone + (["alpha", "beta", "gamma signal"], 3, 4000, ["signal"]), + # falsy quiet_keywords never engages the swap + (["alpha", "beta", "gamma", "delta signal"], 3, 4000, []), + # keyword present but not in a later candidate + (["alpha signal", "beta", "gamma", "delta"], 3, 4000, ["signal"]), + # truncation boundary: remaining == 101 admits a shortened passage + ([_SHORT, _LONG, "gamma"], 3, 201, None), + # truncation boundary: remaining == 100 admits nothing + ([_SHORT, _LONG, "gamma"], 3, 200, None), + # first passage alone blows the budget + ([_LONG, "beta"], 3, 150, None), + # zero seats + (["alpha", "beta"], 0, 4000, None), + ], +) +def test_allocator_behaviour_is_identical_across_both_arms( + texts: list[str], + max_seats: int, + max_chars: int, + quiet_keywords: list[str] | None, +) -> None: + results = _series(len(texts), texts=texts) + manifest = _manifest(*_inventory_for(results)) + + governed = apply_dual_gate_governance( + results, max_seats, max_chars, quiet_keywords, eligibility_manifest=manifest + ) + ungoverned = allocate_ungoverned_context( + results, + max_seats, + max_chars, + quiet_keywords, + evaluation_use_only=True, + ) + + # Identical seats, order, and passage text; they differ only in provenance. + assert [(item.doc_id, item.chunk_text) for item in governed] == [ + (item.doc_id, item.chunk_text) for item in ungoverned + ] + assert all(item.eligibility_run_id == "c0-test-run" for item in governed) + assert all(item.eligibility_run_id is None for item in ungoverned) + + +def test_truncation_boundary_is_strictly_greater_than_one_hundred() -> None: + results = _series(2, texts=[_SHORT, _LONG]) + + admitted = allocate_ungoverned_context( + results, 3, 201, evaluation_use_only=True + ) + assert [item.doc_id for item in admitted] == ["doc-1", "doc-2"] + assert admitted[1].chunk_text == _LONG[:101] + "..." + + excluded = allocate_ungoverned_context( + results, 3, 200, evaluation_use_only=True + ) + assert [item.doc_id for item in excluded] == ["doc-1"] + + +def test_swap_replaces_the_last_seat_with_the_first_later_match() -> None: + texts = ["alpha", "beta", "gamma", "delta signal", "epsilon signal"] + results = _series(5, texts=texts) + allocated = allocate_ungoverned_context( + results, 3, 4000, ["SIGNAL"], evaluation_use_only=True + ) + assert [item.doc_id for item in allocated] == ["doc-1", "doc-2", "doc-4"] + + +# --- allocation is subtractive at document granularity ----------------------- + + +@pytest.mark.parametrize( + ("texts", "max_seats", "max_chars", "quiet_keywords"), + [ + (["alpha", "beta", "gamma", "delta"], 3, 4000, None), + (["alpha", "beta", "gamma", "delta signal"], 3, 4000, ["signal"]), + ([_SHORT, _LONG, "gamma"], 3, 201, None), + ([_SHORT, _LONG, "gamma"], 3, 200, None), + (["alpha", "beta"], 0, 4000, None), + ], +) +def test_allocation_is_an_order_preserving_document_subsequence( + texts: list[str], + max_seats: int, + max_chars: int, + quiet_keywords: list[str] | None, +) -> None: + results = _series(len(texts), texts=texts) + allocated = allocate_ungoverned_context( + results, max_seats, max_chars, quiet_keywords, evaluation_use_only=True + ) + + input_ids = [item.doc_id for item in results] + output_ids = [item.doc_id for item in allocated] + + # No doc_id is invented. + assert set(output_ids) <= set(input_ids) + # Order is preserved: output_ids is a subsequence of input_ids. + remaining = iter(input_ids) + assert all(doc_id in remaining for doc_id in output_ids) diff --git a/mindgraph/tests/test_associate.py b/mindgraph/tests/test_associate.py new file mode 100644 index 0000000..992506b --- /dev/null +++ b/mindgraph/tests/test_associate.py @@ -0,0 +1,128 @@ +"""Phase 9 — semantic association tests (ADR-034).""" + +import pytest + +from mindgraph import cli, db, parser +from mindgraph.query import associate_results, run_query + + +class KeywordEmbedder: + def __init__(self, keyword_to_dim: dict[str, int], dims: int = 384): + self.keyword_to_dim = {k.lower(): v for k, v in keyword_to_dim.items()} + self.dims = dims + + def encode(self, texts, convert_to_numpy=True): + import numpy as np + + out = np.zeros((len(texts), self.dims), dtype=np.float32) + for i, text in enumerate(texts): + lower = text.lower() + for kw, dim in self.keyword_to_dim.items(): + if kw in lower: + out[i, dim] += 1.0 + return out + + +@pytest.fixture +def associate_embedder(): + return KeywordEmbedder({"gamma": 0, "beta": 1, "zzunique": 2}) + + +@pytest.fixture +def associate_db(tmp_path, monkeypatch, associate_embedder): + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: associate_embedder) + + notes = tmp_path / "vault" + notes.mkdir() + (notes / "seed.md").write_text( + "# Seed\nzzunique anchor. gamma protocol overview. [[linked]] (refers).\n" + ) + (notes / "neighbor.md").write_text( + "# Neighbor\ngamma protocol details without explicit link.\n" + ) + (notes / "linked.md").write_text("# Linked\nbeta content only.\n") + + db_path = str(tmp_path / "test.sqlite") + db.init_db(db_path).close() + cli._ingest_directory(notes, db_path) + return db_path + + +class TestAssociate: + def test_associate_finds_semantic_neighbor_not_in_seeds( + self, associate_db, associate_embedder + ): + conn = db.get_db(associate_db) + try: + fused = run_query( + conn, + "zzunique", + associate_embedder, + final_top_k=1, + ) + assert len(fused) == 1 + assert fused[0].path.endswith("seed.md") + + associated = associate_results( + conn, + fused, + associate_embedder, + top_k=5, + seed_k=1, + ) + paths = [r.path for r in associated] + assert any(p.endswith("neighbor.md") for p in paths) + assert all(r.signal == "associated" for r in associated) + assert all(r.association_depth == 1 for r in associated) + assert all(r.rrf_score == 0.0 for r in associated) + finally: + conn.close() + + def test_associate_cli_flag(self, associate_db): + from typer.testing import CliRunner + + runner = CliRunner() + result = runner.invoke( + cli.app, + [ + "query", + "zzunique", + "--db", + associate_db, + "--associate", + "--top-k", + "1", + "--associate-top-k", + "5", + "--json", + ], + ) + assert result.exit_code == 0 + import json + + rows = json.loads(result.stdout) + signals = {row["signal"] for row in rows} + assert "fused" in signals or "lexical" in signals + assert "associated" in signals + + def test_expand_and_associate_are_distinct(self, associate_db, associate_embedder): + conn = db.get_db(associate_db) + try: + rows = run_query( + conn, + "zzunique", + associate_embedder, + final_top_k=1, + expand=True, + expand_top_k=5, + associate=True, + associate_top_k=5, + ) + expanded = [r for r in rows if r.signal == "expanded"] + associated = [r for r in rows if r.signal == "associated"] + assert expanded + assert associated + assert any(r.path.endswith("linked.md") for r in expanded) + assert any(r.path.endswith("neighbor.md") for r in associated) + finally: + conn.close() \ No newline at end of file diff --git a/mindgraph/tests/test_citation_trust.py b/mindgraph/tests/test_citation_trust.py new file mode 100644 index 0000000..860eb9c --- /dev/null +++ b/mindgraph/tests/test_citation_trust.py @@ -0,0 +1,117 @@ +"""Trust classification and partitioning. + +Ranking is trust-blind: a fabricated document is written to be on topic, so it +scores like any other and can take the top rank. On 2026-08-20, live queries +returned quarantined documents from the 2026-08-09 fabricated-citations +incident at rank 1, carrying only a prose warning. These cover the structural +signal that lets a consumer act on that without parsing English. +""" + +from mindgraph import query as query_mod +from mindgraph.models import QueryResult + + +def _result(doc_id: str, citation_class: str = "citable") -> QueryResult: + return QueryResult( + doc_id=doc_id, + chunk_index=0, + path=f"notes/{doc_id}.md", + title=doc_id, + signal="fused", + rrf_score=0.03, + lexical_rank=1, + semantic_rank=1, + citation_class=citation_class, + chunk_text="body", + ) + + +class TestCitationAssessment: + def test_quarantined_status_is_not_citable(self): + cls, warning = query_mod._citation_assessment({"status": "quarantined"}) + assert cls == "not_citable" + assert warning.startswith("NOT CITABLE") + + def test_retracted_and_superseded_are_not_citable(self): + for status in ("retracted", "superseded"): + assert query_mod._citation_assessment({"status": status})[0] == "not_citable" + + def test_fabricated_citation_tag_is_not_citable(self): + cls, _ = query_mod._citation_assessment({"tags": ["fabricated-citation"]}) + assert cls == "not_citable" + + def test_tags_parse_from_a_bare_string(self): + cls, _ = query_mod._citation_assessment({"tags": "[quarantined, other]"}) + assert cls == "not_citable" + + def test_needs_audit_is_unverified_not_barred(self): + cls, warning = query_mod._citation_assessment({"tags": ["needs-audit"]}) + assert cls == "unverified" + assert warning.startswith("UNVERIFIED") + + def test_clean_frontmatter_is_citable_with_no_warning(self): + assert query_mod._citation_assessment({"status": "active"}) == ("citable", None) + + def test_warning_wrapper_agrees_with_classification(self): + for metadata in ( + {"status": "quarantined"}, + {"tags": ["needs-audit"]}, + {"status": "active"}, + ): + cls, warning = query_mod._citation_assessment(metadata) + assert query_mod._provenance_warning(metadata) == warning + assert (warning is None) == (cls == "citable") + + +class TestPartition: + def test_not_citable_is_separated_from_candidates(self): + results = [ + _result("fabricated", "not_citable"), + _result("good"), + _result("capture", "unverified"), + ] + candidates, excluded = query_mod.partition_by_citation(results) + assert [r.doc_id for r in candidates] == ["good", "capture"] + assert [r.doc_id for r in excluded] == ["fabricated"] + + def test_partition_preserves_rank_order_and_drops_nothing(self): + results = [_result(f"d{i}", "not_citable" if i % 2 else "citable") for i in range(6)] + candidates, excluded = query_mod.partition_by_citation(results) + assert len(candidates) + len(excluded) == len(results) + assert [r.doc_id for r in candidates] == ["d0", "d2", "d4"] + assert [r.doc_id for r in excluded] == ["d1", "d3", "d5"] + + def test_a_top_ranked_fabricated_document_cannot_lead_the_candidates(self): + # The live failure: the highest-scoring hit was quarantined. + results = [_result("top_hit_fabricated", "not_citable"), _result("real")] + candidates, _ = query_mod.partition_by_citation(results) + assert candidates[0].doc_id == "real" + + def test_counts_report_all_three_classes(self): + results = [ + _result("a"), + _result("b", "unverified"), + _result("c", "not_citable"), + _result("d", "not_citable"), + ] + assert query_mod.citation_counts(results) == { + "citable": 1, + "unverified": 1, + "not_citable": 2, + } + + def test_counts_are_zero_filled_for_an_empty_result_set(self): + assert query_mod.citation_counts([]) == { + "citable": 0, + "unverified": 0, + "not_citable": 0, + } + + +class TestSerialization: + def test_citation_class_survives_model_dump(self): + row = _result("x", "not_citable").model_dump() + assert row["citation_class"] == "not_citable" + + def test_citation_class_defaults_to_citable(self): + assert _result("x").model_dump()["citation_class"] == "citable" diff --git a/mindgraph/tests/test_doctor.py b/mindgraph/tests/test_doctor.py new file mode 100644 index 0000000..51d8ecf --- /dev/null +++ b/mindgraph/tests/test_doctor.py @@ -0,0 +1,162 @@ +"""Tests for mindgraph doctor/status and query schema fail-fast (MH01).""" + +from __future__ import annotations + +import json +import os +import sqlite3 +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from mindgraph import cli, db + + +@pytest.fixture +def runner(): + return CliRunner() + + +def test_inspect_healthy_db(tmp_path): + path = str(tmp_path / "ok.sqlite") + db.init_db(path).close() + report = db.inspect_database(path, role="test", trust_profile="t") + assert report["ok"] is True + assert report["exists"] is True + assert report["tables_missing"] == [] + assert report["counts"]["documents"] == 0 + + +def test_inspect_read_only_wal_db_without_writable_directory(tmp_path): + locked = tmp_path / "locked" + locked.mkdir() + path = locked / "index.sqlite" + db.init_db(str(path)).close() + os.chmod(path, 0o444) + os.chmod(locked, 0o555) + try: + report = db.inspect_database(str(path), role="projects") + assert report["ok"] is True, report + conn = db.get_db(str(path), read_only=True) + try: + assert conn.execute("PRAGMA query_only").fetchone()[0] == 1 + finally: + conn.close() + finally: + os.chmod(locked, 0o755) + os.chmod(path, 0o644) + + +def test_inspect_missing_file(tmp_path): + path = str(tmp_path / "nope.sqlite") + report = db.inspect_database(path) + assert report["ok"] is False + assert "file_missing" in report["issues"] + + +def test_inspect_stub_empty_sqlite(tmp_path): + """Tiny file without MindGraph tables fails schema check.""" + path = tmp_path / "stub.sqlite" + # Create a nearly-empty sqlite (no mindgraph tables) + conn = sqlite3.connect(str(path)) + conn.execute("CREATE TABLE junk (id INTEGER)") + conn.commit() + conn.close() + report = db.inspect_database(str(path)) + assert report["ok"] is False + assert "missing_required_tables" in report["issues"] + assert "documents_fts" in report["tables_missing"] + assert report["likely_stub"] is True + + +def test_validate_query_schema_raises(tmp_path): + from mindgraph.exceptions import DatabaseError + + path = str(tmp_path / "bad.sqlite") + raw = sqlite3.connect(path) + raw.execute("CREATE TABLE junk (id INTEGER)") + raw.commit() + raw.close() + conn = db.get_db(path) + try: + with pytest.raises(DatabaseError, match="missing tables"): + db.validate_query_schema(conn, path) + finally: + conn.close() + + +def test_find_workspace_stubs(tmp_path): + stub = tmp_path / "mainframe.sqlite" + stub.write_bytes(b"\x00" * 100) + hits = db.find_workspace_stub_sqlite([tmp_path]) + assert len(hits) == 1 + assert hits[0]["size_bytes"] == 100 + + +def test_doctor_cli_single_ok(tmp_path, runner): + path = tmp_path / "ok.sqlite" + db.init_db(str(path)).close() + result = runner.invoke(cli.app, ["doctor", "--db", str(path), "--json"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["ok"] is True + assert len(payload["databases"]) == 1 + assert payload["databases"][0]["ok"] is True + + +def test_doctor_cli_single_fail(tmp_path, runner): + path = tmp_path / "bad.sqlite" + conn = sqlite3.connect(str(path)) + conn.execute("CREATE TABLE junk (id INTEGER)") + conn.commit() + conn.close() + result = runner.invoke(cli.app, ["doctor", "--db", str(path), "--json"]) + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["ok"] is False + + +def test_status_alias(tmp_path, runner): + path = tmp_path / "ok.sqlite" + db.init_db(str(path)).close() + result = runner.invoke(cli.app, ["status", "--db", str(path), "--json"]) + assert result.exit_code == 0, result.output + + +def test_query_fail_fast_on_stub(tmp_path, runner, monkeypatch, caplog): + """Query on incomplete DB exits before embedder load.""" + import logging + + path = tmp_path / "stub.sqlite" + conn = sqlite3.connect(str(path)) + conn.execute("CREATE TABLE junk (id INTEGER)") + conn.commit() + conn.close() + + def boom(*_a, **_k): + raise AssertionError("embedder should not load on schema failure") + + monkeypatch.setattr(cli, "_load_embedder", boom) + with caplog.at_level(logging.ERROR, logger="mindgraph"): + result = runner.invoke(cli.app, ["query", "hello", "--db", str(path)]) + assert result.exit_code == 1 + combined = (result.output or "") + (result.stderr or "") + caplog.text + assert "missing tables" in combined.lower() or "usable MindGraph" in combined + + +def test_doctor_detects_workspace_stub_flag(tmp_path, runner): + path = tmp_path / "ok.sqlite" + db.init_db(str(path)).close() + stub = tmp_path / "mainframe.sqlite" + stub.write_bytes(b"\x00" * 200) + result = runner.invoke( + cli.app, + ["doctor", "--db", str(path), "--workspace", str(tmp_path), "--json"], + ) + # healthy DB → exit 0; stubs listed as non-fatal warnings + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["ok"] is True + assert payload["workspace_stubs"] + assert payload["databases"][0]["ok"] is True diff --git a/mindgraph/tests/test_embedders.py b/mindgraph/tests/test_embedders.py new file mode 100644 index 0000000..f0e9ba0 --- /dev/null +++ b/mindgraph/tests/test_embedders.py @@ -0,0 +1,103 @@ +"""Embedder registry and text formatting tests.""" + +import sys +import types + +import pytest + +from mindgraph import embedders +from mindgraph.exceptions import EmbeddingError + + +def test_resolve_default_embedder(): + spec = embedders.resolve_embedder(None) + assert spec.key == "minilm" + assert spec.dimensions == 384 + + +def test_resolve_bge_and_e5_prefixes(): + bge = embedders.resolve_embedder("bge-small") + assert bge.query_prefix == "query: " + e5 = embedders.resolve_embedder("e5-small") + assert e5.passage_prefix == "passage: " + + +def test_unknown_embedder_raises(): + with pytest.raises(ValueError, match="Unknown embedder"): + embedders.resolve_embedder("not-a-model") + + +def test_mainframe_template_prefixes(): + spec = embedders.resolve_embedder("minilm") + query = embedders.format_query_text( + spec, "regulated crosswalk", template="mainframe" + ) + assert query.startswith("[intent=query]") + passage = embedders.format_passage_text( + spec, + "chunk body", + template="mainframe", + title="GxP Note", + domain="regulated-systems", + doc_type="note", + ) + assert "[domain=regulated-systems]" in passage + assert "[type=note]" in passage + assert "GxP Note" in passage + + +def test_load_sentence_embedder_uses_local_files_only(monkeypatch): + """Loader must resolve models from the local cache only (no hub metadata).""" + captured: dict[str, object] = {} + + class FakeSentenceTransformer: + def __init__(self, model_name_or_path, *args, **kwargs): + captured["model_name_or_path"] = model_name_or_path + captured["args"] = args + captured["kwargs"] = kwargs + + fake_module = types.ModuleType("sentence_transformers") + fake_module.SentenceTransformer = FakeSentenceTransformer + monkeypatch.setitem(sys.modules, "sentence_transformers", fake_module) + + spec = embedders.resolve_embedder("minilm") + model = embedders.load_sentence_embedder(spec) + + assert isinstance(model, FakeSentenceTransformer) + assert captured["model_name_or_path"] == "all-MiniLM-L6-v2" + assert captured["kwargs"].get("local_files_only") is True + + +def test_load_sentence_embedder_missing_cache_raises_embedding_error(monkeypatch): + class BoomSentenceTransformer: + def __init__(self, *args, **kwargs): + raise OSError("model not found in local cache") + + fake_module = types.ModuleType("sentence_transformers") + fake_module.SentenceTransformer = BoomSentenceTransformer + monkeypatch.setitem(sys.modules, "sentence_transformers", fake_module) + + spec = embedders.resolve_embedder("minilm") + with pytest.raises(EmbeddingError, match="local_files_only=True"): + embedders.load_sentence_embedder(spec) + + +def test_bootstrap_sentence_embedder_is_the_explicit_acquisition_path(monkeypatch): + captured: dict[str, object] = {} + + class FakeSentenceTransformer: + def __init__(self, model_name_or_path, *args, **kwargs): + captured["model_name_or_path"] = model_name_or_path + captured["args"] = args + captured["kwargs"] = kwargs + + fake_module = types.ModuleType("sentence_transformers") + fake_module.SentenceTransformer = FakeSentenceTransformer + monkeypatch.setitem(sys.modules, "sentence_transformers", fake_module) + + spec = embedders.resolve_embedder("minilm") + model = embedders.bootstrap_sentence_embedder(spec) + + assert isinstance(model, FakeSentenceTransformer) + assert captured["model_name_or_path"] == "all-MiniLM-L6-v2" + assert captured["kwargs"] == {} diff --git a/mindgraph/tests/test_examples.py b/mindgraph/tests/test_examples.py new file mode 100644 index 0000000..9dee45e --- /dev/null +++ b/mindgraph/tests/test_examples.py @@ -0,0 +1,291 @@ +"""Phase 4 — example-vault retrieval smoke (CI-safe). + +Asserts the expected retrieval surface against the committed +`examples/example-vault/` vault using the deterministic `KeywordEmbedder` +stub from `tests/test_query.py`. The real-model captures in the asset README +come from `scripts/run_example_smoke.py`, which uses +`sentence-transformers/all-MiniLM-L6-v2`; this test does not require network +access. + +Topology (locked in DECISIONS.md § 2026-05-21 — Phase 4 public packaging): + + feedback-loops.md → [[reinforcing-loops]] (illustrates), + [[balancing-loops]] (illustrates) + reinforcing-loops.md → [[systems-archetypes]] (related) + balancing-loops.md → [[systems-archetypes]] (related) + systems-archetypes.md → [[unicycle-mental-model]] (cites, dangling) + mental-models-overview.md → [[feedback-loops]] (related) + bounded-rationality.md → [[feedback-loops]] (related) + unrelated-noise.md → (no edges, off-theme) +""" + +import shutil +from pathlib import Path + +import numpy as np +import pytest + +from mindgraph import cli, db, parser +from mindgraph.query import list_neighbors, run_query + + +class KeywordEmbedder: + """Same deterministic embedder pattern as tests/test_query.py and tests/test_expand.py.""" + + def __init__(self, keyword_to_dim: dict[str, int], dims: int = 384): + self.keyword_to_dim = {k.lower(): v for k, v in keyword_to_dim.items()} + self.dims = dims + + def encode(self, texts, convert_to_numpy=True): + out = np.zeros((len(texts), self.dims), dtype=np.float32) + for i, text in enumerate(texts): + lower = text.lower() + for kw, dim in self.keyword_to_dim.items(): + if kw in lower: + out[i, dim] += 1.0 + return out + + +def _doc_id(rel_path: str) -> str: + return parser.compute_doc_id(rel_path) + + +EXAMPLE_VAULT_DIR = ( + Path(__file__).resolve().parent.parent / "examples" / "example-vault" +) + + +@pytest.fixture +def example_embedder(): + """Sparse KeywordEmbedder for the committed example vault. + + Mapping rationale: + - `circular` → dim 0: unique to feedback-loops.md. Lets the expansion test + scope Phase 2 to the seed cleanly via `final_top_k=1`. + - `satisfic` and `heuristic` → dim 1: `satisfic` is a substring of + `satisficing` and `satisficer` in bounded-rationality.md; `heuristic` + appears in no doc. A query for `heuristic` therefore has zero lexical + hits and pulls bounded-rationality on the semantic side only, isolating + the semantic-only path. + - `balancing` → dim 2: hits balancing-loops.md most heavily plus several + other systems docs. Exercises the fused signal on balancing-loops. + """ + return KeywordEmbedder( + { + "circular": 0, + "satisfic": 1, + "heuristic": 1, + "balancing": 2, + } + ) + + +@pytest.fixture +def example_db(tmp_path, monkeypatch, example_embedder): + """Copy the committed example vault into tmp_path and ingest it. + + Copying into tmp_path keeps the test self-contained and matches the + cli._ingest_directory contract (reads from a real directory tree). + """ + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: example_embedder) + + vault_copy = tmp_path / "example-vault" + shutil.copytree(EXAMPLE_VAULT_DIR, vault_copy) + + db_path = str(tmp_path / "test.sqlite") + db.init_db(db_path).close() + cli._ingest_directory(vault_copy, db_path) + return db_path + + +class TestExampleVaultRetrievalPaths: + """One test per retrieval path the example vault is designed to exercise. + + For paths where the KeywordEmbedder behavior on a small vault is fuzzy + (zero-embedding docs come back from sqlite-vec's KNN at distance 1.0), + the test asserts presence rather than a specific signal label. The + semantic-only test has a controlled mapping so it asserts the signal. + """ + + def test_unique_lexical_keyword_finds_mental_models_doc( + self, example_db, example_embedder + ): + """`antinet` is unique to mental-models-overview.md. + + FTS5 matches that one doc only; the keyword is not in the embedder + mapping, so the semantic signal is fuzzy. The test asserts the doc + lands at the top of the fused result rather than asserting a + particular signal label. + """ + conn = db.get_db(example_db) + try: + results = run_query( + conn, "antinet", example_embedder, final_top_k=5 + ) + finally: + conn.close() + + target = _doc_id("mental-models-overview.md") + doc_ids = [r.doc_id for r in results] + assert target in doc_ids + assert results[0].doc_id == target + + def test_semantic_synonym_finds_bounded_rationality( + self, example_db, example_embedder + ): + """`heuristic` has zero lexical hits across the vault. + + The embedder maps `heuristic` and `satisfic` to the same dim. + bounded-rationality.md contains `satisficing` and `satisficer` + (both match the substring `satisfic`), so it is the only doc with + non-zero activation on that dim. The signal must be `semantic` + because the lexical ranking is empty. + """ + conn = db.get_db(example_db) + try: + results = run_query( + conn, "heuristic", example_embedder, final_top_k=5 + ) + finally: + conn.close() + + by_id = {r.doc_id: r for r in results} + target = _doc_id("bounded-rationality.md") + assert target in by_id + assert by_id[target].signal == "semantic" + assert by_id[target].lexical_rank is None + assert by_id[target].semantic_rank is not None + + def test_fused_query_lands_on_balancing_loops( + self, example_db, example_embedder + ): + """`balancing` matches several docs both lexically and semantically. + + balancing-loops.md has the heaviest activation and the most lexical + occurrences, so it carries the fused signal in the result set. + """ + conn = db.get_db(example_db) + try: + results = run_query( + conn, "balancing", example_embedder, final_top_k=5 + ) + finally: + conn.close() + + by_id = {r.doc_id: r for r in results} + target = _doc_id("balancing-loops.md") + assert target in by_id + assert by_id[target].signal == "fused" + assert by_id[target].lexical_rank is not None + assert by_id[target].semantic_rank is not None + + +class TestExampleVaultGraphSurface: + """Asserts the graph topology committed in the example vault.""" + + def test_dangling_edge_preserved_on_systems_archetypes(self, example_db): + """systems-archetypes.md links to unicycle-mental-model.md (not present). + + list_neighbors must return the row with target_path=None so the CLI + can surface broken links rather than silently dropping them. + """ + conn = db.get_db(example_db) + try: + neighbors = list_neighbors( + conn, _doc_id("systems-archetypes.md") + ) + finally: + conn.close() + + dangling = [ + n + for n in neighbors + if n.target_id == _doc_id("unicycle-mental-model.md") + ] + assert len(dangling) == 1 + assert dangling[0].target_path is None + assert dangling[0].relationship_type == "cites" + + def test_seed_has_two_illustrates_edges(self, example_db): + """feedback-loops.md links to both one-hop targets with `illustrates`.""" + conn = db.get_db(example_db) + try: + neighbors = list_neighbors(conn, _doc_id("feedback-loops.md")) + finally: + conn.close() + + target_ids = {n.target_id for n in neighbors} + assert _doc_id("reinforcing-loops.md") in target_ids + assert _doc_id("balancing-loops.md") in target_ids + assert all(n.relationship_type == "illustrates" for n in neighbors) + assert all(n.target_path is not None for n in neighbors) + + +class TestExampleVaultGraphExpansion: + """Exercises the Phase 3 expansion path against the committed vault.""" + + def test_expansion_walks_seed_two_hops_skipping_dangling( + self, example_db, example_embedder + ): + """Seed query `circular` returns feedback-loops at depth 0. + + --depth 2 reaches reinforcing-loops and balancing-loops at depth 1 + (the seed's two outbound edges) and systems-archetypes at depth 2 + (reached through both one-hop docs; the BFS deduplicates). The + dangling unicycle-mental-model target is skipped by the walk. + + final_top_k=1 scopes Phase 2 to the seed only, matching the + convention from tests/test_expand.py. + """ + conn = db.get_db(example_db) + try: + results = run_query( + conn, + "circular", + example_embedder, + final_top_k=1, + expand=True, + expand_depth=2, + ) + finally: + conn.close() + + by_id = {r.doc_id: r for r in results} + + seed = _doc_id("feedback-loops.md") + assert seed in by_id + assert by_id[seed].expansion_depth == 0 + + reinforcing = _doc_id("reinforcing-loops.md") + balancing = _doc_id("balancing-loops.md") + assert by_id[reinforcing].signal == "expanded" + assert by_id[reinforcing].expansion_depth == 1 + assert by_id[balancing].signal == "expanded" + assert by_id[balancing].expansion_depth == 1 + + archetypes = _doc_id("systems-archetypes.md") + assert by_id[archetypes].signal == "expanded" + assert by_id[archetypes].expansion_depth == 2 + + assert _doc_id("unicycle-mental-model.md") not in by_id + + def test_expansion_excludes_unrelated_noise( + self, example_db, example_embedder + ): + """unrelated-noise.md has no inbound edge from any seed-reachable doc.""" + conn = db.get_db(example_db) + try: + results = run_query( + conn, + "circular", + example_embedder, + final_top_k=1, + expand=True, + expand_depth=3, + ) + finally: + conn.close() + + assert _doc_id("unrelated-noise.md") not in { + r.doc_id for r in results + } diff --git a/mindgraph/tests/test_expand.py b/mindgraph/tests/test_expand.py new file mode 100644 index 0000000..8237a17 --- /dev/null +++ b/mindgraph/tests/test_expand.py @@ -0,0 +1,385 @@ +"""Phase 3 — graph traversal expansion tests. + +Covers the seven ADR exit-measurement scenarios in DECISIONS.md +§ 2026-05-20 — Phase 3 graph expansion. Fixture topology: + + A.md → [[B]] (cites), [[E]] (kind), [[missing-thing]] (refers, dangling) + B.md → [[C]] (refers) + C.md → (no outbound) + D.md → (no edges, unrelated) + E.md → (no outbound) + +A contains the token `alpha`. A and B both contain the token `beta`, which +exercises the dedup scenario (Phase 2 returns both A and B; the walk from A +must not re-add B). +""" + +import json as jsonlib + +import numpy as np +import pytest +from typer.testing import CliRunner + +from mindgraph import cli, db, parser +from mindgraph.models import QueryResult +from mindgraph.query import expand_results, run_query + + +class KeywordEmbedder: + """Same deterministic embedder pattern as tests/test_query.py.""" + + def __init__(self, keyword_to_dim: dict[str, int], dims: int = 384): + self.keyword_to_dim = {k.lower(): v for k, v in keyword_to_dim.items()} + self.dims = dims + + def encode(self, texts, convert_to_numpy=True): + out = np.zeros((len(texts), self.dims), dtype=np.float32) + for i, text in enumerate(texts): + lower = text.lower() + for kw, dim in self.keyword_to_dim.items(): + if kw in lower: + out[i, dim] += 1.0 + return out + + +def _doc_id(rel_path: str) -> str: + return parser.compute_doc_id(rel_path) + + +@pytest.fixture +def expand_embedder(): + return KeywordEmbedder({"alpha": 0, "beta": 1}) + + +@pytest.fixture +def expand_db(tmp_path, monkeypatch, expand_embedder): + """Ingest the ABCDE fixture vault.""" + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: expand_embedder) + + notes = tmp_path / "vault" + notes.mkdir() + (notes / "A.md").write_text( + "Doc A talks about alpha and beta. " + "Refers to [[B]] (cites), [[E]] (kind), " + "and [[missing-thing]] (refers).\n" + ) + (notes / "B.md").write_text( + "Doc B holds beta content. Links to [[C]] (refers).\n" + ) + (notes / "C.md").write_text("Doc C has no outbound edges.\n") + (notes / "D.md").write_text("Doc D is unrelated.\n") + (notes / "E.md").write_text("Doc E has no outbound edges.\n") + + db_path = str(tmp_path / "test.sqlite") + db.init_db(db_path).close() + cli._ingest_directory(notes, db_path) + return db_path + + +class TestExpand: + def test_depth_1_walks_one_hop(self, expand_db, expand_embedder): + """ADR scenario 1: A in Phase 2 (depth=0); B and E in expanded (depth=1). + + The dangling [[missing-thing]] target and the unrelated D are absent. + C is not reached at depth=1. + + final_top_k=1 scopes Phase 2 to A only. With a tiny vault, sqlite-vec's + KNN happily returns zero-embedding docs at distance 1.0, so default + top-k would otherwise pull C/D/E into the Phase 2 block as semantic-only + hits and the walk would find nothing new. + """ + conn = db.get_db(expand_db) + try: + results = run_query( + conn, + "alpha", + expand_embedder, + final_top_k=1, + expand=True, + expand_depth=1, + ) + finally: + conn.close() + + by_id = {r.doc_id: r for r in results} + assert _doc_id("A.md") in by_id + assert by_id[_doc_id("A.md")].expansion_depth == 0 + assert by_id[_doc_id("A.md")].signal in ("lexical", "semantic", "fused") + + assert _doc_id("B.md") in by_id + assert by_id[_doc_id("B.md")].signal == "expanded" + assert by_id[_doc_id("B.md")].expansion_depth == 1 + + assert _doc_id("E.md") in by_id + assert by_id[_doc_id("E.md")].signal == "expanded" + assert by_id[_doc_id("E.md")].expansion_depth == 1 + + assert _doc_id("C.md") not in by_id + assert _doc_id("D.md") not in by_id + assert _doc_id("missing-thing.md") not in by_id + + def test_depth_2_walks_two_hops(self, expand_db, expand_embedder): + """ADR scenario 2: depth=2 reaches C through B.""" + conn = db.get_db(expand_db) + try: + results = run_query( + conn, + "alpha", + expand_embedder, + final_top_k=1, + expand=True, + expand_depth=2, + ) + finally: + conn.close() + + by_id = {r.doc_id: r for r in results} + assert by_id[_doc_id("B.md")].expansion_depth == 1 + assert by_id[_doc_id("E.md")].expansion_depth == 1 + assert _doc_id("C.md") in by_id + assert by_id[_doc_id("C.md")].signal == "expanded" + assert by_id[_doc_id("C.md")].expansion_depth == 2 + + def test_no_expand_matches_phase_2(self, expand_db, expand_embedder): + """ADR scenario 3: without --expand and with depth=0 both equal Phase 2.""" + conn = db.get_db(expand_db) + try: + phase_2 = run_query(conn, "alpha", expand_embedder, final_top_k=1) + no_expand = run_query( + conn, + "alpha", + expand_embedder, + final_top_k=1, + expand=False, + expand_depth=1, + ) + zero_depth = run_query( + conn, + "alpha", + expand_embedder, + final_top_k=1, + expand=True, + expand_depth=0, + ) + finally: + conn.close() + + assert [r.doc_id for r in no_expand] == [r.doc_id for r in phase_2] + assert [r.doc_id for r in zero_depth] == [r.doc_id for r in phase_2] + assert all(r.expansion_depth == 0 for r in phase_2) + + def test_dedup_when_phase_2_already_contains_walk_target( + self, expand_db, expand_embedder + ): + """ADR scenario 6: a walk target already in Phase 2 keeps its Phase 2 signal. + + Query 'beta' matches both A and B lexically. With final_top_k=2 both + come back from Phase 2. The walk from A would normally add B at + depth=1, but B is already in seen. Assert no duplicate and B keeps the + Phase 2 signal. + """ + conn = db.get_db(expand_db) + try: + results = run_query( + conn, + "beta", + expand_embedder, + final_top_k=2, + expand=True, + expand_depth=1, + ) + finally: + conn.close() + + b_rows = [r for r in results if r.doc_id == _doc_id("B.md")] + assert len(b_rows) == 1 + assert b_rows[0].signal in ("lexical", "semantic", "fused") + assert b_rows[0].expansion_depth == 0 + + a_rows = [r for r in results if r.doc_id == _doc_id("A.md")] + assert len(a_rows) == 1 + assert a_rows[0].expansion_depth == 0 + + def test_expand_top_k_caps_appended_results( + self, expand_db, expand_embedder + ): + """ADR scenario 7: --expand-top-k 1 with multiple walked neighbors keeps one.""" + conn = db.get_db(expand_db) + try: + results = run_query( + conn, + "alpha", + expand_embedder, + final_top_k=1, + expand=True, + expand_depth=1, + expand_top_k=1, + ) + finally: + conn.close() + + expanded = [r for r in results if r.signal == "expanded"] + assert len(expanded) == 1 + assert expanded[0].expansion_depth == 1 + assert expanded[0].doc_id in {_doc_id("B.md"), _doc_id("E.md")} + + def test_dangling_edge_does_not_appear_as_expanded( + self, expand_db, expand_embedder + ): + """ADR invariant: dangling edges terminate the walk; no QueryResult row.""" + conn = db.get_db(expand_db) + try: + results = run_query( + conn, + "alpha", + expand_embedder, + final_top_k=1, + expand=True, + expand_depth=3, + ) + finally: + conn.close() + + doc_ids = {r.doc_id for r in results} + assert _doc_id("missing-thing.md") not in doc_ids + for r in results: + assert r.path != "" + assert r.path is not None + + def test_unrelated_doc_not_walked(self, expand_db, expand_embedder): + """ADR scenario 1 corollary: D has no inbound edge from any Phase 2 seed.""" + conn = db.get_db(expand_db) + try: + results = run_query( + conn, + "alpha", + expand_embedder, + final_top_k=1, + expand=True, + expand_depth=3, + ) + finally: + conn.close() + + assert _doc_id("D.md") not in {r.doc_id for r in results} + + +class TestExpandResults: + """Direct unit tests for the expand_results primitive.""" + + def test_empty_seed_list_returns_empty(self, expand_db): + conn = db.get_db(expand_db) + try: + assert expand_results(conn, [], depth=2, expand_top_k=20) == [] + finally: + conn.close() + + def test_zero_depth_returns_empty(self, expand_db, expand_embedder): + conn = db.get_db(expand_db) + try: + phase_2 = run_query(conn, "alpha", expand_embedder, final_top_k=1) + expanded = expand_results(conn, phase_2, depth=0, expand_top_k=20) + finally: + conn.close() + assert expanded == [] + + def test_expanded_results_sorted_by_depth_then_doc_id( + self, expand_db, expand_embedder + ): + """Sort key: (expansion_depth ASC, doc_id ASC, chunk_index ASC).""" + conn = db.get_db(expand_db) + try: + phase_2 = run_query(conn, "alpha", expand_embedder, final_top_k=1) + expanded = expand_results(conn, phase_2, depth=2, expand_top_k=20) + finally: + conn.close() + + keys = [(r.expansion_depth, r.doc_id, r.chunk_index) for r in expanded] + assert keys == sorted(keys) + + def test_direct_expand_preserves_seed_scope_warning( + self, expand_db, expand_embedder + ): + conn = db.get_db(expand_db) + try: + phase_2 = run_query( + conn, + "current alpha status this week", + expand_embedder, + final_top_k=1, + ) + expanded = expand_results(conn, phase_2, depth=1, expand_top_k=20) + finally: + conn.close() + + assert expanded + for row in phase_2 + expanded: + assert row.query_scope_warning is not None + assert row.query_scope_warning.intent == "live_state" + + +class TestExpandCLI: + def test_expand_flag_appends_depth_to_header( + self, expand_db, expand_embedder, monkeypatch + ): + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: expand_embedder) + runner = CliRunner() + result = runner.invoke( + cli.app, + [ + "query", + "alpha", + "--db", + expand_db, + "--top-k", + "1", + "--expand", + "--depth", + "1", + ], + ) + assert result.exit_code == 0 + assert "signal=expanded" in result.stdout + assert "depth=1" in result.stdout + + def test_expand_flag_emits_expansion_depth_in_json( + self, expand_db, expand_embedder, monkeypatch + ): + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: expand_embedder) + runner = CliRunner() + result = runner.invoke( + cli.app, + [ + "query", + "alpha", + "--db", + expand_db, + "--top-k", + "1", + "--expand", + "--depth", + "1", + "--json", + ], + ) + assert result.exit_code == 0 + data = jsonlib.loads(result.stdout) + assert isinstance(data, list) + assert all("expansion_depth" in row for row in data) + expanded_rows = [r for r in data if r["signal"] == "expanded"] + assert expanded_rows + for row in expanded_rows: + assert row["expansion_depth"] >= 1 + assert row["lexical_rank"] is None + assert row["semantic_rank"] is None + assert row["rrf_score"] == 0.0 + + def test_depth_above_cap_rejected(self, expand_db, expand_embedder, monkeypatch): + """Hard cap of 3 enforced at the CLI layer.""" + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: expand_embedder) + runner = CliRunner() + result = runner.invoke( + cli.app, + ["query", "alpha", "--db", expand_db, "--expand", "--depth", "4"], + ) + assert result.exit_code != 0 diff --git a/mindgraph/tests/test_governance.py b/mindgraph/tests/test_governance.py new file mode 100644 index 0000000..370a861 --- /dev/null +++ b/mindgraph/tests/test_governance.py @@ -0,0 +1,104 @@ +"""C-0 eligibility-manifest boundary tests for governed MindGraph context.""" + +from __future__ import annotations + +import pytest + +from mindgraph.models import QueryResult +from mindgraph.query import QueryError, apply_dual_gate_governance + + +def _result(doc_id: str, *, path: str, content_hash: str, text: str = "passage") -> QueryResult: + return QueryResult( + doc_id=doc_id, + chunk_index=0, + path=path, + title=doc_id, + signal="fused", + rrf_score=1.0, + lexical_rank=1, + semantic_rank=1, + chunk_text=text, + content_hash=content_hash, + ) + + +def _manifest(*records: dict) -> dict: + return {"eligibility_run_id": "c0-test-run", "approved_inventory": list(records)} + + +def _approved(doc_id: str, path: str, content_hash: str) -> dict: + return { + "doc_id": doc_id, + "path": path, + "sha256": f"sha256:{content_hash}", + "status": "Approved", + } + + +def test_manifest_is_required_and_cannot_be_empty() -> None: + result = _result("doc-1", path="/vault/10_knowledge/a.md", content_hash="a" * 64) + with pytest.raises(QueryError, match="requires a C-0"): + apply_dual_gate_governance([result], eligibility_manifest=None) + with pytest.raises(QueryError, match="approved_inventory is empty"): + apply_dual_gate_governance([result], eligibility_manifest=_manifest()) + + +def test_status_alone_never_admits_a_result() -> None: + result = _result("doc-1", path="/vault/10_knowledge/a.md", content_hash="a" * 64) + result = result.model_copy(update={"status": "Approved"}) + assert apply_dual_gate_governance( + [result], + eligibility_manifest=_manifest( + _approved("other", "/vault/10_knowledge/b.md", "b" * 64) + ), + ) == [] + + +@pytest.mark.parametrize( + ("record_path", "record_hash"), + [ + ("/vault/10_knowledge/other.md", "a" * 64), + ("/vault/10_knowledge/a.md", "b" * 64), + ], +) +def test_path_or_hash_mismatch_is_excluded(record_path: str, record_hash: str) -> None: + result = _result("doc-1", path="/vault/10_knowledge/a.md", content_hash="a" * 64) + governed = apply_dual_gate_governance( + [result], + eligibility_manifest=_manifest(_approved("doc-1", record_path, record_hash)), + ) + assert governed == [] + + +def test_exact_manifest_match_preserves_consumed_run_identity() -> None: + result = _result("doc-1", path="/vault/10_knowledge/a.md", content_hash="a" * 64) + governed = apply_dual_gate_governance( + [result], + eligibility_manifest=_manifest( + _approved("doc-1", "/vault/10_knowledge/a.md", "a" * 64) + ), + ) + assert len(governed) == 1 + assert governed[0].eligibility_run_id == "c0-test-run" + + +def test_seat_cap_applies_after_manifest_filtering() -> None: + results = [ + _result( + f"doc-{index}", + path=f"/vault/10_knowledge/{index}.md", + content_hash=f"{index:x}" * 64, + ) + for index in range(1, 5) + ] + inventory = [ + _approved(result.doc_id, result.path, result.content_hash or "") + for result in results + ] + governed = apply_dual_gate_governance( + results, + max_seats=3, + eligibility_manifest=_manifest(*inventory), + ) + assert [result.doc_id for result in governed] == ["doc-1", "doc-2", "doc-3"] diff --git a/mindgraph/tests/test_ingest.py b/mindgraph/tests/test_ingest.py new file mode 100644 index 0000000..af3466f --- /dev/null +++ b/mindgraph/tests/test_ingest.py @@ -0,0 +1,709 @@ +import json + +import numpy as np +import pytest +from typer.testing import CliRunner + +from mindgraph import cli, db, parser +from mindgraph.query import list_neighbors + + +class FakeEmbedder: + """Stand-in for sentence-transformers — returns zero-vectors of the right shape.""" + + def encode(self, texts, convert_to_numpy=True): + return np.zeros((len(texts), 384), dtype=np.float32) + + +@pytest.fixture +def fake_embedder(monkeypatch): + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: FakeEmbedder()) + + +@pytest.fixture +def sample_notes(tmp_path): + notes = tmp_path / "notes" + notes.mkdir() + + (notes / "minimal.md").write_text("Just a body, no frontmatter.\n") + + (notes / "with-timeline.md").write_text( + "---\n" + "title: Project Notes\n" + "---\n" + "Project status and goals.\n\n" + "Links to [[people/alice]] (lead).\n\n" + "---\n## Timeline\n- 2026-01-01: kicked off\n" + ) + + people_dir = notes / "people" + people_dir.mkdir() + (people_dir / "alice.md").write_text( + "---\ntitle: Alice\n---\nAlice is a person. Knows [[bob]] (peer).\n" + ) + + return notes + + +@pytest.fixture +def db_path(tmp_path): + return str(tmp_path / "test.sqlite") + + +def test_ingest_end_to_end(sample_notes, db_path, fake_embedder): + db.init_db(db_path).close() + stats = cli._ingest_directory(sample_notes, db_path) + + assert stats["total"] == 3 + assert stats["ingested"] == 3 + assert stats["skipped"] == 0 + assert stats["failed"] == 0 + + conn = db.get_db(db_path) + try: + assert conn.execute("SELECT COUNT(*) FROM documents").fetchone()[0] == 3 + hash_rows = conn.execute("SELECT content_hash FROM documents").fetchall() + assert all(r["content_hash"] for r in hash_rows) + + chunk_count = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0] + assert chunk_count >= 3 + vec_count = conn.execute("SELECT COUNT(*) FROM vec_chunks").fetchone()[0] + assert vec_count == chunk_count + + edges = list( + conn.execute( + "SELECT source_id, target_id, relationship_type FROM edges" + ) + ) + assert len(edges) == 2 + rels = {e["relationship_type"] for e in edges} + assert rels == {"lead", "peer"} + resolved_edges = conn.execute( + """ + SELECT COUNT(*) + FROM edges e + JOIN documents d ON d.id = e.target_id + """ + ).fetchone()[0] + assert resolved_edges == 1 + + timeline_row = conn.execute( + "SELECT timeline_text FROM documents WHERE path = ?", + ("with-timeline.md",), + ).fetchone() + assert "kicked off" in timeline_row["timeline_text"] + + minimal_row = conn.execute( + "SELECT timeline_text FROM documents WHERE path = ?", + ("minimal.md",), + ).fetchone() + assert minimal_row["timeline_text"] is None + + fts_count = conn.execute( + "SELECT COUNT(*) FROM documents_fts" + ).fetchone()[0] + assert fts_count == 3 + finally: + conn.close() + + +def test_ingest_serializes_yaml_date_metadata(tmp_path, db_path, fake_embedder): + notes = tmp_path / "notes" + notes.mkdir() + (notes / "dated.md").write_text( + "---\ntitle: Dated Note\nupdated: 2026-06-19\n---\nDate metadata.\n" + ) + + db.init_db(db_path).close() + stats = cli._ingest_directory(notes, db_path) + assert stats["ingested"] == 1 + assert stats["failed"] == 0 + + conn = db.get_db(db_path) + try: + metadata = json.loads( + conn.execute( + "SELECT metadata_json FROM documents WHERE path = ?", + ("dated.md",), + ).fetchone()["metadata_json"] + ) + assert metadata["updated"] == "2026-06-19" + finally: + conn.close() + + +def test_ingest_resolves_neighbors_to_target_path(tmp_path, db_path, fake_embedder): + notes = tmp_path / "notes" + notes.mkdir() + agents = notes / "agents" + agents.mkdir() + ai_business = notes / "ai-business" + ai_business.mkdir() + + (agents / "source.md").write_text( + "Connects to [[same-domain]] and [[cross-domain]].\n" + ) + (agents / "same-domain.md").write_text("Same-domain target.\n") + (ai_business / "cross-domain.md").write_text("Cross-domain target.\n") + + db.init_db(db_path).close() + cli._ingest_directory(notes, db_path) + + conn = db.get_db(db_path) + try: + neighbors = list_neighbors(conn, parser.compute_doc_id("agents/source.md")) + finally: + conn.close() + + target_paths = {edge.target_path for edge in neighbors} + assert target_paths == {"agents/same-domain.md", "ai-business/cross-domain.md"} + + +def test_reingest_unchanged_source_refreshes_resolved_edges( + tmp_path, db_path, fake_embedder +): + notes = tmp_path / "notes" + notes.mkdir() + (notes / "source.md").write_text("Connects to [[target]] (relates).\n") + + db.init_db(db_path).close() + cli._ingest_directory(notes, db_path) + + conn = db.get_db(db_path) + try: + before = list_neighbors(conn, parser.compute_doc_id("source.md")) + before_rowids = sorted( + r["rowid"] for r in conn.execute("SELECT rowid FROM chunks") + ) + finally: + conn.close() + + assert before[0].target_path is None + + (notes / "target.md").write_text("Target arrives later.\n") + stats = cli._ingest_directory(notes, db_path) + assert stats["ingested"] == 1 + assert stats["skipped"] == 1 + assert stats["failed"] == 0 + + conn = db.get_db(db_path) + try: + after = list_neighbors(conn, parser.compute_doc_id("source.md")) + after_rowids = sorted( + r["rowid"] for r in conn.execute("SELECT rowid FROM chunks") + ) + finally: + conn.close() + + assert after[0].target_path == "target.md" + assert before_rowids == after_rowids[: len(before_rowids)] + + +def test_skip_path_does_not_rewrite_unchanged_edges( + sample_notes, db_path, fake_embedder, monkeypatch +): + db.init_db(db_path).close() + cli._ingest_directory(sample_notes, db_path) + + calls: list[str] = [] + real_replace = db.replace_edges + + def counting_replace(conn, source_id, edges): + calls.append(source_id) + return real_replace(conn, source_id, edges) + + monkeypatch.setattr(db, "replace_edges", counting_replace) + + stats = cli._ingest_directory(sample_notes, db_path) + assert stats["skipped"] == 3 + # Nothing changed on disk and no link resolution changed, so the skip path + # must not issue a single edge rewrite. + assert calls == [] + + +def test_skip_path_rewrites_edges_when_resolution_changes( + tmp_path, db_path, fake_embedder +): + notes = tmp_path / "notes" + notes.mkdir() + agents = notes / "agents" + agents.mkdir() + # `[[foo]]` is a bare stem. With no sibling agents/foo.md it dangles to + # compute_doc_id("foo.md"). + (agents / "source.md").write_text("Links to [[foo]] (rel).\n") + + db.init_db(db_path).close() + cli._ingest_directory(notes, db_path) + + conn = db.get_db(db_path) + try: + before = list_neighbors(conn, parser.compute_doc_id("agents/source.md")) + assert before[0].target_id == parser.compute_doc_id("foo.md") + assert before[0].target_path is None + finally: + conn.close() + + # Add the sibling. source.md is byte-identical (skip path), but `[[foo]]` + # now resolves to agents/foo.md — a different target_id — so the skip path + # must rewrite the edge rather than leave the stale dangling target. + (agents / "foo.md").write_text("The foo target.\n") + stats = cli._ingest_directory(notes, db_path) + assert stats["skipped"] == 1 + assert stats["ingested"] == 1 + + conn = db.get_db(db_path) + try: + after = list_neighbors(conn, parser.compute_doc_id("agents/source.md")) + assert after[0].target_id == parser.compute_doc_id("agents/foo.md") + assert after[0].target_path == "agents/foo.md" + finally: + conn.close() + + +def test_reingest_unchanged_is_skipped(sample_notes, db_path, fake_embedder): + db.init_db(db_path).close() + cli._ingest_directory(sample_notes, db_path) + + conn = db.get_db(db_path) + before_rowids = sorted(r["rowid"] for r in conn.execute("SELECT rowid FROM chunks")) + before_total = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0] + conn.close() + + stats = cli._ingest_directory(sample_notes, db_path) + assert stats["ingested"] == 0 + assert stats["skipped"] == 3 + assert stats["failed"] == 0 + + conn = db.get_db(db_path) + after_rowids = sorted(r["rowid"] for r in conn.execute("SELECT rowid FROM chunks")) + after_total = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0] + conn.close() + + assert before_rowids == after_rowids + assert after_total == before_total + + +def test_reingest_modified_file_refreshes_only_that_file( + sample_notes, db_path, fake_embedder +): + db.init_db(db_path).close() + cli._ingest_directory(sample_notes, db_path) + + conn = db.get_db(db_path) + hashes_before = { + r["path"]: r["content_hash"] + for r in conn.execute("SELECT path, content_hash FROM documents") + } + conn.close() + + (sample_notes / "minimal.md").write_text( + "Completely different content now with [[new/target]] (cites).\n" + ) + + stats = cli._ingest_directory(sample_notes, db_path) + assert stats["ingested"] == 1 + assert stats["skipped"] == 2 + + conn = db.get_db(db_path) + try: + hashes_after = { + r["path"]: r["content_hash"] + for r in conn.execute("SELECT path, content_hash FROM documents") + } + assert hashes_after["minimal.md"] != hashes_before["minimal.md"] + assert hashes_after["with-timeline.md"] == hashes_before["with-timeline.md"] + assert hashes_after["people/alice.md"] == hashes_before["people/alice.md"] + + # The modified file's new edge should be present; old edges from minimal + # (there were none) should still not exist. + edges = list( + conn.execute( + "SELECT relationship_type FROM edges WHERE source_id = ?", + (cli.parser.compute_doc_id("minimal.md"),), + ) + ) + assert [e["relationship_type"] for e in edges] == ["cites"] + finally: + conn.close() + + +def test_ingest_empty_directory(tmp_path, db_path, fake_embedder): + empty = tmp_path / "empty" + empty.mkdir() + db.init_db(db_path).close() + + stats = cli._ingest_directory(empty, db_path) + assert stats == { + "total": 0, + "ingested": 0, + "skipped": 0, + "pruned": 0, + "failed": 0, + } + + +def test_ingest_prunes_database_when_last_source_is_deleted( + tmp_path, db_path, fake_embedder +): + notes = tmp_path / "notes" + notes.mkdir() + only = notes / "only.md" + only.write_text("The final indexed note.\n") + + db.init_db(db_path).close() + assert cli._ingest_directory(notes, db_path)["ingested"] == 1 + only.unlink() + + stats = cli._ingest_directory(notes, db_path) + assert stats["total"] == 0 + assert stats["pruned"] == 1 + conn = db.get_db(db_path) + try: + assert conn.execute("SELECT COUNT(*) FROM documents").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM documents_fts").fetchone()[0] == 0 + finally: + conn.close() + + +def test_ingest_prunes_deleted_file(tmp_path, db_path, fake_embedder): + notes = tmp_path / "notes" + notes.mkdir() + (notes / "keep.md").write_text("This note stays put.\n") + (notes / "remove.md").write_text("This note will be deleted later.\n") + + db.init_db(db_path).close() + stats = cli._ingest_directory(notes, db_path) + assert stats["ingested"] == 2 + assert stats["pruned"] == 0 + + (notes / "remove.md").unlink() + stats = cli._ingest_directory(notes, db_path) + assert stats["pruned"] == 1 + assert stats["skipped"] == 1 # keep.md unchanged + + removed_id = parser.compute_doc_id("remove.md") + conn = db.get_db(db_path) + try: + paths = {r["path"] for r in conn.execute("SELECT path FROM documents")} + assert paths == {"keep.md"} + # All artifacts of the pruned doc are gone, not just the documents row. + for table, col in (("chunks", "doc_id"), ("documents_fts", "id")): + count = conn.execute( + f"SELECT COUNT(*) FROM {table} WHERE {col} = ?", (removed_id,) + ).fetchone()[0] + assert count == 0 + finally: + conn.close() + + +def test_ingest_prunes_renamed_file(tmp_path, db_path, fake_embedder): + notes = tmp_path / "notes" + notes.mkdir() + (notes / "old-name.md").write_text("Stable content that just moves.\n") + + db.init_db(db_path).close() + cli._ingest_directory(notes, db_path) + + (notes / "old-name.md").rename(notes / "new-name.md") + stats = cli._ingest_directory(notes, db_path) + # A rename is a new doc id at the new path plus an orphan at the old one. + assert stats["pruned"] == 1 + assert stats["ingested"] == 1 + + conn = db.get_db(db_path) + try: + paths = {r["path"] for r in conn.execute("SELECT path FROM documents")} + assert paths == {"new-name.md"} + finally: + conn.close() + + +def test_pruned_doc_leaves_inbound_edges_dangling(tmp_path, db_path, fake_embedder): + notes = tmp_path / "notes" + notes.mkdir() + (notes / "source.md").write_text("Points to [[target]] (cites).\n") + (notes / "target.md").write_text("The target of the link.\n") + + db.init_db(db_path).close() + cli._ingest_directory(notes, db_path) + + conn = db.get_db(db_path) + try: + before = list_neighbors(conn, parser.compute_doc_id("source.md")) + assert before[0].target_path == "target.md" + finally: + conn.close() + + (notes / "target.md").unlink() + stats = cli._ingest_directory(notes, db_path) + assert stats["pruned"] == 1 + + conn = db.get_db(db_path) + try: + after = list_neighbors(conn, parser.compute_doc_id("source.md")) + # The inbound edge survives but now dangles (target row removed). + assert len(after) == 1 + assert after[0].target_path is None + finally: + conn.close() + + +def test_ingest_many_namespaces_duplicate_project_paths_and_prunes_union( + tmp_path, db_path, fake_embedder +): + projects = tmp_path / "30_projects" + alpha = projects / "alpha" + beta = projects / "beta" + alpha.mkdir(parents=True) + beta.mkdir(parents=True) + (alpha / "README.md").write_text( + "---\ntitle: Alpha Project\n---\nAlpha status links to [[decisions]] (records).\n" + ) + (alpha / "decisions.md").write_text("Alpha decision record.\n") + (alpha / "workbench").mkdir() + (alpha / "workbench" / "README.md").write_text("Noisy nested source.\n") + (beta / "README.md").write_text( + "---\ntitle: Beta Project\n---\nBeta status is separate.\n" + ) + + def scope(slug, root): + return cli.IngestScope( + root=root, + index_id="mainframe-projects", + trust_profile="project_status", + namespace=slug, + source_root=root, + display_prefix=f"30_projects/{slug}", + include_globs=("README.md", "decisions.md"), + exclude_globs=("workbench/*", "workbench/**/*"), + ) + + db.init_db(db_path).close() + stats = cli._ingest_scopes([scope("alpha", alpha), scope("beta", beta)], db_path) + assert stats["total"] == 3 + assert stats["ingested"] == 3 + assert stats["failed"] == 0 + + alpha_readme_id = parser.compute_scoped_doc_id( + "mainframe-projects", "alpha", "README.md" + ) + beta_readme_id = parser.compute_scoped_doc_id( + "mainframe-projects", "beta", "README.md" + ) + assert alpha_readme_id != beta_readme_id + + conn = db.get_db(db_path) + try: + rows = conn.execute( + """ + SELECT id, path, index_id, trust_profile, namespace, source_root, + source_path, display_path + FROM documents + ORDER BY path + """ + ).fetchall() + assert {row["path"] for row in rows} == { + "30_projects/alpha/README.md", + "30_projects/alpha/decisions.md", + "30_projects/beta/README.md", + } + by_id = {row["id"]: row for row in rows} + assert by_id[alpha_readme_id]["namespace"] == "alpha" + assert by_id[alpha_readme_id]["index_id"] == "mainframe-projects" + assert by_id[alpha_readme_id]["trust_profile"] == "project_status" + assert by_id[alpha_readme_id]["source_path"] == "README.md" + assert by_id[alpha_readme_id]["display_path"] == ( + "30_projects/alpha/README.md" + ) + + neighbors = list_neighbors(conn, alpha_readme_id) + assert len(neighbors) == 1 + assert neighbors[0].target_path == "30_projects/alpha/decisions.md" + finally: + conn.close() + + (beta / "README.md").unlink() + stats = cli._ingest_scopes([scope("alpha", alpha), scope("beta", beta)], db_path) + assert stats["pruned"] == 1 + + conn = db.get_db(db_path) + try: + paths = {row["path"] for row in conn.execute("SELECT path FROM documents")} + assert paths == { + "30_projects/alpha/README.md", + "30_projects/alpha/decisions.md", + } + finally: + conn.close() + + +def test_ingest_many_command_loads_manifest(tmp_path, db_path, fake_embedder): + project = tmp_path / "project" + project.mkdir() + (project / "README.md").write_text("Manifest project status.\n") + manifest = tmp_path / "manifest.json" + manifest.write_text( + json.dumps( + { + "index_id": "mainframe-projects", + "trust_profile": "project_status", + "include": ["README.md"], + "scopes": [ + { + "namespace": "manifest-project", + "root": str(project), + "source_root": str(project), + "display_prefix": "30_projects/manifest-project", + } + ], + } + ) + ) + + runner = CliRunner() + result = runner.invoke(cli.app, ["ingest-many", str(manifest), "--db", db_path]) + assert result.exit_code == 0 + + conn = db.get_db(db_path) + try: + row = conn.execute( + "SELECT path, namespace FROM documents" + ).fetchone() + assert row["path"] == "30_projects/manifest-project/README.md" + assert row["namespace"] == "manifest-project" + finally: + conn.close() + + +def test_ingest_many_allow_failures_keeps_partial_index( + tmp_path, db_path, fake_embedder +): + project = tmp_path / "project" + project.mkdir() + (project / "good.md").write_text("Good project context.\n") + (project / "bad.md").write_text("---\nupdated: bad: yaml\n---\nBad.\n") + manifest = tmp_path / "manifest.json" + manifest.write_text( + json.dumps( + { + "include": ["*.md"], + "scopes": [ + { + "namespace": "partial", + "root": str(project), + "display_prefix": "30_projects/partial", + } + ], + } + ) + ) + + runner = CliRunner() + result = runner.invoke( + cli.app, + ["ingest-many", str(manifest), "--db", db_path, "--allow-failures"], + ) + assert result.exit_code == 0 + + conn = db.get_db(db_path) + try: + paths = {row["path"] for row in conn.execute("SELECT path FROM documents")} + assert paths == {"30_projects/partial/good.md"} + finally: + conn.close() + + +def test_ingest_many_allow_failures_does_not_prune_still_present_bad_file( + tmp_path, db_path, fake_embedder +): + project = tmp_path / "project" + project.mkdir() + (project / "good.md").write_text("Good project context.\n") + (project / "fragile.md").write_text("Initially valid project context.\n") + scope = cli.IngestScope( + root=project, + index_id="mainframe-projects", + trust_profile="project_status", + namespace="partial", + source_root=project, + display_prefix="30_projects/partial", + include_globs=("*.md",), + ) + + db.init_db(db_path).close() + stats = cli._ingest_scopes([scope], db_path) + assert stats["ingested"] == 2 + + (project / "fragile.md").write_text("---\nupdated: bad: yaml\n---\nBad.\n") + stats = cli._ingest_scopes([scope], db_path) + assert stats["failed"] == 1 + assert stats["pruned"] == 0 + + conn = db.get_db(db_path) + try: + paths = {row["path"] for row in conn.execute("SELECT path FROM documents")} + assert paths == { + "30_projects/partial/good.md", + "30_projects/partial/fragile.md", + } + finally: + conn.close() + + +def test_lexical_only_ingest_skips_model_and_writes_no_vectors( + sample_notes, db_path, monkeypatch +): + def fail_load(*_args, **_kwargs): + raise AssertionError("lexical-only ingest must not load a model") + + monkeypatch.setattr(cli, "_load_embedder", fail_load) + stats = cli._ingest_directory(sample_notes, db_path, lexical_only=True) + + assert stats["ingested"] == 3 + conn = db.get_db(db_path) + try: + assert db.get_semantic_enabled(conn) is False + assert conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0] >= 3 + assert conn.execute("SELECT COUNT(*) FROM vec_chunks").fetchone()[0] == 0 + finally: + conn.close() + + +def test_lexical_only_query_skips_model_and_returns_fts_results( + sample_notes, db_path, monkeypatch +): + cli._ingest_directory(sample_notes, db_path, lexical_only=True) + + def fail_load(*_args, **_kwargs): + raise AssertionError("lexical-only query must not load a model") + + monkeypatch.setattr(cli, "_load_embedder", fail_load) + result = CliRunner().invoke( + cli.app, + [ + "query", + "Project status", + "--db", + db_path, + "--lexical-only", + "--json", + "--no-intent", + ], + ) + + assert result.exit_code == 0, result.output + rows = json.loads(result.stdout) + assert rows and rows[0]["path"] == "with-timeline.md" + assert rows[0]["signal"] == "lexical" + + +def test_semantic_query_rejects_lexical_only_database(sample_notes, db_path, caplog): + cli._ingest_directory(sample_notes, db_path, lexical_only=True) + result = CliRunner().invoke( + cli.app, + ["query", "Project status", "--db", db_path, "--no-intent"], + ) + + assert result.exit_code == 1 + assert "lexical-only" in caplog.text.lower() diff --git a/mindgraph/tests/test_intent.py b/mindgraph/tests/test_intent.py new file mode 100644 index 0000000..64b2413 --- /dev/null +++ b/mindgraph/tests/test_intent.py @@ -0,0 +1,782 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import sqlite3 +from pathlib import Path + +import pytest +import yaml + +from mindgraph.intent import ( + IntentGraphValidationError, + IntentStoreError, + IntentVersionConflict, + TraversalLimits, + compile_intent_corpus, + open_intent_store, + resolve_intent, + validate_intent_corpus, +) + + +FIXTURE_PATH = Path(__file__).parent / "fixtures" / "intent_graph_cases.yaml" + + +def load_catalog() -> dict: + return yaml.safe_load(FIXTURE_PATH.read_text(encoding="utf-8")) + + +@pytest.fixture +def catalog() -> dict: + return load_catalog() + + +def phase1_document(catalog: dict) -> dict: + return copy.deepcopy(catalog["documents"]["phase1"]) + + +def predecessor_document(catalog: dict) -> dict: + return copy.deepcopy(catalog["documents"]["predecessor"]) + + +def write_corpus(root: Path, documents: list[dict]) -> Path: + root.mkdir(parents=True, exist_ok=True) + for index, document in enumerate(documents, start=1): + version = document["graph"]["version"].replace(".", "-") + path = root / f"{index:02d}-{version}.yaml" + path.write_text( + yaml.safe_dump(document, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + return root + + +def compile_document(tmp_path: Path, document: dict, name: str = "intent.sqlite"): + source = write_corpus(tmp_path / f"source-{name}", [document]) + destination = tmp_path / name + result = compile_intent_corpus(source, destination) + return destination, result + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def expect_error(source: Path, code: str): + with pytest.raises(IntentGraphValidationError) as raised: + validate_intent_corpus(source) + assert raised.value.code == code + return raised.value + + +def goal(node_id: str, label: str | None = None) -> dict: + return { + "id": node_id, + "kind": "goal", + "label": label or node_id, + "aliases": [], + "status": "active", + "source_refs": [ + "plan://plans/retrieval-remodel.md" + ], + } + + +def edge(source: str, target: str, relation: str) -> dict: + return { + "source_id": source, + "target_id": target, + "relation": relation, + "status": "active", + "source_refs": [ + "plan://plans/retrieval-remodel.md" + ], + } + + +def successor_from(document: dict, version: str, supersedes: str) -> dict: + result = copy.deepcopy(document) + result["graph"]["version"] = version + result["graph"]["supersedes"] = supersedes + result["graph"]["created_at"] = "2026-06-29T00:00:00Z" + result["graph"]["reviewed_at"] = "2026-06-29T00:00:00Z" + return result + + +def test_strict_source_schema_rejects_extra_fields(tmp_path, catalog): + document = phase1_document(catalog) + document["unexpected"] = True + error = expect_error(write_corpus(tmp_path / "source", [document]), "intent_schema_invalid") + assert "unexpected" in (error.location or "") + + +def test_valid_compile_has_expected_schema_and_metadata(tmp_path, catalog): + destination, result = compile_document(tmp_path, phase1_document(catalog)) + + assert result.graph_id == "mainframe.core" + assert result.current_version == "2026-06-29.1" + assert result.version_count == 1 + assert result.node_count == 13 + assert result.edge_count == 12 + assert result.binding_count == 4 + assert result.rule_count == 1 + assert destination.stat().st_mode & 0o777 == 0o600 + + conn = open_intent_store(destination) + try: + tables = { + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ) + } + assert tables == { + "schema_meta", + "graph_versions", + "intent_nodes", + "intent_aliases", + "intent_edges", + "intent_bindings", + "intent_rules", + } + assert conn.execute("PRAGMA query_only").fetchone()[0] == 1 + assert conn.execute("PRAGMA foreign_key_check").fetchall() == [] + finally: + conn.close() + + +def test_canonical_hash_ignores_nonsemantic_list_and_yaml_order(tmp_path, catalog): + first = phase1_document(catalog) + second = copy.deepcopy(first) + second["nodes"].reverse() + second["edges"].reverse() + second["bindings"].reverse() + second["rules"].reverse() + for node in second["nodes"]: + node["aliases"].reverse() + node["source_refs"].reverse() + + first_result = compile_intent_corpus( + write_corpus(tmp_path / "one", [first]), tmp_path / "one.sqlite" + ) + second_result = compile_intent_corpus( + write_corpus(tmp_path / "two", [second]), tmp_path / "two.sqlite" + ) + assert first_result.corpus_hash == second_result.corpus_hash + assert first_result.version_hashes == second_result.version_hashes + assert (tmp_path / "one.sqlite").read_bytes() == (tmp_path / "two.sqlite").read_bytes() + + +def test_semantic_source_change_changes_hash(tmp_path, catalog): + first = phase1_document(catalog) + second = copy.deepcopy(first) + second["nodes"][0]["label"] = "A changed goal label" + one = compile_intent_corpus( + write_corpus(tmp_path / "one", [first]), tmp_path / "one.sqlite" + ) + two = compile_intent_corpus( + write_corpus(tmp_path / "two", [second]), tmp_path / "two.sqlite" + ) + assert one.corpus_hash != two.corpus_hash + + +def test_invalid_compile_preserves_existing_destination_bytes(tmp_path, catalog): + destination, _ = compile_document(tmp_path, phase1_document(catalog)) + before = destination.read_bytes() + invalid = phase1_document(catalog) + invalid["edges"].append(edge("goal.missing", "goal.deep-root", "requires")) + + with pytest.raises(IntentGraphValidationError): + compile_intent_corpus( + write_corpus(tmp_path / "invalid", [invalid]), destination + ) + assert destination.read_bytes() == before + + +def test_corrupt_existing_store_is_not_replaced(tmp_path, catalog): + document = phase1_document(catalog) + destination, _ = compile_document(tmp_path, document) + conn = sqlite3.connect(destination) + try: + conn.execute( + "UPDATE schema_meta SET value = 'missing-version' " + "WHERE key = 'current_version'" + ) + conn.commit() + finally: + conn.close() + before = destination.read_bytes() + + with pytest.raises(IntentStoreError) as raised: + compile_intent_corpus( + write_corpus(tmp_path / "replacement", [document]), destination + ) + assert raised.value.code == "intent_store_invalid" + assert destination.read_bytes() == before + + +def test_duplicate_alias_missing_target_relation_and_kind_errors(tmp_path, catalog): + cases: list[tuple[str, dict, str]] = [] + + duplicate = phase1_document(catalog) + duplicate["nodes"].append(copy.deepcopy(duplicate["nodes"][0])) + cases.append(("duplicate", duplicate, "intent_duplicate_id")) + + alias = phase1_document(catalog) + alias["nodes"][0]["aliases"] = ["Run the route contract checks."] + cases.append(("alias", alias, "intent_alias_ambiguous")) + + missing = phase1_document(catalog) + missing["edges"].append(edge("goal.mindgraph-remodel", "goal.missing", "requires")) + cases.append(("missing", missing, "intent_missing_target")) + + relation = phase1_document(catalog) + relation["edges"].append(edge("goal.deep-root", "goal.deep-1", "causes")) + cases.append(("relation", relation, "intent_relation_invalid")) + + kind = phase1_document(catalog) + kind["edges"].append( + edge("goal.deep-root", "capability.fixture-evaluator", "requires") + ) + cases.append(("kind", kind, "intent_kind_mismatch")) + + for name, document, code in cases: + expect_error(write_corpus(tmp_path / name, [document]), code) + + +@pytest.mark.parametrize("relation", ["requires", "decomposes_to", "next_step"]) +def test_all_controlled_cycles_are_rejected_with_witness( + tmp_path, catalog, relation +): + document = phase1_document(catalog) + document["nodes"].extend( + [goal("goal.cyclic-a", "Cyclic A"), goal("goal.cyclic-b", "Cyclic B")] + ) + document["edges"].extend( + [ + edge("goal.cyclic-a", "goal.cyclic-b", relation), + edge("goal.cyclic-b", "goal.cyclic-a", relation), + ] + ) + error = expect_error( + write_corpus(tmp_path / relation, [document]), "intent_cycle_detected" + ) + assert error.witness == ( + "goal.cyclic-a", + "goal.cyclic-b", + "goal.cyclic-a", + ) + + +def test_binding_availability_contract(tmp_path, catalog): + optional = phase1_document(catalog) + validate_intent_corpus(write_corpus(tmp_path / "optional", [optional])) + + required = phase1_document(catalog) + required["bindings"][0]["availability"] = "unavailable" + expect_error( + write_corpus(tmp_path / "required", [required]), + "intent_binding_unavailable", + ) + + +def test_version_lineage_append_only_and_effective_status(tmp_path, catalog): + predecessor = predecessor_document(catalog) + source_v1 = write_corpus(tmp_path / "v1", [predecessor]) + destination = tmp_path / "intent.sqlite" + compile_intent_corpus(source_v1, destination) + + successor = successor_from( + phase1_document(catalog), "2026-06-29.1", "2026-06-28.1" + ) + result = compile_intent_corpus( + write_corpus(tmp_path / "v2", [predecessor, successor]), destination + ) + assert result.version_count == 2 + assert result.current_version == "2026-06-29.1" + conn = open_intent_store(destination) + try: + statuses = [ + tuple(row) + for row in conn.execute( + "SELECT version, effective_status FROM graph_versions ORDER BY version" + ) + ] + assert statuses == [ + ("2026-06-28.1", "superseded"), + ("2026-06-29.1", "approved"), + ] + finally: + conn.close() + + +def test_approved_version_mutation_and_removal_are_rejected(tmp_path, catalog): + predecessor = predecessor_document(catalog) + successor = successor_from( + phase1_document(catalog), "2026-06-29.1", "2026-06-28.1" + ) + destination = tmp_path / "intent.sqlite" + compile_intent_corpus( + write_corpus(tmp_path / "complete", [predecessor, successor]), destination + ) + before = destination.read_bytes() + + changed = copy.deepcopy(predecessor) + changed["nodes"][0]["label"] = "Changed approved history" + with pytest.raises(IntentVersionConflict) as conflict: + compile_intent_corpus( + write_corpus(tmp_path / "changed", [changed, successor]), destination + ) + assert conflict.value.code == "intent_version_conflict" + assert destination.read_bytes() == before + + successor_as_root = copy.deepcopy(successor) + successor_as_root["graph"]["supersedes"] = None + with pytest.raises(IntentVersionConflict) as removed: + compile_intent_corpus( + write_corpus(tmp_path / "removed", [successor_as_root]), destination + ) + assert removed.value.code == "intent_version_missing" + assert destination.read_bytes() == before + + +def test_missing_predecessor_and_fork_are_rejected(tmp_path, catalog): + missing = successor_from( + phase1_document(catalog), "2026-06-29.1", "2026-06-27.1" + ) + expect_error( + write_corpus(tmp_path / "missing", [missing]), "intent_version_missing" + ) + + root = predecessor_document(catalog) + first = successor_from(root, "2026-06-29.1", "2026-06-28.1") + second = successor_from(root, "2026-06-29.2", "2026-06-28.1") + expect_error( + write_corpus(tmp_path / "fork", [root, first, second]), + "intent_version_fork", + ) + + +def test_open_is_read_only_and_missing_file_is_not_created(tmp_path, catalog): + missing = tmp_path / "missing.sqlite" + with pytest.raises(IntentStoreError): + open_intent_store(missing) + assert not missing.exists() + + destination, _ = compile_document(tmp_path, phase1_document(catalog)) + conn = open_intent_store(destination) + try: + with pytest.raises(sqlite3.OperationalError): + conn.execute("DELETE FROM intent_nodes") + finally: + conn.close() + + +def test_explicit_resolution_returns_exact_prerequisites_trace_and_hints( + tmp_path, catalog +): + destination, _ = compile_document(tmp_path, phase1_document(catalog)) + conn = open_intent_store(destination) + try: + resolution = resolve_intent( + conn, + "Validate the retrieval remodel before implementation.", + intent_id="goal.mindgraph-remodel", + allowed_capability_refs=frozenset( + { + "retriever://mindgraph-projects", + "capability://fixture-evaluator", + } + ), + ) + finally: + conn.close() + + assert resolution.outcome == "resolved" + assert resolution.resolution_method == "explicit" + assert resolution.intent_path == ( + "goal.mindgraph-remodel", + "goal.route-contract-evaluation", + "goal.intent-schema-validation", + ) + assert resolution.prerequisite_goal_ids == ( + "goal.route-contract-evaluation", + "goal.intent-schema-validation", + ) + assert [edge.model_dump() for edge in resolution.edge_path] == [ + { + "source_id": "goal.mindgraph-remodel", + "target_id": "goal.route-contract-evaluation", + "relation": "requires", + }, + { + "source_id": "goal.route-contract-evaluation", + "target_id": "goal.intent-schema-validation", + "relation": "requires", + }, + ] + assert set(resolution.capability_hints) == { + "retriever://mindgraph-projects", + "capability://fixture-evaluator", + } + assert resolution.constraint_ids == ("constraint.review-required",) + assert "blocked_by:constraint.review-required" in resolution.warnings + + +def test_alias_rule_and_no_match_resolution(tmp_path, catalog): + document = phase1_document(catalog) + document["edges"] = [ + item + for item in document["edges"] + if not ( + item["source_id"] == "goal.route-contract-evaluation" + and item["relation"] == "requires" + ) + ] + destination, _ = compile_document(tmp_path, document) + conn = open_intent_store(destination) + try: + alias = resolve_intent( + conn, + " RUN THE ROUTE CONTRACT CHECKS. ", + allowed_capability_refs=frozenset({"capability://fixture-evaluator"}), + ) + rule = resolve_intent( + conn, + "Review the next project status", + scope="PROJECT_STATUS", + allowed_capability_refs=frozenset({"retriever://mindgraph-projects"}), + ) + missing = resolve_intent(conn, "Help me with an unknown goal") + finally: + conn.close() + + assert alias.resolution_method == "alias" + assert alias.intent_path == ("goal.route-contract-evaluation",) + assert rule.resolution_method == "rule" + assert rule.matched_goal_ids == ("goal.project-status-review",) + assert missing.outcome == "fallback" + assert missing.refusal_reason == "intent_no_match" + assert missing.as_contract_result("missing")["behaviors"] == [ + "do not query all stores", + "require explicit scope", + ] + + +def test_tied_top_rules_for_different_goals_refuse(tmp_path, catalog): + document = phase1_document(catalog) + document["rules"].append( + { + "id": "rule.project-status-remodel", + "priority": 100, + "goal_id": "goal.mindgraph-remodel", + "match": { + "scope": "project_status", + "all_terms": ["project", "status"], + "any_terms": ["review"], + }, + "source_refs": ["decision://DECISIONS.md"], + } + ) + destination, _ = compile_document(tmp_path, document) + conn = open_intent_store(destination) + try: + resolution = resolve_intent( + conn, "Review the project status", scope="project_status" + ) + finally: + conn.close() + assert resolution.outcome == "refusal" + assert resolution.refusal_reason == "intent_rule_ambiguous" + + +def test_capability_allowlist_and_optional_unavailable_binding(tmp_path, catalog): + destination, _ = compile_document(tmp_path, phase1_document(catalog)) + conn = open_intent_store(destination) + try: + resolution = resolve_intent( + conn, + "Use the project review goal", + intent_id="goal.project-status-review", + allowed_capability_refs=frozenset({"retriever://mindgraph-projects"}), + ) + finally: + conn.close() + assert resolution.capability_hints == ("retriever://mindgraph-projects",) + assert set(resolution.rejected_capability_hints) == { + "retriever://gmail", + "capability://optional-unavailable", + } + assert "capability_not_allowed:retriever://gmail" in resolution.warnings + assert ( + "binding_unavailable:capability://optional-unavailable" in resolution.warnings + ) + assert "policy rejected untrusted capability hint" in resolution.as_contract_result( + "intent06" + )["behaviors"] + + +def test_depth_and_node_limits_are_inclusive_and_visible(tmp_path, catalog): + destination, _ = compile_document(tmp_path, phase1_document(catalog)) + conn = open_intent_store(destination) + try: + depth = resolve_intent( + conn, "deep", intent_id="goal.deep-root", limits=TraversalLimits(max_depth=2) + ) + nodes = resolve_intent( + conn, + "deep", + intent_id="goal.deep-root", + limits=TraversalLimits(max_depth=32, max_nodes=2), + ) + finally: + conn.close() + assert depth.intent_path == ("goal.deep-root", "goal.deep-1", "goal.deep-2") + assert depth.truncation_reason == "max_depth" + assert depth.as_contract_result("intent07")["reason"] == "intent_traversal_truncated" + assert nodes.intent_path == ("goal.deep-root", "goal.deep-1") + assert nodes.truncation_reason == "max_nodes" + + +def test_runtime_alias_ambiguity_fails_closed(tmp_path, catalog): + destination, _ = compile_document(tmp_path, phase1_document(catalog)) + conn = sqlite3.connect(destination) + conn.row_factory = sqlite3.Row + try: + conn.execute("ALTER TABLE intent_aliases RENAME TO valid_intent_aliases") + conn.execute( + """ + CREATE TABLE intent_aliases ( + graph_id TEXT, version TEXT, normalized_alias TEXT, + node_id TEXT, alias TEXT + ) + """ + ) + conn.executemany( + "INSERT INTO intent_aliases VALUES (?, ?, ?, ?, ?)", + [ + ( + "mainframe.core", + "2026-06-29.1", + "ambiguous", + "goal.mindgraph-remodel", + "Ambiguous", + ), + ( + "mainframe.core", + "2026-06-29.1", + "ambiguous", + "goal.project-status-review", + "Ambiguous", + ), + ], + ) + conn.commit() + resolution = resolve_intent(conn, "ambiguous") + finally: + conn.close() + assert resolution.outcome == "refusal" + assert resolution.refusal_reason == "intent_alias_ambiguous" + assert resolution.capability_hints == () + + +def test_runtime_cycle_fails_closed_without_capability_hints(tmp_path, catalog): + destination, _ = compile_document(tmp_path, phase1_document(catalog)) + conn = sqlite3.connect(destination) + conn.row_factory = sqlite3.Row + try: + conn.execute("PRAGMA foreign_keys = ON") + conn.execute( + """ + INSERT INTO intent_edges VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + "mainframe.core", + "2026-06-29.1", + "goal.intent-schema-validation", + "goal.mindgraph-remodel", + "requires", + "active", + "[]", + ), + ) + conn.commit() + resolution = resolve_intent( + conn, + "cycle", + intent_id="goal.mindgraph-remodel", + allowed_capability_refs=frozenset( + {"retriever://mindgraph-projects", "capability://fixture-evaluator"} + ), + ) + finally: + conn.close() + assert resolution.outcome == "refusal" + assert resolution.refusal_reason == "intent_cycle_detected" + assert resolution.intent_path == ( + "goal.intent-schema-validation", + "goal.mindgraph-remodel", + "goal.route-contract-evaluation", + "goal.intent-schema-validation", + ) + assert resolution.capability_hints == () + + +def _invalid_candidate( + probe_id: str, + error: IntentGraphValidationError, + *, + method: str, + matched: list[str], + path: list[str], +) -> dict: + return { + "id": probe_id, + "outcome": "refusal", + "reason": error.code, + "resolution_method": method, + "matched_goal_ids": matched, + "prerequisite_goal_ids": [], + "intent_path": path, + "capability_hints": [], + "behaviors": [], + "fields": [ + "graph_id", + "graph_version", + "intent_path", + "matched_goal_ids", + "resolution_method", + ], + "graph_id": error.graph_id or "mainframe.core", + "graph_version": error.graph_version or "invalid", + } + + +def build_phase1_candidates(tmp_path: Path, catalog: dict | None = None) -> dict: + """Build actual candidate rows for external Phase 1 evaluator verification.""" + catalog = catalog or load_catalog() + document = phase1_document(catalog) + destination, _ = compile_document(tmp_path, document, "phase1.sqlite") + conn = open_intent_store(destination) + try: + intent01 = resolve_intent( + conn, + "Validate the retrieval remodel before implementation.", + intent_id="goal.mindgraph-remodel", + allowed_capability_refs=frozenset( + { + "retriever://mindgraph-projects", + "capability://fixture-evaluator", + } + ), + ).as_contract_result("intent01_explicit_goal_prerequisites") + intent05 = resolve_intent( + conn, "Handle a goal that is not in the approved graph." + ).as_contract_result("intent05_missing_goal_fallback") + intent06 = resolve_intent( + conn, + "Use a reviewed project goal that contains an untrusted Gmail hint.", + intent_id="goal.project-status-review", + allowed_capability_refs=frozenset({"retriever://mindgraph-projects"}), + ).as_contract_result("intent06_untrusted_route_hint_rejected") + intent07 = resolve_intent( + conn, + "Resolve a deep goal path with the V1 traversal limit.", + intent_id="goal.deep-root", + ).as_contract_result("intent07_bounded_traversal_truncation") + finally: + conn.close() + + alias_document = phase1_document(catalog) + alias_document["edges"] = [ + item + for item in alias_document["edges"] + if not ( + item["source_id"] == "goal.route-contract-evaluation" + and item["relation"] == "requires" + ) + ] + alias_destination, _ = compile_document( + tmp_path, alias_document, "alias.sqlite" + ) + alias_conn = open_intent_store(alias_destination) + try: + intent02 = resolve_intent( + alias_conn, + "Run the route contract checks.", + allowed_capability_refs=frozenset({"capability://fixture-evaluator"}), + ).as_contract_result("intent02_deterministic_alias_resolution") + finally: + alias_conn.close() + + alias_invalid = phase1_document(catalog) + alias_invalid["nodes"][0]["aliases"] = ["Run the route contract checks."] + with pytest.raises(IntentGraphValidationError) as alias_error: + validate_intent_corpus(write_corpus(tmp_path / "ambiguous", [alias_invalid])) + intent03 = _invalid_candidate( + "intent03_ambiguous_alias_refusal", + alias_error.value, + method="none", + matched=[], + path=[], + ) + + cycle_invalid = phase1_document(catalog) + cycle_invalid["nodes"].extend( + [goal("goal.cyclic-a", "Cyclic A"), goal("goal.cyclic-b", "Cyclic B")] + ) + cycle_invalid["edges"].extend( + [ + edge("goal.cyclic-a", "goal.cyclic-b", "requires"), + edge("goal.cyclic-b", "goal.cyclic-a", "requires"), + ] + ) + with pytest.raises(IntentGraphValidationError) as cycle_error: + validate_intent_corpus(write_corpus(tmp_path / "cycle", [cycle_invalid])) + intent04 = _invalid_candidate( + "intent04_cycle_refusal", + cycle_error.value, + method="explicit", + matched=["goal.cyclic-a"], + path=list(cycle_error.value.witness[:-1]), + ) + + return { + "schema_version": "1", + "results": [ + intent01, + intent02, + intent03, + intent04, + intent05, + intent06, + intent07, + ], + } + + +def test_phase1_candidate_adapter_covers_all_seven_cases(tmp_path, catalog): + candidate = build_phase1_candidates(tmp_path, catalog) + results = {item["id"]: item for item in candidate["results"]} + expected = catalog["phase1_expected"] + assert set(results) == set(expected) + + for probe_id, expectation in expected.items(): + observed = results[probe_id] + for key, value in expectation.items(): + if key == "forbidden_capability_hints": + assert not set(value) & set(observed["capability_hints"]) + elif key == "capability_hints": + assert set(observed[key]) == set(value) + else: + assert observed[key] == value + + first = json.dumps(candidate, sort_keys=True, separators=(",", ":")) + second = json.dumps( + build_phase1_candidates(tmp_path / "repeat", catalog), + sort_keys=True, + separators=(",", ":"), + ) + assert first == second diff --git a/mindgraph/tests/test_mcp.py b/mindgraph/tests/test_mcp.py new file mode 100644 index 0000000..8ca7ba1 --- /dev/null +++ b/mindgraph/tests/test_mcp.py @@ -0,0 +1,353 @@ +import json as jsonlib +import sqlite3 +from pathlib import Path + +import pytest +import yaml +from typer.testing import CliRunner + +from mcp.shared.memory import create_connected_server_and_client_session + +from mindgraph import cli, db, parser +from mindgraph import mcp_server +from mindgraph.intent import compile_intent_corpus +from mindgraph.query import list_neighbors, run_query +from tests.test_query import KeywordEmbedder + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +def _doc_id(rel_path: str) -> str: + return parser.compute_doc_id(rel_path) + + +def test_lock_retry_handles_wrapped_sqlite_operational_error(monkeypatch): + calls = 0 + sleeps = [] + + def flaky(): + nonlocal calls + calls += 1 + if calls < 3: + try: + raise sqlite3.OperationalError("database is locked") + except sqlite3.OperationalError as exc: + raise mcp_server.query_mod.QueryError("wrapped lock") from exc + return "ok" + + monkeypatch.setattr(mcp_server.time, "sleep", sleeps.append) + assert mcp_server._run_with_lock_retry(flaky) == "ok" + assert calls == 3 + assert sleeps == [0.5, 1.0] + + +@pytest.fixture +def mcp_embedder(): + return KeywordEmbedder( + { + "feedback": 0, + "loops": 0, + "reinforcing": 1, + "balancing": 2, + "archetype": 3, + "compilers": 4, + } + ) + + +@pytest.fixture +def mcp_db(tmp_path, monkeypatch, mcp_embedder): + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: mcp_embedder) + + notes = tmp_path / "vault" + notes.mkdir() + (notes / "feedback-loops.md").write_text( + "Feedback loops explain circular system behavior. " + "See [[reinforcing-loops]] (illustrates) and " + "[[balancing-loops]] (illustrates).\n" + ) + (notes / "reinforcing-loops.md").write_text( + "Reinforcing loops amplify change over repeated cycles. " + "Related to [[systems-archetypes]] (related).\n" + ) + (notes / "balancing-loops.md").write_text( + "Balancing loops counter drift and push a system toward a setpoint. " + "Related to [[systems-archetypes]] (related).\n" + ) + (notes / "systems-archetypes.md").write_text( + "A systems archetype is a recurring feedback structure. " + "This note cites [[missing-model]] (cites).\n" + ) + (notes / "unrelated.md").write_text( + "Compilers transform source code into executable forms.\n" + ) + + db_path = str(tmp_path / "test.sqlite") + db.init_db(db_path).close() + cli._ingest_directory(notes, db_path) + return db_path + + +@pytest.fixture +def mcp_runtime(mcp_db, mcp_embedder): + conn = mcp_server.open_database(mcp_db) + server = mcp_server.create_server(conn, mcp_embedder) + try: + yield server, conn + finally: + conn.close() + + +@pytest.fixture +def mcp_intent_db(tmp_path): + fixture = Path(__file__).parent / "fixtures" / "intent_graph_cases.yaml" + catalog = yaml.safe_load(fixture.read_text(encoding="utf-8")) + source = tmp_path / "intent-source" + source.mkdir() + (source / "mainframe-core.yaml").write_text( + yaml.safe_dump(catalog["documents"]["phase1"], sort_keys=False), + encoding="utf-8", + ) + destination = tmp_path / "intent.sqlite" + compile_intent_corpus(source, destination) + return destination + + +@pytest.fixture +def mcp_runtime_with_intent(mcp_db, mcp_embedder, mcp_intent_db): + conn = mcp_server.open_database(mcp_db) + server = mcp_server.create_server( + conn, + mcp_embedder, + intent_db_path=str(mcp_intent_db), + ) + try: + yield server, conn + finally: + conn.close() + + +def _tool_json(result): + assert result.isError is False + assert len(result.content) == 1 + assert result.content[0].type == "text" + return jsonlib.loads(result.content[0].text) + + +@pytest.mark.anyio +async def test_server_start_happy_path_lists_tools(mcp_runtime): + server, _conn = mcp_runtime + + async with create_connected_server_and_client_session(server) as session: + result = await session.list_tools() + + tool_names = {tool.name for tool in result.tools} + assert {"query", "graph_neighbors"} <= tool_names + + +def test_server_start_missing_db_is_clean_error(tmp_path, mcp_embedder): + missing = tmp_path / "missing.sqlite" + + with pytest.raises(mcp_server.MCPServerStartupError) as exc: + mcp_server.open_database(str(missing)) + + assert "does not exist" in str(exc.value) + assert not missing.exists() + + +@pytest.mark.anyio +async def test_query_tool_shape_matches_cli_json_surface(mcp_runtime, mcp_embedder): + server, conn = mcp_runtime + + async with create_connected_server_and_client_session(server) as session: + result = await session.call_tool( + "query", + { + "question": "feedback loops", + "final_top_k": 3, + }, + ) + + tool_rows = _tool_json(result) + expected = [ + row.model_dump() + for row in run_query( + conn, + "feedback loops", + mcp_embedder, + final_top_k=3, + ) + ] + assert tool_rows == expected + + +@pytest.mark.anyio +async def test_query_tool_envelope_includes_intent_and_routing_metadata( + mcp_runtime_with_intent, +): + server, _conn = mcp_runtime_with_intent + + async with create_connected_server_and_client_session(server) as session: + result = await session.call_tool( + "query", + { + "question": "Run the route contract checks.", + "final_top_k": 3, + "envelope": True, + }, + ) + + payload = _tool_json(result) + assert set(payload) == { + "schema_version", + "intent_resolution", + "routing", + "results", + "not_citable", + "citation_counts", + } + assert payload["schema_version"] == "1" + assert payload["intent_resolution"]["graph_id"] == "mainframe.core" + assert payload["intent_resolution"]["graph_version"] == "2026-06-29.1" + assert payload["intent_resolution"]["outcome"] == "resolved" + assert payload["intent_resolution"]["method"] == "alias" + assert payload["intent_resolution"]["matched_goals"] == [ + "goal.route-contract-evaluation" + ] + assert payload["routing"]["mode"] == "single_database" + assert payload["routing"]["selected_retrievers"] == ["mcp-bound-db"] + assert payload["routing"]["reason_codes"] == ["intent_resolved"] + assert isinstance(payload["results"], list) + + +@pytest.mark.anyio +async def test_graph_neighbors_tool_shape_matches_cli_json_surface(mcp_runtime): + server, conn = mcp_runtime + doc_id = _doc_id("feedback-loops.md") + + async with create_connected_server_and_client_session(server) as session: + result = await session.call_tool("graph_neighbors", {"doc_id": doc_id}) + + tool_rows = _tool_json(result) + expected = [row.model_dump() for row in list_neighbors(conn, doc_id)] + assert tool_rows == expected + + +@pytest.mark.anyio +async def test_query_tool_routes_expand_parameters(mcp_runtime): + server, _conn = mcp_runtime + + async with create_connected_server_and_client_session(server) as session: + result = await session.call_tool( + "query", + { + "question": "feedback loops", + "final_top_k": 1, + "expand": True, + "expand_depth": 2, + "expand_top_k": 3, + }, + ) + + rows = _tool_json(result) + by_path = {row["path"]: row for row in rows} + assert by_path["feedback-loops.md"]["expansion_depth"] == 0 + assert by_path["reinforcing-loops.md"]["signal"] == "expanded" + assert by_path["reinforcing-loops.md"]["expansion_depth"] == 1 + assert by_path["balancing-loops.md"]["signal"] == "expanded" + assert by_path["balancing-loops.md"]["expansion_depth"] == 1 + assert by_path["systems-archetypes.md"]["signal"] == "expanded" + assert by_path["systems-archetypes.md"]["expansion_depth"] == 2 + + +@pytest.mark.anyio +async def test_query_tool_rejects_negative_expand_limit(mcp_runtime): + server, _conn = mcp_runtime + async with create_connected_server_and_client_session(server) as session: + result = await session.call_tool( + "query", + { + "question": "feedback loops", + "expand": True, + "expand_top_k": -1, + }, + ) + assert result.isError is True + assert "expand_top_k" in result.content[0].text + + +@pytest.mark.anyio +async def test_query_tool_emits_scope_warning(mcp_runtime): + server, _conn = mcp_runtime + + async with create_connected_server_and_client_session(server) as session: + result = await session.call_tool( + "query", + { + "question": "current feedback status this week", + "final_top_k": 3, + }, + ) + + rows = _tool_json(result) + assert rows + assert rows[0]["query_scope_warning"]["intent"] == "live_state" + assert ( + rows[0]["query_scope_warning"]["recommended_trust_profile"] + == "time_bound_live_state" + ) + + +@pytest.mark.anyio +async def test_graph_neighbors_preserves_dangling_target(mcp_runtime): + server, _conn = mcp_runtime + + async with create_connected_server_and_client_session(server) as session: + result = await session.call_tool( + "graph_neighbors", + {"doc_id": _doc_id("systems-archetypes.md")}, + ) + + rows = _tool_json(result) + assert len(rows) == 1 + assert rows[0]["target_id"] == _doc_id("missing-model.md") + assert rows[0]["target_path"] is None + + +@pytest.mark.anyio +async def test_graph_neighbors_unknown_doc_id_is_clean_tool_error(mcp_runtime): + server, _conn = mcp_runtime + + async with create_connected_server_and_client_session(server) as session: + result = await session.call_tool( + "graph_neighbors", + {"doc_id": "not-a-real-doc"}, + ) + + assert result.isError is True + assert len(result.content) == 1 + assert "unknown doc_id" in result.content[0].text + assert "Traceback" not in result.content[0].text + + +def test_serve_mcp_help_is_registered(): + runner = CliRunner() + + result = runner.invoke(cli.app, ["serve-mcp", "--help"]) + + assert result.exit_code == 0 + assert "-db" in result.stdout + + +def test_serve_mcp_missing_db_exits_cleanly(tmp_path): + runner = CliRunner() + missing = tmp_path / "missing.sqlite" + + result = runner.invoke(cli.app, ["serve-mcp", "--db", str(missing)]) + + assert result.exit_code == 1 + assert "does not exist" in result.stderr + assert not Path(missing).exists() diff --git a/mindgraph/tests/test_neighbors.py b/mindgraph/tests/test_neighbors.py new file mode 100644 index 0000000..38dc52b --- /dev/null +++ b/mindgraph/tests/test_neighbors.py @@ -0,0 +1,157 @@ +import json as jsonlib + +import numpy as np +import pytest +from typer.testing import CliRunner + +from mindgraph import cli, db, parser +from mindgraph.models import NeighborResult +from mindgraph.query import list_neighbors + + +class _ZeroEmbedder: + def encode(self, texts, convert_to_numpy=True): + return np.zeros((len(texts), 384), dtype=np.float32) + + +@pytest.fixture +def fake_embedder(monkeypatch): + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: _ZeroEmbedder()) + + +@pytest.fixture +def neighbors_db(tmp_path, fake_embedder): + notes = tmp_path / "vault" + notes.mkdir() + (notes / "source.md").write_text( + "Refers to [[target_a]] (cites), [[target_b]], " + "and [[missing/x]] (refers).\n" + ) + (notes / "target_a.md").write_text("Target A.\n") + (notes / "target_b.md").write_text("Target B.\n") + (notes / "no_edges.md").write_text("Pure prose with no outbound links.\n") + + db_path = str(tmp_path / "test.sqlite") + db.init_db(db_path).close() + cli._ingest_directory(notes, db_path) + return db_path + + +def _doc_id(rel: str) -> str: + return parser.compute_doc_id(rel) + + +class TestListNeighbors: + def test_returns_neighbor_result_objects(self, neighbors_db): + conn = db.get_db(neighbors_db) + try: + edges = list_neighbors(conn, _doc_id("source.md")) + finally: + conn.close() + assert all(isinstance(e, NeighborResult) for e in edges) + + def test_returns_all_outbound_edges_including_dangling(self, neighbors_db): + conn = db.get_db(neighbors_db) + try: + edges = list_neighbors(conn, _doc_id("source.md")) + finally: + conn.close() + target_ids = {e.target_id for e in edges} + assert _doc_id("target_a.md") in target_ids + assert _doc_id("target_b.md") in target_ids + assert _doc_id("missing/x.md") in target_ids + assert len(edges) == 3 + + def test_resolves_source_path_on_every_edge(self, neighbors_db): + conn = db.get_db(neighbors_db) + try: + edges = list_neighbors(conn, _doc_id("source.md")) + finally: + conn.close() + assert all(e.source_path == "source.md" for e in edges) + + def test_dangling_edge_has_null_target_path(self, neighbors_db): + conn = db.get_db(neighbors_db) + try: + edges = list_neighbors(conn, _doc_id("source.md")) + finally: + conn.close() + by_target = {e.target_id: e for e in edges} + dangling = by_target[_doc_id("missing/x.md")] + assert dangling.target_path is None + assert dangling.relationship_type == "refers" + + def test_existing_edges_have_resolved_target_path(self, neighbors_db): + conn = db.get_db(neighbors_db) + try: + edges = list_neighbors(conn, _doc_id("source.md")) + finally: + conn.close() + by_target = {e.target_id: e for e in edges} + assert by_target[_doc_id("target_a.md")].target_path == "target_a.md" + assert by_target[_doc_id("target_b.md")].target_path == "target_b.md" + + def test_relationship_type_preserved_including_null(self, neighbors_db): + conn = db.get_db(neighbors_db) + try: + edges = list_neighbors(conn, _doc_id("source.md")) + finally: + conn.close() + by_target = {e.target_id: e for e in edges} + assert by_target[_doc_id("target_a.md")].relationship_type == "cites" + assert by_target[_doc_id("target_b.md")].relationship_type is None + assert by_target[_doc_id("missing/x.md")].relationship_type == "refers" + + def test_source_with_no_edges_returns_empty(self, neighbors_db): + conn = db.get_db(neighbors_db) + try: + edges = list_neighbors(conn, _doc_id("no_edges.md")) + finally: + conn.close() + assert edges == [] + + def test_sort_order_target_id_ascending(self, neighbors_db): + conn = db.get_db(neighbors_db) + try: + edges = list_neighbors(conn, _doc_id("source.md")) + finally: + conn.close() + target_ids = [e.target_id for e in edges] + assert target_ids == sorted(target_ids) + + +class TestNeighborsCLI: + def test_neighbors_command_text_output(self, neighbors_db): + runner = CliRunner() + result = runner.invoke( + cli.app, ["neighbors", _doc_id("source.md"), "--db", neighbors_db] + ) + assert result.exit_code == 0 + assert "target_path" in result.stdout + + def test_neighbors_command_json_output(self, neighbors_db): + runner = CliRunner() + result = runner.invoke( + cli.app, + ["neighbors", _doc_id("source.md"), "--db", neighbors_db, "--json"], + ) + assert result.exit_code == 0 + data = jsonlib.loads(result.stdout) + assert len(data) == 3 + for row in data: + for field in ( + "source_id", + "target_id", + "relationship_type", + "source_path", + "target_path", + ): + assert field in row + + def test_neighbors_command_reports_no_edges_message(self, neighbors_db): + runner = CliRunner() + result = runner.invoke( + cli.app, ["neighbors", _doc_id("no_edges.md"), "--db", neighbors_db] + ) + assert result.exit_code == 0 + assert "no outbound edges" in result.stdout diff --git a/mindgraph/tests/test_parser.py b/mindgraph/tests/test_parser.py new file mode 100644 index 0000000..f211fef --- /dev/null +++ b/mindgraph/tests/test_parser.py @@ -0,0 +1,480 @@ +import pytest + +from mindgraph.exceptions import ParseError +from mindgraph.parser import ( + LinkResolver, + canonical_trailing_slug, + chunk_truth, + compute_doc_id, + extract_document_graph_edges, + extract_graph_edges, + extract_metadata_link_targets, + normalize_link_label, + parse_document, + parse_frontmatter, + split_page_model, + strip_apparatus, +) + + +class TestParseFrontmatter: + def test_with_frontmatter(self): + text = "---\ntitle: Hello\ntags: [a, b]\n---\nBody here" + meta, body = parse_frontmatter(text) + assert meta == {"title": "Hello", "tags": ["a", "b"]} + assert body == "Body here" + + def test_without_frontmatter(self): + text = "Just body, no frontmatter" + meta, body = parse_frontmatter(text) + assert meta == {} + assert body == text + + def test_empty_frontmatter(self): + text = "---\n\n---\nBody" + meta, body = parse_frontmatter(text) + assert meta == {} + assert body == "Body" + + def test_malformed_yaml_raises(self): + text = "---\ntitle: : :\n bad indent\n---\nBody" + with pytest.raises(ParseError): + parse_frontmatter(text) + + def test_non_mapping_frontmatter_raises(self): + text = "---\n- just\n- a\n- list\n---\nBody" + with pytest.raises(ParseError): + parse_frontmatter(text) + + +class TestSplitPageModel: + def test_truth_only(self): + body = "# Heading\n\nSome content here." + truth, timeline = split_page_model(body) + assert truth == "# Heading\n\nSome content here." + assert timeline is None + + def test_truth_and_timeline(self): + body = "Truth content here.\n\n---\n## Timeline\n\n- 2026-01-01: thing\n- 2026-02-01: other" + truth, timeline = split_page_model(body) + assert truth == "Truth content here." + assert timeline == "- 2026-01-01: thing\n- 2026-02-01: other" + + def test_internal_hr_not_split(self): + """A plain `---` HR in the Truth section must not trigger the split.""" + body = "Section A\n\n---\n\nSection B\n\n---\n\nSection C" + truth, timeline = split_page_model(body) + assert truth == body.strip() + assert timeline is None + + def test_internal_hr_then_real_timeline(self): + """Internal HRs come first, then a real `## Timeline` block.""" + body = ( + "Section A\n\n---\n\nSection B\n\n" + "---\n## Timeline\n- event" + ) + truth, timeline = split_page_model(body) + assert truth.startswith("Section A") + assert "Section B" in truth + assert "## Timeline" not in truth + assert timeline == "- event" + + def test_case_insensitive_heading(self): + body = "Truth\n\n---\n## TIMELINE\n- e" + _, timeline = split_page_model(body) + assert timeline == "- e" + + def test_blank_lines_between_dash_and_heading(self): + body = "Truth\n\n---\n\n## Timeline\n- e" + truth, timeline = split_page_model(body) + assert truth == "Truth" + assert timeline == "- e" + + +class TestExtractGraphEdges: + @pytest.fixture + def link_resolver(self): + docs = [ + parse_document( + "agents/source.md", + b"---\ntitle: Source\n---\nBody", + ), + parse_document( + "agents/foo.md", + b"---\ntitle: Agent Foo\n---\nBody", + ), + parse_document( + "ai-business/cross-domain.md", + b"---\ntitle: Cross Domain Note\n---\nBody", + ), + parse_document( + "knowledge-systems/title-target.md", + b"---\ntitle: Display Title\n---\nBody", + ), + parse_document( + "finance/duplicate.md", + b"---\ntitle: Shared Title\n---\nBody", + ), + parse_document( + "agents/duplicate.md", + b"---\ntitle: Shared Title\n---\nBody", + ), + ] + return LinkResolver.from_documents(docs) + + def test_plain_link(self): + edges = extract_graph_edges("see [[people/alice]]", source_id="src") + assert len(edges) == 1 + assert edges[0].source_id == "src" + assert edges[0].target_id == compute_doc_id("people/alice.md") + assert edges[0].relationship_type is None + + def test_link_with_relationship(self): + edges = extract_graph_edges( + "see [[people/alice]] (works_at)", source_id="src" + ) + assert len(edges) == 1 + assert edges[0].relationship_type == "works_at" + + def test_multiple_links_in_paragraph(self): + text = "intro [[a]] middle [[b]] (rel) end [[c/d]]" + edges = extract_graph_edges(text, source_id="src") + assert len(edges) == 3 + assert edges[1].relationship_type == "rel" + assert edges[2].target_id == compute_doc_id("c/d.md") + + def test_nested_brackets_ignored(self): + """A `[[link[with]inner]]` shape should not produce a malformed edge.""" + edges = extract_graph_edges( + "[[normal]] and [[link[inner]brackets]]", source_id="src" + ) + assert len(edges) == 1 + assert edges[0].target_id == compute_doc_id("normal.md") + + def test_link_already_has_md_extension(self): + edges = extract_graph_edges("[[notes/foo.md]]", source_id="src") + assert edges[0].target_id == compute_doc_id("notes/foo.md") + + def test_resolves_explicit_path_target(self, link_resolver): + edges = extract_graph_edges( + "[[agents/foo]] and [[agents/foo.md]]", + source_id="src", + link_resolver=link_resolver, + source_path="agents/source.md", + ) + + assert [e.target_id for e in edges] == [ + compute_doc_id("agents/foo.md"), + compute_doc_id("agents/foo.md"), + ] + + def test_resolves_same_directory_bare_target_first(self, link_resolver): + edges = extract_graph_edges( + "[[foo]]", + source_id="src", + link_resolver=link_resolver, + source_path="agents/source.md", + ) + + assert edges[0].target_id == compute_doc_id("agents/foo.md") + + def test_resolves_unique_cross_domain_stem(self, link_resolver): + edges = extract_graph_edges( + "[[cross-domain]]", + source_id="src", + link_resolver=link_resolver, + source_path="agents/source.md", + ) + + assert edges[0].target_id == compute_doc_id("ai-business/cross-domain.md") + + def test_resolves_unique_title(self, link_resolver): + edges = extract_graph_edges( + "[[Display Title]]", + source_id="src", + link_resolver=link_resolver, + source_path="agents/source.md", + ) + + assert edges[0].target_id == compute_doc_id("knowledge-systems/title-target.md") + + def test_ambiguous_target_remains_dangling(self, link_resolver): + edges = extract_graph_edges( + "[[duplicate]] and [[Shared Title]]", + source_id="src", + link_resolver=link_resolver, + source_path="knowledge-systems/source.md", + ) + + assert [e.target_id for e in edges] == [ + compute_doc_id("duplicate.md"), + compute_doc_id("Shared Title.md"), + ] + + def test_missing_target_remains_dangling(self, link_resolver): + edges = extract_graph_edges( + "[[missing]]", + source_id="src", + link_resolver=link_resolver, + source_path="agents/source.md", + ) + + assert edges[0].target_id == compute_doc_id("missing.md") + + def test_resolves_unique_canonical_trailing_slug(self): + docs = [ + parse_document( + "regulated-systems/2026-06-13__regulated-systems__raw__gxp-pharma-source-catalog.md", + b"---\ntitle: Catalog\n---\nBody", + ), + parse_document( + "regulated-systems/source.md", + b"---\ntitle: Source\n---\nBody", + ), + ] + resolver = LinkResolver.from_documents(docs) + edges = extract_graph_edges( + "[[gxp-pharma-source-catalog]]", + source_id="src", + link_resolver=resolver, + source_path="regulated-systems/source.md", + ) + + assert edges[0].target_id == docs[0].id + + def test_ambiguous_trailing_slug_remains_dangling(self): + docs = [ + parse_document( + "regulated-systems/2026-06-13__regulated-systems__note__shared-slug.md", + b"---\ntitle: One\n---\nBody", + ), + parse_document( + "knowledge-systems/2026-06-14__knowledge-systems__note__shared-slug.md", + b"---\ntitle: Two\n---\nBody", + ), + ] + resolver = LinkResolver.from_documents(docs) + edges = extract_graph_edges( + "[[shared-slug]]", + source_id="src", + link_resolver=resolver, + source_path="regulated-systems/source.md", + ) + + assert edges[0].target_id == compute_doc_id("shared-slug.md") + + +class TestCanonicalTrailingSlug: + def test_extracts_slug_from_canonical_stem(self): + stem = "2026-06-13__regulated-systems__raw__gxp-pharma-source-catalog" + assert canonical_trailing_slug(stem) == "gxp-pharma-source-catalog" + + def test_non_canonical_stem_returns_none(self): + assert canonical_trailing_slug("legacy-short-name") is None + + +class TestMetadataLinks: + def test_extract_metadata_link_targets_dedupes(self): + metadata = { + "links": [ + "hybrid-memory-in-practice", + "[[hybrid-memory-in-practice]]", + "data-integrity-alcoa", + ] + } + assert extract_metadata_link_targets(metadata) == [ + "hybrid-memory-in-practice", + "data-integrity-alcoa", + ] + + def test_normalize_link_label_strips_wrapper_and_alias(self): + assert normalize_link_label("[[target|Display]]") == "target" + + +class TestExtractDocumentGraphEdges: + def test_indexes_frontmatter_and_body_without_duplicates(self): + docs = [ + parse_document( + "knowledge-systems/2026-06-13__knowledge-systems__note__hybrid-memory-in-practice.md", + b"---\ntitle: Hybrid\n---\nBody", + ), + parse_document( + "knowledge-systems/2026-06-04__knowledge-systems__note__knowledge-graph-rag-architecture.md", + b"---\ntitle: KG RAG\n---\nBody", + ), + parse_document( + "knowledge-systems/source.md", + ( + "---\n" + "title: Source\n" + 'links: ["hybrid-memory-in-practice", "knowledge-graph-rag-architecture"]\n' + "---\n" + "See also [[hybrid-memory-in-practice]] (extends).\n" + ).encode("utf-8"), + ), + ] + resolver = LinkResolver.from_documents(docs) + edges = extract_document_graph_edges(docs[2], link_resolver=resolver) + + assert len(edges) == 2 + assert {edge.target_id for edge in edges} == {docs[0].id, docs[1].id} + hybrid_edge = next(edge for edge in edges if edge.target_id == docs[0].id) + assert hybrid_edge.relationship_type == "extends" + + +class TestStripApparatus: + """Document apparatus is excluded from semantic chunks, not lexical FTS.""" + + def test_plain_prose_is_unchanged(self): + body = "The retrieval floor stays at 0.40.\n\nX1 was re-run twice." + assert strip_apparatus(body) == body + + def test_callout_banner_is_dropped(self): + body = "Real finding here about entailment.\n\n> **WARNING.** Quarantined capture." + assert "WARNING" not in strip_apparatus(body) + assert "entailment" in strip_apparatus(body) + + def test_markdown_alert_block_is_dropped(self): + body = ( + "Real finding here about entailment.\n\n" + "> [!WARNING]\n" + "> Quarantined capture.\n" + ) + out = strip_apparatus(body) + assert "Quarantined capture" not in out + assert "entailment" in out + + def test_substantive_blockquote_is_preserved(self): + body = ( + "> The audit record reports the same tree hash after the rebuild.\n" + "> The result is still a nomination for inspection.\n" + ) + out = strip_apparatus(body) + assert "same tree hash after the rebuild" in out + assert "nomination for inspection" in out + + def test_ingest_wrapper_metadata_row_is_dropped(self): + body = "**Access:** 00_inbox/x.md **Fetch method:** none **Audit verdict:** pending" + assert strip_apparatus(body) == body # fail-safe: nothing else survives + keep = "A real sentence with enough words to survive the filter.\n" + body + assert "Fetch method" not in strip_apparatus(keep) + + def test_reference_list_is_dropped_but_citing_prose_survives(self): + body = ( + "- [Some Note](10_knowledge/agents/note.md)\n" + "- `bin/mindgraph query`\n" + "\n" + "Retrieval degrades when `bin/mindgraph query` runs against a stub database.\n" + ) + out = strip_apparatus(body) + assert "Some Note" not in out + assert "Retrieval degrades" in out + + def test_tables_and_fenced_code_are_dropped(self): + body = "Prose that must survive the stripping pass.\n\n| a | b |\n|---|---|\n\n```bash\necho hi\n```" + out = strip_apparatus(body) + assert "echo hi" not in out and "| a |" not in out + assert "must survive" in out + + def test_heading_keeps_its_words_and_loses_its_marker(self): + assert strip_apparatus("## Retrieval floor") == "Retrieval floor" + + def test_all_apparatus_document_falls_back_to_original(self): + # An index page must stay indexable if no prose survives. + body = "| a | b |\n|---|---|\n\n- [One](a.md)\n- [Two](b.md)" + assert strip_apparatus(body) == body + + def test_chunk_truth_applies_the_strip(self): + body = "> **WARNING.** banner text\n\nThe daemon health check returns ok when ready." + chunks = chunk_truth(body) + assert chunks and all("WARNING" not in c for c in chunks) + + def test_chunk_truth_keeps_substantive_blockquote(self): + body = "> The quoted evidence survived two independent rebuilds." + chunks = chunk_truth(body) + assert chunks and "quoted evidence survived" in chunks[0] + + +class TestChunkTruth: + def test_empty(self): + assert chunk_truth("") == [] + assert chunk_truth(" \n\n ") == [] + + def test_single_short_paragraph(self): + assert chunk_truth("hello world") == ["hello world"] + + def test_respects_max_chars(self): + # Two paragraphs that together exceed max_chars must split. + p1 = "a" * 600 + p2 = "b" * 600 + body = f"{p1}\n\n{p2}" + chunks = chunk_truth(body, max_chars=1000) + assert len(chunks) == 2 + assert chunks[0] == p1 + assert chunks[1] == p2 + + def test_packs_paragraphs_until_limit(self): + p1 = "a" * 400 + p2 = "b" * 400 + p3 = "c" * 400 + body = f"{p1}\n\n{p2}\n\n{p3}" + chunks = chunk_truth(body, max_chars=1000) + # First two pack together (400 + 2 + 400 = 802 ≤ 1000), third spills. + assert len(chunks) == 2 + assert "a" * 400 in chunks[0] + assert "b" * 400 in chunks[0] + assert chunks[1] == p3 + + def test_oversized_paragraph_with_no_boundary_is_hard_split(self): + # A paragraph larger than max_chars with no whitespace boundary is + # hard-split so each chunk respects the bound (the old behavior let a + # single ~200KB chunk through, ~99.5% invisible to the embedder). + p = "x" * 2000 + chunks = chunk_truth(p, max_chars=1000) + assert len(chunks) >= 2 + assert all(len(c) <= 1000 for c in chunks) + assert "".join(chunks) == p # no content lost + + def test_oversized_paragraph_splits_on_sentence_boundary(self): + # Two sentences in one paragraph that together exceed max_chars but each + # fit should split at the sentence boundary, not mid-word. + s1 = "A" * 600 + "." + s2 = "B" * 600 + "." + chunks = chunk_truth(f"{s1} {s2}", max_chars=1000) + assert chunks == [s1, s2] + + def test_no_chunk_exceeds_max_chars(self): + # Invariant: whatever the input shape, every chunk respects the bound. + body = "\n\n".join(["word " * 300, "y" * 5000, "short tail."]) + chunks = chunk_truth(body, max_chars=1000) + assert chunks + assert all(len(c) <= 1000 for c in chunks) + + +class TestParseDocument: + def test_end_to_end(self): + body = ( + "---\n" + "title: My Note\n" + "domain: personal\n" + "---\n" + "Truth content with [[people/alice]] (knows).\n\n" + "---\n## Timeline\n- 2026-01-01: created" + ) + doc = parse_document("notes/my-note.md", body.encode("utf-8")) + assert doc.id == compute_doc_id("notes/my-note.md") + assert doc.title == "My Note" + assert doc.metadata["domain"] == "personal" + assert "people/alice" in doc.truth_text + assert doc.timeline_text == "- 2026-01-01: created" + assert doc.content_hash # not empty + + def test_title_falls_back_to_filename(self): + body = b"No frontmatter, just body." + doc = parse_document("notes/example.md", body) + assert doc.title == "example" + assert doc.timeline_text is None + + def test_invalid_utf8_raises(self): + with pytest.raises(ParseError): + parse_document("notes/bad.md", b"\xff\xfe\x00invalid") diff --git a/mindgraph/tests/test_query.py b/mindgraph/tests/test_query.py new file mode 100644 index 0000000..eed640b --- /dev/null +++ b/mindgraph/tests/test_query.py @@ -0,0 +1,903 @@ +import json as jsonlib +import logging +import os +from pathlib import Path + +import numpy as np +import pytest +import yaml +from typer.testing import CliRunner + +from mindgraph import cli, db, parser +from mindgraph.intent import compile_intent_corpus +from mindgraph.models import QueryResult +from mindgraph.query import ( + DEFAULT_SCOPE_VOCABULARY, + RRF_K, + MAX_QUERY_TOP_K, + QueryError, + SCOPE_VOCABULARY_ENV, + ScopeVocabulary, + WEAK_FIT_DISTANCE_THRESHOLD, + _is_weak_fit, + _vocabulary_from_env, + active_scope_vocabulary, + classify_query_scope, + load_scope_vocabulary, + fetch_lexical_ranking, + fetch_semantic_ranking, + rrf_fuse, + run_query, + sanitize_fts5_query, +) + + +# --- Deterministic stub embedder --------------------------------------------- # + + +class KeywordEmbedder: + """Maps keywords (and synonyms) to specific embedding dimensions. + + Each occurrence of a known keyword in a text adds 1.0 to its assigned dim. + Synonyms that share a dim let the test model "semantic similarity without + textual overlap" (e.g. `striped horse` shares a dim with `zebra`, so a doc + that says `striped horse` is semantically close to the query `zebra` even + though FTS5 will not match the literal token). + """ + + def __init__(self, keyword_to_dim: dict[str, int], dims: int = 384): + self.keyword_to_dim = {k.lower(): v for k, v in keyword_to_dim.items()} + self.dims = dims + + def encode(self, texts, convert_to_numpy=True): + out = np.zeros((len(texts), self.dims), dtype=np.float32) + for i, text in enumerate(texts): + lower = text.lower() + for kw, dim in self.keyword_to_dim.items(): + if kw in lower: + out[i, dim] += 1.0 + return out + + +def _doc_id(rel_path: str) -> str: + return parser.compute_doc_id(rel_path) + + +# --- Unit tests: sanitize_fts5_query ----------------------------------------- # + + +class TestSanitizeFTS5Query: + def test_plain_words_or_joined(self): + assert sanitize_fts5_query("cat behavior") == "cat OR behavior" + + def test_strips_uppercase_operator_keywords(self): + assert sanitize_fts5_query("cat AND dog") == "cat OR dog" + assert sanitize_fts5_query("cat OR dog") == "cat OR dog" + assert sanitize_fts5_query("cat NOT dog") == "cat OR dog" + assert sanitize_fts5_query("cat NEAR dog") == "cat OR dog" + + def test_strips_operator_chars(self): + assert sanitize_fts5_query('"cat" *behavior*') == "cat OR behavior" + assert sanitize_fts5_query("foo:bar (baz)") == "foo OR bar OR baz" + assert sanitize_fts5_query("hat^2 -dog") == "hat OR 2 OR dog" + + def test_operator_only_input_returns_empty(self): + assert sanitize_fts5_query("AND OR NOT NEAR") == "" + assert sanitize_fts5_query('""()*:^-') == "" + + def test_empty_input(self): + assert sanitize_fts5_query("") == "" + assert sanitize_fts5_query(" ") == "" + + def test_lowercase_operators_kept_as_tokens(self): + # FTS5 only treats uppercase as operators. + assert sanitize_fts5_query("cat and dog") == "cat OR and OR dog" + + def test_apostrophes_and_question_marks_do_not_survive(self): + # The headline defect: these used to reach the MATCH parser and crash. + assert ( + sanitize_fts5_query("what's blocking the pipeline?") + == "what OR s OR blocking OR the OR pipeline" + ) + + def test_strips_arbitrary_punctuation(self): + # Allowlist: only \w+ runs survive, every other byte is a boundary. + assert ( + sanitize_fts5_query("node.js, react & vue! a/b=c") + == "node OR js OR react OR vue OR tag OR a OR b OR c" + ) + + def test_punctuation_only_input_returns_empty(self): + assert sanitize_fts5_query("?!.,;:/=%[]<>|~@#$&") == "" + + def test_unicode_tokens_preserved(self): + # \w is unicode-aware, and non-ASCII codepoints are valid FTS5 barewords. + assert sanitize_fts5_query("café señor") == "café OR señor" + + +# --- Unit tests: rrf_fuse ---------------------------------------------------- # + + +class TestRRFFuse: + def test_empty_inputs(self): + assert rrf_fuse([], []) == [] + + def test_lexical_only_doc_has_no_semantic_rank(self): + out = rrf_fuse([("a", 1)], []) + assert len(out) == 1 + doc_id, chunk_index, score, lex_rank, sem_rank, sem_distance = out[0] + assert doc_id == "a" + assert chunk_index is None + assert lex_rank == 1 + assert sem_rank is None + assert sem_distance is None + assert score == pytest.approx(1 / (RRF_K + 1)) + + def test_semantic_only_doc_has_no_lexical_rank(self): + out = rrf_fuse([], [("a", 3, 1, 0.42)]) + assert len(out) == 1 + doc_id, chunk_index, score, lex_rank, sem_rank, sem_distance = out[0] + assert doc_id == "a" + assert chunk_index == 3 + assert lex_rank is None + assert sem_rank == 1 + assert sem_distance == 0.42 + + def test_fused_doc_outranks_solo_doc(self): + # doc 'a' is in both lists; doc 'b' is only in lexical at the same rank. + out = rrf_fuse([("b", 1), ("a", 2)], [("a", 0, 1, 0.42)]) + # 'a' = 1/62 + 1/61 ; 'b' = 1/61. 'a' must be ahead. + assert out[0][0] == "a" + assert out[1][0] == "b" + + def test_tie_break_by_doc_id_ascending(self): + # Both docs tied at lex rank 1, no semantic. RRF scores equal, doc_id wins. + out = rrf_fuse([("zeta", 1)], [("alpha", 0, 1, 0.42)]) + # zeta has lex=1 only, alpha has sem=1 only. Same RRF = 1/61. + # Tie broken by doc_id ascending: alpha < zeta. + assert out[0][0] == "alpha" + assert out[1][0] == "zeta" + + def test_top_k_limit(self): + lex = [(f"d{i:02d}", i + 1) for i in range(20)] + out = rrf_fuse(lex, [], top_k=5) + assert len(out) == 5 + + def test_zero_top_k_returns_empty(self): + assert rrf_fuse([("a", 1)], [], top_k=0) == [] + + def test_score_rounding_happens_at_caller_not_fuse(self): + # rrf_fuse should return raw float; caller may round. + out = rrf_fuse([("a", 1)], []) + # 1 / 61 is not a terminating decimal. + assert out[0][2] != round(out[0][2], 2) or out[0][2] == round(out[0][2], 2) + + @pytest.mark.parametrize("top_k", [-1, MAX_QUERY_TOP_K + 1, True]) + def test_invalid_top_k_is_rejected_instead_of_becoming_unbounded(self, top_k): + with pytest.raises(QueryError, match="final_top_k"): + rrf_fuse([("a", 1)], [], top_k=top_k) + + +# --- Unit tests: weak-fit heuristic ------------------------------------------ # + + +class TestWeakFit: + def test_semantic_only_above_threshold_is_weak(self): + assert _is_weak_fit(None, 1, WEAK_FIT_DISTANCE_THRESHOLD + 0.1) is True + + def test_semantic_only_at_or_below_threshold_is_not_weak(self): + # Strict > threshold: a distance exactly at the cutoff is not weak. + assert _is_weak_fit(None, 1, WEAK_FIT_DISTANCE_THRESHOLD) is False + assert _is_weak_fit(None, 1, WEAK_FIT_DISTANCE_THRESHOLD - 0.1) is False + + def test_fused_result_never_weak(self): + # Lexical corroboration present -> not weak even at a large distance. + assert _is_weak_fit(2, 1, WEAK_FIT_DISTANCE_THRESHOLD + 5) is False + + def test_lexical_only_never_weak(self): + assert _is_weak_fit(1, None, None) is False + + def test_missing_distance_is_not_weak(self): + assert _is_weak_fit(None, 1, None) is False + + +# --- Unit tests: query-scope warning ---------------------------------------- # + + +class TestQueryScopeWarning: + def test_flags_inbox_routing_queries(self): + warning = classify_query_scope("latest inbox captures waiting for routing") + assert warning is not None + assert warning.intent == "inbox_state" + assert warning.recommended_trust_profile == "inbox_or_ingest_queue" + + def test_flags_current_live_state_queries(self): + warning = classify_query_scope("current job hunt status this week") + assert warning is not None + assert warning.intent == "live_state" + assert warning.recommended_trust_profile == "time_bound_live_state" + + def test_flags_project_status_queries(self): + warning = classify_query_scope("agent harness next gate project status") + assert warning is not None + assert warning.intent == "project_status" + assert warning.recommended_trust_profile == "project_status" + + def test_ordinary_durable_knowledge_query_has_no_warning(self): + assert classify_query_scope("agent memory remember cite forget") is None + + +class TestScopeVocabulary: + """The warning terms are configurable; the defaults must not shift.""" + + def test_default_patterns_are_unchanged(self): + expected = ( + r"\b(inbox|captures?|routing queue|ready queue|waiting for routing" + r"|00_inbox|01_ingest)\b", + r"\b(30_projects|project status|active project|project_state" + r"|next_action|next action|project readme|state\.md|handoff|next gate)\b", + r"\b(current|latest|today|this week|this month|right now|now|recent" + r"|live|as of)\b", + r"\b(job hunt|finance|calendar|workflow metrics|telemetry|live state" + r"|status|blocked?|blockers?|remaining|next)\b", + ) + actual = tuple(p.pattern for p in DEFAULT_SCOPE_VOCABULARY._patterns) + assert actual == expected + + def test_custom_terms_replace_defaults_for_that_branch(self): + vocab = ScopeVocabulary(inbox_terms=("unfiled", "to sort")) + warning = classify_query_scope("some unfiled notes", vocab) + assert warning is not None and warning.intent == "inbox_state" + assert classify_query_scope("inbox captures", vocab) is None + + def test_unspecified_branches_keep_defaults(self): + vocab = ScopeVocabulary(inbox_terms=("unfiled",)) + assert vocab.freshness_terms == DEFAULT_SCOPE_VOCABULARY.freshness_terms + assert vocab.project_terms == DEFAULT_SCOPE_VOCABULARY.project_terms + + def test_empty_term_list_disables_that_branch(self): + vocab = ScopeVocabulary(inbox_terms=()) + assert classify_query_scope("inbox captures waiting for routing", vocab) is None + + def test_live_state_needs_both_freshness_and_state_terms(self): + vocab = ScopeVocabulary(live_state_terms=("incident",)) + assert classify_query_scope("latest incident", vocab).intent == "live_state" + assert classify_query_scope("incident", vocab) is None + + def test_from_mapping_partial_override(self): + vocab = ScopeVocabulary.from_mapping({"project_terms": ["standup"]}) + assert vocab.project_terms == ("standup",) + assert vocab.inbox_terms == DEFAULT_SCOPE_VOCABULARY.inbox_terms + + def test_from_mapping_rejects_unknown_key(self): + with pytest.raises(QueryError, match="unknown scope vocabulary key"): + ScopeVocabulary.from_mapping({"bogus_terms": ["x"]}) + + def test_from_mapping_rejects_non_list_value(self): + with pytest.raises(QueryError, match="must be a list of strings"): + ScopeVocabulary.from_mapping({"inbox_terms": "notalist"}) + + def test_load_from_json_file(self, tmp_path): + path = tmp_path / "vocab.json" + path.write_text(jsonlib.dumps({"inbox_terms": ["unfiled"]})) + vocab = load_scope_vocabulary(path) + assert vocab.inbox_terms == ("unfiled",) + + def test_load_missing_file_raises(self, tmp_path): + with pytest.raises(QueryError, match="not found"): + load_scope_vocabulary(tmp_path / "absent.json") + + def test_load_invalid_json_raises(self, tmp_path): + path = tmp_path / "vocab.json" + path.write_text("{not json") + with pytest.raises(QueryError, match="not valid JSON"): + load_scope_vocabulary(path) + + def test_load_non_object_raises(self, tmp_path): + path = tmp_path / "vocab.json" + path.write_text("[1, 2]") + with pytest.raises(QueryError, match="must contain a JSON object"): + load_scope_vocabulary(path) + + def test_env_var_overrides_active_vocabulary(self, tmp_path, monkeypatch): + path = tmp_path / "vocab.json" + path.write_text(jsonlib.dumps({"inbox_terms": ["unfiled"]})) + monkeypatch.setenv(SCOPE_VOCABULARY_ENV, str(path)) + _vocabulary_from_env.cache_clear() + try: + assert active_scope_vocabulary().inbox_terms == ("unfiled",) + assert classify_query_scope("some unfiled notes").intent == "inbox_state" + finally: + _vocabulary_from_env.cache_clear() + + def test_no_env_var_uses_defaults(self, monkeypatch): + monkeypatch.delenv(SCOPE_VOCABULARY_ENV, raising=False) + _vocabulary_from_env.cache_clear() + try: + assert active_scope_vocabulary() == DEFAULT_SCOPE_VOCABULARY + finally: + _vocabulary_from_env.cache_clear() + + +# --- Integration tests: against a small ingested vault ----------------------- # + + +@pytest.fixture +def keyword_embedder(): + # Dim 0: zebra-family. Dim 1: compiler-family. Dim 2: elephant-family. + return KeywordEmbedder( + { + "zebra": 0, + "zebras": 0, + "striped horse": 0, + "savannah": 0, + "compiler": 1, + "programming": 1, + "elephant": 2, + "trunk": 2, + } + ) + + +@pytest.fixture +def vault_db(tmp_path, monkeypatch, keyword_embedder): + """Ingest a small vault designed to exercise each retrieval signal.""" + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: keyword_embedder) + + notes = tmp_path / "vault" + notes.mkdir() + # Lexical-only winner: contains the literal token `zebra`. + (notes / "lex.md").write_text( + "The zebra is the subject of this note. Single mention.\n" + ) + # Semantic-only winner: no literal `zebra` but a synonym `striped horse`. + (notes / "sem.md").write_text( + "About striped horses that live on the savannah grass.\n" + ) + # Fused: contains the literal `zebra` plus extra synonyms. + (notes / "fused.md").write_text( + "The zebra roams the savannah. Striped horse with hooves.\n" + ) + # Unrelated baseline. + (notes / "unrelated.md").write_text( + "About compilers and programming languages.\n" + ) + + db_path = str(tmp_path / "test.sqlite") + db.init_db(db_path).close() + cli._ingest_directory(notes, db_path) + return db_path + + +class TestFetchLexicalRanking: + def test_returns_only_docs_with_query_token(self, vault_db): + conn = db.get_db(vault_db) + try: + ranking = fetch_lexical_ranking(conn, "zebra", top_k=10) + finally: + conn.close() + doc_ids = [doc_id for doc_id, _ in ranking] + assert _doc_id("lex.md") in doc_ids + assert _doc_id("fused.md") in doc_ids + assert _doc_id("sem.md") not in doc_ids + assert _doc_id("unrelated.md") not in doc_ids + + def test_empty_query_returns_empty_list(self, vault_db): + conn = db.get_db(vault_db) + try: + assert fetch_lexical_ranking(conn, "") == [] + assert fetch_lexical_ranking(conn, "AND OR NOT") == [] + finally: + conn.close() + + def test_punctuation_query_does_not_crash_and_still_matches(self, vault_db): + # Regression for the FTS5 syntax-error defect: a natural-language query + # with an apostrophe and question mark must run, and the surviving + # `zebra` token must still retrieve the zebra docs. + conn = db.get_db(vault_db) + try: + ranking = fetch_lexical_ranking(conn, "what's the zebra?", top_k=10) + finally: + conn.close() + doc_ids = [doc_id for doc_id, _ in ranking] + assert _doc_id("lex.md") in doc_ids + assert _doc_id("fused.md") in doc_ids + + def test_top_k_limits_result_count(self, vault_db): + conn = db.get_db(vault_db) + try: + ranking = fetch_lexical_ranking(conn, "zebra striped", top_k=1) + finally: + conn.close() + assert len(ranking) <= 1 + + def test_ranks_are_one_based_and_strictly_increasing(self, vault_db): + conn = db.get_db(vault_db) + try: + ranking = fetch_lexical_ranking(conn, "zebra", top_k=10) + finally: + conn.close() + ranks = [r for _, r in ranking] + assert ranks + assert ranks[0] == 1 + assert all(b > a for a, b in zip(ranks, ranks[1:])) + + +class TestFetchSemanticRanking: + def test_returns_docs_via_synonym_match(self, vault_db, keyword_embedder): + emb = keyword_embedder.encode(["zebra"])[0].tolist() + conn = db.get_db(vault_db) + try: + ranking = fetch_semantic_ranking(conn, emb, top_k=20) + finally: + conn.close() + doc_ids = [doc_id for doc_id, _, _, _ in ranking] + # sem.md has no `zebra` but is reachable via the synonym embedding. + assert _doc_id("sem.md") in doc_ids + assert _doc_id("lex.md") in doc_ids + assert _doc_id("fused.md") in doc_ids + + def test_at_most_one_chunk_per_document(self, vault_db, keyword_embedder): + emb = keyword_embedder.encode(["zebra"])[0].tolist() + conn = db.get_db(vault_db) + try: + ranking = fetch_semantic_ranking(conn, emb, top_k=50) + finally: + conn.close() + doc_ids = [doc_id for doc_id, _, _, _ in ranking] + assert len(doc_ids) == len(set(doc_ids)) + + def test_zero_top_k_returns_empty(self, vault_db, keyword_embedder): + emb = keyword_embedder.encode(["zebra"])[0].tolist() + conn = db.get_db(vault_db) + try: + assert fetch_semantic_ranking(conn, emb, top_k=0) == [] + finally: + conn.close() + + +class TestRunQuery: + def test_returns_query_result_objects(self, vault_db, keyword_embedder): + conn = db.get_db(vault_db) + try: + results = run_query(conn, "zebra", keyword_embedder) + finally: + conn.close() + assert results + assert all(isinstance(r, QueryResult) for r in results) + assert all(r.signal in ("lexical", "semantic", "fused") for r in results) + + def test_signal_attribution(self, vault_db, keyword_embedder): + conn = db.get_db(vault_db) + try: + results = run_query(conn, "zebra", keyword_embedder, final_top_k=10) + finally: + conn.close() + by_path = {r.path: r for r in results} + # sem.md has no `zebra` but a synonym semantic match. + assert by_path["sem.md"].signal == "semantic" + assert by_path["sem.md"].lexical_rank is None + assert by_path["sem.md"].semantic_rank is not None + # lex.md has both the lexical match and the synonym semantic match. + assert by_path["lex.md"].signal == "fused" + # fused.md is in both rankings. + assert by_path["fused.md"].signal == "fused" + + def test_provenance_fields_populated(self, vault_db, keyword_embedder): + conn = db.get_db(vault_db) + try: + results = run_query(conn, "zebra", keyword_embedder, final_top_k=3) + finally: + conn.close() + assert results + for r in results: + assert r.doc_id + assert r.path + assert r.title + assert r.chunk_text + assert r.chunk_index >= 0 + assert r.rrf_score > 0 + + def test_scoped_ingest_provenance_fields_populated( + self, tmp_path, monkeypatch, keyword_embedder + ): + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: keyword_embedder) + notes = tmp_path / "knowledge" + notes.mkdir() + (notes / "scoped.md").write_text("The zebra appears in durable knowledge.\n") + db_path = str(tmp_path / "scoped.sqlite") + cli._ingest_directory( + notes, + db_path, + index_id="mainframe-knowledge", + trust_profile="durable_knowledge", + namespace="knowledge", + source_root=notes, + display_prefix="10_knowledge", + ) + + conn = db.get_db(db_path) + try: + results = run_query(conn, "zebra", keyword_embedder, final_top_k=1) + finally: + conn.close() + + assert len(results) == 1 + row = results[0] + assert row.path == "10_knowledge/scoped.md" + assert row.display_path == "10_knowledge/scoped.md" + assert row.index_id == "mainframe-knowledge" + assert row.trust_profile == "durable_knowledge" + assert row.namespace == "knowledge" + assert row.source_root == str(notes) + assert row.source_path == "scoped.md" + + def test_final_top_k_limits_output(self, vault_db, keyword_embedder): + conn = db.get_db(vault_db) + try: + results = run_query(conn, "zebra", keyword_embedder, final_top_k=2) + finally: + conn.close() + assert len(results) <= 2 + + def test_operator_only_query_yields_only_semantic_signal( + self, vault_db, keyword_embedder + ): + conn = db.get_db(vault_db) + try: + results = run_query(conn, "AND OR NOT", keyword_embedder) + finally: + conn.close() + # Lexical ranking is empty (sanitized to empty MATCH), so every result + # must carry the `semantic` signal. + for r in results: + assert r.signal == "semantic" + assert r.lexical_rank is None + + def test_degrades_to_semantic_when_lexical_ranking_errors( + self, vault_db, keyword_embedder, monkeypatch + ): + # If FTS5 raises (e.g. a malformed index), run_query must not fail the + # whole call — it degrades to semantic-only with the lexical side empty. + from mindgraph import query as query_mod + + def boom(*args, **kwargs): + raise query_mod.QueryError("FTS5 query failed: simulated") + + monkeypatch.setattr(query_mod, "fetch_lexical_ranking", boom) + conn = db.get_db(vault_db) + try: + results = run_query(conn, "zebra", keyword_embedder) + finally: + conn.close() + assert results # semantic side still produced candidates + for r in results: + assert r.lexical_rank is None + assert r.signal == "semantic" + + def test_rrf_score_rounded_to_six_decimals(self, vault_db, keyword_embedder): + conn = db.get_db(vault_db) + try: + results = run_query(conn, "zebra", keyword_embedder, final_top_k=1) + finally: + conn.close() + assert results + assert results[0].rrf_score == round(results[0].rrf_score, 6) + + def test_results_carry_distance_and_weak_fit_fields( + self, vault_db, keyword_embedder + ): + conn = db.get_db(vault_db) + try: + results = run_query(conn, "zebra", keyword_embedder, final_top_k=10) + finally: + conn.close() + by_path = {r.path: r for r in results} + # A fused result has lexical corroboration, so it is never weak_fit, + # whatever its semantic distance. + assert by_path["fused.md"].signal == "fused" + assert by_path["fused.md"].weak_fit is False + # Raw distance is exposed: a float when there is a semantic rank, else None. + for r in results: + assert isinstance(r.weak_fit, bool) + if r.semantic_rank is not None: + assert isinstance(r.semantic_distance, float) + else: + assert r.semantic_distance is None + + def test_scope_warning_survives_fused_result( + self, vault_db, keyword_embedder + ): + conn = db.get_db(vault_db) + try: + results = run_query( + conn, + "current zebra status this week", + keyword_embedder, + final_top_k=10, + ) + finally: + conn.close() + + by_path = {r.path: r for r in results} + assert by_path["fused.md"].signal == "fused" + assert by_path["fused.md"].weak_fit is False + assert by_path["fused.md"].query_scope_warning is not None + assert by_path["fused.md"].query_scope_warning.intent == "live_state" + for row in results: + assert row.query_scope_warning is not None + assert row.query_scope_warning.intent == "live_state" + + +class TestQueryResultMetadata: + def test_emits_frontmatter_type_domain_status( + self, tmp_path, monkeypatch, keyword_embedder + ): + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: keyword_embedder) + notes = tmp_path / "vault" + notes.mkdir() + (notes / "typed.md").write_text( + "---\ntitle: Typed Note\ntype: note\ndomain: agents\nstatus: stable\n---\n" + "The zebra roams the savannah.\n" + ) + (notes / "bare.md").write_text( + "The zebra appears here with no frontmatter at all.\n" + ) + db_path = str(tmp_path / "meta.sqlite") + db.init_db(db_path).close() + cli._ingest_directory(notes, db_path) + + conn = db.get_db(db_path) + try: + results = run_query(conn, "zebra", keyword_embedder, final_top_k=10) + finally: + conn.close() + + by_path = {r.path: r for r in results} + assert by_path["typed.md"].doc_type == "note" + assert by_path["typed.md"].domain == "agents" + assert by_path["typed.md"].status == "stable" + # A source with no frontmatter carries nulls, not empty strings. + assert by_path["bare.md"].doc_type is None + assert by_path["bare.md"].domain is None + assert by_path["bare.md"].status is None + + +class TestQueryCLI: + def test_query_command_runs_text_output( + self, vault_db, keyword_embedder, monkeypatch + ): + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: keyword_embedder) + runner = CliRunner() + result = runner.invoke( + cli.app, ["query", "zebra", "--db", vault_db, "--top-k", "3"] + ) + assert result.exit_code == 0 + assert "signal=" in result.stdout + assert "rrf_score=" in result.stdout + + def test_query_command_reads_wal_db_from_nonwritable_directory( + self, vault_db, keyword_embedder, monkeypatch + ): + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: keyword_embedder) + db_path = Path(vault_db) + parent = db_path.parent + before = db_path.read_bytes() + os.chmod(db_path, 0o444) + os.chmod(parent, 0o555) + try: + result = CliRunner().invoke( + cli.app, ["query", "zebra", "--db", vault_db, "--json"] + ) + assert result.exit_code == 0, result.output + assert jsonlib.loads(result.stdout) + assert db_path.read_bytes() == before + finally: + os.chmod(parent, 0o755) + os.chmod(db_path, 0o644) + + def test_query_command_reports_embedder_load_failure_cleanly( + self, tmp_path, monkeypatch, caplog + ): + db_path = tmp_path / "query.sqlite" + db.init_db(str(db_path)).close() + + def fail_load(_spec): + raise RuntimeError("offline fixture") + + monkeypatch.setattr(cli.embedders, "load_sentence_embedder", fail_load) + with caplog.at_level(logging.ERROR, logger="mindgraph"): + result = CliRunner().invoke( + cli.app, + ["query", "hello", "--db", str(db_path), "--no-intent"], + ) + assert result.exit_code == 1 + assert not isinstance(result.exception, RuntimeError) + assert "Failed to load embedding model" in caplog.text + + def test_query_command_json_output( + self, vault_db, keyword_embedder, monkeypatch + ): + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: keyword_embedder) + runner = CliRunner() + result = runner.invoke( + cli.app, + ["query", "zebra", "--db", vault_db, "--json", "--top-k", "3"], + ) + assert result.exit_code == 0 + data = jsonlib.loads(result.stdout) + assert isinstance(data, list) + assert len(data) <= 3 + if data: + row = data[0] + for field in ( + "doc_id", + "chunk_index", + "path", + "title", + "doc_type", + "domain", + "status", + "index_id", + "trust_profile", + "namespace", + "source_path", + "display_path", + "signal", + "rrf_score", + "lexical_rank", + "semantic_rank", + "semantic_distance", + "weak_fit", + "query_scope_warning", + "chunk_text", + ): + assert field in row + # `source_root` is the ingest root on the indexing machine. It used + # to ship in every serialized row, which handed an absolute host + # path to any CLI or MCP caller. `path` already locates the + # document relatively, so the root is redundant downstream. + assert "source_root" not in row + + def test_serialized_results_carry_no_absolute_host_paths( + self, vault_db, keyword_embedder, monkeypatch + ): + """No serialized query field may be an absolute filesystem path. + + Written as a property over every field rather than a check on + `source_root` by name, so a new path-bearing field fails here instead + of leaking to callers. + """ + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: keyword_embedder) + result = CliRunner().invoke( + cli.app, + ["query", "zebra", "--db", vault_db, "--json", "--top-k", "3"], + ) + assert result.exit_code == 0 + rows = jsonlib.loads(result.stdout) + assert rows, "fixture must return at least one row for this to bite" + offenders = [ + (index, field, value) + for index, row in enumerate(rows) + for field, value in row.items() + if isinstance(value, str) and value.startswith("/") + ] + assert not offenders, f"absolute paths in serialized results: {offenders}" + + def test_query_command_json_envelope_output( + self, tmp_path, vault_db, keyword_embedder, monkeypatch + ): + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: keyword_embedder) + fixture = Path(__file__).parent / "fixtures" / "intent_graph_cases.yaml" + catalog = yaml.safe_load(fixture.read_text(encoding="utf-8")) + source = tmp_path / "intent-source" + source.mkdir() + (source / "mainframe-core.yaml").write_text( + yaml.safe_dump(catalog["documents"]["phase1"], sort_keys=False), + encoding="utf-8", + ) + intent_db = tmp_path / "intent.sqlite" + compile_intent_corpus(source, intent_db) + + runner = CliRunner() + result = runner.invoke( + cli.app, + [ + "query", + "Run the route contract checks.", + "--db", + vault_db, + "--json", + "--envelope", + "--intent-db", + str(intent_db), + "--top-k", + "3", + ], + ) + + assert result.exit_code == 0 + data = jsonlib.loads(result.stdout) + assert set(data) == { + "schema_version", + "intent_resolution", + "routing", + "results", + "not_citable", + "citation_counts", + } + assert data["schema_version"] == "1" + assert data["intent_resolution"]["graph_id"] == "mainframe.core" + assert data["intent_resolution"]["graph_version"] == "2026-06-29.1" + assert data["intent_resolution"]["outcome"] == "resolved" + assert data["intent_resolution"]["method"] == "alias" + assert data["intent_resolution"]["matched_goals"] == [ + "goal.route-contract-evaluation" + ] + assert data["routing"]["mode"] == "single_database" + assert data["routing"]["selected_retrievers"] == ["cli-bound-db"] + assert data["routing"]["reason_codes"] == ["intent_resolved"] + assert isinstance(data["results"], list) + + def test_query_command_text_output_prints_scope_warning( + self, vault_db, keyword_embedder, monkeypatch + ): + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: keyword_embedder) + runner = CliRunner() + result = runner.invoke( + cli.app, + [ + "query", + "current zebra status this week", + "--db", + vault_db, + "--top-k", + "3", + ], + ) + assert result.exit_code == 0 + assert "scope warning:" in result.stdout + assert "recommended_trust_profile=time_bound_live_state" in result.stdout + + def test_query_command_json_output_emits_scope_warning( + self, vault_db, keyword_embedder, monkeypatch + ): + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: keyword_embedder) + runner = CliRunner() + result = runner.invoke( + cli.app, + [ + "query", + "latest inbox captures waiting for routing", + "--db", + vault_db, + "--json", + "--top-k", + "3", + ], + ) + assert result.exit_code == 0 + data = jsonlib.loads(result.stdout) + assert data + assert data[0]["query_scope_warning"]["intent"] == "inbox_state" + + +@pytest.mark.parametrize( + ("kwargs", "field"), + [ + ({"lexical_top_k": -1}, "lexical_top_k"), + ({"semantic_top_k": MAX_QUERY_TOP_K + 1}, "semantic_top_k"), + ({"expand": True, "expand_depth": 4}, "expand_depth"), + ({"expand": True, "expand_top_k": -1}, "expand_top_k"), + ({"associate": True, "associate_seed_k": -1}, "associate_seed_k"), + ], +) +def test_run_query_rejects_invalid_resource_limits_before_work( + vault_db, keyword_embedder, kwargs, field +): + conn = db.get_db(vault_db) + try: + with pytest.raises(QueryError, match=field): + run_query(conn, "zebra", keyword_embedder, **kwargs) + finally: + conn.close() diff --git a/mindgraph/tests/test_routing.py b/mindgraph/tests/test_routing.py new file mode 100644 index 0000000..ce09c8b --- /dev/null +++ b/mindgraph/tests/test_routing.py @@ -0,0 +1,684 @@ +from __future__ import annotations + +import hashlib +import json +import sqlite3 +from dataclasses import FrozenInstanceError +from pathlib import Path + +import numpy as np +import pytest +import yaml +from pydantic import ValidationError + +from mindgraph import cli, db +from mindgraph.intent import ( + IntentResolution, + compile_intent_corpus, + open_intent_store, +) +from mindgraph.models import QueryResult +from mindgraph import query as query_mod +from mindgraph.routing import ( + CapabilityRegistry, + MindGraphQueryRetriever, + RefusalRule, + RetrieverDescriptor, + RetrieverRegistration, + RouteRequest, + RouterPolicy, + RoutingError, + decide_route, + open_query_store_read_only, + orchestrate, +) + + +ROUTING_FIXTURE = Path(__file__).parent / "fixtures" / "phase3_routing_cases.yaml" +INTENT_FIXTURE = Path(__file__).parent / "fixtures" / "intent_graph_cases.yaml" + + +def load_routing_fixture() -> dict: + return yaml.safe_load(ROUTING_FIXTURE.read_text(encoding="utf-8")) + + +def make_result( + retriever_id: str, + *, + trust_profile: str, + score: float, + path: str | None = None, +) -> QueryResult: + display_path = path or f"{retriever_id}/result.md" + return QueryResult( + doc_id=f"{retriever_id}-doc", + chunk_index=0, + path=display_path, + title=f"{retriever_id} result", + index_id=retriever_id, + trust_profile=trust_profile, + namespace=retriever_id, + source_root=f"/fixture/{retriever_id}", + source_path="result.md", + display_path=display_path, + signal="fused", + rrf_score=score, + lexical_rank=1, + semantic_rank=1, + semantic_distance=0.25, + chunk_text=f"fixture row from {retriever_id}", + ) + + +class StubRetriever: + def __init__(self, results: list[QueryResult] | None = None) -> None: + self.results = results or [] + self.calls: list[tuple[str, int]] = [] + + def retrieve(self, query_text: str, *, final_top_k: int) -> list[QueryResult]: + self.calls.append((query_text, final_top_k)) + return list(self.results[:final_top_k]) + + +class FailingRetriever: + def __init__(self, message: str) -> None: + self.message = message + self.calls = 0 + + def retrieve(self, query_text: str, *, final_top_k: int) -> list[QueryResult]: + self.calls += 1 + raise RuntimeError(self.message) + + +def descriptor( + retriever_id: str, + capability_ref: str, + trust_profile: str, + *, + available: bool = True, +) -> RetrieverDescriptor: + return RetrieverDescriptor( + retriever_id=retriever_id, + capability_ref=capability_ref, + trust_profile=trust_profile, + source_surface=f"fixture://{retriever_id}", + available=available, + unavailable_reason=None if available else "fixture_unavailable", + ) + + +def make_registry( + *, + durable: object | None = None, + projects: object | None = None, + extras: tuple[RetrieverRegistration, ...] = (), +) -> CapabilityRegistry: + durable_executor = durable if durable is not None else StubRetriever() + project_executor = projects if projects is not None else StubRetriever() + return CapabilityRegistry( + ( + RetrieverRegistration( + descriptor( + "mindgraph-projects", + "retriever://mindgraph-projects", + "project_status", + ), + project_executor, + ), + RetrieverRegistration( + descriptor( + "mindgraph-durable", + "retriever://mindgraph-durable", + "durable_knowledge", + ), + durable_executor, + ), + *extras, + ) + ) + + +@pytest.fixture +def policy() -> RouterPolicy: + return RouterPolicy.model_validate(load_routing_fixture()["policy"]) + + +def unavailable_resolution(reason: str = "intent_unavailable") -> IntentResolution: + return IntentResolution( + graph_id="unavailable", + graph_version="unavailable", + source_hash="unavailable", + outcome="fallback", + resolution_method="none", + refusal_reason=reason, + ) + + +def resolved( + *, + hints: tuple[str, ...] = (), + rejected: tuple[str, ...] = (), + warnings: tuple[str, ...] = (), +) -> IntentResolution: + return IntentResolution( + graph_id="mainframe.core", + graph_version="2026-06-30.1", + source_hash="fixture-hash", + outcome="resolved", + resolution_method="explicit", + matched_goal_ids=("goal.fixture",), + intent_path=("goal.fixture",), + capability_hints=hints, + rejected_capability_hints=rejected, + warnings=warnings, + ) + + +@pytest.fixture +def intent_conn(tmp_path: Path): + catalog = yaml.safe_load(INTENT_FIXTURE.read_text(encoding="utf-8")) + source = tmp_path / "intent-source" + source.mkdir() + (source / "mainframe-core.yaml").write_text( + yaml.safe_dump(catalog["documents"]["phase1"], sort_keys=False), + encoding="utf-8", + ) + destination = tmp_path / "intent.sqlite" + compile_intent_corpus(source, destination) + conn = open_intent_store(destination) + try: + yield conn + finally: + conn.close() + + +class TinyEmbedder: + def encode(self, texts, convert_to_numpy=True, show_progress_bar=False): + output = np.zeros((len(texts), 384), dtype=np.float32) + for index, text in enumerate(texts): + lowered = text.lower() + if "zebra" in lowered or "striped horse" in lowered: + output[index, 0] = 1.0 + if "compiler" in lowered: + output[index, 1] = 1.0 + return output + + +@pytest.fixture +def indexed_db(tmp_path: Path, monkeypatch): + embedder = TinyEmbedder() + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: embedder) + notes = tmp_path / "vault" + notes.mkdir() + (notes / "zebra.md").write_text( + "A zebra is a striped horse on the savannah.\n", encoding="utf-8" + ) + (notes / "compiler.md").write_text( + "A compiler translates programming languages.\n", encoding="utf-8" + ) + destination = tmp_path / "documents.sqlite" + stats = cli._ingest_directory( + notes, + str(destination), + index_id="fixture-durable", + trust_profile="durable_knowledge", + namespace="fixture", + source_root=notes, + display_prefix="10_knowledge/fixture", + ) + assert stats["failed"] == 0 + return destination, embedder + + +def test_wire_contracts_are_strict_and_duplicate_capabilities_fail(): + with pytest.raises(ValidationError): + RouteRequest(query_id="route.strict", query_text="test", surprise=True) + with pytest.raises(ValidationError): + RouteRequest( + query_id="route.duplicate", + query_text="test", + allowed_capability_refs=( + "retriever://mindgraph-durable", + "retriever://mindgraph-durable", + ), + ) + with pytest.raises(ValidationError): + RefusalRule(rule_id="privacy.empty", reason_code="privacy_refusal") + + privacy = RefusalRule( + rule_id="privacy.raw-transcript", + reason_code="privacy_refusal", + phrases=("raw transcript",), + ) + assert privacy.matches("Find the raw transcript, please.") is True + assert privacy.matches("Find the straw transcript, please.") is False + + +def test_registry_rejects_duplicate_ids_capabilities_and_executor_mismatch(): + first = RetrieverRegistration( + descriptor("one", "retriever://one", "durable_knowledge"), StubRetriever() + ) + duplicate_id = RetrieverRegistration( + descriptor("one", "retriever://two", "project_status"), StubRetriever() + ) + with pytest.raises(RoutingError, match="duplicate retriever ID"): + CapabilityRegistry((first, duplicate_id)) + + duplicate_capability = RetrieverRegistration( + descriptor("two", "retriever://one", "project_status"), StubRetriever() + ) + with pytest.raises(RoutingError, match="duplicate capability ref"): + CapabilityRegistry((first, duplicate_capability)) + + with pytest.raises(RoutingError, match="has no executor"): + RetrieverRegistration( + descriptor("missing", "retriever://missing", "project_status") + ) + with pytest.raises(RoutingError, match="has an executor"): + RetrieverRegistration( + descriptor( + "future", "retriever://future", "future_trust", available=False + ), + StubRetriever(), + ) + + registry = CapabilityRegistry((first,)) + with pytest.raises(FrozenInstanceError): + registry._registrations = () + with pytest.raises(TypeError): + registry._by_id["other"] = first + + +@pytest.mark.parametrize( + "case_id", + ["explicit_durable", "explicit_projects", "explicit_federated"], +) +def test_explicit_routes_follow_reviewed_fixture(case_id: str, policy: RouterPolicy): + case = load_routing_fixture()["requests"][case_id] + decision = decide_route( + RouteRequest.model_validate(case["request"]), + unavailable_resolution(), + make_registry(), + policy, + ) + assert decision.outcome == case["expected_outcome"] + assert list(decision.selected_retriever_ids) == case["expected_retrievers"] + assert decision.reason_codes == (case["expected_reason"],) + + +def test_explicit_federation_requires_policy_permission(policy: RouterPolicy): + request = RouteRequest( + query_id="route.federation-denied", + query_text="Use both stores", + mode="federated", + ) + decision = decide_route( + request, + unavailable_resolution(), + make_registry(), + policy.model_copy(update={"allow_federated": False}), + ) + assert decision.outcome == "refusal" + assert decision.selected_retriever_ids == () + assert decision.reason_codes == ("federation_not_allowed",) + + +def test_automatic_intent_hint_selects_only_allowed_project_store( + policy: RouterPolicy, intent_conn +): + case = load_routing_fixture()["requests"]["automatic_project_hint"] + project = StubRetriever( + [ + make_result( + "mindgraph-projects", trust_profile="project_status", score=0.4 + ) + ] + ) + envelope = orchestrate( + RouteRequest.model_validate(case["request"]), + intent_conn=intent_conn, + registry=make_registry(projects=project), + policy=policy, + ) + assert envelope.outcome == "success" + assert envelope.decision.selected_retriever_ids == ("mindgraph-projects",) + assert envelope.decision.reason_codes == (case["expected_reason"],) + assert [batch.retriever_id for batch in envelope.batches] == [ + "mindgraph-projects" + ] + assert project.calls == [(case["request"]["query_text"], 10)] + + +def test_missing_intent_store_preserves_explicit_scope_and_safe_default( + policy: RouterPolicy, +): + durable = StubRetriever( + [make_result("mindgraph-durable", trust_profile="durable_knowledge", score=0.2)] + ) + registry = make_registry(durable=durable) + explicit = orchestrate( + RouteRequest( + query_id="route.no-intent-explicit", + query_text="durable method", + mode="durable", + ), + intent_conn=None, + registry=registry, + policy=policy, + ) + fallback = orchestrate( + RouteRequest( + query_id="route.no-intent-auto", query_text="unclassified", mode="auto" + ), + intent_conn=None, + registry=registry, + policy=policy, + ) + assert explicit.outcome == "success" + assert explicit.decision.reason_codes == ("explicit_durable_scope",) + assert fallback.outcome == "fallback" + assert fallback.decision.selected_retriever_ids == ("mindgraph-durable",) + assert fallback.decision.reason_codes == ("safe_default",) + + +def test_missing_or_unmatched_intent_without_default_refuses(policy: RouterPolicy): + no_default = policy.model_copy(update={"safe_default_retriever_id": None}) + registry = make_registry() + unavailable = decide_route( + RouteRequest(query_id="route.intent-missing", query_text="unknown"), + unavailable_resolution(), + registry, + no_default, + ) + no_match = decide_route( + RouteRequest(query_id="route.intent-no-match", query_text="unknown"), + unavailable_resolution("intent_no_match"), + registry, + no_default, + ) + assert unavailable.reason_codes == ("intent_unavailable",) + assert no_match.reason_codes == ("intent_no_match",) + assert unavailable.selected_retriever_ids == no_match.selected_retriever_ids == () + + +@pytest.mark.parametrize( + ("refusal_reason", "expected"), + [ + ("intent_alias_ambiguous", "intent_ambiguous"), + ("intent_rule_ambiguous", "intent_ambiguous"), + ("intent_cycle_detected", "intent_invalid"), + ("intent_store_invalid", "intent_invalid"), + ], +) +def test_ambiguous_cycle_and_corrupt_intent_never_route( + refusal_reason: str, expected: str, policy: RouterPolicy +): + resolution = IntentResolution( + graph_id="mainframe.core", + graph_version="corrupt", + source_hash="corrupt", + outcome="refusal", + resolution_method="none", + refusal_reason=refusal_reason, + ) + decision = decide_route( + RouteRequest(query_id="route.intent-refusal", query_text="test"), + resolution, + make_registry(), + policy, + ) + assert decision.outcome == "refusal" + assert decision.selected_retriever_ids == () + assert decision.reason_codes == (expected,) + + +def test_disallowed_or_unavailable_capability_is_not_replaced_by_default( + policy: RouterPolicy, +): + registry = make_registry( + extras=( + RetrieverRegistration( + descriptor( + "future-live", + "retriever://future-live", + "time_bound_live_state", + available=False, + ) + ), + ) + ) + disallowed = decide_route( + RouteRequest(query_id="route.disallowed", query_text="test"), + resolved( + rejected=("retriever://mindgraph-projects",), + warnings=( + "capability_not_allowed:retriever://mindgraph-projects", + ), + ), + registry, + policy, + ) + unavailable = decide_route( + RouteRequest( + query_id="route.unavailable", + query_text="test", + allowed_capability_refs=("retriever://future-live",), + ), + resolved(hints=("retriever://future-live",)), + registry, + policy, + ) + assert disallowed.reason_codes == ("capability_not_allowed",) + assert unavailable.reason_codes == ("capability_unavailable",) + assert disallowed.selected_retriever_ids == unavailable.selected_retriever_ids == () + + +def test_raw_transcript_policy_refuses_before_retriever_runs(policy: RouterPolicy): + case = load_routing_fixture()["requests"]["raw_transcript_refusal"] + durable = StubRetriever() + envelope = orchestrate( + RouteRequest.model_validate(case["request"]), + intent_conn=None, + registry=make_registry(durable=durable), + policy=policy, + ) + assert envelope.outcome == "refusal" + assert envelope.decision.reason_codes == (case["expected_reason"],) + assert envelope.batches == () + assert durable.calls == [] + candidate = envelope.as_contract_result("route07_raw_transcript_request") + assert candidate["outcome"] == "refusal_or_explicit_consent_gate" + assert candidate["reason"] == "raw_transcripts_not_in_default_retrieval" + + +def test_grouped_federation_preserves_local_order_without_global_rerank( + policy: RouterPolicy, +): + durable = StubRetriever( + [ + make_result( + "mindgraph-durable", trust_profile="durable_knowledge", score=0.01 + ), + make_result( + "mindgraph-durable-2", + trust_profile="durable_knowledge", + score=0.005, + ), + ] + ) + projects = StubRetriever( + [make_result("mindgraph-projects", trust_profile="project_status", score=0.99)] + ) + envelope = orchestrate( + RouteRequest( + query_id="route.grouped", + query_text="plan across both", + mode="federated", + ), + intent_conn=None, + registry=make_registry(durable=durable, projects=projects), + policy=policy, + ) + assert envelope.outcome == "success" + assert [batch.retriever_id for batch in envelope.batches] == [ + "mindgraph-durable", + "mindgraph-projects", + ] + assert [row.local_rank for row in envelope.batches[0].rows] == [1, 2] + assert envelope.batches[0].rows[0].result.rrf_score == 0.01 + assert envelope.batches[1].rows[0].result.rrf_score == 0.99 + candidate = envelope.as_contract_result("route.grouped") + assert candidate["selected_retrievers"] == [ + "mindgraph-durable", + "mindgraph-projects", + ] + assert "group results by retriever and trust profile" in candidate["behaviors"] + + +def test_partial_failures_are_visible_when_one_or_all_retrievers_fail( + policy: RouterPolicy, +): + durable_result = make_result( + "mindgraph-durable", trust_profile="durable_knowledge", score=0.1 + ) + one_failure = orchestrate( + RouteRequest( + query_id="route.partial-one", query_text="both", mode="federated" + ), + intent_conn=None, + registry=make_registry( + durable=StubRetriever([durable_result]), + projects=FailingRetriever("project fixture failed"), + ), + policy=policy, + ) + all_fail = orchestrate( + RouteRequest( + query_id="route.partial-all", query_text="both", mode="federated" + ), + intent_conn=None, + registry=make_registry( + durable=FailingRetriever("durable fixture failed"), + projects=FailingRetriever("project fixture failed"), + ), + policy=policy, + ) + assert one_failure.outcome == "partial" + assert one_failure.partial is True + assert [batch.retriever_id for batch in one_failure.batches] == [ + "mindgraph-durable" + ] + assert [failure.retriever_id for failure in one_failure.failures] == [ + "mindgraph-projects" + ] + assert all_fail.outcome == "partial" + assert all_fail.batches == () + assert [failure.retriever_id for failure in all_fail.failures] == [ + "mindgraph-durable", + "mindgraph-projects", + ] + + +def test_mismatched_retriever_trust_is_isolated_as_a_failure(policy: RouterPolicy): + mislabeled = StubRetriever( + [ + make_result( + "mindgraph-projects", + trust_profile="durable_knowledge", + score=0.9, + ) + ] + ) + envelope = orchestrate( + RouteRequest( + query_id="route.provenance-mismatch", + query_text="project status", + mode="projects", + ), + intent_conn=None, + registry=make_registry(projects=mislabeled), + policy=policy, + ) + assert envelope.outcome == "partial" + assert envelope.batches == () + assert envelope.failures[0].error_type == "RoutingError" + assert "unexpected trust profiles" in envelope.failures[0].message + + +def test_decisions_and_envelopes_serialize_byte_deterministically(policy: RouterPolicy): + durable = StubRetriever( + [make_result("mindgraph-durable", trust_profile="durable_knowledge", score=0.1)] + ) + request = RouteRequest( + query_id="route.deterministic", query_text="durable", mode="durable" + ) + registry = make_registry(durable=durable) + first = orchestrate( + request, intent_conn=None, registry=registry, policy=policy + ).model_dump_json() + second = orchestrate( + request, intent_conn=None, registry=registry, policy=policy + ).model_dump_json() + assert first.encode("utf-8") == second.encode("utf-8") + assert json.loads(first)["batches"][0]["retriever_id"] == "mindgraph-durable" + + +def test_read_only_adapter_matches_direct_query_and_does_not_change_store(indexed_db): + db_path, embedder = indexed_db + direct_conn = db.get_db(str(db_path)) + try: + direct = query_mod.run_query( + direct_conn, "zebra", embedder, final_top_k=5 + ) + finally: + direct_conn.close() + before = hashlib.sha256(db_path.read_bytes()).hexdigest() + + query_store = open_query_store_read_only(db_path) + try: + assert query_store.execute("PRAGMA query_only").fetchone()[0] == 1 + with pytest.raises(sqlite3.OperationalError): + query_store.execute("CREATE TABLE forbidden_write(id INTEGER)") + finally: + query_store.close() + + retriever = MindGraphQueryRetriever( + descriptor=descriptor( + "mindgraph-durable", + "retriever://mindgraph-durable", + "durable_knowledge", + ), + db_path=db_path, + embedder=embedder, + ) + adapted = retriever.retrieve("zebra", final_top_k=5) + after = hashlib.sha256(db_path.read_bytes()).hexdigest() + + assert [item.model_dump() for item in adapted] == [ + item.model_dump() for item in direct + ] + assert before == after + assert adapted[0].trust_profile == "durable_knowledge" + assert adapted[0].display_path.startswith("10_knowledge/fixture/") + + +def test_policy_must_reference_registered_retrievers(policy: RouterPolicy): + registry = CapabilityRegistry( + ( + RetrieverRegistration( + descriptor( + "mindgraph-durable", + "retriever://mindgraph-durable", + "durable_knowledge", + ), + StubRetriever(), + ), + ) + ) + with pytest.raises(RoutingError, match="policy references missing retrievers"): + decide_route( + RouteRequest(query_id="route.invalid-policy", query_text="test"), + unavailable_resolution(), + registry, + policy, + ) diff --git a/mindgraph/tests/test_shared_mcp.py b/mindgraph/tests/test_shared_mcp.py new file mode 100644 index 0000000..e1dcb4b --- /dev/null +++ b/mindgraph/tests/test_shared_mcp.py @@ -0,0 +1,305 @@ +import json +import os +import signal +import sqlite3 +import threading +import time +from unittest.mock import AsyncMock + +import pytest +from mcp.shared.memory import create_connected_server_and_client_session +from httpx import ASGITransport, AsyncClient + +from mindgraph import cli, daemon, idle_lifecycle, mcp_proxy, mcp_server +from mindgraph.exceptions import MindgraphError +from tests.test_mcp import mcp_db, mcp_embedder +from tests.test_query import KeywordEmbedder + + +def _payload(result): + return json.loads(result.content[0].text) + + +def test_streamable_http_defaults_and_configuration(monkeypatch): + conn = sqlite3.connect(":memory:") + server = mcp_server.create_shared_server( + {"knowledge": (conn, "durable_knowledge")}, KeywordEmbedder({}) + ) + assert server.settings.host == "127.0.0.1" + assert server.settings.port == 8000 + assert server.settings.streamable_http_path == "/mcp" + called = [] + monkeypatch.setattr(server, "run", lambda transport: called.append(transport)) + mcp_server.run_streamable_http(server) + assert called == ["streamable-http"] + + +def test_shared_server_rejects_non_loopback(): + with pytest.raises(mcp_server.MCPServerStartupError, match="loopback"): + mcp_server.create_shared_server({}, KeywordEmbedder({}), host="0.0.0.0") + + +@pytest.mark.anyio +async def test_scopes_are_explicit_and_not_blended(tmp_path, monkeypatch): + embedder = KeywordEmbedder({"knowledgeonly": 0, "projectonly": 1}) + monkeypatch.setattr(cli, "_load_embedder", lambda *_a, **_k: embedder) + knowledge_vault = tmp_path / "knowledge-vault" + projects_vault = tmp_path / "projects-vault" + knowledge_vault.mkdir(); projects_vault.mkdir() + (knowledge_vault / "durable-only.md").write_text( + "knowledgeonly durable architecture note\n", encoding="utf-8" + ) + (projects_vault / "project-only.md").write_text( + "projectonly active project status\n", encoding="utf-8" + ) + knowledge_db = tmp_path / "knowledge.sqlite" + projects_db = tmp_path / "projects.sqlite" + cli._ingest_directory(knowledge_vault, str(knowledge_db)) + cli._ingest_directory(projects_vault, str(projects_db)) + first = mcp_server.open_database(str(knowledge_db)) + second = mcp_server.open_database(str(projects_db)) + server = mcp_server.create_shared_server( + {"knowledge": (first, "durable_knowledge"), + "projects": (second, "project_status")}, embedder, + ) + try: + async with create_connected_server_and_client_session(server) as session: + knowledge = _payload(await session.call_tool( + "query", {"question": "knowledgeonly", "scope": "knowledge"})) + projects = _payload(await session.call_tool( + "query", {"question": "projectonly", "scope": "projects"})) + invalid = await session.call_tool( + "query", {"question": "anything", "scope": "both"}) + assert knowledge["scope"] == "knowledge" + assert knowledge["trust_profile"] == "durable_knowledge" + assert projects["scope"] == "projects" + assert projects["trust_profile"] == "project_status" + assert {row["path"] for row in knowledge["results"]} == {"durable-only.md"} + assert {row["path"] for row in projects["results"]} == {"project-only.md"} + assert invalid.isError is True + finally: + first.close(); second.close() + + +def test_open_database_readonly_rejects_writes(mcp_db): + conn = mcp_server.open_database_readonly(mcp_db) + try: + assert conn.execute("PRAGMA query_only").fetchone()[0] == 1 + with pytest.raises(sqlite3.OperationalError, match="readonly"): + conn.execute("DELETE FROM documents") + finally: + conn.close() + + +@pytest.mark.anyio +async def test_proxy_forwards_list_and_call(): + remote = AsyncMock() + remote.list_tools.return_value.tools = [] + remote.call_tool.return_value = {"ok": True} + proxy = mcp_proxy.create_proxy_server(remote) + async with create_connected_server_and_client_session(proxy) as session: + assert (await session.list_tools()).tools == [] + await session.call_tool("query", {"scope": "knowledge", "question": "x"}) + remote.call_tool.assert_awaited_once_with( + "query", {"scope": "knowledge", "question": "x"}) + + +@pytest.mark.anyio +async def test_remote_session_initializes_and_propagates_error(monkeypatch): + initialized = AsyncMock(side_effect=RuntimeError("init failed")) + class FakeClient: + async def __aenter__(self): return self + async def __aexit__(self, *args): return False + initialize = initialized + class Transport: + async def __aenter__(self): return (object(), object(), lambda: None) + async def __aexit__(self, *args): return False + monkeypatch.setattr(mcp_proxy, "streamablehttp_client", lambda _url: Transport()) + monkeypatch.setattr(mcp_proxy, "ClientSession", lambda *_args: FakeClient()) + with pytest.raises(RuntimeError, match="init failed"): + async with mcp_proxy.remote_session("http://127.0.0.1:9/mcp"): + pass + + +def test_daemon_status_stop_and_health_use_isolated_state(tmp_path, monkeypatch): + assert daemon.status(tmp_path) == {"status": "stopped", "pid": None} + daemon.paths(tmp_path)[0].write_text("99999999\n") + assert daemon.stop(tmp_path)["status"] == "stopped" + monkeypatch.setattr(daemon.urllib.request, "urlopen", + lambda *_a, **_k: (_ for _ in ()).throw(OSError("offline"))) + assert daemon.health("http://127.0.0.1:9/health")["status"] == "unhealthy" + + +def test_daemon_start_and_stop_tracks_real_child(tmp_path): + result = daemon.start( + tmp_path, [os.sys.executable, "-c", "import time; time.sleep(30)"]) + try: + assert daemon.status(tmp_path)["status"] == "running" + assert daemon.stop(tmp_path, timeout=2)["status"] == "stopped" + finally: + if daemon.alive(result["pid"]): + os.kill(result["pid"], signal.SIGKILL) + + +def test_daemon_status_reports_healthy_external_supervisor(tmp_path, monkeypatch): + monkeypatch.setattr(daemon, "health", lambda *_a, **_k: { + "status": "ok", "pid": 4242, "scopes": [], + }) + result = daemon.status(tmp_path, "http://127.0.0.1:8000/health") + assert result["status"] == "running" + assert result["supervision"] == "external" + assert result["pid"] == 4242 + + +def test_daemon_stop_refuses_pid_mismatch(tmp_path, monkeypatch): + daemon.paths(tmp_path)[0].write_text("111\n") + monkeypatch.setattr(daemon, "health", lambda *_a, **_k: { + "status": "ok", "pid": 222, "scopes": [], + }) + assert daemon.stop(tmp_path, health_url="http://127.0.0.1:1/health") == { + "status": "refused_pid_mismatch", "pid": 111, "observed_pid": 222, + } + + +def test_daemon_stop_refuses_healthy_listener_without_identity(tmp_path, monkeypatch): + monkeypatch.setattr(daemon, "health", lambda *_a, **_k: { + "status": "ok", "scopes": [], + }) + assert daemon.stop(tmp_path, health_url="http://127.0.0.1:1/health") == { + "status": "refused_unverified_listener", "pid": None, + } + + +def test_daemon_stop_refuses_live_pid_without_start_identity(tmp_path, monkeypatch): + daemon.paths(tmp_path)[0].write_text(f"{os.getpid()}\n") + monkeypatch.setattr(daemon, "health", lambda *_a, **_k: {"status": "unhealthy"}) + assert daemon.stop(tmp_path, health_url="http://127.0.0.1:1/health") == { + "status": "refused_unverified_pid", "pid": os.getpid(), + } + + +def test_idle_lifecycle_never_exits_in_flight_and_honors_lease(): + now = [0.0] + stopped = [] + lifecycle = idle_lifecycle.IdleLifecycle( + 60, lease_ttl=30, clock=lambda: now[0], request_shutdown=lambda: stopped.append(True) + ) + with lifecycle.request(): + now[0] = 120 + assert lifecycle.should_shutdown() is False + token = lifecycle.acquire_lease() + now[0] = 140 + assert lifecycle.should_shutdown() is False + lifecycle.release_lease(token) + now[0] = 201 + assert lifecycle.should_shutdown() is True + + +def test_idle_lifecycle_monitor_requests_shutdown_only_after_grace(): + stopped = threading.Event() + lifecycle = idle_lifecycle.IdleLifecycle(0.15, request_shutdown=stopped.set) + lifecycle.start() + try: + time.sleep(0.05) + assert not stopped.is_set() + assert stopped.wait(1) + finally: + lifecycle.stop() + + +@pytest.mark.anyio +async def test_shared_server_lease_endpoint_tracks_and_releases(): + lifecycle = idle_lifecycle.IdleLifecycle(60) + server = mcp_server.create_shared_server( + {"knowledge": (sqlite3.connect(":memory:"), "durable_knowledge")}, + KeywordEmbedder({}), lifecycle=lifecycle, + ) + async with AsyncClient( + transport=ASGITransport(app=server.streamable_http_app()), + base_url="http://test", + ) as client: + acquired = (await client.post("/lifecycle/lease")).json() + assert lifecycle.snapshot()["active_leases"] == 1 + renewed = (await client.post( + "/lifecycle/lease", headers={"X-MindGraph-Lease": acquired["lease"]} + )).json() + assert renewed["lease"] == acquired["lease"] + assert (await client.delete( + "/lifecycle/lease", headers={"X-MindGraph-Lease": acquired["lease"]} + )).json()["status"] == "released" + assert lifecycle.snapshot()["active_leases"] == 0 + + +def test_daemon_start_serializes_concurrent_callers(tmp_path): + command = [os.sys.executable, "-c", "import time; time.sleep(30)"] + barrier = threading.Barrier(3) + results = [] + def launch(): + barrier.wait() + results.append(daemon.start(tmp_path, command)) + threads = [threading.Thread(target=launch) for _ in range(2)] + for thread in threads: + thread.start() + barrier.wait() + for thread in threads: + thread.join() + try: + assert {result["status"] for result in results} == {"started", "running"} + assert len({result["pid"] for result in results}) == 1 + finally: + assert daemon.stop(tmp_path, timeout=2)["status"] == "stopped" + + +def test_daemon_endpoint_args_rejects_non_loopback(): + assert daemon.daemon_endpoint_args("http://127.0.0.1:8123/custom") == ( + "127.0.0.1", 8123, "/custom" + ) + with pytest.raises(ValueError, match="loopback"): + daemon.daemon_endpoint_args("https://example.com/mcp") + assert daemon.health_url("::1", 8123) == "http://[::1]:8123/health" + + +def test_idle_opt_in_requires_explicit_valid_marker(tmp_path): + assert daemon.idle_opt_in(tmp_path) is None + (tmp_path / "idle-lifecycle.enabled").write_text("900\n") + assert daemon.idle_opt_in(tmp_path) == 900 + (tmp_path / "idle-lifecycle.enabled").write_text("30\n") + assert daemon.idle_opt_in(tmp_path) is None + + +def test_parse_scope_specs_name_only_defaults_trust_to_name(): + assert cli.parse_scope_specs(["notes=/tmp/notes.sqlite"]) == { + "notes": ("/tmp/notes.sqlite", "notes") + } + + +def test_parse_scope_specs_explicit_trust_profile(): + assert cli.parse_scope_specs(["notes:durable_knowledge=/tmp/n.sqlite"]) == { + "notes": ("/tmp/n.sqlite", "durable_knowledge") + } + + +def test_parse_scope_specs_allows_equals_and_colon_in_path(): + parsed = cli.parse_scope_specs(["a=/tmp/x:y=z.sqlite"]) + assert parsed == {"a": ("/tmp/x:y=z.sqlite", "a")} + + +def test_parse_scope_specs_multiple_scopes(): + parsed = cli.parse_scope_specs(["a=/tmp/a.sqlite", "b:vol=/tmp/b.sqlite"]) + assert parsed == {"a": ("/tmp/a.sqlite", "a"), "b": ("/tmp/b.sqlite", "vol")} + + +def test_parse_scope_specs_empty_is_empty_dict(): + assert cli.parse_scope_specs(None) == {} + assert cli.parse_scope_specs([]) == {} + + +@pytest.mark.parametrize("bad", ["notes", "=/tmp/a.sqlite", "notes=", ":t=/tmp/a.sqlite"]) +def test_parse_scope_specs_rejects_malformed(bad): + with pytest.raises(MindgraphError): + cli.parse_scope_specs([bad]) + + +def test_parse_scope_specs_rejects_duplicate_names(): + with pytest.raises(MindgraphError): + cli.parse_scope_specs(["a=/tmp/1.sqlite", "a=/tmp/2.sqlite"]) diff --git a/mindgraph/uv.lock b/mindgraph/uv.lock new file mode 100644 index 0000000..b78c3ac --- /dev/null +++ b/mindgraph/uv.lock @@ -0,0 +1,2412 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "cuda-bindings" +version = "13.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder", marker = "sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/21/8464d133752951c154feafb3b65c297e7d80f301183d220bec4c830f1441/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86", size = 6073403, upload-time = "2026-05-29T23:11:36.22Z" }, + { url = "https://files.pythonhosted.org/packages/a8/1f/5ef51f5fbaa5d4d3201bb3d7555af028ec1aa4416275ccbf73c9e34e3d2d/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9851b0caa8bfd3bc6fa054eaf57bea7c8e9c3a62db2d2621224677f49f3c53d0", size = 6675244, upload-time = "2026-05-29T23:11:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, + { url = "https://files.pythonhosted.org/packages/95/7a/c5e3c34a409b148f5c0f5a4ea374158f95d488862c1dffedf9aa5c639df9/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04436a9364059c84b8f9636f359eccda1cf814341f5b670c71d80d2f79dbc708", size = 6674166, upload-time = "2026-05-29T23:11:45.478Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/6d2e9047d1fb243dbaa364b01e0297534b9ed7fd27dba1c9f361519cf69b/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a", size = 6657965, upload-time = "2026-05-29T23:11:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/2394f8163360f8391f8f1b7e72d300a82724edb81a7b7084c799fbd4c91f/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf", size = 5920504, upload-time = "2026-05-29T23:11:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/ef9b6a63f7dc432712a462c816662e662e00d38caa9b861c8c2588195d03/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7", size = 6476660, upload-time = "2026-05-29T23:11:59.188Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/bff68ce829999c1e4209c761bbf903b1c06ec570416ddb25020864ad5907/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ab2f74ed65bfef4163ba07a8db16f1085e0729291db12a2423aff84ee8278b8", size = 6013639, upload-time = "2026-05-29T23:12:03.509Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e0/c8a1f0c8f9ffdea4f5fe6dbab89b326cef4d85caf489dad39e209da89416/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd4c814d311ec08c981f6dded1dbe7d4b371067ee4f6c14cccec4bde9590f80", size = 6534419, upload-time = "2026-05-29T23:12:05.633Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/83b1f563925b290f2d11a01a77a84013ba56052fe3653a5bef3ccfbb43d6/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3c772dfff49681541d59630c90f858e173ac926b9c593a2b7123f2a1043cc76", size = 5809771, upload-time = "2026-05-29T23:12:10.422Z" }, + { url = "https://files.pythonhosted.org/packages/12/20/e79b4bfe98f075195afb6343d41c498f9dbd2d161d7021d4d28bceb83581/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36febb7c1079d68a981dbbd8d5a67235b399802b82075c9388624719607e52b9", size = 6358584, upload-time = "2026-05-29T23:12:12.767Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/c8/26f2e4aae92f11522a96043892ba39a90eac610d5242523aa863212bc1c7/cuda_pathfinder-1.5.5-py3-none-any.whl", hash = "sha256:0228c023f95d1480f143ef5c8922d27a2ab052087a942e81dc289c9eb8f91689", size = 51671, upload-time = "2026-05-27T01:21:25.413Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" }, +] + +[package.optional-dependencies] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux'" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "sys_platform == 'linux'" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux'" }, +] +curand = [ + { name = "nvidia-curand", marker = "sys_platform == 'linux'" }, +] +cusolver = [ + { name = "nvidia-cusolver", marker = "sys_platform == 'linux'" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "sys_platform == 'linux'" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux'" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux'" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "sys_platform == 'linux'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/2d/57fd21d84d93efb4bd0b962383790e19dd1bc053501b4264c97903b4e83e/hf_xet-1.5.1.tar.gz", hash = "sha256:51ef4500dab3764b41135ee1381a4b62ce56fc54d4c92b719b59e597d6df5bf6", size = 876636, upload-time = "2026-06-08T23:02:53.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/ee/dd9ba7beae1005e54131b7d45263cc74c8a066d47d354e6d58ae9445a388/hf_xet-1.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:dbf48c0d02cf0b2e568944330c60d9120c272dabe013bd892d48e25bc6797577", size = 4069485, upload-time = "2026-06-08T23:02:13.193Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/9cae6cfeb4e03070874e73e5c97c66eb90369d3206b6a2b1ef5f96520888/hf_xet-1.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78e4e5192ad2b674c2e1160b651cb9134db974f8ae1835bdfbfb0166b894a43", size = 3838493, upload-time = "2026-06-08T23:02:15.282Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b4/d5c01e0eb6d9f2ca2dacd84d0d1b71e6cfbb2ef3208c968528e010e9b3d7/hf_xet-1.5.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6f7a04a8ad962422e225bc49fbbac99dc1806764b1f3e54dbd154bffa7593947", size = 4505658, upload-time = "2026-06-08T23:02:17.196Z" }, + { url = "https://files.pythonhosted.org/packages/76/c5/29a7598c0c6383c523dc22186d577f4e04267a626cd95ae60f67c00bfe66/hf_xet-1.5.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d48199c2bf4f8df0adc55d31d1368b6ec0e4d4f45bc86b08038089c23db0bed8", size = 4292822, upload-time = "2026-06-08T23:02:18.608Z" }, + { url = "https://files.pythonhosted.org/packages/04/9a/dceaf6ca69390126b86ea825fb354b93d01163199070b7bd849225de9468/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:97f212a88d14bbf573619a74b7fecb238de77d08fc702e54dec6f78276ca3283", size = 4491255, upload-time = "2026-06-08T23:02:20.124Z" }, + { url = "https://files.pythonhosted.org/packages/48/a7/e5a7afaacf6c1791fdbeeac42951fb81c3d2bc482992b115dedcc86d963e/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f61e3665892a6c8c5e765395838b8ddf36185da835253d4bc4509a81e49fb342", size = 4711062, upload-time = "2026-06-08T23:02:21.863Z" }, + { url = "https://files.pythonhosted.org/packages/53/49/2802f8433c9742ce281bddc1e65c02c32268ca3098d66828b05e12e45ee2/hf_xet-1.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f4ad3ebd4c32dd2b27099d69dc7b2df821e30767e46fb6ee6a0713778243b8ff", size = 4017205, upload-time = "2026-06-08T23:02:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5a/50c71195b9fb883659f596e7252faf4c18c58e753a9013bdbf9bac5d2250/hf_xet-1.5.1-cp313-cp313t-win_arm64.whl", hash = "sha256:8298485c1e36e7e67cbd01eeb1376619b7af43d4f1ec245caae306f890a8a32d", size = 3845426, upload-time = "2026-06-08T23:02:25.124Z" }, + { url = "https://files.pythonhosted.org/packages/05/24/5e0c28f80371c17d49fed004597d9d132cb75c1f6f53db2cb95f459d2312/hf_xet-1.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3474760d10e3bb6f92ff3f024fcb00c0b3e4001e9b035c7483e49a5dd17aa70f", size = 4069676, upload-time = "2026-06-08T23:02:26.759Z" }, + { url = "https://files.pythonhosted.org/packages/d2/17/261ba565b6a4d960fb478f61fdf919c0be5824645aaf1c319eca660c1611/hf_xet-1.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6762d89b9e3267dfd502b29b2a327b4525f33b17e7b509a78d94e2151a30ce30", size = 3838509, upload-time = "2026-06-08T23:02:28.573Z" }, + { url = "https://files.pythonhosted.org/packages/4e/44/7ffdc2e184b0d41fc0f683ba3936ef669ab63cf242cf36ef50e57d683668/hf_xet-1.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf67e6ed10260cef62e852789dc91ebb03f382d5bdc4b1dbeb64763ea275e7d6", size = 4505881, upload-time = "2026-06-08T23:02:30.257Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/788060d5aa4d5e671f1a31bf69624c314eb2d8babab3aa562f9e5d53444e/hf_xet-1.5.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c6b6cd08ca095058780b50b8ce4d6cbf6787bcf27841705d58a9d32246e3e47a", size = 4292995, upload-time = "2026-06-08T23:02:31.993Z" }, + { url = "https://files.pythonhosted.org/packages/22/93/c5540cbd6b55529b7dc42f6734e88cebee21aefbea34128b66229df56c57/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e1af0de8ca6f190d4294a28b88023db64a1e2d1d719cab044baf75bec569e7a9", size = 4491570, upload-time = "2026-06-08T23:02:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/03/f3/9d8ceab30f44f36c1679b1b8683054c71a0dadc787dbf07421891742d3ca/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4f561cbbb92f80960772059864b7fb07eae879adde1b2e781ec6f86f6ac26c59", size = 4711565, upload-time = "2026-06-08T23:02:35.454Z" }, + { url = "https://files.pythonhosted.org/packages/cd/54/27ed9a5e2cc583b4df82f75a03a4df8dbf55f5a9fa1f47f1fadfb20dbeac/hf_xet-1.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:e7dbb40617410f432182d918e37c12303fe6700fd6aa6c5964e30a535a4461d6", size = 4017343, upload-time = "2026-06-08T23:02:37.14Z" }, + { url = "https://files.pythonhosted.org/packages/ae/12/ecb2fc8d45e767580e3a37faa97cb895608b614965567efb4f18cff67e27/hf_xet-1.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6071d5ccb4d8d2cbd5fea5cc798da4f0ba3f44e25369591c4e89a4987050e61d", size = 3845716, upload-time = "2026-06-08T23:02:39.073Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d8/5e54cf37434759d1f4f2ba9b66077ff9d4c4e1f37b6bd7975da5c40d94ab/hf_xet-1.5.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6abd35c3221eff63836618ddfb954dcf84798603f71d8e33e3ed7b04acfdbe6e", size = 4077794, upload-time = "2026-06-08T23:02:40.656Z" }, + { url = "https://files.pythonhosted.org/packages/35/94/4b2ecfbad8f8b04701a23aefb62f540b9137d058b7e1dbef16a32676f0e9/hf_xet-1.5.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:94e761bbd266bf4c03cee73753916062665ce8365aa40ed321f45afcb934b41e", size = 3845354, upload-time = "2026-06-08T23:02:42.702Z" }, + { url = "https://files.pythonhosted.org/packages/de/cc/f99f4bc7295023d7bd9ebbfd51f75cc530ca262c1227666268b8208f4b77/hf_xet-1.5.1-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:892e3a3a3aecc12aded8b93cf4f9cd059282c7de0732f7d55026f3abdf474350", size = 4514864, upload-time = "2026-06-08T23:02:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6e/21f7e5a2381278bd3b7b7a5a4d90038518bb6308a0c1daf5d9f8268bb178/hf_xet-1.5.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a93df2039190502835b1db8cd7e178b0b7b889fe9ab51299d5ced26e0dd879a4", size = 4303784, upload-time = "2026-06-08T23:02:46.203Z" }, + { url = "https://files.pythonhosted.org/packages/35/0e/f992bb6927ac1cb30ef74e62268f551f338bc32b2191f7c96a44c6f7283e/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0c97106032ef70467b4f6bc2d0ccc266d7613ee076afc56516c502f87ce1c4a6", size = 4500703, upload-time = "2026-06-08T23:02:47.628Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d1/90a498d05447980b977b1669246eeeeae4cfb0ea3e7a286eaba627f91bf9/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6208adb15d192b90e4c2ad2a27ed864359b2cb0f2494eb6d7c7f3699ac02e2bf", size = 4719498, upload-time = "2026-06-08T23:02:49.268Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b6/20f99cfe97cc663a711f7b33cc21d4793e51968e9a26125b4afcd77315ba/hf_xet-1.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:f7b3002f95d1c13e24bcb4537baa8f0eb3838957067c91bb4959bc004a6435f5", size = 4026419, upload-time = "2026-06-08T23:02:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/fa/77453694888f03e5a8c8852d1514a0894d8e81c622d39edbaf308ea0dcf4/hf_xet-1.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:93d090b57b211133f6c0dab0205ef5cb6d89162979ba75a74845045cc3063b8e", size = 3855178, upload-time = "2026-06-08T23:02:52.452Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/7e/fad82ad491b226e832d2da90a1a59f36acd4526cda8c726f639834754aa4/huggingface_hub-1.20.1.tar.gz", hash = "sha256:9f6d63bfbeab2d2a8357200a9bc4f18cd2c8bfac9579f792f5922e77bf6471d0", size = 859910, upload-time = "2026-06-18T22:06:53.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/b5/ff8516e74b459da3dce9567540c39f2d305ee7a2655109f6802873ff1588/huggingface_hub-1.20.1-py3-none-any.whl", hash = "sha256:274448a45c1ba6f112fe2fb168ead05574c654faa156904157a84085cfae14bd", size = 719837, upload-time = "2026-06-18T22:06:51.486Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mcp" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2", size = 636040, upload-time = "2026-06-16T21:37:17.996Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mindgraph" +version = "0.2.0" +source = { editable = "." } +dependencies = [ + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "sqlite-vec" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pytest" }, +] +full = [ + { name = "mcp" }, + { name = "sentence-transformers" }, +] +mcp = [ + { name = "mcp" }, +] +semantic = [ + { name = "sentence-transformers" }, +] + +[package.metadata] +requires-dist = [ + { name = "mcp", marker = "extra == 'full'", specifier = ">=1.0.0,<2" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0,<2" }, + { name = "numpy", marker = "extra == 'dev'", specifier = ">=1.24.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pyyaml", specifier = ">=6.0.1" }, + { name = "sentence-transformers", marker = "extra == 'full'", specifier = ">=3.0.0" }, + { name = "sentence-transformers", marker = "extra == 'semantic'", specifier = ">=3.0.0" }, + { name = "sqlite-vec", specifier = ">=0.1.6" }, + { name = "typer", specifier = ">=0.12.0" }, +] +provides-extras = ["semantic", "mcp", "full", "dev"] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "narwhals" +version = "2.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/3c/c4ef2164a71c1a63d7f1ae411c4082c5fa872405106db60a4b7114989ad7/narwhals-2.22.1.tar.gz", hash = "sha256:d62920805a0a43b7ff8b54b0c0d3142d796f8a9301836ada37e573d6a33cbcd9", size = 647493, upload-time = "2026-06-05T12:34:34.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53", size = 454815, upload-time = "2026-06-05T12:34:32.289Z" }, +] + +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.20.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588, upload-time = "2026-03-09T19:29:34.474Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.29.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712, upload-time = "2026-03-03T05:34:20.843Z" }, + { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000, upload-time = "2026-03-03T05:36:24.472Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.5.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ed/0ad2c8edf634918eb4484365d3819fa7bd7f58daf807fe7fb21812c316e5/regex-2026.5.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a9e1328e17c84c1a5d22ec9f785ecef4a967fab9a42b6a8dc3bcbebd0a0c9e44", size = 489438, upload-time = "2026-05-09T23:11:29.374Z" }, + { url = "https://files.pythonhosted.org/packages/89/a9/4ed972ad263963b860b7c3e86e0e1bcc791def47b43b8c8efe57e710f139/regex-2026.5.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bfe1ce50cbfb569d74e1e4337da6468961f31dbea55fd85aa5de59c0947a805a", size = 291270, upload-time = "2026-05-09T23:11:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/16/81/075930d9fa28c4ea1f53398dd015ee7c882f623539759113cda1257f4b82/regex-2026.5.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15ee42209947f4ca045412eae98416317238163618ace2a8e54f99586a466733", size = 289198, upload-time = "2026-05-09T23:11:35.769Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c8/5cdfbf0b5dc6599e1b6131eff43262e5275d4ec3469ce10216061659aadb/regex-2026.5.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4bb445ff3f725f59df8f6014edb547ee928ec7023a774f6a39a3f953038cbb2", size = 784765, upload-time = "2026-05-09T23:11:37.689Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ca/ae5fd6edc59b7f84b904b31d6ec39a860cbcecd10f64bd5a062ca83a4864/regex-2026.5.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:446ddd671e43ab535810c4b21cff7104945c701d4a14d1e6d1cd6f4e445a8bea", size = 852115, upload-time = "2026-05-09T23:11:39.973Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ce/a91cf555afb51f3b74a182e24ba073b91ea7bb64592fc4b315c111bb19fd/regex-2026.5.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b92817338591505f282cf3864c145244b1edcf5381d237038df955001091538", size = 899503, upload-time = "2026-05-09T23:11:42.48Z" }, + { url = "https://files.pythonhosted.org/packages/55/7f/725a0a2b245a4cf0c4bab29d0e97c74285d94136a65d1b55a6459a583502/regex-2026.5.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b8a143aca6c39b446ea8092cde25cc8fe9304d4f5fecfbc1a9dbb0282703c2", size = 794093, upload-time = "2026-05-09T23:11:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2a/996efbd59ce6b5d4a09e3af6180ceb62af171f4a9a6fb557d2f0ae0d462b/regex-2026.5.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0f03aa6898aaaac4592479821df16e68e8d0e29e903e65d8f2dfb2f19028a989", size = 786234, upload-time = "2026-05-09T23:11:46.882Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/8731e8b8806174c9cdd5903f80a14990331c1f42fc4209b540952e9e010d/regex-2026.5.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed457d8e98ae812ed7732bef7bf78de78e834eae0372a74e23ca90ef21d910f9", size = 769895, upload-time = "2026-05-09T23:11:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/9a/0b/932473194bd563f342a412ae2ffbbd6da608306a2bc4e99249a41c2b0b92/regex-2026.5.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71b61c5bfe1c806332defc42ad6c780b3c55f661986d7f40283a3a88274b4c00", size = 774991, upload-time = "2026-05-09T23:11:51.261Z" }, + { url = "https://files.pythonhosted.org/packages/98/80/9523d196010031df25f7177ee0a467efbee436324038e5d99def17a57515/regex-2026.5.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:3b1e39888c5e0c7d92cea4fc777396c4a90363b05de75d02eb459a4752200808", size = 848790, upload-time = "2026-05-09T23:11:53.232Z" }, + { url = "https://files.pythonhosted.org/packages/3c/07/56987b35e89edf47e4a38cf2845aeee476bfa688a6bdbd3e820cda461dc1/regex-2026.5.9-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6ba42b2e7e7f46cf68cc6a5ca36fa07959f9bbd9c6bdcc47b6ee76549a590248", size = 757679, upload-time = "2026-05-09T23:11:55.82Z" }, + { url = "https://files.pythonhosted.org/packages/04/2a/ff713fff0c566507c06a4ce2dc0ae8e7eeebc88811a95fc81cf1e7d534dd/regex-2026.5.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:c010eb8caca74bdb40c07498d7ece26b4428fd3f04aa8a72c9ac6f79e8faaac6", size = 837116, upload-time = "2026-05-09T23:11:57.934Z" }, + { url = "https://files.pythonhosted.org/packages/77/90/df6d982b03e3614785c6937ba51b57f6733d97d2ee1c9bc7531dbfab3a54/regex-2026.5.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a6a563446a41adc451393dc6b8e6ad87979efaee3c8738690a8d1b08ebead1b4", size = 782081, upload-time = "2026-05-09T23:11:59.607Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/4e88a5f7c3e98489aac4dd23142723d907b2a595b4a6abcbacabefeded09/regex-2026.5.9-cp310-cp310-win32.whl", hash = "sha256:954cc214c04663ee6d266fc61739cad83054683048de65c5bd1d640ad28098ac", size = 266247, upload-time = "2026-05-09T23:12:01.116Z" }, + { url = "https://files.pythonhosted.org/packages/6a/40/4b224cb0582b2dca1786726e6cdabe26abbf757d7f6718332f186da155d2/regex-2026.5.9-cp310-cp310-win_amd64.whl", hash = "sha256:b310768746dd314ea6e2ff4cc89ef215426813396ff4e94ee8e6f7096c8b6e03", size = 278416, upload-time = "2026-05-09T23:12:03.2Z" }, + { url = "https://files.pythonhosted.org/packages/12/4d/014fbe803204cab0947ee428f09f658a29632053dde1d3c6176bb4f0fd4c/regex-2026.5.9-cp310-cp310-win_arm64.whl", hash = "sha256:19c16ceb4a267a8789e25733e583983eeab9f0f8664e66b0bd1c5d21f14c2d4b", size = 270413, upload-time = "2026-05-09T23:12:04.649Z" }, + { url = "https://files.pythonhosted.org/packages/c2/dc/c1f2df4027e82fc54b5a473e4b250f5139faca49a0fbe29a48668d228f34/regex-2026.5.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ccf5249114cc3e772ecdd88a98a86eca0fd74c61ce32a94743758c083fc05d48", size = 489445, upload-time = "2026-05-09T23:12:06.111Z" }, + { url = "https://files.pythonhosted.org/packages/03/d2/59f01110660081cce9c0bc30ebd0b5ee250dacf658e3248ed92f01e0e8ee/regex-2026.5.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46f1326ca6e65b0879d23ca302c0f2415aad42ff0309b9c818e7949fe19a41d8", size = 291271, upload-time = "2026-05-09T23:12:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/58/b6/14b2c84ff90ddb370c81d27503f4a0fcf071496416f4855f6cc8c5d81c35/regex-2026.5.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef31cbfe458e21c6122ba8150ff060e0c7789ed0d26eb423f25472584920b555", size = 289212, upload-time = "2026-05-09T23:12:09.266Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/4db86529117320de0c84afd90e70bb47434625875e34fcef9d8c127c5b16/regex-2026.5.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:992604d02e6d9c6d786c24a706a71ecffe1020fc1ef264044474cd81fa2c3919", size = 792310, upload-time = "2026-05-09T23:12:11.416Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/fe4800cd322f862ecffd2d553409b20d80650e5ed71b9d178f853d020b82/regex-2026.5.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9411dd64ca95477225734a93dfc8583b51916b8d5942f99d6cac21e09965451", size = 861721, upload-time = "2026-05-09T23:12:13.681Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d0/b3618a895dd8feb897c61bb2954edd265e1767d82a01d53065d5871127a3/regex-2026.5.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4a3ff360dfb836fecdb93a4598f9d6e2ac81e3e397125145c6221bf58cf4c", size = 906460, upload-time = "2026-05-09T23:12:15.443Z" }, + { url = "https://files.pythonhosted.org/packages/33/6f/1481597e859ef19508b345eec4afd1416ed6e6b459c75a64026ef193aecf/regex-2026.5.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a661a7d270a61f7cf460caee8b9fa2d5ef9e5c681234bcb9e0fe14f488e7dfc", size = 799843, upload-time = "2026-05-09T23:12:16.892Z" }, + { url = "https://files.pythonhosted.org/packages/73/59/955734c803f59108deccba3597ae440c76b62a652733c0006e6243758420/regex-2026.5.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f079e50a0d3cc3cd5091fa9ff45869a2e6b2cd35895731edafb0327901a8d86d", size = 773610, upload-time = "2026-05-09T23:12:19.127Z" }, + { url = "https://files.pythonhosted.org/packages/68/8f/70c04a236d651c81881dac42ef8538bddda6121434509d0a22d9e601503b/regex-2026.5.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4ebe8f0b5ec5a5024dc4a4c59f444c4e9afc5f2abdbb8962065b75d27fb971f9", size = 781645, upload-time = "2026-05-09T23:12:20.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/96/05c7434d88185e5d27fe54aeb74df86bd77cd79f52f0b4eae54faa8fea70/regex-2026.5.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:97cf3bc1b7d7d2306772ec07366c80d9df00ff79e79cea32898883a646d2fae2", size = 854473, upload-time = "2026-05-09T23:12:22.465Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/6e3d8202d981f3117004bf341ee74893ba4ba8a9fbaf4b94615846550a08/regex-2026.5.9-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0f9eede6a5cbdc02d4978090186390936e1776a7d1359b21e41014c609880bcf", size = 763311, upload-time = "2026-05-09T23:12:24.351Z" }, + { url = "https://files.pythonhosted.org/packages/93/c7/e7737f1526b3fb32bd4c337fd6c71c3ebb5c8296fc34d11197e0955d2e35/regex-2026.5.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:01f0f5f55f4b64dacec85dc116d3c05fd23ad3ff037bbc73a2085775953c2611", size = 844593, upload-time = "2026-05-09T23:12:26.341Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/0daffb1a535bb39f422c3d200f4ab023c71110ad66a32b366bee708baba0/regex-2026.5.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1268eddd8486dc561d08eee1156e40aa3a8fe10f4bdec8fa653b455fcbffd12c", size = 789167, upload-time = "2026-05-09T23:12:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fc/294fe4fac4f2ed67207b17471815870c1c45b3a489e08e0ac96daea16ef6/regex-2026.5.9-cp311-cp311-win32.whl", hash = "sha256:8676474c07469d6f33dd1085ca2cd45f65785f32518f2b20e36d9953ca07f994", size = 266249, upload-time = "2026-05-09T23:12:30.141Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b0/8dce459f6245bcf8f6e9f23ac9569f1a0f15c131cc0745e82b43226204cf/regex-2026.5.9-cp311-cp311-win_amd64.whl", hash = "sha256:246de9d60aa3f8538b519834dd95cbf276ea263d6a7bd5a3666dc3fa0230505b", size = 278423, upload-time = "2026-05-09T23:12:31.676Z" }, + { url = "https://files.pythonhosted.org/packages/db/8d/f9aeff6ad63a3ef720386f2907e6d34a35a510a6e498ebad28b0fb3f6ab6/regex-2026.5.9-cp311-cp311-win_arm64.whl", hash = "sha256:d726ca3f0d76969bf1e8e477d160d3d666bbf999f6860bd314889e5345782046", size = 270420, upload-time = "2026-05-09T23:12:33.194Z" }, + { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" }, + { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" }, + { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" }, + { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" }, + { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" }, + { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, + { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, + { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, + { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, + { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, + { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, + { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, + { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, + { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, + { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, + { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, + { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036", size = 355609, upload-time = "2026-05-28T11:58:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc", size = 348460, upload-time = "2026-05-28T11:58:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164", size = 381031, upload-time = "2026-05-28T11:58:53.775Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead", size = 387121, upload-time = "2026-05-28T11:58:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece", size = 501026, upload-time = "2026-05-28T11:58:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb", size = 391865, upload-time = "2026-05-28T11:58:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda", size = 378012, upload-time = "2026-05-28T11:58:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a", size = 391111, upload-time = "2026-05-28T11:59:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0", size = 409225, upload-time = "2026-05-28T11:59:02.433Z" }, + { url = "https://files.pythonhosted.org/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a", size = 556487, upload-time = "2026-05-28T11:59:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2", size = 620798, upload-time = "2026-05-28T11:59:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2", size = 584053, upload-time = "2026-05-28T11:59:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f", size = 214390, upload-time = "2026-05-28T11:59:08.402Z" }, + { url = "https://files.pythonhosted.org/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a", size = 231097, upload-time = "2026-05-28T11:59:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b", size = 226361, upload-time = "2026-05-28T11:59:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea", size = 357151, upload-time = "2026-05-28T12:01:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb", size = 350195, upload-time = "2026-05-28T12:01:54.901Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df", size = 381850, upload-time = "2026-05-28T12:01:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7", size = 387899, upload-time = "2026-05-28T12:01:58.212Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc", size = 501618, upload-time = "2026-05-28T12:01:59.888Z" }, + { url = "https://files.pythonhosted.org/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162", size = 394003, upload-time = "2026-05-28T12:02:01.482Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251", size = 379778, upload-time = "2026-05-28T12:02:03.197Z" }, + { url = "https://files.pythonhosted.org/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a", size = 392359, upload-time = "2026-05-28T12:02:04.817Z" }, + { url = "https://files.pythonhosted.org/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b", size = 412820, upload-time = "2026-05-28T12:02:06.401Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34", size = 557243, upload-time = "2026-05-28T12:02:08.013Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c", size = 623541, upload-time = "2026-05-28T12:02:09.661Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" }, +] + +[[package]] +name = "safetensors" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", +] +dependencies = [ + { name = "joblib", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "threadpoolctl", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/3e/daed796fd69cce768b8788401cc464ea90b306fb196ae1ffed0b98182859/scikit_learn-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b33579c10a3081d076ab403df4a4190da4f4432d443521674637677dc91e61f", size = 9336221, upload-time = "2025-09-09T08:20:19.328Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ce/af9d99533b24c55ff4e18d9b7b4d9919bbc6cd8f22fe7a7be01519a347d5/scikit_learn-1.7.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:36749fb62b3d961b1ce4fedf08fa57a1986cd409eff2d783bca5d4b9b5fce51c", size = 8653834, upload-time = "2025-09-09T08:20:22.073Z" }, + { url = "https://files.pythonhosted.org/packages/58/0e/8c2a03d518fb6bd0b6b0d4b114c63d5f1db01ff0f9925d8eb10960d01c01/scikit_learn-1.7.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7a58814265dfc52b3295b1900cfb5701589d30a8bb026c7540f1e9d3499d5ec8", size = 9660938, upload-time = "2025-09-09T08:20:24.327Z" }, + { url = "https://files.pythonhosted.org/packages/2b/75/4311605069b5d220e7cf5adabb38535bd96f0079313cdbb04b291479b22a/scikit_learn-1.7.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a847fea807e278f821a0406ca01e387f97653e284ecbd9750e3ee7c90347f18", size = 9477818, upload-time = "2025-09-09T08:20:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9b/87961813c34adbca21a6b3f6b2bea344c43b30217a6d24cc437c6147f3e8/scikit_learn-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:ca250e6836d10e6f402436d6463d6c0e4d8e0234cfb6a9a47835bd392b852ce5", size = 8886969, upload-time = "2025-09-09T08:20:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/43/83/564e141eef908a5863a54da8ca342a137f45a0bfb71d1d79704c9894c9d1/scikit_learn-1.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7509693451651cd7361d30ce4e86a1347493554f172b1c72a39300fa2aea79e", size = 9331967, upload-time = "2025-09-09T08:20:32.421Z" }, + { url = "https://files.pythonhosted.org/packages/18/d6/ba863a4171ac9d7314c4d3fc251f015704a2caeee41ced89f321c049ed83/scikit_learn-1.7.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0486c8f827c2e7b64837c731c8feff72c0bd2b998067a8a9cbc10643c31f0fe1", size = 8648645, upload-time = "2025-09-09T08:20:34.436Z" }, + { url = "https://files.pythonhosted.org/packages/ef/0e/97dbca66347b8cf0ea8b529e6bb9367e337ba2e8be0ef5c1a545232abfde/scikit_learn-1.7.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89877e19a80c7b11a2891a27c21c4894fb18e2c2e077815bcade10d34287b20d", size = 9715424, upload-time = "2025-09-09T08:20:36.776Z" }, + { url = "https://files.pythonhosted.org/packages/f7/32/1f3b22e3207e1d2c883a7e09abb956362e7d1bd2f14458c7de258a26ac15/scikit_learn-1.7.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8da8bf89d4d79aaec192d2bda62f9b56ae4e5b4ef93b6a56b5de4977e375c1f1", size = 9509234, upload-time = "2025-09-09T08:20:38.957Z" }, + { url = "https://files.pythonhosted.org/packages/9f/71/34ddbd21f1da67c7a768146968b4d0220ee6831e4bcbad3e03dd3eae88b6/scikit_learn-1.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:9b7ed8d58725030568523e937c43e56bc01cadb478fc43c042a9aca1dacb3ba1", size = 8894244, upload-time = "2025-09-09T08:20:41.166Z" }, + { url = "https://files.pythonhosted.org/packages/a7/aa/3996e2196075689afb9fce0410ebdb4a09099d7964d061d7213700204409/scikit_learn-1.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d91a97fa2b706943822398ab943cde71858a50245e31bc71dba62aab1d60a96", size = 9259818, upload-time = "2025-09-09T08:20:43.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/5d/779320063e88af9c4a7c2cf463ff11c21ac9c8bd730c4a294b0000b666c9/scikit_learn-1.7.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:acbc0f5fd2edd3432a22c69bed78e837c70cf896cd7993d71d51ba6708507476", size = 8636997, upload-time = "2025-09-09T08:20:45.468Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d0/0c577d9325b05594fdd33aa970bf53fb673f051a45496842caee13cfd7fe/scikit_learn-1.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5bf3d930aee75a65478df91ac1225ff89cd28e9ac7bd1196853a9229b6adb0b", size = 9478381, upload-time = "2025-09-09T08:20:47.982Z" }, + { url = "https://files.pythonhosted.org/packages/82/70/8bf44b933837ba8494ca0fc9a9ab60f1c13b062ad0197f60a56e2fc4c43e/scikit_learn-1.7.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d6e9deed1a47aca9fe2f267ab8e8fe82ee20b4526b2c0cd9e135cea10feb44", size = 9300296, upload-time = "2025-09-09T08:20:50.366Z" }, + { url = "https://files.pythonhosted.org/packages/c6/99/ed35197a158f1fdc2fe7c3680e9c70d0128f662e1fee4ed495f4b5e13db0/scikit_learn-1.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:6088aa475f0785e01bcf8529f55280a3d7d298679f50c0bb70a2364a82d0b290", size = 8731256, upload-time = "2025-09-09T08:20:52.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/93/a3038cb0293037fd335f77f31fe053b89c72f17b1c8908c576c29d953e84/scikit_learn-1.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b7dacaa05e5d76759fb071558a8b5130f4845166d88654a0f9bdf3eb57851b7", size = 9212382, upload-time = "2025-09-09T08:20:54.731Z" }, + { url = "https://files.pythonhosted.org/packages/40/dd/9a88879b0c1104259136146e4742026b52df8540c39fec21a6383f8292c7/scikit_learn-1.7.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:abebbd61ad9e1deed54cca45caea8ad5f79e1b93173dece40bb8e0c658dbe6fe", size = 8592042, upload-time = "2025-09-09T08:20:57.313Z" }, + { url = "https://files.pythonhosted.org/packages/46/af/c5e286471b7d10871b811b72ae794ac5fe2989c0a2df07f0ec723030f5f5/scikit_learn-1.7.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:502c18e39849c0ea1a5d681af1dbcf15f6cce601aebb657aabbfe84133c1907f", size = 9434180, upload-time = "2025-09-09T08:20:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fd/df59faa53312d585023b2da27e866524ffb8faf87a68516c23896c718320/scikit_learn-1.7.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a4c328a71785382fe3fe676a9ecf2c86189249beff90bf85e22bdb7efaf9ae0", size = 9283660, upload-time = "2025-09-09T08:21:01.71Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c7/03000262759d7b6f38c836ff9d512f438a70d8a8ddae68ee80de72dcfb63/scikit_learn-1.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:63a9afd6f7b229aad94618c01c252ce9e6fa97918c5ca19c9a17a087d819440c", size = 8702057, upload-time = "2025-09-09T08:21:04.234Z" }, + { url = "https://files.pythonhosted.org/packages/55/87/ef5eb1f267084532c8e4aef98a28b6ffe7425acbfd64b5e2f2e066bc29b3/scikit_learn-1.7.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9acb6c5e867447b4e1390930e3944a005e2cb115922e693c08a323421a6966e8", size = 9558731, upload-time = "2025-09-09T08:21:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/93/f8/6c1e3fc14b10118068d7938878a9f3f4e6d7b74a8ddb1e5bed65159ccda8/scikit_learn-1.7.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:2a41e2a0ef45063e654152ec9d8bcfc39f7afce35b08902bfe290c2498a67a6a", size = 9038852, upload-time = "2025-09-09T08:21:08.628Z" }, + { url = "https://files.pythonhosted.org/packages/83/87/066cafc896ee540c34becf95d30375fe5cbe93c3b75a0ee9aa852cd60021/scikit_learn-1.7.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98335fb98509b73385b3ab2bd0639b1f610541d3988ee675c670371d6a87aa7c", size = 9527094, upload-time = "2025-09-09T08:21:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2b/4903e1ccafa1f6453b1ab78413938c8800633988c838aa0be386cbb33072/scikit_learn-1.7.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191e5550980d45449126e23ed1d5e9e24b2c68329ee1f691a3987476e115e09c", size = 9367436, upload-time = "2025-09-09T08:21:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/b5/aa/8444be3cfb10451617ff9d177b3c190288f4563e6c50ff02728be67ad094/scikit_learn-1.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:57dc4deb1d3762c75d685507fbd0bc17160144b2f2ba4ccea5dc285ab0d0e973", size = 9275749, upload-time = "2025-09-09T08:21:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/dee5acf66837852e8e68df6d8d3a6cb22d3df997b733b032f513d95205b7/scikit_learn-1.7.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fa8f63940e29c82d1e67a45d5297bdebbcb585f5a5a50c4914cc2e852ab77f33", size = 9208906, upload-time = "2025-09-09T08:21:18.557Z" }, + { url = "https://files.pythonhosted.org/packages/3c/30/9029e54e17b87cb7d50d51a5926429c683d5b4c1732f0507a6c3bed9bf65/scikit_learn-1.7.2-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f95dc55b7902b91331fa4e5845dd5bde0580c9cd9612b1b2791b7e80c3d32615", size = 8627836, upload-time = "2025-09-09T08:21:20.695Z" }, + { url = "https://files.pythonhosted.org/packages/60/18/4a52c635c71b536879f4b971c2cedf32c35ee78f48367885ed8025d1f7ee/scikit_learn-1.7.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9656e4a53e54578ad10a434dc1f993330568cfee176dff07112b8785fb413106", size = 9426236, upload-time = "2025-09-09T08:21:22.645Z" }, + { url = "https://files.pythonhosted.org/packages/99/7e/290362f6ab582128c53445458a5befd471ed1ea37953d5bcf80604619250/scikit_learn-1.7.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96dc05a854add0e50d3f47a1ef21a10a595016da5b007c7d9cd9d0bffd1fcc61", size = 9312593, upload-time = "2025-09-09T08:21:24.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/87/24f541b6d62b1794939ae6422f8023703bbf6900378b2b34e0b4384dfefd/scikit_learn-1.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:bb24510ed3f9f61476181e4db51ce801e2ba37541def12dc9333b946fc7a9cf8", size = 8820007, upload-time = "2025-09-09T08:21:26.713Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'win32'", +] +dependencies = [ + { name = "joblib", marker = "python_full_version >= '3.11'" }, + { name = "narwhals", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "threadpoolctl", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686, upload-time = "2026-06-02T11:53:05.439Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782, upload-time = "2026-06-02T11:53:08.904Z" }, + { url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419, upload-time = "2026-06-02T11:53:11.551Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411, upload-time = "2026-06-02T11:53:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736, upload-time = "2026-06-02T11:53:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564, upload-time = "2026-06-02T11:53:21.2Z" }, + { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, + { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, + { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, + { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, + { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, +] + +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, + { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, + { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, + { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, + { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, + { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, + { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, + { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, + { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, + { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, + { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + +[[package]] +name = "sentence-transformers" +version = "5.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scikit-learn", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "torch" }, + { name = "tqdm" }, + { name = "transformers" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/56/d2cb00765a6b15c994a7fccf20f9032f16e8193ca49147cb5155166ad744/sentence_transformers-5.6.0.tar.gz", hash = "sha256:0e7164d051e416c1853ade7c274ff52af3f9da0f4be7f0b83d734c27699e1057", size = 453194, upload-time = "2026-06-16T14:01:56.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c1/dc1582b79e9a2eb0cddf9559cd9bcdff084f541d6fe881fdd9d98630dba7/sentence_transformers-5.6.0-py3-none-any.whl", hash = "sha256:d2075b5e687a1611005e20ab04a6846994d51adfcf39610aed066af3c0c0b81f", size = 596411, upload-time = "2026-06-16T14:01:55.103Z" }, +] + +[[package]] +name = "setuptools" +version = "81.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sqlite-vec" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/85/9fad0045d8e7c8df3e0fa5a56c630e8e15ad6e5ca2e6106fceb666aa6638/sqlite_vec-0.1.9-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb", size = 131171, upload-time = "2026-03-31T08:02:31.717Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/3677e0cd2f92e5ebc43cd29fbf565b75582bff1ccfa0b8327c7508e1084f/sqlite_vec-0.1.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c", size = 165434, upload-time = "2026-03-31T08:02:32.712Z" }, + { url = "https://files.pythonhosted.org/packages/00/d4/f2b936d3bdc38eadcbd2a87875815db36430fab0363182ba5d12cd8e0b51/sqlite_vec-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9", size = 160076, upload-time = "2026-03-31T08:02:33.796Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ad/6afd073b0f817b3e03f9e37ad626ae341805891f23c74b5292818f49ac63/sqlite_vec-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786", size = 163388, upload-time = "2026-03-31T08:02:34.888Z" }, + { url = "https://files.pythonhosted.org/packages/42/89/81b2907cda14e566b9bf215e2ad82fc9b349edf07d2010756ffdb902f328/sqlite_vec-0.1.9-py3-none-win_amd64.whl", hash = "sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32", size = 292804, upload-time = "2026-03-31T08:02:36.035Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, + { url = "https://files.pythonhosted.org/packages/84/04/655b79dbcc9b3ac5f1479f18e931a344af67e5b7d3b251d2dcdcd7558592/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:753d47ebd4542742ef9261d9da92cd545b2cacbb48349a1225466745bb866ec4", size = 3282301, upload-time = "2026-01-05T10:40:34.858Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/e4851401f3d8f6f45d8480262ab6a5c8cb9c4302a790a35aa14eeed6d2fd/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e10bf9113d209be7cd046d40fbabbaf3278ff6d18eb4da4c500443185dc1896c", size = 3161308, upload-time = "2026-01-05T10:40:40.737Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6e/55553992a89982cd12d4a66dddb5e02126c58677ea3931efcbe601d419db/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64d94e84f6660764e64e7e0b22baa72f6cd942279fdbb21d46abd70d179f0195", size = 3718964, upload-time = "2026-01-05T10:40:46.56Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/b1c87148aa15e099243ec9f0cf9d0e970cc2234c3257d558c25a2c5304e6/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f01a9c019878532f98927d2bacb79bbb404b43d3437455522a00a30718cdedb5", size = 3373542, upload-time = "2026-01-05T10:40:52.803Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "torch" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/ed/ff0c4f8cef63977a646dc80e40c05cae873f4097b12dc87e1cd7e1cecf42/torch-2.12.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:ec56e82be6a8b0c036771a77f7d32ad3c299770571af9815b3dafe61434389d5", size = 87967927, upload-time = "2026-06-17T21:08:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/85/1b/c8ecf60c9dba535f9ea341c359c600c0bd877a7ca14b3296f13316321847/torch-2.12.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:42cd7339bf266f14944710e8274be63e7e012bb937834a8d85a8327a9860eba6", size = 426366829, upload-time = "2026-06-17T21:07:18.574Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d6/73d4a3f27e00526e98086f3a64ab609af1345cca62367749fbc3c8e4b83c/torch-2.12.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a7817f0f89a796d9de239d06f69faf5d7e19a6a5db6710a5ead777c912f9f50a", size = 532144834, upload-time = "2026-06-17T21:08:00.633Z" }, + { url = "https://files.pythonhosted.org/packages/e3/51/4010c8fa6f9d1f42c054a321970ca95ec58e4e4494f5b53a34c3f3c9e310/torch-2.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:2af3d9cc866e0a15ae7635ff0a9c61d6624a353ad657f5bcd8d86c26cdc64693", size = 122949863, upload-time = "2026-06-17T21:08:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e3/750b3e3548635ceac03ba255daa26dbc7ed66ca3484dc4b4d955ab7f4501/torch-2.12.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:107df6888624bdea41508f9aeb6149d9333c737a5530ceecb56c904e811369ae", size = 426379894, upload-time = "2026-06-17T21:06:55.077Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ca/ed24783da629ff3e640ba3f70a7639e9045d3d88b93ee6bc47b8a28a1f2c/torch-2.12.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6e29e7e74d05bda7d955c75e99459f878ebd970ef851b4057edbd3b34a5eb4a3", size = 532169264, upload-time = "2026-06-17T21:08:17.65Z" }, + { url = "https://files.pythonhosted.org/packages/46/61/c63f0158446f3a98ea672b004d761b848911eba567ea4a624c7db5aadc04/torch-2.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:a513506cfda3c1c78dabeb6574c1597538c0254b3d39af174dde35d8177f4ce3", size = 122953086, upload-time = "2026-06-17T21:08:27.69Z" }, + { url = "https://files.pythonhosted.org/packages/f0/54/efb7ebca77970012b0cc21687a55d70eb2ba514b2c2b8e18d9fb1222f3be/torch-2.12.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:d2dd0f2c5f7ccbddaf34cade0deaf476808368f902b9cdb7f36a2ab42301bc0e", size = 87991951, upload-time = "2026-06-17T21:07:49.309Z" }, + { url = "https://files.pythonhosted.org/packages/1e/00/4210d76ca7424981f04033ebe7e48816ab83287a62538747a58825db770c/torch-2.12.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:2de4e19b88a481482c6c75291f2d6a52eda3ce51f311b29aa9b68499c830c07c", size = 426382721, upload-time = "2026-06-17T21:06:41.842Z" }, + { url = "https://files.pythonhosted.org/packages/76/1f/bc9f5a5aa569307076365f25afcebacb22e9c754b1bcfbaaa146627c7fda/torch-2.12.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:649e4ced014ba646f76f8cb9c9726735a6323eb321b7919f942790a923f90921", size = 532261322, upload-time = "2026-06-17T21:06:06.673Z" }, + { url = "https://files.pythonhosted.org/packages/9e/49/c549461daa008159d006a76a991fbc2f26fa8bac27a4030c858463dcb20f/torch-2.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:e86550597877fb272ddc52db2f85b82cb601ea7bd932576a0340152cae2200b3", size = 122988095, upload-time = "2026-06-17T21:07:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4a/0300261818e1560d72cc160ac826005507e8b7ca0a35788b591436d05b4a/torch-2.12.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c75e93173c700bccd6bfcc4a9d19ce242ab6dacd1f1781483027a16239b9e650", size = 87992358, upload-time = "2026-06-17T21:07:40.299Z" }, + { url = "https://files.pythonhosted.org/packages/30/a7/874a5ca05e8f159211dca7921060f7057acc1adb26431e119fd150623efc/torch-2.12.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:fcb61ccd20784b62bdd78ec84238a5cfb383b4994902e03bac95505ab360884c", size = 426386134, upload-time = "2026-06-17T21:07:31.481Z" }, + { url = "https://files.pythonhosted.org/packages/e1/75/20bb8fe9c1ad6538cce8cd0391b51927ae5af0b17ed1eab44b8824465dc1/torch-2.12.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f4afc8083dff08719edbea346644476e3cec0cf40ebe256be0ee5d5b7c7e8c0d", size = 532268019, upload-time = "2026-06-17T21:05:37.925Z" }, + { url = "https://files.pythonhosted.org/packages/d1/fa/824ddb662af55b2eabc0dbb7b57c7c0b1bcd93693754a2b8509ec4d16490/torch-2.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:f92609e3b3ce72f25e2eb780d043ced2480c1a86c47c852604fc7a9108648386", size = 122987777, upload-time = "2026-06-17T21:07:09.49Z" }, + { url = "https://files.pythonhosted.org/packages/63/b7/1b49fe7086ea36839cc80abc43174c43d0ab6f676c0891c871c162f44fe3/torch-2.12.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e9b6f7d2dd66ea87a3ae620069d31335d594c06effb1a383bdd21cfe61e44ece", size = 88010025, upload-time = "2026-06-17T21:07:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/d7/06/5b44063a6545036dcc680d2d303b137d9176cfb2cc1e1863e3ef94abeb52/torch-2.12.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:7973ccd3d2cd35c74449213f7bded199bec6c6247e705cbeda7407af79703d91", size = 426392891, upload-time = "2026-06-17T21:05:52.261Z" }, + { url = "https://files.pythonhosted.org/packages/f8/dd/c9ce9a4b0eb3c5bb92d9ea56766e2c22559f0b45171149188494edcce80f/torch-2.12.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c64ac4aac16be5e296dcd912305605804b203333c690bf98c55bc09494ee92ad", size = 532272494, upload-time = "2026-06-17T21:06:22.72Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/f3a601fc1b1f663ff269bfe553654e638651939aa6563e8daa7167c33098/torch-2.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:f6dc4caf7eb4adb38a2d9f536b51db56310fdd1254e69a2d96767e1367c892b3", size = 122987254, upload-time = "2026-06-17T21:06:33.199Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/b8087556cf81ddd808dbeb34afb8396d7ae7a1694ab489f08b1a0004e7d0/torch-2.12.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:2afbb2bdaa8a95040e733f05492ddf133c3967c9b7ce0abd218d704b6cab437d", size = 88303173, upload-time = "2026-06-17T21:05:06.603Z" }, + { url = "https://files.pythonhosted.org/packages/4a/07/fe09d1699fbed2afa10ebc692ff2b99d113f2605b6748cea633989e2789a/torch-2.12.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:97eba061fcb042fed191400b15568990073d67eaacaa6ee9b7ca01dd8b790fe9", size = 426404009, upload-time = "2026-06-17T21:04:57.557Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f7/0ce4f6c1962c60ded7270e0a9eb560fb615c92b89d332cf9e3dff36d5ecc/torch-2.12.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3867b861391701012adb2df93360efb88494dca245a185e3bb7624495cfe3f33", size = 532184292, upload-time = "2026-06-17T21:05:17.526Z" }, + { url = "https://files.pythonhosted.org/packages/70/db/e384c12aba30320ca92aaaf557456cbcb26f04b4df307728bb8f019f5000/torch-2.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dd15595f8fc764cffde8c6361a3beb6ef69a028c851b1b3e70e077f615980d4e", size = 123231142, upload-time = "2026-06-17T21:05:27.061Z" }, +] + +[[package]] +name = "tqdm" +version = "4.68.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, +] + +[[package]] +name = "transformers" +version = "5.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/7c/8240f612819718100a9346dc28dea6a11370c3ca9c8c6eabadd3dea4ef29/transformers-5.12.1.tar.gz", hash = "sha256:679ee731c8225347889ad4fb3b2c926a62e9da3b7d284e9d12c791da7272466b", size = 8924054, upload-time = "2026-06-15T17:27:50.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/56/bbd60dd8668055803bf8ba55a81f9b8a8b31497f620109a9671d26a2076d/transformers-5.12.1-py3-none-any.whl", hash = "sha256:2a5e109d2021265df7098ffbb738295acaf5ad256f12cbc586db2ea4dcbb1a8a", size = 11150587, upload-time = "2026-06-15T17:27:46.679Z" }, +] + +[[package]] +name = "triton" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/ea/629cc37436ca5df93ce98956d09cd2ca1498bfee8ef4972d2fe48b9f958c/triton-3.7.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3daf64305d6cea88d3334c65ebc9bcd0c64c9564a977084366aa768d57cbcf64", size = 184551013, upload-time = "2026-06-17T20:03:37.551Z" }, + { url = "https://files.pythonhosted.org/packages/15/76/c79c34311625227a288df3e483fc5cdf3d596624cbd4b4758c4cbdc14af3/triton-3.7.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee89fbf782ec2ad50391dd1cf26cbea4f4467154c37f4773026da8fc31c0f58e", size = 197596267, upload-time = "2026-06-17T19:53:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f9/19d842d06a08559534fa1eaab6ca551b1bcf40f06620bddec1babaa2772d/triton-3.7.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a0e1cd4c4a76370ed74a8432a53cea28716827d19e40ffc732233e35ceb3f6", size = 184664887, upload-time = "2026-06-17T20:03:42.913Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5e/fce69606f7f240297f163e25539906732b199530d486ce67ae319877e821/triton-3.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6744957e9fd610a29680ec2346057d0c86948ed3812468670719f391e94b44a5", size = 197701306, upload-time = "2026-06-17T19:53:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/94/fa/f856e24deb462d5f18bd4b5a746957862ab9b6ee5834bda60605ec348366/triton-3.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9497f2e696ee368862a181a90b2dcc03ca978cc4f602abd67c7d81022a6988e1", size = 184692359, upload-time = "2026-06-17T20:03:48.288Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6f/fb96d15db6f36d6eae4cafb998c2e0353bf59d7c4ea1662d7497f269134a/triton-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e40869937a68206ec70d7f25bb7ec6433cb083f9135e1f36dbd318dc449a728", size = 197719725, upload-time = "2026-06-17T19:53:20.419Z" }, + { url = "https://files.pythonhosted.org/packages/00/42/c5089d4d9327fcd1e862c599cc2927f39418f84dd11a84cb2ccff9d4787a/triton-3.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdbfc09d9ec58bc5e68321525653220de7515c199e7a8097a97c85e62b52cd0a", size = 184694629, upload-time = "2026-06-17T20:03:53.444Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/2c3ac59253ae8892b6f307875263dd23dc875cdf732d3aea40d6d41fb7cb/triton-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58c0e131da05134a2a4788ccbcc0c1105cf0f54c8e98f19e34cd465396dc15eb", size = 197729241, upload-time = "2026-06-17T19:53:27.801Z" }, + { url = "https://files.pythonhosted.org/packages/40/71/e01aa7ad573883ed9456f130226babdec70b005e098c4d6226a6238e761b/triton-3.7.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4ea396a06171f1f1f58cbd39c70b09294398f7dd7c620939bab54ad6f934fa", size = 184705764, upload-time = "2026-06-17T20:03:59.064Z" }, + { url = "https://files.pythonhosted.org/packages/a4/09/5683146fda6a2b569deb78ccfd8fbfea8bfe55f726b081c0a6bb18dd6f28/triton-3.7.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2020153b08280415ec0da6607834e79166442147e78e144df06b508c75b186d2", size = 197729537, upload-time = "2026-06-17T19:53:35.516Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/448220c3092019f9fdfab39ec47985968181d67da34b44f6a7f6280a5cbb/triton-3.7.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c58e4c61f0c73b5dba3b5d19b4a7093c32f90dc18b2a7f121a7c16ccd31107b7", size = 184814760, upload-time = "2026-06-17T20:04:04.984Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ac/229b7d4589d2e5937310e72c6d46e89599d16a4a12b479ffa1499fee8eb8/triton-3.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10ba85fa2cca4a2fbdeb36bf1cb082f2c252bda55bf9fccd74f65ec5bc647e68", size = 197824404, upload-time = "2026-06-17T19:53:42.772Z" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +]