diff --git a/.env.example b/.env.example index 3268f192..6c05b76c 100644 --- a/.env.example +++ b/.env.example @@ -14,8 +14,6 @@ CURRENT_DOMAIN=https://example.com # Server Configuration PORT=8999 -BASE_RECURSION_LIMIT=1000 -ESTIMATED_CHUNKS=30 MAX_VISITS=1 # Which halves of the pipeline run. `ontology` stops after the ontology block and # writes no facts; `facts` skips the ontology block entirely and extracts only @@ -27,11 +25,69 @@ RENDER_MODE=ontology_and_facts # ontology | facts | ontolo # reliably than nested objects. LLM_GRAPH_FORMAT=jsonld # jsonld | turtle +# Syntax of the `# ONTOLOGY` chapter in the facts render and critic prompts. +# `auto` (default) takes the cheapest chapter the render mode can legally read: +# `term_sheet` under RENDER_MODE=facts, `inherit` otherwise. `inherit` follows +# LLM_GRAPH_FORMAT; `turtle` shows the ontology as Turtle whatever the wire +# format is, which spends fewer characters per triple than pretty-printed +# JSON-LD; `term_sheet` replaces the serialized graph with a line-per-term +# listing -- name, surface forms, type, hierarchy, domain/range and usage +# contract -- and is by a wide margin the cheapest of the three. +# Context only: the graph payloads the model emits stay in LLM_GRAPH_FORMAT. +# `term_sheet` requires RENDER_MODE=facts, because the ontology loop patches +# the statements in its chapter and so needs one that is a graph; asking for it +# explicitly on another mode is rejected, where `auto` just yields a graph. +# Changing the resolved chapter invalidates the LLM cache for facts calls. +ONTOLOGY_CHAPTER_FORMAT=auto # auto | inherit | turtle | term_sheet + +# Character caps on the text literals of the ontology chapter. Nothing else in +# the pipeline bounds a single literal, so without these the chapter costs what +# a catalog's authors chose to write rather than what it declares -- one +# rambling rdfs:comment is paid for on every call of every unit. A tersely +# authored catalog is unaffected: these are bounds, not reductions, and unset +# leaves every literal exactly as authored. Clipping happens on a word boundary +# and leaves a visible marker, so a clipped definition reads as clipped. All +# four join the LLM cache key. +# NAMING -- rdfs:label / skos:prefLabel / skos:altLabel +# CONTRACT -- skos:scopeNote / skos:definition (when a term applies) +# PROSE -- rdfs:comment and the remaining SKOS notes +# ONTOLOGY_TEXT_MAX_CHARS_NAMING=80 +# ONTOLOGY_TEXT_MAX_CHARS_CONTRACT=240 +# ONTOLOGY_TEXT_MAX_CHARS_PROSE=160 + +# Ceiling on the summed length of all text literals in one ontology chapter -- +# the backstop for a catalog that stays inside the per-role caps by holding very +# many short terms. Over budget, prose is tightened then dropped, then +# contracts, and only then are names clipped to a floor; names are never +# dropped, so a chapter that still does not fit is passed through with a warning +# rather than made unusable. Read the effect back from the run manifest +# (text_chars_before/after, literals_clipped, literals_dropped). +# ONTOLOGY_TEXT_TOTAL_BUDGET=40000 + # Where the per-unit schema shown to the LLM comes from. `selected_single_ontology` # spends one extra LLM call per content unit to pick a catalog ontology; # `selected_vector_search_ontology` needs a vector backend (QDRANT_URI or # LANCEDB_ENABLED) and is the only mode that runs the consistency critic. ONTOLOGY_CONTEXT_MODE=selected_single_ontology # selected_single_ontology | selected_vector_search_ontology | fixed_single_ontology + +# Whether that schema is resolved per content unit or once per document. +# `unit` gives each unit the smallest chapter -- and a different one, so no two +# calls share a prompt prefix and a provider's prefix cache serves none of them: +# the document pays the ontology chapter at full price once per unit. `document` +# unions the per-unit contexts and shows the union to every unit: larger per +# call, identical across the fan-out, so charged at the cached rate on every +# call after the first. Recall-safe by construction (the union contains +# everything each unit's own retrieval selected); what it costs is precision, +# since a unit also sees its siblings' terms. +ONTOLOGY_CONTEXT_SCOPE=unit # unit | document + +# How many content units to run to completion before fanning the rest out. +# A prefix cache is populated by a request that has already *completed*, so a +# fan-out issuing N calls that share a prefix simultaneously has all N miss it; +# running one unit first turns the other N-1 into hits, at the wall-clock cost +# of one serialized call. Only worth setting together with +# ONTOLOGY_CONTEXT_SCOPE=document, where the calls actually share a prefix. +FANOUT_WARMUP_UNITS=0 # Setting this to a non-empty value FORCES fixed_single_ontology, whatever # ONTOLOGY_CONTEXT_MODE says above. #ONTOLOGY_CONTEXT_FIXED_ONTOLOGY_ID=my_ontology_id # IRI, ontology_id, or author prefix @@ -42,6 +98,18 @@ ONTOLOGY_CONTEXT_MODE=selected_single_ontology # selected_single_ontology # cannot fit is passed through with a warning rather than silently gutted. ONTOLOGY_CONTEXT_MAX_TRIPLES=4000 +# Stop the run when a FACTS unit's ontology context resolves to zero triples, +# instead of extracting with no catalog vocabulary. For facts an empty context +# is not a degraded extraction, it is a different one: the renderer falls back +# on whatever standard vocabulary the prompt names, and the SHACL gate then has +# no node to constrain, so the run reports a vacuous pass. Turn it on when you +# extract against a curated catalog, where an empty context can only mean the +# catalog failed to load. Off by default because the default render mode builds +# ontologies too, and a corpus with no catalog is that mode's starting point. +# It never applies to an ONTOLOGY unit, whose empty context is the signal to +# create a new ontology. +ONTOLOGY_CONTEXT_REQUIRED=false + # NOT a context bound -- a runaway-growth backstop on the per-unit ontology # working graph, all-or-nothing, unset (unlimited) by default. Use # ONTOLOGY_CONTEXT_MAX_TRIPLES above for prompt size. @@ -52,7 +120,7 @@ ENABLE_ONTOLOGY_CONSOLIDATION=false # LLM Configuration # Available providers: openai, ollama, anthropic, google LLM_PROVIDER=openai -LLM_MODEL_NAME=gpt-4o-mini +LLM_MODEL_NAME=gpt-5.4 LLM_TEMPERATURE=0.0 LLM_API_KEY=your_openai_api_key_here # LLM_CACHE_ENABLED=true @@ -132,6 +200,22 @@ CHUNK_SECTION_TAG_MIN_CHARS=80 # Every routing decision is logged, since a false positive silences a section. # Detection is subject-domain-agnostic but English/Latin-script bound. # CHUNK_BIBLIOGRAPHY_MODE=skip +# Drop content units below this many characters before the extraction fan-out; +# 0 disables. Distinct from CHUNK_MIN_SIZE, which the chunker aims at while +# merging segments -- a heading stub can still leave chunking well under it, +# and every such unit costs a retrieval, a render and a critic call. +# CHUNK_MIN_UNIT_CHARS=0 +# Front/back-matter routing: units headed author information, notes, ORCID, +# data availability, competing interests, licence, supporting information etc. +# that state no unit-adjacent number, or whose tokens are mostly emails, URLs, +# ORCIDs and initials. extract (default) keeps them flagged is_non_content; +# skip drops them before the fan-out. Every decision is logged and skipped +# units are counted in the run manifest. English/Latin-script bound. +# CHUNK_NON_CONTENT_MODE=extract +# Split a sized unit at the sentence boundary nearest its midpoint while it +# states more unit-adjacent numbers than this (0 disables). Pieces never go +# below CHUNK_MIN_SIZE. Targets dense units only, unlike lowering the sizes. +# CHUNK_MAX_MEASUREMENTS_PER_UNIT=0 # Terms the citation-metadata prompt uses in citations_only mode. Bibliographic # entries are not domain content, so these are configuration, not retrieval. # Override for catalogs modelling citations with bibo, FaBiO, DCMI, etc. @@ -152,6 +236,11 @@ CHUNK_SECTION_TAG_MIN_CHARS=80 # CONVERTER_OCR_BITMAP_AREA_THRESHOLD=0.05 # TEMP workaround for publisher-PDF ligature splits. # CONVERTER_REPAIR_LIGATURE_GAPS=true +# Repair pattern-local conversion artifacts at conversion time: </>/& +# entities, carriage-return column wraps, flattened exponents ("2 x 10 6") and +# single-sided ligature gaps that cannot read as two words ("a ffected"). +# Joins the converter cache key when on, so enabling it re-converts. +# CONVERTER_REPAIR_NUMERIC_ARTIFACTS=false # Triple Store Configuration # Omit FUSEKI_URI for the default in-memory pyoxigraph backend (process lifetime). @@ -205,6 +294,11 @@ AGG_SIMILARITY_THRESHOLD=0.80 # AGG_INITIALS_DISTINCT_GUARD=true # veto labels differing only by initials # AGG_NATURAL_KEY_MERGE=true # merge pull from shared identifier values # AGG_TYPE_GUARD_UNTYPED=permissive # or strict: typed-vs-untyped fails closed +# Suffix minted fact IRIs with their unit index (`__u`) before +# aggregation, so the same local name minted by two units is a merge decision +# (clustered, then guarded) rather than an accidental fusion. Final IRIs never +# carry the suffix. +# AGG_UNIT_SCOPED_FACT_IRIS=true # Embedding provider (vector store / retrieval) EMBEDDING_PROVIDER=huggingface @@ -233,7 +327,17 @@ QDRANT_USE_GRPC=false # Backend selection: auto (default; Qdrant if QDRANT_URI is set, LanceDB if # enabled, otherwise vector retrieval is disabled), qdrant, lancedb, none. # VECTOR_STORE_BACKEND=auto -VECTOR_STORE_TOP_K=20 +# Candidates each query window offers to selection. NOT the same knob as how +# many atoms survive it -- ONTOLOGY_PATCH_MAX_ATOMS_BASE caps the retained set, +# so raising this does not enlarge the snapshot: it fills the same budget from a +# wider field. Costs vector-search time and nothing in the prompt. +VECTOR_STORE_TOP_K=40 +# Depth of the sparse (BM25) lane when it should differ from TOP_K; unset = TOP_K. +# Lane depth is a weight in disguise, because lanes are fused by reciprocal rank: +# a sparse list of length N votes at ranks 1..N however weak its tail is. Dense and +# lexical retrieval also fail differently -- dense into topical near-misses, lexical +# into unrelated text sharing a token -- so one knob for both mis-tunes one of them. +# VECTOR_STORE_BM25_TOP_K= VECTOR_STORE_INDUCED_SUBGRAPH_DEPTH=2 VECTOR_STORE_INDUCED_SUBGRAPH_MAX_TOTAL_TRIPLES=1200 VECTOR_STORE_INDUCED_SUBGRAPH_ESTIMATED_TRIPLES_PER_QUERY=24 @@ -243,6 +347,56 @@ VECTOR_STORE_INDUCED_SUBGRAPH_ESTIMATED_TRIPLES_PER_QUERY=24 # VECTOR_STORE_INDUCED_SUBGRAPH_TYPE_PROMOTION_SCORE_FACTOR=1.0 # VECTOR_STORE_INDUCED_SUBGRAPH_SEED_ORDER=score # or ontology_round_robin # VECTOR_STORE_PROPOSITION_WINDOW_SENTENCES=2 +# Sentences advanced between windows; unset strides by the window size, so windows +# are contiguous and disjoint. A smaller stride overlaps them, which matters because +# a statement straddling a window boundary otherwise appears in no window at all. +# Overlap costs queries, and once MAX_WINDOWS binds it costs coverage elsewhere. +# VECTOR_STORE_PROPOSITION_WINDOW_STRIDE= +# Characters per query window. Unset bounds windows by sentence count instead +# (the historical behaviour, reproduced exactly). When set it REPLACES the +# sentence bound -- they are two ways of saying the same thing, and honouring +# both means the tighter one silently wins. +# A sentence count bounds the wrong quantity: what decides whether a query works +# is its length, since that governs how many terms one vector must carry and +# whether the encoder truncates it. Two sentences of technical prose span an +# order of magnitude in length. The splitter also breaks on every period with no +# abbreviation handling, so "J. Phys. Chem. Lett." is four sentences and a +# two-sentence window over a citation retrieves nothing. A budget caps the long +# windows AND coalesces the short fragments. Set it below the encoder's sequence +# limit to make truncation impossible. Query-side only: no reindex. +# VECTOR_STORE_PROPOSITION_WINDOW_MAX_CHARS= +# Encoder word pieces per query window. Unset leaves the bounds above in charge; +# when set it REPLACES both of them and characters stop being consulted. +# This is the only bound stated in the unit the encoder itself counts in. +# Characters per token drift with notation, so a character budget that fits one +# passage truncates the next; set below the encoder's sequence limit, a token +# budget rules truncation out by construction. It is also the only bound that +# cuts a sentence longer than the budget -- at a whitespace boundary -- because +# such a sentence is truncated by the encoder anyway and its tail then reaches +# no lane at all. Needs a provider that exposes a tokenizer; without one it +# degrades to a character approximation and logs that it did. No reindex. +# VECTOR_STORE_PROPOSITION_WINDOW_MAX_TOKENS= +# Fraction of a window repeated at the start of the next one, under a character +# or token budget. WINDOW_STRIDE is the sentence-granular spelling of the same +# idea and does not apply under a budget, where a sentence says nothing about +# how much text is shared. Overlap multiplies queries, so it costs embedding +# time and, once MAX_WINDOWS binds, coverage elsewhere in the unit. +# VECTOR_STORE_PROPOSITION_WINDOW_OVERLAP=0.0 +# Rejoin window fragments the period-splitter created inside an abbreviation, an +# initial or a citation run -- general English and bibliographic shapes only, +# never a domain vocabulary. Off by default because it changes the windows every +# existing measurement was taken on. +# VECTOR_STORE_PROPOSITION_ABBREVIATION_AWARE=false +# Forbid a window break between a number and the unit it is written with, or +# inside a range, using number/unit shapes rather than a unit vocabulary. Only +# binds where a cut inside a sentence is possible, i.e. under a token budget +# with a sentence over budget. +# VECTOR_STORE_PROPOSITION_MEASUREMENT_AWARE=false +# Windows over the bound are sampled evenly rather than truncated, so the excerpt +# stays covered end to end -- but text in dropped windows reaches no dense or +# sparse lane. Note also that a window longer than the embedding model's sequence +# limit is truncated by the encoder, silently; watch queries_truncated in the +# retrieval metrics before widening the window. # VECTOR_STORE_PROPOSITION_MAX_WINDOWS=16 # VECTOR_STORE_PROPOSITION_RETRIEVAL_ENABLED=true # VECTOR_STORE_CONSISTENCY_CRITIC_MIN_FUSED_SCORE=0.5 @@ -261,18 +415,59 @@ VECTOR_STORE_INDUCED_SUBGRAPH_ESTIMATED_TRIPLES_PER_QUERY=24 # VECTOR_STORE_LEXICAL_TRIGGER_FUSION=max_merge # or append (legacy) # Query-side unit signals: number-adjacent tokens ("4-15 days", "200 kV", "0.5 %") # matched case-insensitively/plural-tolerantly against catalog surfaces; matched -# entities join the seeds outside the semantic budget. Default off pending the -# recall sweep. -# VECTOR_STORE_QUERY_UNIT_SIGNALS_ENABLED=false +# entities join the seeds outside the semantic budget. On by default -- the +# mechanism fits quantitative extraction exactly, and it is query-time only, so +# switching it needs no reindex. Turn it off for a catalog whose surface forms +# are not Latin-script, or one whose facts are not quantities. +# VECTOR_STORE_QUERY_UNIT_SIGNALS_ENABLED=true + +# Ontology delta post-checks and critic +# Review-and-patch passes per ontology unit -- each one is a PROVIDER CALL. +# Defaults to 0, which is what deployments have always done in practice: the +# ontology critic was gated behind a spare render slot and so never ran by +# default. Enabling it is a real cost increase, so it is opt-in. +# ONTOLOGY_CRITIC_PASSES=0 +# Stricter delete limits than the facts side: an ontology delete propagates onto +# shared, versioned catalog terminals, so its blast radius is every document +# using the term rather than this unit. +# ONTOLOGY_CRITIC_MAX_DELETE_SHARE=0.10 +# ONTOLOGY_CRITIC_MIN_DELETES=3 +# Deterministic ontology findings that block acceptance -- the destructive or +# lossy subset only. Blocking on every mandatory finding would include +# missing_label, which fires whenever a render mints an unlabelled term: routine, +# and a permanent per-unit tax rather than a defect signal. +# ONTOLOGY_ACCEPT_BLOCKING_FINDING_KINDS=["foreign_delete","foreign_namespace","subclass_cycle","role_confusion"] # Facts rendering post-checks and repair # FACTS_OBJECT_PROPERTY_LITERAL_CHECK=true -# Finding-driven repair renders per unit -- each one is a PROVIDER CALL. Only -# MANDATORY findings trigger one (advisory numeric-coverage gaps ride along but -# never fire it on their own). Fires even at MAX_VISITS=1, where the LLM critic -# never runs, so the default costs up to two calls per unit; 0 disables. -# FACTS_LLM_REPAIR_VISITS=1 -# FACTS_PROPERTY_ALIAS_MIN_RATIO=0.85 +# Review-and-patch passes per facts unit -- each one is a PROVIDER CALL. A pass +# re-runs the deterministic checks for free, sends the graph and its findings to +# the critic, and applies what comes back as a compiled patch. The default costs +# two calls per unit (one extraction, one review); 0 is extraction only. +# FACTS_CRITIC_PASSES=1 +# Deprecated alias for FACTS_CRITIC_PASSES, honoured for one release. The +# separate finding-driven repair render it named no longer exists: its budget +# and its trigger both belong to the critic pass, which applies fixes itself +# instead of describing them to a second call. + +# Limits on what one critic pass may destroy. Past the share every fix that +# removes something goes back as residual and only pure additions land (a +# REPLACE without its delete half would put the new value beside the old one); +# the floor keeps short units correctable, where a single legitimate correction +# is already a large fraction of the graph. +# FACTS_CRITIC_MAX_DELETE_SHARE=0.25 +# FACTS_CRITIC_MIN_DELETES=5 +# Whether a REPLACE may delete statements about one subject while writing about +# another. That is a rename: carried out literally it orphans the old node and +# leaves the new one bare. +# FACTS_CRITIC_ALLOW_SUBJECT_RENAME=false +# Deterministic near-miss predicate rewrite. A predicate absent from both the +# unit's ontology context and the full catalog is rewritten only to a catalog +# term whose name tokens contain, are contained by, or spell the same as its +# own; this ratio only breaks a tie between several such terms. Similarity +# alone never licenses a rewrite -- a high ratio also joins names that differ +# by one token, which is a different property. +# FACTS_PROPERTY_ALIAS_MIN_RATIO=0.95 # Predicates whose literals are machine-resolvable codes: a node carrying # qudt:ucumCode "d" and no unit link gains qudt:unit unit:DAY when exactly one # catalog individual declares that code. JSON array of predicate IRIs. @@ -283,6 +478,35 @@ VECTOR_STORE_INDUCED_SUBGRAPH_ESTIMATED_TRIPLES_PER_QUERY=24 # findings without repairing. A pass is kept only if it strictly reduces them. # FACTS_MERGE_REPAIR_PASSES=1 # FACTS_SUSPECT_MULTI_VALUE_SEVERITY=error # or warning +# +# --- Ablation arms: off by default, each changes extraction output --- +# Report an IRI-branch SUSPECT_MULTI_VALUE as an error only when the objects +# came from different units. A predicate that is single-valued elsewhere says +# nothing on its own, and error findings drive the un-merge repair -- so a +# statement one unit asserted with two genuine objects gets repaired away. +# FACTS_SUSPECT_MULTI_VALUE_REQUIRE_CROSS_UNIT=false +# --- end ablation arms --- +# Keep identifier digit groups ("600/92", "10.1234/example") out of the +# numeric-coverage inventory, so the critic is not asked to structure a file +# number into numeric properties. Set false to list every digit group. +# FACTS_NUMERIC_IDENTIFIER_GUARD=true +# In facts-only runs, seed the merge/validate ontology context from the +# snapshots the units resolved. With no ontology stage there are no reduced +# artifacts to merge, so both the aggregator's guards and the validation gate +# otherwise see an empty vocabulary (watch validated_without_ontology_context). +# Set false to reproduce the empty-context behaviour. +# FACTS_CONTEXT_FROM_UNITS=true +# Skip the facts critic for a unit whose render holds fewer triples than this. +# A critic reviewing an empty graph scores it perfect and bills a call; the +# default skips exactly the empty renders. 0 reviews everything. +# FACTS_CRITIC_MIN_TRIPLES=1 +# Completion passes per facts unit, in PROVIDER CALLS, run after the critic +# loop and only when the numeric inventory still lists measurements (a number +# with its unit) absent from the graph. The pass is insert-only: it is shown a +# term sheet instead of the ontology chapter and the missed measurements with +# their context, and each new subject it writes is kept or rolled back by the +# same regression check a critic fix goes through. 0 disables the pass. +# FACTS_COMPLETION_PASSES=0 # Collapse duplicate literals differing only in language tag or datatype # ("X"@en / "X"^^xsd:string / "X") before validation; lang-tagged form wins. # FACTS_LITERAL_VARIANT_DEDUPE=true @@ -327,6 +551,35 @@ VECTOR_STORE_INDUCED_SUBGRAPH_ESTIMATED_TRIPLES_PER_QUERY=24 # violators that assert nothing (rdf:type/rdfs:label only). Never invents data. # FACTS_SHACL_AUTOFIX=prune # FACTS_SHACL_AUTOFIX_PASSES=1 +# Render the loaded shapes as a CONFORMANCE REQUIREMENTS chapter in the facts +# render and critic prompts, so the renderer is shown the same rulebook the +# gate validates against. The chapter is derived at run time (sh:message +# verbatim where the author wrote one), and the terms the shapes require are +# exempt from UNKNOWN_TERM (full catalog, whatever the mode). Modes: +# 'full' = the whole catalog, capped and memoized per tenancy; 'context' = +# per-unit selection, keeping only shapes whose targets/paths intersect the +# unit's resolved ontology snapshot; 'auto' (default) = 'full' while the +# catalog fits the line cap, 'context' once it outgrows it. A deployment +# without shapes sees an unchanged prompt in every mode. +# FACTS_SHAPES_PROMPT_CONTRACT=auto # off | auto | full | context +# Cap on total rule lines in the shapes-derived chapter; in 'auto' also the +# threshold at which per-unit selection takes over from the whole catalog. +# Truncation is noted in-text, so the model does not read absence as +# nonexistence. +# FACTS_SHAPES_PROMPT_MAX_LINES=60 +# Cap on missing-numeric mentions listed across the two NUMERIC_COVERAGE +# findings. Measurements (a number next to a unit) come first in text order, +# each with its surrounding context; bare numbers follow shortest-first and +# fill what is left of the cap. 0 disables both findings. +# FACTS_NUMERIC_COVERAGE_LIMIT=30 +# Which NUMERIC_COVERAGE findings are mandatory, so unextracted source +# numerics block unit acceptance: 'off' (default) keeps both advisory, +# 'measurements' blocks on unit-adjacent numbers only, 'all' blocks on bare +# numbers too. The booleans true/false are accepted as all/off. Coverage is +# advisory by default because the critic judges per mention whether it is a +# quantity or an artifact; 'measurements' is the setting for runs that treat +# a missed unit-adjacent value as a defect. +# FACTS_NUMERIC_COVERAGE_MANDATORY=off # off | measurements | all # Vocabulary the facts prompt names as the fallback for bounded/approximate # quantities when retrieval supplied no suitable class. JSON object keyed by @@ -339,17 +592,38 @@ VECTOR_STORE_INDUCED_SUBGRAPH_ESTIMATED_TRIPLES_PER_QUERY=24 # uses it. # FACTS_ADDITIONAL_STANDARD_NAMESPACES=["https://schema.org/", "http://schema.org/"] +# Floor on the fraction of a render's distinct schema terms (predicates and +# rdf:type objects, excluding minted instances and RDF/RDFS/OWL/XSD/SKOS/DC/PROV +# plumbing) that must come from the unit's ontology context. Below it, a +# mandatory DOMAIN_ADHERENCE finding asks for a rewrite. Catches the failure no +# per-triple check can see: a render that says everything in a generic +# vocabulary is well-formed term by term, exempt from UNKNOWN_TERM, and matched +# by no shape -- it reads as extracted while answering nothing. Set 0 to +# disable, and leave it disabled if you extract without a catalog. Calibrate +# against the share your own healthy runs report. +# FACTS_DOMAIN_ADHERENCE_MIN_SHARE=0.15 +# Fewest distinct schema terms a render must use before its catalog share is +# judged at all. A share over one or two terms is noise -- a front-matter unit +# typing an identifier and an author generically has not abandoned the catalog +# -- and the mandatory finding it raised drove the critic into retyping the +# identifier as a quantity value. +# FACTS_DOMAIN_ADHERENCE_MIN_TERMS=4 + # Embedded LanceDB alternative (optional; local directory via lancedb.connect) # LANCEDB_ENABLED=true # LANCEDB_DATA_DIR=~/.lancedb_data # Ontology patch retrieval (default: max_score dedupe → global score order → window-scaled cap) # A per-ontology seed quota of 0 means no round-robin; set >0 to cap seeds per ontology. -ONTOLOGY_PATCH_CROSS_QUERY_MERGE_MODE=max_score +ONTOLOGY_PATCH_CROSS_QUERY_MERGE_MODE=max_score # max_score | sum_score ONTOLOGY_PATCH_PER_ONTOLOGY_SEED_QUOTA=0 ONTOLOGY_PATCH_SEEDS_PER_WINDOW=4 ONTOLOGY_PATCH_MAX_ATOMS_BASE=96 ONTOLOGY_PATCH_MAX_ATOMS=96 +# Relevance floor for "this unit has no relevant ontology", as a FRACTION of the +# best score a window could achieve (an atom ranked first in every lane) -- not an +# absolute number, because the lane weights and the fusion rank constant both +# rescale that scale. 0 disables. ONTOLOGY_PATCH_MIN_MERGED_MAX_SCORE=0.18 ONTOLOGY_PATCH_MMR_LAMBDA=1.0 # Snapshot-adequacy mechanisms. Values below are the shipped defaults, tuned one @@ -367,6 +641,20 @@ ONTOLOGY_PATCH_MMR_LAMBDA=1.0 # must-have module (measured: 300 covers a 256-triple module, 0 disables). # ONTOLOGY_PATCH_PER_ONTOLOGY_ATOM_FLOOR=2 # ONTOLOGY_PATCH_SMALL_MODULE_CLOSURE_MAX_TRIPLES=300 + +# Ceiling on the triples ALL whole-module closures may add to one snapshot. +# Unset = unlimited (every module that fits MAX_TRIPLES and won a seed is +# included). When set, modules are admitted in order of retrieval relevance -- +# the best score any of their atoms achieved -- until the budget is spent, and a +# module too large for what is left is skipped rather than ending the pass, so a +# smaller one behind it still gets in. +# Relevance and NOT seed count: a module can win at most as many seeds as it has +# terms, so counting them ranks modules by size and would exclude exactly the +# small, sharply relevant vocabulary this closure exists to admit whole. A +# seventeen-triple module that is precisely the document's subject can never +# out-count a large peripheral one. The manifest reports module_closure_iris, +# module_closure_declined_iris and module_closure_triples. +# ONTOLOGY_PATCH_SMALL_MODULE_CLOSURE_MAX_TOTAL_TRIPLES= # Reserve seed slots for predicate-role atoms; prose reads as noun phrases, so # classes win a shared ranking and the properties that link them get crowded out. # ONTOLOGY_PATCH_PER_ROLE_ATOM_FLOOR=12 @@ -375,13 +663,8 @@ ONTOLOGY_PATCH_MMR_LAMBDA=1.0 # is inert context. 0 disables. # ONTOLOGY_PATCH_SCHEMA_CLOSURE_MAX_ENTITIES=32 # ONTOLOGY_PATCH_SCHEMA_CLOSURE_ANCESTOR_DEPTH=2 -# Advanced precision knobs (off by default): -# ONTOLOGY_PATCH_PER_QUERY_CORE_SCORE_RATIO=0.0 -# ONTOLOGY_PATCH_PER_QUERY_NEIGHBORHOOD_SCORE_RATIO=0.0 -# ONTOLOGY_PATCH_PER_QUERY_BM25_SCORE_RATIO=0.0 +# Advanced precision knob (off by default): # ONTOLOGY_PATCH_MERGED_SCORE_RATIO=0.0 -# ONTOLOGY_PATCH_MAX_ATOMS_TIER1=12 -# ONTOLOGY_PATCH_MIN_ENTITY_SCORE=0.3 # Web grounding (search-later: nodes run without search first) WEB_SEARCH_ENABLED=false @@ -426,24 +709,71 @@ LOGGING_LEVEL=info # Per-request provider LLM timeout in seconds. # LLM_REQUEST_TIMEOUT_SECONDS=180 -# Render attempts per unit loop. At 1 the LLM critic never runs (the single -# render is also the final one). Raise to 2+ to enable the critic pass. +# Retries of a FAILED fresh extraction, and nothing else. A render that +# succeeded is never repeated -- improving it is what the critic passes are for, +# and re-extracting a whole unit to fix a local defect reliably introduces new +# ones. Raise this only if renders are failing outright. # MAX_VISITS_PER_NODE=1 -# Critic attempts per render attempt. Unset couples it to MAX_VISITS_PER_NODE, -# whose worst case is that value squared in billed critic calls -- reachable -# only when the critic keeps requesting external evidence (WEB_SEARCH_ENABLED), -# since a critic that fails without a search request breaks out of the loop. -# Set to 1 to cap that path. No effect unless MAX_VISITS_PER_NODE>=2. -# MAX_CRITIC_VISITS_PER_NODE= +# Deprecated and inert. It capped critic retries within one render attempt, a +# path reachable only through external evidence, and the loop no longer retries +# a critic inside a pass. Still recorded in the run manifest so an existing +# setting stays auditable. The budget you now want is FACTS_CRITIC_PASSES. + +# Constrain OpenAI decoding to valid JSON (response_format json_object). Every +# response is parsed as a JSON envelope whatever LLM_GRAPH_FORMAT is, so this +# makes a class of syntax error unreachable rather than repaired. Off by +# default: OpenAI rejects the request unless the prompt mentions JSON. +# LLM_JSON_MODE=false # LLM provider extras. THINK / NUM_PREDICT / NUM_CTX apply to Ollama only. # LLM_THINK= # LLM_NUM_PREDICT= # LLM_NUM_CTX= -# Concurrent in-flight LLM calls (alias: MAX_INFLIGHT). +# Reasoning budget. A knob the configured model does not read is warned about +# and ignored. Both join the LLM cache key, so changing one re-issues every +# prompt. Read budget.reasoning_share_of_output first: at 0.0 the model is not +# a reasoning model and neither knob changes its bill. +# Discrete reasoning depth: none | minimal | low | medium | high | xhigh +# (unset = provider default). Read by OpenAI reasoning models as +# reasoning_effort and by Gemini 3+ as thinking_level. The vocabulary is the +# union across providers and model generations -- the floor is spelled minimal +# by some models and none by others -- so which levels a model accepts is the +# provider's business; an unsupported one is rejected and aborts the run. +# LLM_REASONING_EFFORT= +# OpenAI prompt-cache routing hint. The provider caches by prompt prefix either +# way; this keeps requests that share one from being spread across shards that +# each rebuild the entry — which is what a unit fan-out issuing N calls with the +# same ontology chapter otherwise does. Any stable string; one per deployment or +# per corpus is the useful granularity, and it must NOT vary per request or it +# defeats itself. Not a secret, and not part of the LLM disk-cache key: it +# changes routing, never the response. +#LLM_PROMPT_CACHE_KEY= + +# Gemini 2.5 thinking models: thinking-token cap; 0 disables thinking where the +# model allows it, -1 lets the model choose (unset = provider default). +# Superseded from Gemini 3 on -- those models read LLM_REASONING_EFFORT, and +# setting both on Google is rejected at startup. +# LLM_THINKING_BUDGET= +# Concurrent in-flight LLM calls (alias: MAX_INFLIGHT). PARALLEL_WORKERS above +# this cap only queue (llm/inflight_wait in the budget); a startup warning +# says so. # LLM_MAX_INFLIGHT=16 +# Sustained provider request rate (requests/second), paced by a per-process +# token bucket on request starts. Complements LLM_MAX_INFLIGHT, which caps +# concurrency but not rate: a fan-out of short calls can exceed a provider +# tier's requests-per-minute while never holding many connections. Set from +# your tier (e.g. 500 RPM -> 8; leave headroom); unset = unpaced. Throttles +# that slip through anyway are counted as llm/rate_limited in the budget. +# LLM_REQUESTS_PER_SECOND= +# Retry budget handed to the provider SDK for transport failures (rate +# limits, connection resets) -- the SDKs back off and honour Retry-After. +# Unset keeps each SDK's default (OpenAI 2, Anthropic 2, Google 6). Raise it +# when a tier throttles; the pipeline itself never retries transport +# failures. Ignored by Ollama. +# LLM_MAX_RETRIES= + # Sparse (BM25) embedding model for the lexical lane. # EMBEDDING_BM25_MODEL_NAME=Qdrant/bm25 @@ -453,6 +783,13 @@ LOGGING_LEVEL=info # VECTOR_STORE_FUSION_CORE_WEIGHT=0.7 # VECTOR_STORE_FUSION_NEIGHBORHOOD_WEIGHT=0.15 # VECTOR_STORE_FUSION_BM25_WEIGHT=0.8 +# Smoothing added to each rank before the reciprocal: weight / (constant + rank). +# 0 is the historical behaviour and decays brutally -- rank 2 is worth half of +# rank 1 -- so fused order is decided by which lane put what first, and a lane's +# deep tail still votes at full weight. Raising it makes agreement ACROSS lanes +# outweigh position WITHIN one. Far above the lane depth every rank is equal, +# which throws the ranking away. +# VECTOR_STORE_FUSION_RANK_CONSTANT=0.0 # --- What gets indexed (changing any of these requires a reindex) ---------- # Surface forms read from the graph. The symbol list is the indexing half of @@ -474,10 +811,6 @@ LOGGING_LEVEL=info # VECTOR_STORE_SYMBOL_CASE_MISMATCH_POLICY=demote # VECTOR_STORE_SYMBOL_CASE_MISMATCH_DEMOTE_FACTOR=0.5 -# --- Relative score floors (0 disables; all off by default) ---------------- -# ONTOLOGY_PATCH_MIN_CORE_QUERY_BEST_SCORE=0.0 -# ONTOLOGY_PATCH_MIN_NEIGHBORHOOD_QUERY_BEST_SCORE=0.0 -# ONTOLOGY_PATCH_MIN_BM25_QUERY_BEST_SCORE=0.0 # Dump per-ontology ranking diagnostics into retrieval metrics. # ONTOLOGY_PATCH_DUMP_ONTOLOGY_RANKS=false diff --git a/.env.example.minimal b/.env.example.minimal index 05c1666f..5f9e7d44 100644 --- a/.env.example.minimal +++ b/.env.example.minimal @@ -82,7 +82,6 @@ MAX_VISITS_PER_NODE=1 # Extra bounded repair calls per facts unit, driven by machine-found # violations. Fires even at MAX_VISITS_PER_NODE=1, so a facts unit costs up to # two provider calls by default. Set 0 for exactly one call per unit. -FACTS_LLM_REPAIR_VISITS=1 # Concurrent content-unit workers. Bounded in practice by LLM_MAX_INFLIGHT # above; raising this alone will not make a rate-limited run faster. @@ -148,7 +147,7 @@ AGG_EMBEDDING_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 #EMBEDDING_DIMENSION=384 # How much schema each unit is shown. The two knobs that actually move recall. -#VECTOR_STORE_TOP_K=20 +#VECTOR_STORE_TOP_K=40 #VECTOR_STORE_INDUCED_SUBGRAPH_MAX_TOTAL_TRIPLES=1200 # Drop and rebuild the partition on startup. Required after any change to the diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 9b2f779d..fe42067e 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -15,19 +15,24 @@ jobs: with: version: "0.10.12" enable-cache: true - # `ty check ontocast test` is a whole-program check, so every extra - # whose code it walks has to be importable here: `server` (FastAPI app - # + CLI), `documents` (docling-core types), `qdrant` and `graph`. - # Syncing `dev` alone -- correct before the dependency tiering moved - # those packages out of the base install -- made ty fail on ~40 - # unresolved imports. - # - # This deliberately stops short of `--all-extras` (8.8 GB vs 633 MB; - # torch, docling/easyocr, lancedb, pygraphviz) because the heavy tier - # is instead whitelisted in `[tool.ty.analysis] allowed-unresolved-imports` - # in pyproject.toml. A new unresolved-import failure here means an - # extra crossed that line: either add it below, or whitelist it there. - - name: Install dependencies (extras that ty type-checks) - run: uv sync --extra dev --extra server --extra documents --extra qdrant --extra graph --no-install-project + # `setup-uv`'s own cache covers uv's package cache, not the hook + # environments, which live in ~/.cache/pre-commit. Without this step + # every run rebuilt them from scratch and re-resolved each hook's + # dependencies, so an unchanged repository could go red on a transitive + # release. Keying on the config hash means a hook or pin change rebuilds + # and everything else reuses. + - name: Cache hook environments + uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} + # This job is lint and formatting only, so it needs nothing but the + # `dev` extra. Type checking runs in `tests.yml`, whose `unit` job + # already installs every extra -- a whole-program check gets better + # answers where the whole program is actually importable. + - name: Install dependencies + run: uv sync --extra dev --no-install-project - name: Run pre-commit run: uv run pre-commit run --all-files + env: + SKIP: ty diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6044e411..6a30b8d1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -97,6 +97,16 @@ jobs: enable-cache: true - name: Install dependencies run: uv sync --all-extras + # `ty check ontocast test` is a whole-program check, so it belongs where + # the whole program is installed. Under a partial install the optional + # tier has to be whitelisted as unresolved, which types those modules as + # `Any` and silently stops ty checking any call into them; here every + # extra is already present for the test run, so the check costs one step + # and no extra install. One matrix leg is enough -- the source is the + # same on both, and 3.13 only runs on main. + - name: Type check + if: matrix.python-version == '3.12' + run: uv run ty check ontocast test # No .env exists in a clean checkout; pytest-dotenv no-ops on a missing # file, and integration tests self-skip when their service is # unreachable, so this runs offline. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6ee75c5a..1879c467 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,15 +37,25 @@ repos: # uv.lock is excluded: it is generated by `uv lock` in uv's own format, so # reformatting it here made every dependency change fail CI and then be # rewritten by the next uv run. -- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks - rev: v2.16.0 - hooks: - - id: pretty-format-toml - args: [--autofix] - exclude: ^uv\.lock$ + # + # `toml-sort` is the only TOML formatter here, and that is the point. + # `pretty-format-toml` from language-formatters-pre-commit-hooks used to run + # alongside it, but that hook is a thin wrapper around toml-sort -- so the + # suite was running one formatter twice, through two independently resolved + # copies of it. They drifted apart: toml-sort 0.25.0 swapped its engine from + # tomlkit to tomlrt, which writes inline tables as `{ k = v }` where tomlkit + # writes `{k = v}`. With the wrapper on a fresh resolve and this hook pinned + # by `rev`, each one reverted the other's spacing on the `authors` table, + # every run, forever. A single formatter cannot disagree with itself. + # + # tomlkit is pinned because `rev` does not pin it: pre-commit resolves a + # hook's dependencies when it builds the environment, so a months-old cached + # environment and a fresh CI one can format differently from identical config + # -- which is how this passed locally while failing in CI. Bump deliberately. - repo: https://github.com/pappasam/toml-sort rev: v0.24.3 hooks: - id: toml-sort args: [-ia] exclude: ^uv\.lock$ + additional_dependencies: [tomlkit==0.15.1] diff --git a/CHANGELOG.md b/CHANGELOG.md index 00cc40e7..27d13012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,365 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.6.2] - unreleased +## [0.6.4] - unreleased + +*0.6.3 was never tagged. Its entries ship here.* + +### Added + +- **Retrieval proposition window bounds** (`VECTOR_STORE_PROPOSITION_*`, all off + by default): `WINDOW_MAX_TOKENS` (token-budget packing; only bound that prevents + encoder truncation by construction), `ABBREVIATION_AWARE`, `MEASUREMENT_AWARE`, + `WINDOW_OVERLAP`. Unset preserves prior windowing byte-for-byte. + +- **Startup rejection** when `ONTOLOGY_PATCH_MMR_LAMBDA < 1.0` is combined with + non-zero atom floors (MMR selects the full budget; floors are incompatible). + +- **Startup warning** when `ONTOLOGY_PATCH_MAX_ATOMS` exceeds the window-scaled + ceiling and cannot bind; names the effective cap. + +- **Run manifest `prompting` block:** `llm_graph_format`, `ontology_chapter_format`, + `ontology_context_scope`, `fanout_warmup_units`, `parallel_workers`, + `embedding_model_name`. `llm` gains `prompt_cache_key`, `max_inflight`. + +- **Retrieval knobs** (each defaults to prior behaviour): + - `VECTOR_STORE_BM25_TOP_K` — sparse lane depth independent of `TOP_K`. + - `VECTOR_STORE_FUSION_RANK_CONSTANT` — `weight / (constant + rank)` smoothing. + - `VECTOR_STORE_PROPOSITION_WINDOW_STRIDE` — overlapping query windows; lifts + `PROPOSITION_WINDOW_SENTENCES` `le=4` ceiling. + - Query telemetry: `queries_truncated`, `query_sequence_limit`. + +- **Retrieval-recall tests** (`test/test_retrieval_recall.py`, `test/retrieval_gt.py`): + seed recall vs snapshot recall; no LLM. Env: `ONTOCAST_RECALL_CORPUS`, + `ONTOCAST_RECALL_COLLECTION_SUFFIX`, `ONTOCAST_RECALL_SKIP_INDEX`, + `ONTOCAST_RECALL_JSON`. + +- **`ONTOLOGY_PATCH_SMALL_MODULE_CLOSURE_MAX_TOTAL_TRIPLES`** — cap total triples + from whole-module closures; modules admitted by retrieval relevance, not seed count. + Metrics: `module_closure_*`, `relevance_by_ontology`. + +- **`ONTOLOGY_CONTEXT_SCOPE=document`** — resolve one ontology context union for all + units (prefix-cache friendly; recall-safe; empty union falls back to per-unit). + +- **`FANOUT_WARMUP_UNITS`** (default `0`) — complete N units before fan-out so a + prefix cache can warm. Useful with document-scoped context. + +- **`LLM_PROMPT_CACHE_KEY`** — OpenAI routing hint for prefix-cache shard affinity. + +- **Provider rate limits:** `LLM_REQUESTS_PER_SECOND` (token bucket), + `LLM_MAX_RETRIES` (SDK transport). Counters: `llm/rate_limited`. Recorded in + manifest `llm` block. + +- **Shapes prompt contract** (`FACTS_SHAPES_PROMPT_CONTRACT`, default `auto`): + SHACL shapes as `# CONFORMANCE REQUIREMENTS` (`full|context|auto|off`); + `FACTS_SHAPES_PROMPT_MAX_LINES`; terms join `ValidationPolicy.contract_exempt_terms`. + +- **Quantitative completeness** — facts prompt requires every stated measurement; + critic proposes ADD fixes for NUMERIC COVERAGE misses. Caps: + `FACTS_NUMERIC_COVERAGE_LIMIT` (default 30), + `FACTS_NUMERIC_COVERAGE_MANDATORY` (default off). + +- **`--facts-user-instruction` on `ontocast process`**. Manifest records length only. + +- **Run manifest records effective validation settings** (`validation_config`: + shapes contract mode, JSON mode, numeric-coverage policy, etc.) and a selection + census (`labeled_units`, `unlabeled_units`, `section_label_histogram`). + +- **`--ontology-dir` / `--shapes-dir` on `serve` and `process`** — override or clear + (`""`) seed directories. + +- **Missing seed directory fails `process` at startup** (serve warns; empty dir warns). + +- **Critic review-and-patch passes:** `FACTS_CRITIC_PASSES` (default `1`), + `ONTOLOGY_CRITIC_PASSES` (default `0`). Each pass compiles critic fixes into a + `GraphUpdate` (no extra LLM call). Delete-share caps roll back over-aggressive + patches; no-op fixes dropped. Telemetry on manifest and retrieval metrics. + +- **`ONTOLOGY_ACCEPT_BLOCKING_FINDING_KINDS`** — configure which deterministic + ontology findings block unit acceptance (same role as + `FACTS_ACCEPT_BLOCKING_SEVERITY` on the facts side). + +- **`ONTOLOGY_CONTEXT_REQUIRED`** (default `false`) — fail the run when a facts + unit's ontology context resolves to zero triples. Without this, an empty context + still runs (generic vocabulary, vacuous SHACL). Ontology units are not gated. + +- **SHACL conformance summary** gains `shacl_focus_nodes` and `shacl_vacuous`; + `conforms` is `null` when no focus nodes matched (not `true`). Target matching + follows `rdfs:subClassOf`. + +- **`DOMAIN_ADHERENCE` finding** — mandatory when a unit uses too little catalog + vocabulary (`FACTS_DOMAIN_ADHERENCE_MIN_SHARE`, default `0.15` of schema terms + in the graph; `0` disables). + +- **Catalog/index agreement check at startup** — empty catalog + populated index fails; + extra indexed IRIs warn. + +- **Critic telemetry on run manifest:** `fix_action_severity_histogram` (fixes keyed + `ACTION:severity`), `accept_reason_histogram`, plus + `facts_critic_fixes_applied` / `facts_critic_fixes_residual` in retrieval metrics. + +- **`FACTS_CONTEXT_FROM_UNITS`** (default off here; see Changed) — under + `RENDER_MODE=facts`, seed the merge/gate ontology vocabulary from the snapshots + each unit retrieved (no ontology stage runs otherwise). + +- **`FACTS_SUSPECT_MULTI_VALUE_REQUIRE_CROSS_UNIT`** (default off) — the IRI branch of + `SUSPECT_MULTI_VALUE` reports an error only when merge evidence links the + conflicting objects across units (`cross_unit_object_pairs` on + `AggregationResult`). + +- **`FACTS_NUMERIC_IDENTIFIER_GUARD`** (default off here; see Changed) — keep + identifier digit groups (e.g. after `/` or `:`) out of the numeric-coverage + inventory. + +- **`CHUNK_MIN_UNIT_CHARS`** (default `0`) — drop content units below a character + floor before fan-out. Distinct from `CHUNK_MIN_SIZE` (chunker merge target). + +- **`LLM_JSON_MODE`** (default off) — OpenAI `response_format: json_object`; rejected + unless the prompt mentions JSON. + +- **`ontocast process --keep-provenance`** — retain chunk provenance in facts dump. + +- **`ONTOLOGY_CHAPTER_FORMAT=term_sheet`** — line-per-term `# ONTOLOGY` chapter for + facts mode. Requires `RENDER_MODE=facts`. Changes LLM cache key. + +- **Ontology chapter text caps:** `ONTOLOGY_TEXT_MAX_CHARS_NAMING|CONTRACT|PROSE`, + `ONTOLOGY_TEXT_TOTAL_BUDGET`. Word-boundary clip with marker; counters: + `chapter/text_*`, `chapter/literals_clipped`. + +- **Cloud reasoning:** `LLM_REASONING_EFFORT`, `LLM_THINKING_BUDGET` (mutually + exclusive on Google; recorded in manifest; join cache key when set). + +- **`AGG_UNIT_SCOPED_FACT_IRIS`** (default `true`) — rewrite instance IRIs to + `__u` before aggregation; served IRIs unscoped. + +- **Startup warning** when `AGG_SIMILARITY_THRESHOLD` is changed but + `AGG_CANDIDATE_SIMILARITY_THRESHOLD` stays at default — the former belongs to + cross-graph `EntityAligner`, not the in-pipeline aggregator. + +- **`AtomicToolBox.catalog_terms()`** — memoised union of catalog-declared terms, + keyed on content-addressed ontology ids. Parse-time repairs use it to tell + "absent from unit snapshot" from "absent from catalog". + +- **Batch `*.facts.validation.json`** gains `unit_repairs` (parse-time + `GraphRepairRecord`s) and `unit_failures` (unit index, phase, stage, reason). + +- **`llm/calls_failed` budget counter** — every provider call that raised. + Timeouts and rate limits remain separate subsets. + +- **`ONTOLOGY_CHAPTER_FORMAT`** (`auto|inherit|turtle|term_sheet`). `turtle` pins the + facts `# ONTOLOGY` chapter wire regardless of `LLM_GRAPH_FORMAT`; does not change + output wire or ontology-loop chapters. + +- **Startup warning** when `PARALLEL_WORKERS > LLM_MAX_INFLIGHT` — a unit never + issues two LLM calls at once, so excess workers only queue on the semaphore. + +- **Front/back-matter routing** (`CHUNK_NON_CONTENT_MODE`: `extract|skip`) — detect + author blocks, ORCID, licence, data availability, etc. `extract` (default) tags + `SourceUnit.is_non_content`; `skip` drops before fan-out. + +- **Density-aware chunk split** (`CHUNK_MAX_MEASUREMENTS_PER_UNIT`, default off) — + split units that exceed a cap of unit-adjacent numbers at the nearest sentence or + paragraph boundary. + +- **`CONVERTER_REPAIR_NUMERIC_ARTIFACTS`** (default off) — pattern-local fixes in + converted values: HTML entities, column wraps, flattened exponents, ligature gaps. + +- **`ontocast.util.measurement_lexicon`** — shared scanner for unit-adjacent numbers; + used by density split, numeric coverage, and proposition windows. + +- **Facts completion pass** (`FACTS_COMPLETION_PASSES`, default `0`) — after the + critic loop, insert-only recovery for measurements still listed in the + numeric-coverage inventory. + +### Changed + +- **Log levels:** prefix reconciliation, aggregator `rdf:reifies` skip, unlabeled + sections → DEBUG/INFO (reduces WARNING noise). + +- **Text cap clipping** keeps definition + one applicability sentence; marker and cap + semantics unchanged. All caps still unset by default. + +- **`ONTOLOGY_CHAPTER_FORMAT` default `auto`:** `term_sheet` for facts, + `inherit` otherwise. Resolved at config build (manifest/cache never show `auto`). + Set `inherit` to restore prior chapter. One-time facts cache-key change. + +- **`VECTOR_STORE_PROPOSITION_WINDOW_MAX_CHARS`** — character-budget query windows when + set (replaces sentence bound; query-side only; no reindex). + +- **`EmbeddingTool.token_lengths`** reports encoder word pieces; `count_over_limit` + delegates to it. + +- **`VECTOR_STORE_TOP_K` default `40`** (was `20`). Does not enlarge retained atom + budget (`ONTOLOGY_PATCH_MAX_ATOMS_BASE` caps selection). Set `20` to restore. + +- **`VECTOR_STORE_QUERY_UNIT_SIGNALS_ENABLED` default `true`** — match tokens after + numbers (e.g. `days` in "4-15 days") against catalog unit surfaces and add hits as + snapshot seeds outside the semantic budget. Query-time only; no reindex. + +- **Predicate alias repair:** requires token containment; never rewrites catalog-declared + predicates; ratio is tie-break only (`FACTS_PROPERTY_ALIAS_MIN_RATIO` default `0.95`). + +- **Facts prompt chapter order:** `preamble → conformance → ontology → TASK → …` for + shared prefix through ontology chapter. Invalidates cached prompts. + +- **`chars_received`** uses normalised text for all providers. + +- **Batch manifest selection census** populated from workflow state merge-back. + +- **`FACTS_CONTEXT_FROM_UNITS` and `FACTS_NUMERIC_IDENTIFIER_GUARD` default `true`**. + +- **Startup catalog check** requires populated catalog only under `RENDER_MODE=facts`. + +- **Retrieval-integrity checks** (catalog vs vector index) no longer consult + `ONTOLOGY_CONTEXT_REQUIRED` — index/catalog disagreement is always an error, + not a preference. + +- **Seed TTLs replayed** into a new tenancy scope on first use; `FACTS_SHAPES_DIR` + seeded the same way. Existing ontologies in the scope are not overwritten. + +- **Critic cites triple ids** (Turtle inline / JSON-LD index table); applies compiled + `GraphUpdate` patches directly. Finding-driven repair render removed. + +- **Unified `run_unit_loop`** for facts and ontology. `MAX_VISITS` bounds renders only; + critic improves via passes, not re-render. Ontology critic gates on deterministic + findings (not score). Facts critic runs when `FACTS_CRITIC_PASSES > 0`. + +- **Fix payload parsing** format-tolerant (`@value`, `@id`, bare IRIs). + +- **Facts prompt vocabulary precedence** — catalog over generic vocabularies; no bare-number + entities. + +- **Default OpenAI model `gpt-5.4`** (`LLM_MODEL_NAME`). + +- Test markers match `pyproject.toml`; `test/test_prompt_templates.py` smoke coverage. + +### Deprecated + +- `FACTS_LLM_REPAIR_VISITS` — alias for `FACTS_CRITIC_PASSES` (one release). +- `MAX_CRITIC_VISITS_PER_NODE` — inert; still recorded in manifest. + +### Removed + +- **Dead code:** unused `SPARQLTool` operation API; conditional `SPARQLTool` build; + `BASE_RECURSION_LIMIT` / `ESTIMATED_CHUNKS`; facts `{fact_chapter}` / + `{improvement_instruction}` slots; `OntologyManager.update_ontology()`. + +- **Unused retrieval knobs:** six per-channel score gates + helper; + `ONTOLOGY_PATCH_CROSS_QUERY_MERGE_MODE=hybrid` and tier settings (`max_score` / + `sum_score` remain). + +- **`render_facts_update`** and facts update-render mode — nothing populated the + dispatch field after a successful render. Also removed: `_findings_instruction`, + non-indexed `format_facts_chapter`. + +- **`FACTS_LLM_REPAIR_VISITS`**, **`MAX_CRITIC_VISITS_PER_NODE`** (manifest + `loops.max_critic_visits` removed). + +### Fixed + +- **Deterministic semantic chunking** — seeded PCA/UMAP; `CHUNKER_CACHE_FORMAT_VERSION`. + Chunk boundaries differ from earlier versions. Short blocks pack by size directly. + +- **Gate shapes-vs-catalog cross-check** no longer flags properties the validator + would never reject — uses the full catalog and the same exemption policy as the + unit loop. + +- **`UNIT_SYMBOL_CASE_MISMATCH`** — case-folded unit symbol collision with competing + catalog unit. + +- **Numeric coverage** keyed on `(number, unit)` pairs for measurement lane. + +- **`non_catalog_vocabulary`** exempts document metadata (`DOCUMENT_METADATA_TERMS`). + +- **Prefix map** installed for full unit loop; critic/completion reconcile against catalog. + +- **Converter:** hyphenated ranges not rewritten as exponents; repair rules version in + cache key when repair enabled. + +- **Critic rollback** judged on declared inserts; rollback warning names finding kinds. + +- **Invalid literal datatypes** refused at patch apply; prefix overrides reconciled to + run bindings. + +- **Placeholder prune** walks up referrers — removing a bad value no longer leaves a + stub node with a standing `sh:minCount` violation. + +- **Scoped ToolBox cache** prepares the vector store when a later request needs one + (a non-vector request no longer leaves the scope unindexed for the process lifetime). + Flush resets readiness after dropping collections. + +- **`--wipe-vector-store`** runs when vector store configured regardless of mode. + +- **`/flush`** uses shared `resolve_tenant_project` (blank tenant → 400). + +- **Warning** when `ONTOLOGY_CONTEXT_SCOPE=document` conflicts with per-unit shapes + contract (`FACTS_SHAPES_PROMPT_CONTRACT=context`). + +- **Unit-path manifest** omits inert `ontology_context_scope` / `fanout_warmup_units`. + +- **"Is this a domain namespace?"** defined once (was duplicated in delta partitioner + and prompt domain-ontology clause). + +- **`/ontologies` and `/shapes`** share one scoped-ToolBox factory (was two identical + closures). + +- **Atom floors** (`ONTOLOGY_PATCH_PER_ONTOLOGY_ATOM_FLOOR`, + `ONTOLOGY_PATCH_PER_ROLE_ATOM_FLOOR`) apply on every selection path; the removed + `hybrid` merge mode had silently skipped them. + +- **Relevance gate** (`ONTOLOGY_PATCH_MIN_MERGED_MAX_SCORE`) scaled relative to fused + score — decouples from `VECTOR_STORE_FUSION_RANK_CONSTANT`. + +- **LanceDB atom schema declared** (not inferred from first batch). + +- **Byte-stable ontology chapter** for cache — blank-node relabelling, sorted JSON-LD, + deterministic term-sheet labels. Prompt path only; one-time facts cache-key change. + +- **`LLMConfigurationError`** on provider config rejection — aborts batch, exit `78` + (`llm/calls_rejected`). Throttling and input-length 400s remain per-unit. + +- **All-units-failed file** in failed-file list (non-zero CLI exit); dumps retained. + +- **gpt-5 temperature pin** matches series only (`gpt-5`, `gpt-5-mini`, `gpt-5-nano`). + +- **Unit acceptance status** set after the critic patch is applied, from + `material_defects()` on the post-patch findings (not the pre-patch verdict). + +- **Empty catalog fails facts `process`**; vector index must be populated after materialize. + `serve` warns on empty catalog. + +- **`EmptyOntologyContextError` / `OntologyContextConfigError`** propagate from fan-out. + +- **Seed sync** repairs a partition that listed an ontology IRI but served no terms — + checks term definitions, not just the `owl:Ontology` header. + +- **Empty ontology-context diagnostic** checks catalog state first (not only the + vector index); records `candidate_hits` and `threshold_rejected` before the + score gate. + +- **Zero-atom reindex** logged per ontology. + +- **All-unrecognised section labels** rejected at parse (CLI usage error). + +- **Graph serialization** raises diagnostic naming term, type, position. + +- **Fuseki `serialize_graph`** falls back to default facts dataset. + +- **CI:** `ty check` in unit job (full extras); single `toml-sort` formatter with pinned + `tomlkit`; `pyarrow.**` allowlist removed. + +### Documentation + +- **`docs/user_guide/configuration.md`:** `VECTOR_STORE_BM25_TOP_K`, + `VECTOR_STORE_FUSION_RANK_CONSTANT`, + `ONTOLOGY_PATCH_SMALL_MODULE_CLOSURE_MAX_TOTAL_TRIPLES`; corrected `VECTOR_STORE_TOP_K` + default (`40`). +- Facts-loop diagrams regenerated; `demo/README.md` sample paths fixed. +- Public callables fully annotated; `uv run mkdocs build` warning-free. +- Guides updated: `LLM_JSON_MODE`, `CHUNK_MIN_UNIT_CHARS`, section-label rule, + validation arms, `--keep-provenance`. + +## [0.6.2] - 2026-08-29 ### Breaking @@ -388,11 +746,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 manifest. `_merge_workflow_state_into_agent_state` copies an explicit field list off the astream chunk, and `facts_loop_telemetry` / `ontology_loop_telemetry` / `ontology_reduce_metrics` were not on it — so - the case10 sampling run's manifests reported `critic: {calls: 0}` while - their own `retrieval_metrics` recorded 20 facts-critic and 26 - ontology-critic calls, and the score distributions had to be mined out of - the LLM cache again, which is exactly what the manifest blocks exist to - prevent. The three fields are now copied, and `RunManifest` gains + a batch run's manifests reported `critic: {calls: 0}` while their own + `retrieval_metrics` recorded the calls that had actually been billed, and + the score distributions had to be mined out of the LLM cache again, which + is exactly what the manifest blocks exist to prevent. The three fields are now copied, and `RunManifest` gains `ontology_reduce_metrics` — the reduce policies' evidence (`minted_duplicates` and pairs, `deletes_dropped_unredeclared`, `apply_deletes_no_match`, `fresh_ontologies_merged`) was computed and then diff --git a/README.md b/README.md index e1fe1979..9444b275 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ uv add "ontocast[server,openai]" # or: pip install "ontocast[server,openai]" ``` -Common add-ons: `doc-processing` (PDF/DOCX), `lancedb` or `qdrant` (ontology retrieval), `shacl` (shape validation). +Common add-ons: `doc-processing` (PDF/DOCX), `semantic-chunking` (clustering-based chunk boundaries; pulls torch, a multi-GB download — without it chunking falls back to paragraph/sentence splits), `lancedb` or `qdrant` (ontology retrieval), `shacl` (shape validation). ```sh uv add "ontocast[server,openai,doc-processing,lancedb,shacl]" @@ -75,9 +75,9 @@ Omit `FUSEKI_URI` for in-memory pyoxigraph. Details: [Quick Start](https://growg ### Supplying Your Ontologies -OntoCast uses seed ontologies (in Turtle `.ttl` format) to guide extraction. Provide yours in two ways: +OntoCast can guide extraction with seed ontologies (in Turtle `.ttl` format), and can build them for you when you have none. Provide yours in two ways: -1. **Directory Seed:** Set `ONTOCAST_ONTOLOGY_DIRECTORY=/path/to/your/ontologies` in your `.env`. All `.ttl` files in that folder sync automatically on startup. +1. **Directory Seed:** Set `ONTOCAST_ONTOLOGY_DIRECTORY=/path/to/your/ontologies` in your environment, or pass `--ontology-dir /path/to/your/ontologies` for a single run. All `.ttl` files in that folder sync automatically on startup. 2. **API Upload:** Register schemas dynamically with the running server: ```bash curl -X POST "http://localhost:8999/ontologies?tenant=ontocast&project=test" -F "file=@my_ontology.ttl" @@ -87,8 +87,9 @@ OntoCast uses seed ontologies (in Turtle `.ttl` format) to guide extraction. Pro ## Configuration -Start from [`.env.example.minimal`](.env.example.minimal) — 47 variables instead -of 202, grouped by the decision they belong to. Then pick a +Start from [`.env.example.minimal`](.env.example.minimal) — the few dozen +variables that decide what a run does, out of the full surface in +`.env.example`, grouped by the decision they belong to. Then pick a [playbook](https://growgraph.github.io/ontocast/user_guide/playbooks/) for what you are actually doing: evaluating, building an ontology, populating facts, scaling to a large catalog, or serving it. @@ -101,10 +102,10 @@ things: | `RENDER_MODE` | `ontology_and_facts` | Which halves run. `ontology` writes no facts; `facts` skips the ontology block and extracts only against the catalog you already have | | `ONTOLOGY_CONTEXT_MODE` | `selected_single_ontology` | Where each unit's schema comes from: LLM catalog selection, vector retrieval, or one pinned ontology | | `LLM_GRAPH_FORMAT` | `jsonld` | Wire encoding the LLM emits graphs in; `turtle` is the legacy alternative | -| `MAX_VISITS_PER_NODE` | `1` | Render/critic retry budget. At `1` the LLM critic never runs | +| `MAX_VISITS_PER_NODE` | `1` | Retries of a **failed** render. The critic's budget is `FACTS_CRITIC_PASSES` | | `PARALLEL_WORKERS` | `16` | Concurrent content-unit workers | | `LLM_PROVIDER` / `LLM_MODEL_NAME` / `LLM_API_KEY` | `openai` | Provider selection and credentials | -| `ONTOCAST_ONTOLOGY_DIRECTORY` | — | Seed ontologies synced on startup | +| `ONTOCAST_ONTOLOGY_DIRECTORY` | — | Seed ontologies synced on startup (CLI: `--ontology-dir`; empty string means none) | | `FUSEKI_URI` | — | Triple store; unset means in-memory pyoxigraph | `RENDER_MODE`, `ONTOLOGY_CONTEXT_MODE` and `LLM_GRAPH_FORMAT` are also diff --git a/demo/README.md b/demo/README.md index fc45cec0..caa4cfb9 100644 --- a/demo/README.md +++ b/demo/README.md @@ -12,29 +12,36 @@ This demo shows how OntoCast can extract a knowledge graph from a document and v ## Demo Steps +Run every command below from the repository root, so the relative paths resolve. + ### 1. Start the OntoCast Server Make sure you have OntoCast installed and a triple store (optional) running. Then start the server: ```bash -ONTOCAST_ONTOLOGY_DIRECTORY=../test/data/ontologies uv run ontocast serve +ONTOCAST_ONTOLOGY_DIRECTORY=test/data/ontologies uv run ontocast serve ``` ### 2. Process a Sample Document -You can use the provided `sample.pdf` or `sample.txt` in this directory. +Two example PDFs ship in this directory. Either works: **For PDF:** ```bash -curl -X POST http://localhost:8999/process -F "file=@demo/sample.pdf" +curl -X POST http://localhost:8999/process \ + -F "file=@demo/A_Brief_Introduction_To_AI.pdf" ``` **For plain text:** ```bash -curl -X POST http://localhost:8999/process -H "Content-Type: application/json" -d '{"text": "Your document text here"}' +curl -X POST http://localhost:8999/process \ + -H "Content-Type: application/json" \ + -d '{"text": "Your document text here"}' ``` The response will include extracted facts and ontology in Turtle format. +`ttl/response.json` is a recorded response you can inspect without running the +server. --- @@ -50,9 +57,10 @@ The response will include extracted facts and ontology in Turtle format. --- ## Files in This Directory -- `sample.pdf` – Example document for demo -- `sample.txt` – Example plain text for demo -- (Optional) `sample.ttl` – Example Turtle output +- `A_Brief_Introduction_To_AI.pdf` – Example document for the demo +- `water-resources-management-plan-overview.pdf` – Second example document +- `ttl/response.json` – Recorded `/process` response for reference +- `figs/thames-water.png` – Screenshot used above --- @@ -63,4 +71,4 @@ The response will include extracted facts and ontology in Turtle format. --- ## Need Help? -Open an issue or check the [OntoCast documentation](https://growgraph.github.io/ontocast). \ No newline at end of file +Open an issue or check the [OntoCast documentation](https://growgraph.github.io/ontocast). diff --git a/docs/architecture/ontology_catalog.md b/docs/architecture/ontology_catalog.md index 945937dd..ee4e3135 100644 --- a/docs/architecture/ontology_catalog.md +++ b/docs/architecture/ontology_catalog.md @@ -74,9 +74,19 @@ actually changes. The first assignment is the exception: it happens at startup, before `initialize()`, which populates the catalog itself — resyncing there would just fetch everything twice. -Seed TTLs from `ONTOCAST_ONTOLOGY_DIRECTORY` are **not** replayed on a switch. They are a -startup bootstrap; materializing them into a different tenant as a side effect of a query -parameter would be a surprise. +Seed TTLs from `ONTOCAST_ONTOLOGY_DIRECTORY` **are** replayed on a switch, but only where +the partition serves nothing of its own. Withholding them was the more conservative +choice on paper and the worse one in practice: a scope whose catalog is empty for want of +a bootstrap is the same fault as a startup one, and the tenant extracted against no +vocabulary at all rather than being surprised by a write. + +The test is whether the partition serves *terms*, not whether it lists the IRI. A catalog +read builds an ontology from its `owl:Ontology` subject and fills the graph separately, so +one whose graph never arrived still answers with a few triples about itself — a non-empty +graph that defines nothing. An ontology that does define terms is never overwritten: a +previous run's evolved terminal outranks whatever is on disk. + +`FACTS_SHAPES_DIR` follows the same rule. ## Reading the catalog diff --git a/docs/assets/facts_loop.lr.png b/docs/assets/facts_loop.lr.png index be4366eb..02b24219 100644 Binary files a/docs/assets/facts_loop.lr.png and b/docs/assets/facts_loop.lr.png differ diff --git a/docs/assets/facts_loop.lr.svg b/docs/assets/facts_loop.lr.svg index 0ebd13a5..9dde1ccb 100644 --- a/docs/assets/facts_loop.lr.svg +++ b/docs/assets/facts_loop.lr.svg @@ -1,5 +1,5 @@ - + @@ -81,72 +81,132 @@ - + - + + + + + + + + + + + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -189,334 +249,502 @@ - + - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + - - - - + + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + - - + + - - - - - - - + + + diff --git a/docs/assets/facts_loop.mmd b/docs/assets/facts_loop.mmd index 2d55e1d6..d1001aa4 100644 --- a/docs/assets/facts_loop.mmd +++ b/docs/assets/facts_loop.mmd @@ -18,21 +18,25 @@ config: flowchart TD; start([Unit start]) ctx[Resolve / apply
ontology context] - render_loop{render attempt
1 … max_visits} + render_loop{failed render
1 … MAX_VISITS} render_facts[Render facts] - final_render{final render
attempt?} - quarantine[Surface unresolved
quarantine] + pass_loop{critic pass
1 … FACTS_CRITIC_PASSES} + findings[Deterministic checks
(no LLM call)] + criticise_facts[Criticise facts
(cites statement ids)] + patch[Compile, screen, apply
patch (no LLM call)] + converged{changed nothing, or
rolled back?} done([Return unit state]) - criticise_facts[Criticise facts] exhausted([Return (retries exhausted)]) start --> ctx; ctx --> render_loop; render_loop --> render_facts; - render_facts -.->|success| final_render; + render_facts -.->|success| pass_loop; render_facts -.->|fail| render_loop; - final_render -.->|no| criticise_facts; - criticise_facts -.->|success| done; - criticise_facts -.->|fail| render_loop; render_loop -.->|exhausted| exhausted; - final_render -.->|yes| quarantine; - quarantine --> done; + pass_loop -.->|pass| findings; + findings --> criticise_facts; + criticise_facts -.->|accept or reject| patch; + patch --> converged; + converged -.->|yes| done; + converged -.->|no| pass_loop; + pass_loop -.->|budget spent| done; diff --git a/docs/assets/facts_loop.png b/docs/assets/facts_loop.png index a78307db..4109981e 100644 Binary files a/docs/assets/facts_loop.png and b/docs/assets/facts_loop.png differ diff --git a/docs/assets/facts_loop.svg b/docs/assets/facts_loop.svg index f64e9f16..5aa91286 100644 --- a/docs/assets/facts_loop.svg +++ b/docs/assets/facts_loop.svg @@ -1,5 +1,5 @@ - + @@ -81,43 +81,55 @@ - + - + + + + + + + + + + + + + - + - + - + - + - + - + - + - + - + @@ -126,27 +138,75 @@ - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -189,315 +249,484 @@ - + - + + + + + + + + + + + + + + + + + + + - + - + - + - + - - - - - - - - - - + + + + + + + + + + - + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - + + + - - - - - - - - - - - - + + + + + + + + + + + + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - - - - - - - - + + + + + + + + + - - + + - - - - + + + + - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - + + - - - - - - - + + + + + + + - + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + + + + - - + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + - - - - + + - - + + - - - - - - - + + + diff --git a/docs/assets/facts_loop_evidence.lr.png b/docs/assets/facts_loop_evidence.lr.png index c8d89e2b..0cffc835 100644 Binary files a/docs/assets/facts_loop_evidence.lr.png and b/docs/assets/facts_loop_evidence.lr.png differ diff --git a/docs/assets/facts_loop_evidence.lr.svg b/docs/assets/facts_loop_evidence.lr.svg index 83187219..fdc11335 100644 --- a/docs/assets/facts_loop_evidence.lr.svg +++ b/docs/assets/facts_loop_evidence.lr.svg @@ -1,5 +1,5 @@ - + @@ -81,94 +81,148 @@ - + - + - + - + - + - + - + - + + + + + + + + + + + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -198,605 +252,724 @@ - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + - - - - - - - - - - - + + + + + + + + - - - - - - - - - + + + + + - - + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + - - + + - - - + + + + + + + + - - - - - - - + + + + + + - - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + - - + + + + + + + + + + + + + - - + + - + - - - - - - + + + + + + - - - - - + + + + + - - - + + + - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + diff --git a/docs/assets/facts_loop_evidence.mmd b/docs/assets/facts_loop_evidence.mmd index ae77de04..88dd47b2 100644 --- a/docs/assets/facts_loop_evidence.mmd +++ b/docs/assets/facts_loop_evidence.mmd @@ -18,40 +18,41 @@ config: flowchart TD; start([Unit start]) ctx[Resolve / apply
ontology context] - render_loop{render attempt
1 … max_visits} + render_loop{failed render
1 … MAX_VISITS} render_facts[Render facts] + pass_loop{critic pass
1 … FACTS_CRITIC_PASSES} + findings[Deterministic checks
(no LLM call)] + criticise_facts[Criticise facts
(cites statement ids)] + patch[Compile, screen, apply
patch (no LLM call)] + converged{changed nothing, or
rolled back?} + done([Return unit state]) + exhausted([Return (retries exhausted)]) render_fail_search{initiate_search?} evid_r[Plan + fetch
web evidence] rerender[Re-render facts] - final_render{final render
attempt?} - quarantine[Surface unresolved
quarantine] - done([Return unit state]) - critic_loop{critic attempt
1 … max_visits} - criticise_facts[Criticise facts] critic_fail_search{initiate_search?} evid_c[Plan + fetch
web evidence] recritic[Re-criticise facts] - exhausted([Return (retries exhausted)]) start --> ctx; ctx --> render_loop; render_loop --> render_facts; - render_facts -.->|success| final_render; + render_facts -.->|success| pass_loop; render_facts -.->|fail| render_fail_search; render_fail_search -.->|yes| evid_r; evid_r --> rerender; - rerender -.->|success| final_render; + rerender -.->|success| pass_loop; rerender -.->|fail| render_loop; render_fail_search -.->|no| render_loop; - final_render -.->|no| critic_loop; - critic_loop --> criticise_facts; - criticise_facts -.->|success| done; - criticise_facts -.->|fail| critic_fail_search; + render_loop -.->|exhausted| exhausted; + pass_loop -.->|pass| findings; + findings --> criticise_facts; + criticise_facts -.->|accept| patch; + criticise_facts -.->|reject| critic_fail_search; critic_fail_search -.->|yes| evid_c; evid_c --> recritic; - recritic -.->|success| done; - recritic -.->|fail| critic_loop; - critic_fail_search -.->|no| render_loop; - critic_loop -.->|exhausted| render_loop; - render_loop -.->|exhausted| exhausted; - final_render -.->|yes| quarantine; - quarantine --> done; + recritic -.->|accept or reject| patch; + critic_fail_search -.->|no| patch; + patch --> converged; + converged -.->|yes| done; + converged -.->|no| pass_loop; + pass_loop -.->|budget spent| done; diff --git a/docs/assets/facts_loop_evidence.png b/docs/assets/facts_loop_evidence.png index 271c0de6..cb073cc0 100644 Binary files a/docs/assets/facts_loop_evidence.png and b/docs/assets/facts_loop_evidence.png differ diff --git a/docs/assets/facts_loop_evidence.svg b/docs/assets/facts_loop_evidence.svg index 29d05e14..6c4822c4 100644 --- a/docs/assets/facts_loop_evidence.svg +++ b/docs/assets/facts_loop_evidence.svg @@ -1,5 +1,5 @@ - + @@ -81,58 +81,70 @@ - + - + - + - + - + - + - + - + + + + + + + + + + + + + - + - + - + - + - + - + - + - + - + @@ -141,34 +153,76 @@ - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -198,578 +252,700 @@ - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + - + - + - + - + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - + + + + - + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - + + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - - - - - - - - - - - - - + + - - - + + - + - - - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - + + + + - - + + + + + + + + + + + + + - - + + - + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + diff --git a/docs/assets/ontology_loop.lr.png b/docs/assets/ontology_loop.lr.png index afbb74e5..f9141165 100644 Binary files a/docs/assets/ontology_loop.lr.png and b/docs/assets/ontology_loop.lr.png differ diff --git a/docs/assets/ontology_loop.lr.svg b/docs/assets/ontology_loop.lr.svg index 299e541d..77c00f7e 100644 --- a/docs/assets/ontology_loop.lr.svg +++ b/docs/assets/ontology_loop.lr.svg @@ -1,5 +1,5 @@ - + @@ -42,93 +42,180 @@ - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -171,301 +258,511 @@ - + - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + - - - - - - + + + + + + - - - - + + + + - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + - - + + - - - - - - - + + + diff --git a/docs/assets/ontology_loop.mmd b/docs/assets/ontology_loop.mmd index 8d8558bc..6414467c 100644 --- a/docs/assets/ontology_loop.mmd +++ b/docs/assets/ontology_loop.mmd @@ -17,20 +17,26 @@ config: flowchart TD; start([Unit start]) - ctx[Resolve unit
ontology context] - render_loop{render attempt
1 … max_visits} + ctx[Resolve / apply
ontology context] + render_loop{failed render
1 … MAX_VISITS} render_ontology[Render ontology] - final_render{final render
attempt?} + pass_loop{critic pass
1 … ONTOLOGY_CRITIC_PASSES} + findings[Deterministic checks
(no LLM call)] + criticise_ontology[Criticise ontology
(cites statement ids)] + patch[Compile, screen, apply
patch (no LLM call)] + converged{changed nothing, or
rolled back?} done([Return unit state]) - criticise_ontology[Criticise ontology] exhausted([Return (retries exhausted)]) start --> ctx; ctx --> render_loop; render_loop --> render_ontology; - render_ontology -.->|success| final_render; + render_ontology -.->|success| pass_loop; render_ontology -.->|fail| render_loop; - final_render -.->|no| criticise_ontology; - criticise_ontology -.->|success| done; - criticise_ontology -.->|fail| render_loop; render_loop -.->|exhausted| exhausted; - final_render -.->|yes| done; + pass_loop -.->|pass| findings; + findings --> criticise_ontology; + criticise_ontology -.->|accept or reject| patch; + patch --> converged; + converged -.->|yes| done; + converged -.->|no| pass_loop; + pass_loop -.->|budget spent| done; diff --git a/docs/assets/ontology_loop.png b/docs/assets/ontology_loop.png index d7e2ecfb..57b7ef80 100644 Binary files a/docs/assets/ontology_loop.png and b/docs/assets/ontology_loop.png differ diff --git a/docs/assets/ontology_loop.svg b/docs/assets/ontology_loop.svg index 0f0cad10..8f6fb679 100644 --- a/docs/assets/ontology_loop.svg +++ b/docs/assets/ontology_loop.svg @@ -1,5 +1,5 @@ - + @@ -45,64 +45,88 @@ - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + - + - + - + - + - + - + @@ -111,27 +135,87 @@ - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -174,278 +258,493 @@ - + - + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + - - - - - - - - - - + + + + + + + + + + - + - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - - - - - - - - + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + - + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + - - + + - - - - - - - + + + + - + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + - - - - + + - - + + - - - - - - - + + + diff --git a/docs/assets/ontology_loop_evidence.lr.png b/docs/assets/ontology_loop_evidence.lr.png index 28261e4f..a46995b2 100644 Binary files a/docs/assets/ontology_loop_evidence.lr.png and b/docs/assets/ontology_loop_evidence.lr.png differ diff --git a/docs/assets/ontology_loop_evidence.lr.svg b/docs/assets/ontology_loop_evidence.lr.svg index b07a89e0..93f01ffa 100644 --- a/docs/assets/ontology_loop_evidence.lr.svg +++ b/docs/assets/ontology_loop_evidence.lr.svg @@ -1,5 +1,5 @@ - + @@ -42,121 +42,199 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -186,578 +264,739 @@ - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + - - - - - - - - - + + + + + - - + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - - - - - - - - - - + + + - - - - - - - + + + + + - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + - - + + - - + + + + + + + + + + + + + - + - - - - - - + + + + + + - - - - - + + + + + - - - + + + - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + diff --git a/docs/assets/ontology_loop_evidence.mmd b/docs/assets/ontology_loop_evidence.mmd index 48e22e18..b0bc0552 100644 --- a/docs/assets/ontology_loop_evidence.mmd +++ b/docs/assets/ontology_loop_evidence.mmd @@ -17,39 +17,42 @@ config: flowchart TD; start([Unit start]) - ctx[Resolve unit
ontology context] - render_loop{render attempt
1 … max_visits} + ctx[Resolve / apply
ontology context] + render_loop{failed render
1 … MAX_VISITS} render_ontology[Render ontology] + pass_loop{critic pass
1 … ONTOLOGY_CRITIC_PASSES} + findings[Deterministic checks
(no LLM call)] + criticise_ontology[Criticise ontology
(cites statement ids)] + patch[Compile, screen, apply
patch (no LLM call)] + converged{changed nothing, or
rolled back?} + done([Return unit state]) + exhausted([Return (retries exhausted)]) render_fail_search{initiate_search?} evid_r[Plan + fetch
web evidence] rerender[Re-render ontology] - final_render{final render
attempt?} - done([Return unit state]) - critic_loop{critic attempt
1 … max_visits} - criticise_ontology[Criticise ontology] critic_fail_search{initiate_search?} evid_c[Plan + fetch
web evidence] recritic[Re-criticise ontology] - exhausted([Return (retries exhausted)]) start --> ctx; ctx --> render_loop; render_loop --> render_ontology; - render_ontology -.->|success| final_render; + render_ontology -.->|success| pass_loop; render_ontology -.->|fail| render_fail_search; render_fail_search -.->|yes| evid_r; evid_r --> rerender; - rerender -.->|success| final_render; + rerender -.->|success| pass_loop; rerender -.->|fail| render_loop; render_fail_search -.->|no| render_loop; - final_render -.->|no| critic_loop; - critic_loop --> criticise_ontology; - criticise_ontology -.->|success| done; - criticise_ontology -.->|fail| critic_fail_search; + render_loop -.->|exhausted| exhausted; + pass_loop -.->|pass| findings; + findings --> criticise_ontology; + criticise_ontology -.->|accept| patch; + criticise_ontology -.->|reject| critic_fail_search; critic_fail_search -.->|yes| evid_c; evid_c --> recritic; - recritic -.->|success| done; - recritic -.->|fail| critic_loop; - critic_fail_search -.->|no| render_loop; - critic_loop -.->|exhausted| render_loop; - render_loop -.->|exhausted| exhausted; - final_render -.->|yes| done; + recritic -.->|accept or reject| patch; + critic_fail_search -.->|no| patch; + patch --> converged; + converged -.->|yes| done; + converged -.->|no| pass_loop; + pass_loop -.->|budget spent| done; diff --git a/docs/assets/ontology_loop_evidence.png b/docs/assets/ontology_loop_evidence.png index c463e872..3aed7ef6 100644 Binary files a/docs/assets/ontology_loop_evidence.png and b/docs/assets/ontology_loop_evidence.png differ diff --git a/docs/assets/ontology_loop_evidence.svg b/docs/assets/ontology_loop_evidence.svg index 79b44f04..7b13a526 100644 --- a/docs/assets/ontology_loop_evidence.svg +++ b/docs/assets/ontology_loop_evidence.svg @@ -1,5 +1,5 @@ - + @@ -45,82 +45,106 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + - + - + - + - + - + - + @@ -129,34 +153,88 @@ - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -186,552 +264,715 @@ - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + - + - + - + - + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - + + + + - + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - + + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + - - - + + - + - - - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - + + + - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - + + + + + + + + + + + + + - - + + - + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + diff --git a/docs/contributing.md b/docs/contributing.md index c9b571ea..666ac56b 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -101,8 +101,8 @@ So: numbers for theirs. - **Do not** name a corpus, a benchmark, or an individual evaluation run — in prose, in a changelog entry, in a test name, or in a docstring. A reader - outside this workspace cannot resolve those names, and a reader inside it - should be reading the measurement system instead. + outside this repository cannot resolve those names, and a reader inside the + project should be reading the measurement system instead. - Justify a default by its **mechanism** ("saturates quickly", "gates everything below it"), not by the run that chose it. @@ -113,12 +113,50 @@ This is about *claims*, not vocabulary. Fixture data is exempt: an example names ### Retrieval quality -There is no in-repo recall harness. Retrieval quality is measured in -`ontocast-validation`, not here — see [Measurement lives -elsewhere](#measurement-lives-elsewhere). What remains in-repo: -`test/test_retrieval_predicate_recall.py` for predicate-surface coverage, and -the per-run `retrieval_metrics` reported by the API and batch dumps. See -[Ontology Context — Diagnostics](user_guide/ontology_context.md#diagnostics). +`test/test_retrieval_recall.py` measures whether a relevant catalog term +actually survives to the prompt snapshot — the thing the plumbing tests, which +assert ordering and parameter pass-through against fake vectors, cannot see. It +reports two numbers per run: + +- **seed recall** — the expected term reached the final seed set, so it survived + vector search, the cross-window merge and the atom cap; +- **snapshot recall** — the term is also *defined* in the returned graph, so it + survived induced-subgraph expansion, budget caps and component pruning. + +The gap between them attributes a loss to the graph stage rather than the vector +stage, which is what makes a regression localisable without bisecting. Wiring +and scoring live in `test/retrieval_runner.py`; `test/retrieval_sweep.py` reuses +them to sweep retrieval parameters in one process. + +It makes **no LLM call** — retrieval is embeddings and graph work — so it needs +no provider credentials, and the toolbox's eagerly constructed client is pinned +to a provider that needs no API key. The backend may be an embedded LanceDB, so +no service has to be running: + +```bash +ONTOCAST_RECALL_CORPUS= LANCEDB_ENABLED=true \ + uv run pytest test/test_retrieval_recall.py -m "integration and slow" -s +``` + +A corpus is a directory holding `cases.jsonl` (`{"id", "text", +"expected_iris", "ontology_iri"}`) and `ontologies/*.ttl`; the tests skip +themselves when `ONTOCAST_RECALL_CORPUS` is unset, and the corpora themselves +live in `ontocast-validation` rather than here. `ONTOCAST_RECALL_JSON` writes +the funnel as JSON. One index serves a whole sweep — every retrieval knob is +applied at merge or expansion time, so none of them joins the embedding +fingerprint — which is what `ONTOCAST_RECALL_COLLECTION_SUFFIX` and +`ONTOCAST_RECALL_SKIP_INDEX` are for. + +Two cautions. Real embeddings are not bit-reproducible run to run, so compare +arms by rank and by which ontologies contributed no seed term at all, not by +exact percentages; and the on-topic precision figure is a noise proxy that +penalises legitimately retrieved parent terms from another module. This is an +ablation instrument, not an equality assertion. + +Also in-repo: `test/test_retrieval_predicate_recall.py` for predicate-surface +coverage, and the per-run `retrieval_metrics` reported by the API and batch +dumps. See [Ontology Context — +Diagnostics](user_guide/ontology_context.md#diagnostics). ## Documentation diff --git a/docs/getting_started/installation.md b/docs/getting_started/installation.md index cfd21d74..990541f5 100644 --- a/docs/getting_started/installation.md +++ b/docs/getting_started/installation.md @@ -37,7 +37,7 @@ for you. | `qdrant` | Qdrant vector store | Pulls `qdrant-client` and gRPC | | `lancedb` | Embedded LanceDB vector store (no external service) | | | `sparse` | `fastembed` BM25 sparse embeddings | Implied by `qdrant` and `lancedb`; pulls an ONNX runtime | -| `semantic-chunking` | Clustering-based chunker (`CHUNK_SEGMENTER=semantic`) | Pulls `torch` and `sentence-transformers`; multi-GB download. The model is shared with retrieval and disambiguation when `CHUNK_EMBEDDING_MODEL` matches theirs | +| `semantic-chunking` | Clustering-based chunker (`CHUNK_SEGMENTER=semantic`) | Without it the default segmenter falls back to paragraph/sentence splitting with only a log warning. Pulls `torch` and `sentence-transformers`; multi-GB download. The model is shared with retrieval and disambiguation when `CHUNK_EMBEDDING_MODEL` matches theirs | | `graph` | `networkx` ontology lineage graphs | | | `shacl` | SHACL validation of aggregated facts (`FACTS_SHAPES_DIR` or inline `sh:NodeShape`) plus the LLM-free shape-driven autofix | Without it, shape validation logs a warning and does nothing — see [Validation](../user_guide/validation.md) | | `web-search` | Optional web grounding (`WEB_SEARCH_ENABLED=true`) | | diff --git a/docs/getting_started/quickstart.md b/docs/getting_started/quickstart.md index 19663532..6f150c89 100644 --- a/docs/getting_started/quickstart.md +++ b/docs/getting_started/quickstart.md @@ -63,13 +63,11 @@ PORT=8999 # LLM Configuration LLM_PROVIDER=openai LLM_API_KEY=your-api-key-here -LLM_MODEL_NAME=gpt-4o-mini +LLM_MODEL_NAME=gpt-5.4 LLM_TEMPERATURE=0.0 # Server Configuration MAX_VISITS=1 -BASE_RECURSION_LIMIT=1000 -ESTIMATED_CHUNKS=30 RENDER_MODE=ontology_and_facts # ontology | facts | ontology_and_facts ONTOLOGY_CONTEXT_MAX_TRIPLES=4000 # prompt budget for the ontology chapter PARALLEL_WORKERS=16 @@ -134,13 +132,26 @@ ontocast process --input-path /path/to/document.pdf --max-visits 2 # Drop and recreate the vector partition before reindex ontocast serve --wipe-vector-store +# Point this run at a seed catalog, whatever the environment says +ontocast process --input-path ./docs --ontology-dir ./my-ontologies + +# Run with no seed catalog at all: an ontology-rendering run builds the first +# ontology from the corpus. The empty string overrides a configured directory. +ontocast process --input-path ./docs --ontology-dir '' + # Separate facts vs ontology dump folders ontocast process --input-path ./docs \ --facts-output-dir ./out/facts \ --ontology-output-dir ./out/ontologies ``` -**Note:** Paths and directories are configured via the `.env` file. +`--ontology-dir` is the **input** catalog; `--ontology-output-dir` is where +results are written. `--shapes-dir` overrides `FACTS_SHAPES_DIR` the same way. + +**Note:** Other paths and directories are configured via the `.env` file. +`ontocast process` refuses to start when `--ontology-dir` names something that +is not a directory — a mistyped path is indistinguishable downstream from +"deliberately none", and surfaces much later as an unexplained empty catalog. ### Receive Results @@ -173,11 +184,10 @@ OntoCast uses a hierarchical configuration system: |----------|-------------|---------| | `LLM_API_KEY` | API key for LLM provider | Required for openai / anthropic / google | | `LLM_PROVIDER` | `openai`, `ollama`, `anthropic`, or `google` | openai | -| `LLM_MODEL_NAME` | Model name | gpt-4o-mini | +| `LLM_MODEL_NAME` | Model name | gpt-5.4 | | `FUSEKI_URI` + `FUSEKI_AUTH` | Persistent triple store | Omit for in-memory (default) | | `ONTOCAST_ONTOLOGY_DIRECTORY` | Seed ontology TTL files | Optional bootstrap | | `MAX_VISITS` | Maximum visits per node | 1 | -| `BASE_RECURSION_LIMIT` | Base recursion limit for workflow | 1000 | | `ONTOLOGY_CONTEXT_MAX_TRIPLES` | Triple budget for the ontology sent to the LLM | 4000 | | `ONTOLOGY_MAX_TRIPLES` | Growth backstop on the ontology working graph (not a context cap) | unset | | `ENABLE_ONTOLOGY_CONSOLIDATION` | Run ontology consolidation pass | false | diff --git a/docs/index.md b/docs/index.md index 486a9c85..d9e3214c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -42,7 +42,7 @@ uv add "ontocast[server,openai]" # or: pip install "ontocast[server,openai]" ``` -Common add-ons: `doc-processing` (PDF/DOCX), `lancedb` or `qdrant` (ontology retrieval), `shacl` (shape validation). +Common add-ons: `doc-processing` (PDF/DOCX), `semantic-chunking` (clustering-based chunk boundaries; pulls torch, a multi-GB download — without it chunking falls back to paragraph/sentence splits), `lancedb` or `qdrant` (ontology retrieval), `shacl` (shape validation). ```sh uv add "ontocast[server,openai,doc-processing,lancedb,shacl]" @@ -72,9 +72,9 @@ Omit `FUSEKI_URI` for in-memory pyoxigraph. Details: [Quick Start Guide](getting ### Supplying Your Ontologies -OntoCast uses seed ontologies (in Turtle `.ttl` format) to guide extraction. Provide yours in two ways: +OntoCast can guide extraction with seed ontologies (in Turtle `.ttl` format), and can build them for you when you have none. Provide yours in two ways: -1. **Directory Seed:** Set `ONTOCAST_ONTOLOGY_DIRECTORY=/path/to/your/ontologies` in your `.env`. All `.ttl` files in that folder sync automatically on startup. +1. **Directory Seed:** Set `ONTOCAST_ONTOLOGY_DIRECTORY=/path/to/your/ontologies` in your environment, or pass `--ontology-dir /path/to/your/ontologies` for a single run. All `.ttl` files in that folder sync automatically on startup. 2. **API Upload:** Register schemas dynamically with the running server: ```bash curl -X POST "http://localhost:8999/ontologies?tenant=ontocast&project=test" -F "file=@my_ontology.ttl" @@ -84,8 +84,9 @@ OntoCast uses seed ontologies (in Turtle `.ttl` format) to guide extraction. Pro ## Configuration -Start from `.env.example.minimal` — 47 variables instead of 202, grouped by the -decision they belong to. Then pick a [playbook](user_guide/playbooks.md) for what +Start from `.env.example.minimal` — the few dozen variables that decide what a +run does, out of the full surface in `.env.example`, grouped by the decision +they belong to. Then pick a [playbook](user_guide/playbooks.md) for what you are actually doing: evaluating, building an ontology, populating facts, scaling to a large catalog, or serving it. @@ -97,10 +98,10 @@ things: | `RENDER_MODE` | `ontology_and_facts` | Which halves run. `ontology` writes no facts; `facts` skips the ontology block and extracts only against the catalog you already have — see [Render Mode](user_guide/configuration.md#render-mode-render_mode) | | `ONTOLOGY_CONTEXT_MODE` | `selected_single_ontology` | Where each unit's schema comes from: LLM catalog selection, vector retrieval, or one pinned ontology — see [Ontology Context](user_guide/ontology_context.md) | | `LLM_GRAPH_FORMAT` | `jsonld` | Wire encoding the LLM emits graphs in; `turtle` is the legacy alternative | -| `MAX_VISITS_PER_NODE` | `1` | Render/critic retry budget. At `1` the LLM critic never runs | +| `MAX_VISITS_PER_NODE` | `1` | Retries of a **failed** render. The critic's budget is `FACTS_CRITIC_PASSES` | | `PARALLEL_WORKERS` | `16` | Concurrent content-unit workers | | `LLM_PROVIDER` / `LLM_MODEL_NAME` / `LLM_API_KEY` | `openai` | Provider selection and credentials | -| `ONTOCAST_ONTOLOGY_DIRECTORY` | — | Seed ontologies synced on startup | +| `ONTOCAST_ONTOLOGY_DIRECTORY` | — | Seed ontologies synced on startup (CLI: `--ontology-dir`; empty string means none) | | `FUSEKI_URI` | — | Triple store; unset means in-memory pyoxigraph | `RENDER_MODE`, `ONTOLOGY_CONTEXT_MODE` and `LLM_GRAPH_FORMAT` are also diff --git a/docs/user_guide/aggregation.md b/docs/user_guide/aggregation.md index 4a8d8443..a262e80b 100644 --- a/docs/user_guide/aggregation.md +++ b/docs/user_guide/aggregation.md @@ -37,24 +37,28 @@ AGG_SIMILARITY_THRESHOLD=0.80 | `AGG_INITIALS_DISTINCT_GUARD` | Veto merges between entities whose labels are identical except for conflicting initials ("company S." vs "company T.") | `true` | | `AGG_NATURAL_KEY_MERGE` | Positive identity evidence: instances sharing an identical short string value on a single-valued identifier-like predicate become merge candidates | `true` | | `AGG_TYPE_GUARD_UNTYPED` | `permissive` lets a typed entity merge with an untyped one; `strict` fails typed-vs-untyped pairs closed | `permissive` | +| `AGG_UNIT_SCOPED_FACT_IRIS` | Suffix every unit's minted fact IRIs with the unit index before aggregation, so a local name two units both chose is a merge decision (clustered, then guarded) rather than a name-keyed fusion. `false` restores identity by name across units | `true` | Lower thresholds merge more aggressively (fewer duplicate entities, higher false-merge risk). Raise the threshold when precision matters more than recall. ## How Disambiguation Works -1. **Candidate extraction** — entities from each unit's facts graph -2. **Embedding** — dense vectors from `AGG_EMBEDDING_MODEL` -3. **Symbolic checks** — labels, `skos:altName`, IRI compatibility; **identical `URIRef` always compatible** (e.g. the same ontology class appearing in predicted and ground-truth graphs clusters with score 1.0 even when labels are missing or embeddings disagree) -4. **Merge guards** — safety checks that block identity merges between entities that provably describe distinct things: *literal-conflict* (disjoint numeric/temporal values on one predicate, e.g. 30 vs 230 μJ/cm², or string identifier sets with no compatible cross-pair; `AGG_LITERAL_CONFLICT_GUARD`), *functional-object conflict* (disjoint IRI objects on a schema-declared or empirically single-valued predicate, e.g. two different `qudt:unit`s), *sibling* (two objects of one subject never merge — range bounds, sample series, grant lists), *initials-distinct* (labels identical except for conflicting initials mark distinct entities; `AGG_INITIALS_DISTINCT_GUARD`), and a *strict lexical bar* for literal-bearing entities (only exact label/normal-form matches; fuzzy tiers are reserved for entities without data payloads). Guard vetoes hold **cluster-wide**: a vetoed pair cannot be united through a chain of accepted edges either. -5. **Natural-key evidence** (`AGG_NATURAL_KEY_MERGE`) — the one *positive* symbolic signal: two instances asserting the identical short string value on a single-valued identifier-like predicate (schema max-1, or observed single-valued on every subject) become merge candidates even when labels and embeddings disagree — "Application no. 36760/06" is the same case wherever its number appears. All guards still apply. -6. **Clustering** — connected components over similarity + compatibility edges, subject to the cluster-wide vetoes -7. **URI rewrite** — merge graphs under canonical entity URIs; a triple whose subject and object became identical only through the merge (a self-loop no source asserted) is dropped with a warning -8. **Provenance** — track which unit contributed each merged triple -9. **Validation gate** (stategraph only) — after merging, the `VALIDATE_FACTS` +1. **Unit scoping** (`AGG_UNIT_SCOPED_FACT_IRIS`) — each unit graph's instance IRIs under the fact namespaces are rewritten to `__u` right after sanitization. Units mint names independently, so `cd:temperature_value` from two units is a coincidence until the guards below say otherwise; without the suffix the two were one entity by dictionary collision and the gate's un-merge repair could not split a node that was never a cluster. Predicates, `rdf:type` objects, schema targets and class/property-typed IRIs are exempt. Served IRIs never carry the suffix — two unmerged same-name entities become `` and `_1` in unit order — while `aggregation_clusters` and the decisions show the scoped source IRIs +2. **Candidate extraction** — entities from each unit's facts graph +3. **Embedding** — dense vectors from `AGG_EMBEDDING_MODEL` +4. **Symbolic checks** — labels, `skos:altName`, IRI compatibility; **identical `URIRef` always compatible** (e.g. the same ontology class appearing in predicted and ground-truth graphs clusters with score 1.0 even when labels are missing or embeddings disagree) +5. **Merge guards** — safety checks that block identity merges between entities that provably describe distinct things: *literal-conflict* (disjoint numeric/temporal values on one predicate, e.g. 30 vs 230 μJ/cm², or string identifier sets with no compatible cross-pair; `AGG_LITERAL_CONFLICT_GUARD`), *functional-object conflict* (disjoint IRI objects on a schema-declared or empirically single-valued predicate, e.g. two different `qudt:unit`s), *sibling* (two objects of one subject never merge — range bounds, sample series, grant lists), *initials-distinct* (labels identical except for conflicting initials mark distinct entities; `AGG_INITIALS_DISTINCT_GUARD`), and a *strict lexical bar* for literal-bearing entities (only exact label/normal-form matches; fuzzy tiers are reserved for entities without data payloads). Guard vetoes hold **cluster-wide**: a vetoed pair cannot be united through a chain of accepted edges either. +6. **Natural-key evidence** (`AGG_NATURAL_KEY_MERGE`) — the one *positive* symbolic signal: two instances asserting the identical short string value on a single-valued identifier-like predicate (schema max-1, or observed single-valued on every subject) become merge candidates even when labels and embeddings disagree — "Application no. 36760/06" is the same case wherever its number appears. All guards still apply. +7. **Clustering** — connected components over similarity + compatibility edges, subject to the cluster-wide vetoes +8. **URI rewrite** — merge graphs under canonical entity URIs; a triple whose subject and object became identical only through the merge (a self-loop no source asserted) is dropped with a warning +9. **Provenance** — track which unit contributed each merged triple +10. **Validation gate** (stategraph only) — after merging, the `VALIDATE_FACTS` node checks post-merge invariants and applies LLM-free repairs; see [Validation and SHACL](validation.md). *Merge-signature* error findings on merged subjects turn the offending cluster into pair vetoes and - the facts units are re-aggregated (`FACTS_MERGE_REPAIR_PASSES`, default 1); + the facts units are re-aggregated (`FACTS_MERGE_REPAIR_PASSES`, default 1) — + including a cross-unit name collision, which unit scoping turns into a + two-member cluster the veto can dissolve; because vetoes hold cluster-wide, a veto pass dissolves the flagged cluster instead of being re-defeated by transitive closure. SHACL findings are deliberately not part of that loop — a constraint violation is not evidence diff --git a/docs/user_guide/api.md b/docs/user_guide/api.md index 9e2ad21e..1b23a8da 100644 --- a/docs/user_guide/api.md +++ b/docs/user_guide/api.md @@ -39,7 +39,7 @@ rejected with **400**; it is never silently replaced by the default. | `tenant` | Tenant name for store partitioning (default: `ontocast`) | | `project` | Project name (default: `test`) | | `render_mode` | Which pipeline blocks run: `ontology`, `facts`, or `ontology_and_facts`. Defaults to `RENDER_MODE` — see [Render Mode](configuration.md#render-mode-render_mode) | -| `max_visits` | Per-request render/critic retry budget (≥ 1) | +| `max_visits` | Per-request budget for retrying a **failed** render (≥ 1). The critic's budget is `FACTS_CRITIC_PASSES`, which is server-side | | `strip_provenance` | When true, omit reification/provenance from returned Turtle | | `llm_graph_format` | `jsonld` (default) or `turtle` for this request | | `ontology_context_mode` | Per-request ontology context mode. **Ignored when `ontology_context_fixed_ontology_id` is non-empty** — see [Ontology Context Mode](configuration.md#ontology-context-mode-ontology_context_mode) | diff --git a/docs/user_guide/concepts.md b/docs/user_guide/concepts.md index 99186697..041fc62a 100644 --- a/docs/user_guide/concepts.md +++ b/docs/user_guide/concepts.md @@ -123,6 +123,11 @@ Rules: - After each successful run, dumps provenance-stripped Turtle: - Facts: `doc.facts.ttl` (or `doc.L3.facts.ttl` for JSONL line 3) - Ontology artifacts: `doc.ontology.ttl` (or `doc..ontology.ttl` when multiple) +- `--keep-provenance` retains chunk-level provenance in the facts dump instead + of stripping it. Stripping stays the default, but a stripped graph carries no + chunk references at all, so nothing in it can be traced back to a source span, + re-verified against the document, or audited. Same meaning as the HTTP + `strip_provenance` parameter, inverted. - Dump destination defaults to siblings of each input. Override with `--output-dir` (shared), or separately with `--facts-output-dir` / `--ontology-output-dir`. **HTTP:** pass `document_metadata` as a JSON object field (JSON body) or stringified JSON (multipart / query). @@ -275,7 +280,7 @@ Document processing uses a **parallel map/reduce** architecture: - **Map**: each content unit runs an independent ontology or facts loop (bounded by `PARALLEL_WORKERS`) - **Reduce**: normalize merged ontology updates; merge and disambiguate facts across units -- Per-request `max_visits` overrides the server default for render/critic retry budgets +- Per-request `max_visits` overrides the server default for retrying a **failed** render ## Facts Extraction Model diff --git a/docs/user_guide/configuration.md b/docs/user_guide/configuration.md index beda1ce8..9b7b1d9b 100644 --- a/docs/user_guide/configuration.md +++ b/docs/user_guide/configuration.md @@ -11,7 +11,7 @@ OntoCast configuration is powered by Pydantic `BaseSettings` and is loaded from If you are configuring OntoCast for the first time, start from [Configuration Playbooks](playbooks.md) and `.env.example.minimal` instead: - 47 variables, grouped by decision, with a playbook per task. Come back here + 46 variables, grouped by decision, with a playbook per task. Come back here for the full surface once you know which knob you need. ## Overview @@ -51,7 +51,7 @@ Config ```bash LLM_PROVIDER=openai # openai | ollama | anthropic | google -LLM_MODEL_NAME=gpt-4o-mini +LLM_MODEL_NAME=gpt-5.4 LLM_TEMPERATURE=0.0 LLM_API_KEY=your_api_key_here # required for openai, anthropic, google LLM_BASE_URL=http://localhost:11434 # optional (ollama; anthropic proxy URL) @@ -59,13 +59,23 @@ LLM_BASE_URL=http://localhost:11434 # optional (ollama; anthropic proxy URL) | Provider | Example `LLM_MODEL_NAME` | `LLM_API_KEY` | |----------|--------------------------|---------------| -| `openai` | `gpt-4o-mini` | Required | +| `openai` | `gpt-5.4` | Required | | `ollama` | `llama3.1` | Not used (`LLM_BASE_URL` required) | | `anthropic` | `claude-sonnet-4-20250514` | Required | | `google` | `gemini-2.0-flash` | Required | OntoCast uses `LLM_API_KEY` for all cloud providers (not `ANTHROPIC_API_KEY` / `GOOGLE_API_KEY`). +`LLM_JSON_MODE=true` constrains OpenAI decoding to syntactically valid JSON +(`response_format: json_object`). Every response is parsed as a JSON envelope +whatever `LLM_GRAPH_FORMAT` is set to -- Turtle only makes the graph fields flat +strings *inside* that envelope -- so this makes a class of syntax error +unreachable rather than repaired after the fact. It is off by default because +OpenAI rejects a request in this mode unless the word "JSON" appears in the +prompt, which is a property of the prompt set in use. Strict `json_schema` mode +is deliberately not offered: it requires closed schemas and the graph fields are +open. OpenAI only; other providers ignore it. + #### `LLM_MODEL_NAME` accepts any string The model enums in `ontocast.config` (`OpenAIModel`, `OllamaModel`, `ClaudeModel`, @@ -108,16 +118,41 @@ A hung provider call holds both a unit-worker slot and an `LLM_MAX_INFLIGHT` slot, so without `LLM_REQUEST_TIMEOUT_SECONDS` a couple of them permanently shrink the pipeline's effective width. A timed-out call fails only its own unit. +**Cloud reasoning controls:** + +```bash +LLM_REASONING_EFFORT= # none|minimal|low|medium|high|xhigh: OpenAI + # reasoning_effort, Gemini 3+ thinking_level; + # unset = provider default +LLM_THINKING_BUDGET= # Gemini 2.5 thinking_budget: 0 off, -1 model-chosen, + # N caps it; unset uses the model default +``` + **Ollama-specific generation controls** (ignored by other providers): ```bash -LLM_THINK= # true/false: thinking mode for qwen3, deepseek-r1, ... - # unset uses the model default +LLM_THINK= # true/false: thinking mode for qwen3, deepseek-r1, ... (Ollama) LLM_NUM_PREDICT= # max tokens to generate; unset = Ollama default LLM_NUM_CTX= # context window (prompt + output). Ollama defaults to # 2048-4096; raise to 16384+ for large prompts ``` +`LLM_REASONING_EFFORT` and `LLM_THINKING_BUDGET` are two spellings of one +lever: reasoning tokens are counted inside the output total and can dominate +it (`reasoning_share_of_output` in the run manifest says by how much). +`LLM_REASONING_EFFORT` is the discrete one, read by OpenAI reasoning models +and by Gemini 3+; `LLM_THINKING_BUDGET` is the Gemini 2.5 integer spelling, +superseded from Gemini 3 on. Its vocabulary is the union across providers and +across model generations of one provider: the floor of the scale is spelled +`minimal` by some models and `none` by others, and `xhigh` is newer than both. +Which levels a given model accepts is the provider's business — nothing here +maps model names to levels, so a level the model does not take comes back as a +rejected request and aborts the run rather than being silently downgraded. On Google the two are mutually exclusive and +setting both is rejected at startup; setting the budget on a Gemini 3+ model +logs a warning and is ignored. Each knob joins the LLM cache key only when it +is set — leaving it unset keeps existing cache entries valid, setting it +re-issues every prompt. + `LLM_THINK=false` guarantees a non-empty `content` response; `true` captures reasoning separately. Leaving it unset can yield an empty response when a thinking model spends its whole budget reasoning — raise `LLM_NUM_PREDICT` @@ -188,17 +223,22 @@ failing together do not re-issue in lockstep. Two rules bound them: ```bash HOST=127.0.0.1 # loopback by default; see note below PORT=8999 -BASE_RECURSION_LIMIT=1000 # LangGraph step ceiling, scaled by ESTIMATED_CHUNKS -ESTIMATED_CHUNKS=30 # expected units per document; only sizes the limit above MAX_VISITS_PER_NODE=1 # canonical name; MAX_VISITS is an accepted alias -#MAX_CRITIC_VISITS_PER_NODE= # unset: critic shares the MAX_VISITS bound +FACTS_CRITIC_PASSES=1 # review-and-patch passes per facts unit RENDER_MODE=ontology_and_facts # which pipeline blocks run — see below LLM_GRAPH_FORMAT=jsonld # jsonld | turtle (legacy) — see below +ONTOLOGY_CHAPTER_FORMAT=auto # auto | inherit | turtle | term_sheet: the facts prompts' ontology chapter only — see below +# ONTOLOGY_TEXT_MAX_CHARS_NAMING=80 # cap on labels / alt labels in that chapter (unset = as authored) +# ONTOLOGY_TEXT_MAX_CHARS_CONTRACT=240 # cap on scope notes / definitions +# ONTOLOGY_TEXT_MAX_CHARS_PROSE=160 # cap on rdfs:comment and other notes +# ONTOLOGY_TEXT_TOTAL_BUDGET=40000 # ceiling on all chapter text together ONTOLOGY_CONTEXT_MODE=selected_single_ontology # where per-unit schema comes from — see below +ONTOLOGY_CONTEXT_SCOPE=unit # unit | document: one chapter per unit, or one shared per document #ONTOLOGY_CONTEXT_FIXED_ONTOLOGY_ID=catalog_iri_or_id_or_prefix ONTOLOGY_CONTEXT_MAX_TRIPLES=4000 # prompt budget for the ontology chapter — see below #ONTOLOGY_MAX_TRIPLES= # write-path growth backstop, NOT a context cap; unset = unlimited -PARALLEL_WORKERS=16 # concurrent content-unit workers; see Performance before raising +PARALLEL_WORKERS=16 # concurrent content-unit workers; startup warns when above LLM_MAX_INFLIGHT +FANOUT_WARMUP_UNITS=0 # units to finish before fanning out the rest (prefix-cache warm-up) ENABLE_ONTOLOGY_CONSOLIDATION=false # optional post-normalization merge pass; inert for multi-ontology documents # MAX_CONCURRENT_PROCESSES=4 # optional cap on simultaneous /process handlers # MAX_TENANCY_SCOPES=16 # resident per-tenant/project ToolBoxes (LRU) @@ -211,32 +251,27 @@ ENABLE_ONTOLOGY_CONSOLIDATION=false # optional post-normalization merge pas can target any tenancy partition. Set `HOST=0.0.0.0` only behind a proxy that authenticates; the server logs a warning when you do. -!!! note "`MAX_VISITS=1` means the LLM critic never runs — but not that there is only one LLM call" - - A visit budget of 1 makes the single render also the final one, and a - critique that cannot drive a retry is skipped. The **finding-driven repair** - still runs: up to `FACTS_LLM_REPAIR_VISITS` (default `1`) additional - `render_facts_update` calls when mandatory findings remain, so a facts unit - costs up to two provider calls at the default. Only its *trigger* is - deterministic. Set `FACTS_LLM_REPAIR_VISITS=0` for exactly one call per - unit, or `MAX_VISITS=2`+ to enable `criticise_facts` / - `criticise_ontology`. The ontology loop has no repair stage, so there - `MAX_VISITS=1` really is one call per unit. See - [Validation](validation.md#how-many-llm-calls-a-facts-unit-really-costs). +!!! note "The two per-unit budgets are independent" -!!! note "What `MAX_VISITS=2`+ actually costs" + `MAX_VISITS` retries a render that **failed**. A render that succeeded is + never repeated: re-extracting a whole unit to fix a local defect is the + expensive answer to a cheap question, and reliably introduces new defects. + Raise it only if renders are failing outright. - Less than the nested loops suggest. The critic loop is bounded by - `MAX_VISITS` as well, so its nominal worst case is that value *squared* — - but a critic that fails **without requesting external evidence** breaks out - of the loop immediately. With web grounding off (the default) the critic - therefore runs at most **once per render**, and only the last render is - skipped. The quadratic case needs `WEB_SEARCH_ENABLED=true` and a critic - that keeps asking for evidence. + `FACTS_CRITIC_PASSES` (default `1`) buys review-and-patch passes. Each pass + re-runs the deterministic checks for free, sends the graph and its findings + to the critic, and applies what comes back as a compiled patch — no second + call to carry out the fixes. So a facts unit costs **two** provider calls at + the defaults, whatever `MAX_VISITS` is. Set `FACTS_CRITIC_PASSES=0` for + exactly one call per unit. - `MAX_CRITIC_VISITS_PER_NODE` caps that path explicitly. Leave it unset to - keep the coupling to `MAX_VISITS`; set it to `1` for exactly one critique - per render. + Neither budget multiplies the other. A pass also ends the loop early when it + changed nothing or was rolled back, so raising the pass count buys passes + only while they are still doing something. See + [Validation](validation.md#how-many-llm-calls-a-facts-unit-really-costs). + + The ontology loop is the same loop, with `ONTOLOGY_CRITIC_PASSES` defaulting + to `0`. `MAX_CONCURRENT_PROCESSES` **queues** requests beyond the limit; they are not rejected. @@ -259,6 +294,9 @@ CHUNK_SECTION_SCHEMA_DETECT_MIN_MARGIN=1.8 # factor over the runner-up CHUNK_SECTION_SCHEMA_DETECT_CONTENT_MIN_MARGIN=4.0 # stricter margin for the content tier CHUNK_SECTION_FILTER_ON_EMPTY=warn # warn (default) | error CHUNK_BIBLIOGRAPHY_MODE=skip # skip | citations_only | domain_facts +CHUNK_MIN_UNIT_CHARS=0 # chars; drop units below this before the fan-out (0 = off) +CHUNK_NON_CONTENT_MODE=extract # extract | skip — front/back-matter units (authors, notes, licence, …) +CHUNK_MAX_MEASUREMENTS_PER_UNIT=0 # split units stating more unit-adjacent numbers than this (0 = off) ``` `CHUNK_SEGMENTER=semantic` (default) detects section spans on the markdown @@ -267,6 +305,17 @@ oversized section block, so chunks never straddle sections and inherit their section label deterministically. `docling` uses Docling `HybridChunker` structural segments instead. +The semantic split is itself deterministic: its dimensionality reduction is +seeded, so the same text and `CHUNK_*` settings produce the same content units +on every run and in every process, and two runs of one document read identical +inputs. Chunk boundaries changed in 0.6.4 when the seeding landed; chunk-cache +entries written earlier are not reused. + +Semantic chunking needs the `semantic-chunking` extra. Without it the default +`semantic` segmenter still runs, but splits oversized blocks on paragraph and +sentence boundaries instead of clustering them, and says so only in a log +warning at startup. Install the extra if boundaries matter. + `CHUNK_EMBEDDING_MODEL` is the sentence-transformers checkpoint used for semantic chunking and embedding-based schema detection. It shares one process-wide model with `EMBEDDING_MODEL_NAME` (retrieval) and @@ -289,6 +338,43 @@ against the default model's score distribution — re-derive them if you change it. Retrieval and chunking dimensions are independent: the vector store's dimension is fixed in its collection schema, the chunker's is not. +`CHUNK_MIN_UNIT_CHARS` drops content units shorter than the given number of +characters before the extraction fan-out; `0` (the default) disables the floor. +It is distinct from `CHUNK_MIN_SIZE`, which is a target the chunker aims at +while merging segments -- a heading stub or a caption fragment can still leave +chunking well under it. Every such unit costs a patch retrieval, a full render +and a critic call to extract from a fragment; the per-node durations and the +budget summary are what to size it against. Each drop is logged individually, +as with bibliography routing. + +`CHUNK_NON_CONTENT_MODE` routes front and back matter: units headed author +information, contributions, notes, ORCID, data or code availability, competing +or conflicting interests, licence, open access, supporting information, +associated content, additional information, publisher's note, funding or +acknowledgements (also any unit whose section label is `acknowledgements`) +**that state no unit-adjacent number**; units whose tokens are mostly emails, +URLs, ORCIDs and initials; and short licence notices. A measurement anywhere in +the unit keeps it -- the heading list is evidence, not a verdict. `extract` +(the default) keeps such units and flags them `is_non_content`, which +downstream checks that assume domain prose read; `skip` drops them before the +fan-out. Routing order is `CHUNK_MIN_UNIT_CHARS` → bibliography → non-content, +every decision is logged, and the run manifest counts what each rule dropped +(`undersized_units_skipped`, `bibliography_units_skipped`, +`non_content_units_skipped`). Like bibliography detection it is +English/Latin-script bound -- elsewhere it only under-detects. + +`CHUNK_MAX_MEASUREMENTS_PER_UNIT` splits a sized unit at the sentence or +paragraph boundary nearest its midpoint while it states more unit-adjacent +numbers than the cap, recursing on the pieces; `0` (the default) disables it. +Pieces inherit the unit's headings, references and section label and never +fall below `CHUNK_MIN_SIZE` (a unit shorter than twice the floor stays whole). +Unlike lowering `CHUNK_MIN_SIZE` / `CHUNK_MAX_SIZE`, it reaches only the units +where extraction has the most to lose -- a dense methods paragraph -- and +leaves sparse prose in large units where the ontology chapter is paid for +once. Each split is logged. A number counts when it stands next to a unit +token (`96 meV`, `77 K`, `0.5 %`), matched against the built-in unit lexicon +(`ontocast.util.measurement_lexicon`); a range yields one mention per number. + #### Section classification `CHUNK_SECTION_CLASSIFIER` selects how far the classification cascade runs. @@ -362,6 +448,14 @@ is documented in [Structured documents](concepts.md#structured-documents). #### Section filtering +An unrecognised section label is dropped with a warning, but a list where +**every** label is unrecognised is rejected outright (HTTP `422`, or a usage +error from the CLI). The two differ because their consequences do: a partly +recognised list still expresses an intent, while an empty result reads +downstream as an explicit "no sections" and therefore *replaces* the resolved +schema's `default_exclude` instead of adding to it. Failing there keeps a typo +from silently switching off section handling the caller never touched. + `CHUNK_SECTION_FILTER_ON_EMPTY` decides what happens when a section selection removes **every** segment: @@ -399,6 +493,7 @@ CONVERTER_PROFILE=default # default | born_digital # CONVERTER_FORCE_FULL_PAGE_OCR=false # CONVERTER_OCR_BITMAP_AREA_THRESHOLD=0.05 # CONVERTER_REPAIR_LIGATURE_GAPS=false # on under profile=born_digital +# CONVERTER_REPAIR_NUMERIC_ARTIFACTS=false # entities, CR wraps, flattened exponents; not part of born_digital ``` Recommended preset for publisher PDFs with selectable text: @@ -419,6 +514,7 @@ That preset currently implies: Notes: - `CONVERTER_REPAIR_LIGATURE_GAPS` repairs ASCII `fi` / `fl` / `ff` gap patterns that Docling passes through on some publisher PDFs. It is off by default but **on** under `CONVERTER_PROFILE=born_digital`, and it participates in the converter cache key, so flipping it re-converts. It becomes removable — as a breaking change — once Docling normalises these patterns upstream. +- `CONVERTER_REPAIR_NUMERIC_ARTIFACTS` repairs pattern-local artifacts that the export leaves inside values: the HTML entities `<` `>` `&` `"` `'` (an explicit map, not a general unescape), carriage-return column wraps (`\r` plus inline whitespace before a newline; a blank line after it stays a paragraph break), flattened exponents (`2 × 10 6` → `2 × 10^6`, and a bare `10 6` only behind `~`, `≈`, `∼`, `≃` or "order of"), and single-sided ligature gaps with only one reading (a gap before `ff…`, a gap after a letter-preceded `fi`/`fl`; `the field`, `fl oz`, `cliff face` are left alone). Only the product form reads a **sign**: `3 × 10 −3` becomes `3 × 10^-3`, while `∼10−15` is left as written, because a publisher typesets a numeric range with the same minus-sign character a negative exponent uses and behind a bare cue the range is the commoner reading -- leaving a real bare negative exponent alone costs nothing downstream, rewriting a range destroys the value invisibly. It does **not** touch superscript/subscript duplication, citation markers fused into a value, or a gap before `fi`/`fl` -- those need layout evidence the exported text no longer carries. Off by default and not part of `born_digital`; it composes with `CONVERTER_REPAIR_LIGATURE_GAPS` (the two-sided rule runs first) and joins the converter cache key only when on, together with a repair-rules version so that a corrected rule re-converts instead of being served text the old rule produced. Every conversion cached with repair off stays cached. - Prefer `CONVERTER_PROFILE=born_digital` for text-selectable PDFs before trying heavier OCR settings. - If OCR remains enabled and you pick `rapidocr`, set `CONVERTER_OCR_LANG=english` for English scans; RapidOCR's upstream default language is Chinese. @@ -438,7 +534,7 @@ No environment variables. Pass on `POST /process`, multipart form, JSON body, or | `document_metadata` | `--document-metadata` | JSON object of caller-asserted document identity (DOI/ISBN, ids, title, typed entities) — see [Concepts](concepts.md#document-level-identity-metadata) | ```bash -ontocast process --input-path ./papers/ \ +ontocast process --input-path ./documents/ \ --output-dir ./out \ --target-sections results,methods \ --summarize-sections results \ @@ -511,7 +607,7 @@ Applies to both Qdrant and LanceDB: # auto (default): Qdrant if QDRANT_URI is set, LanceDB if enabled, otherwise # vector retrieval is disabled. Explicit: qdrant | lancedb | none. # VECTOR_STORE_BACKEND=auto -VECTOR_STORE_TOP_K=20 +VECTOR_STORE_TOP_K=40 VECTOR_STORE_INDUCED_SUBGRAPH_DEPTH=2 VECTOR_STORE_INDUCED_SUBGRAPH_MAX_TOTAL_TRIPLES=1200 VECTOR_STORE_INDUCED_SUBGRAPH_ESTIMATED_TRIPLES_PER_QUERY=24 @@ -521,6 +617,11 @@ VECTOR_STORE_INDUCED_SUBGRAPH_ESTIMATED_TRIPLES_PER_QUERY=24 # VECTOR_STORE_INDUCED_SUBGRAPH_TYPE_PROMOTION_SCORE_FACTOR=1.0 # VECTOR_STORE_INDUCED_SUBGRAPH_SEED_ORDER=score # VECTOR_STORE_PROPOSITION_WINDOW_SENTENCES=2 +# VECTOR_STORE_PROPOSITION_WINDOW_MAX_CHARS= # length bound; replaces the sentence one +# VECTOR_STORE_PROPOSITION_WINDOW_MAX_TOKENS= # encoder-token bound; replaces both +# VECTOR_STORE_PROPOSITION_WINDOW_OVERLAP=0.0 # fractional overlap under a budget +# VECTOR_STORE_PROPOSITION_ABBREVIATION_AWARE=false +# VECTOR_STORE_PROPOSITION_MEASUREMENT_AWARE=false # VECTOR_STORE_PROPOSITION_MAX_WINDOWS=16 # VECTOR_STORE_PROPOSITION_RETRIEVAL_ENABLED=true # VECTOR_STORE_CONSISTENCY_CRITIC_MIN_FUSED_SCORE=0.5 @@ -539,7 +640,8 @@ VECTOR_STORE_INDUCED_SUBGRAPH_ESTIMATED_TRIPLES_PER_QUERY=24 | Variable | Default | Role | |----------|---------|------| -| `VECTOR_STORE_TOP_K` | `20` | Vector hits per channel per proposition window | +| `VECTOR_STORE_TOP_K` | `40` | Vector hits per channel per proposition window. This is how many candidates a window *offers* selection, not how many survive it — `ONTOLOGY_PATCH_MAX_ATOMS_BASE` caps the retained set, so depth fills the same budget from a wider field rather than enlarging the snapshot. Costs vector-search time, not prompt budget | +| `VECTOR_STORE_BM25_TOP_K` | *(unset)* | Depth of the sparse (BM25) lane when it should differ from `TOP_K`; unset uses `TOP_K` for every lane. Depth is a weight in disguise under reciprocal-rank fusion — a lane of length N votes at ranks 1..N however weak its tail — and the dense and sparse lanes stop paying at different depths, so one knob for both mis-tunes one of them | | `VECTOR_STORE_INDUCED_SUBGRAPH_DEPTH` | `2` | BFS depth for hub seed expansion | | `VECTOR_STORE_INDUCED_SUBGRAPH_HUB_SEED_COUNT` | `16` | Top seeds that receive full BFS budget (`0` = all seeds) | | `VECTOR_STORE_INDUCED_SUBGRAPH_ANCESTOR_CLOSURE_DEPTH` | `3` | `rdfs:subClassOf` hops in the schema shell | @@ -552,18 +654,24 @@ VECTOR_STORE_INDUCED_SUBGRAPH_ESTIMATED_TRIPLES_PER_QUERY=24 | `VECTOR_STORE_LABEL_PREDICATES` | `rdfs:label`, `skos:prefLabel`, `dcterms:title`, `skos:altLabel`, `dcterms:alternative` | Predicates whose literals are **indexed** as declared labels, in descending priority. Changing this requires a reindex | | `VECTOR_STORE_SYMBOL_PREDICATES` | `skos:notation`, `qudt:symbol`, `qudt:ucumCode` | Predicates whose literals are **indexed** as symbols, collected against their own budget so multilingual labels cannot crowd them out. The indexing half of the pair above; configuring only the retrieval half changes what surfaces without changing what is stored. Changing this requires a reindex | | `VECTOR_STORE_PROPOSITION_WINDOW_SENTENCES` | `2` | Sentences per proposition window for multi-query retrieval | +| `VECTOR_STORE_PROPOSITION_WINDOW_MAX_CHARS` | unset | Characters per window; **replaces** the sentence bound when set. Caps windows the encoder would truncate, and coalesces the fragments the period-splitter creates (`J. Phys. Chem. Lett.` is four "sentences") | +| `VECTOR_STORE_PROPOSITION_WINDOW_MAX_TOKENS` | unset | Encoder word pieces per window; **replaces** both bounds above when set. The only bound stated in the unit the encoder truncates on, so below the sequence limit it rules truncation out by construction — and the only one that cuts an over-long sentence (at whitespace) rather than handing it to the encoder to cut. Needs a provider with a tokenizer; without one it approximates by characters and logs that it did | +| `VECTOR_STORE_PROPOSITION_WINDOW_OVERLAP` | `0.0` | Fraction of a window repeated at the start of the next, under a character or token budget. `VECTOR_STORE_PROPOSITION_WINDOW_STRIDE` is the sentence-granular spelling and does not apply under a budget. Overlap multiplies queries, and once `PROPOSITION_MAX_WINDOWS` binds it buys them by dropping coverage elsewhere | +| `VECTOR_STORE_PROPOSITION_ABBREVIATION_AWARE` | `false` | Rejoin fragments the period-splitter created inside an abbreviation, an initial or a citation run. General English and bibliographic shapes only — never a domain vocabulary. Off by default: it changes the windows every existing measurement was taken on | +| `VECTOR_STORE_PROPOSITION_MEASUREMENT_AWARE` | `false` | Forbid a break between a number and its unit, or inside a range, from the number/unit *shapes* in the shared measurement lexicon. Only binds where a cut inside a sentence is possible, i.e. under a token budget | | `VECTOR_STORE_PROPOSITION_MAX_WINDOWS` | `16` | Cap on windows per excerpt; when a chunk has more, windows are sampled at an even stride spanning both endpoints (not “first N only”) | | `VECTOR_STORE_PROPOSITION_RETRIEVAL_ENABLED` | `true` | Multi-query proposition retrieval for induced-graph mode | | `VECTOR_STORE_CONSISTENCY_CRITIC_MIN_FUSED_SCORE` | `0.5` | Min weighted reciprocal-rank score for the consistency critic to flag a cross-ontology conflict (not cosine). Renamed from `VECTOR_STORE_CONSISTENCY_CRITIC_SIMILARITY_THRESHOLD` (old default `0.7`) | | `VECTOR_STORE_FUSION_CORE_WEIGHT` | `0.7` | Dense core-vector weight in rank fusion (weights are normalized, so only ratios matter) | | `VECTOR_STORE_FUSION_NEIGHBORHOOD_WEIGHT` | `0.15` | Dense neighborhood-vector weight; the neighborhood text describes a term's edges, so it corroborates the core lane more than it adds to it | | `VECTOR_STORE_FUSION_BM25_WEIGHT` | `0.8` | Sparse BM25 weight. A term whose surface form is a symbol (`meV`, a chemical formula) is often invisible to the dense lanes, so the sparse lane is its only evidence — see [Ontology Context](ontology_context.md#bm25-index-recreate) | +| `VECTOR_STORE_FUSION_RANK_CONSTANT` | `0.0` | Smoothing added to each rank before the reciprocal in lane fusion: a lane contributes `weight / (constant + rank)`. At `0.0` the decay is brutal — rank 2 is worth half of rank 1 — so fused order is decided almost entirely by which lane ranked first. Raising it makes agreement *across* lanes outweigh position *within* one; a constant far above the lane depth flattens the ranking away entirely | | `VECTOR_STORE_INDEX_UNDESCRIBED_IRIS` | `false` | Atomize IRIs an ontology only *references* (object/predicate position) in addition to ones it describes. Reindex on change | | `VECTOR_STORE_EMBED_STANDARD_VOCAB_IRIS` | `false` | Atomize RDF/OWL/SKOS/DC/SHACL/schema.org IRIs instead of skipping them. Reindex on change | | `VECTOR_STORE_EXTRA_EXCLUDED_NAMESPACE_PREFIXES` | *(empty)* | Extra IRI prefixes never atomized from ontology sources, on top of the standard-vocabulary set. Reindex on change | | `VECTOR_STORE_EMBEDDING_BATCH_SIZE` | `64` | Texts per embedding request during ontology indexing (raise for remote APIs; lower if VRAM-bound) | | `VECTOR_STORE_REINDEX_CONCURRENCY` | `2` | Max ontologies materialized/reindexed in parallel at `ToolBox.initialize` | -| `VECTOR_STORE_WIPE_ON_INIT` | `false` | Drop the current tenant/project vector partition before recreate+reindex (clean slate; also CLI `--wipe-vector-store`) | +| `VECTOR_STORE_WIPE_ON_INIT` | `false` | Drop the current tenant/project vector partition before recreate+reindex (clean slate; also CLI `--wipe-vector-store`). The drop runs whenever a vector store is configured; the recreate+reindex needs `ONTOLOGY_CONTEXT_MODE=selected_vector_search_ontology` | | `VECTOR_STORE_PRUNE_ORPHAN_IRIS_ON_INIT` | `true` | Delete indexed ontology IRIs absent from the synchronized catalog (covers IRI renames without a full wipe) | | `VECTOR_STORE_LEXICAL_TRIGGER_ENABLED` | `true` | Exact-match lane for notation/symbol tokens in raw chunk text | | `VECTOR_STORE_LEXICAL_TRIGGER_PREDICATES` | `skos:notation`, `qudt:symbol`, `qudt:ucumCode` | Predicate IRIs whose literals become case-preserved triggers | @@ -574,17 +682,28 @@ VECTOR_STORE_INDUCED_SUBGRAPH_ESTIMATED_TRIPLES_PER_QUERY=24 | `VECTOR_STORE_LEXICAL_TRIGGER_MAX_ATOMS` | `16` | Additive cap on trigger seeds per retrieval call (outside semantic budget) | | `VECTOR_STORE_LEXICAL_TRIGGER_SCORE` | `0.35` | Score assigned to trigger hits (calibrated against fused rank scores: rank-1 core = 0.583, merged floor = 0.18) | | `VECTOR_STORE_LEXICAL_TRIGGER_FUSION` | `max_merge` | `max_merge` promotes an already-retrieved atom to `max(semantic, trigger)` score; `append` (legacy) only adds unseen atoms | -| `VECTOR_STORE_QUERY_UNIT_SIGNALS_ENABLED` | `false` | Match number-adjacent tokens ("4-15 days", "200 kV", "0.5 %") case-insensitively and plural-tolerantly against catalog labels/symbols/UCUM codes; matched entities join the snapshot seeds at trigger score, outside the semantic budget | +| `VECTOR_STORE_QUERY_UNIT_SIGNALS_ENABLED` | `true` | Match number-adjacent tokens ("4-15 days", "200 kV", "0.5 %") case-insensitively and plural-tolerantly against catalog labels/symbols/UCUM codes; matched entities join the snapshot seeds at trigger score, outside the semantic budget. Query-time only, so switching it needs no reindex | | `VECTOR_STORE_SYMBOL_CASE_MISMATCH_POLICY` | `demote` | Merge-time treatment of atoms whose declared symbol surfaces (`skos:notation`, `qudt:symbol`, `qudt:ucumCode`) match a query token only case-insensitively with no exact-case match anywhere — the BM25/dense text is case-folded, so prose "meV" also retrieves `unit:MegaEV` (symbol "MeV"). `demote` multiplies the atom score, `drop` removes it, `off` keeps legacy behavior; exact-case and label-only matches are never touched | | `VECTOR_STORE_SYMBOL_CASE_MISMATCH_DEMOTE_FACTOR` | `0.5` | Score multiplier applied under the `demote` policy | | `FACTS_OBJECT_PROPERTY_LITERAL_CHECK` | `true` | Quarantine string literals on predicates whose schema range is a class (e.g. `qudt:unit`); surfaced to the facts critic and the deterministic repair loop | -| `FACTS_LLM_REPAIR_VISITS` | `1` | Finding-driven repair budget per unit, **in provider calls**: extra update renders fed with machine-found MANDATORY fixes (quarantined literals, unknown/near-miss terms, `rdfs:domain` contradictions) and numeric-coverage candidates. Fires even at `MAX_VISITS=1`, where the LLM critic never runs. `0` leaves the residue to the LLM-free repairs and the gate | -| `FACTS_PROPERTY_ALIAS_MIN_RATIO` | `0.85` | SequenceMatcher cutoff for deterministic near-miss property rewrites in catalog namespaces (token containment always qualifies, e.g. `qudt:value` → `qudt:numericValue`) | +| `FACTS_CRITIC_PASSES` | `1` | Review-and-patch passes per unit, **in provider calls**. Each pass re-runs the deterministic checks for free, sends the graph and its findings to the critic, and applies what comes back as a compiled patch. `0` leaves the residue to the LLM-free repairs and the gate | +| `FACTS_CRITIC_MIN_TRIPLES` | `1` | Skip the facts critic for a unit whose render holds fewer triples than this. A critic shown an empty graph scores it perfect and bills a call for nothing; the default skips exactly the empty renders, recorded as `skipped` rather than reviewed. `0` reviews every unit | +| `FACTS_CRITIC_MAX_DELETE_SHARE` | `0.25` | Largest share of a unit graph one pass may remove; past it the pass keeps its inserts and drops its deletes | +| `FACTS_CRITIC_MIN_DELETES` | `5` | Deletions always permitted regardless of share, so short units stay correctable | +| `FACTS_CRITIC_ALLOW_SUBJECT_RENAME` | `false` | Whether a `REPLACE` may delete about one subject while writing about another | +| `FACTS_COMPLETION_PASSES` | `0` | Insert-only completion passes per unit, **in provider calls**, run after the critic loop above and only while the numeric-coverage inventory still lists a measurement absent from the graph. Shown a compact term sheet and the unit's existing catalog-typed subjects instead of the full ontology chapter; every proposed fix is `action=ADD` and goes through the same per-subject regression check a critic fix does. `0` (default) disables it — see [Validation](validation.md#completion-pass-insert-only-recovery) | +| `FACTS_NUMERIC_COVERAGE_LIMIT` | `30` | Cap on missing-numeric mentions listed in one `NUMERIC_COVERAGE` finding. Bounds prompt size; mentions are listed shortest-first, not by relevance. `0` disables the finding | +| `FACTS_NUMERIC_COVERAGE_MANDATORY` | `off` | Which `NUMERIC_COVERAGE` findings block unit acceptance. `measurements` blocks on numbers written next to a unit; `all` also on bare numbers; `off` keeps both advisory, leaving the critic to judge per mention whether a number is a quantity or an artifact. `true`/`false` are accepted as `all`/`off` | +| `ONTOLOGY_CRITIC_PASSES` | `0` | The same budget for ontology units, opt-in | +| `ONTOLOGY_CRITIC_MAX_DELETE_SHARE` | `0.10` | Stricter than the facts side: an ontology delete propagates onto shared catalog terminals | +| `ONTOLOGY_CRITIC_MIN_DELETES` | `3` | Deletions always permitted regardless of share | +| `ONTOLOGY_ACCEPT_BLOCKING_FINDING_KINDS` | destructive subset | Deterministic ontology findings that block acceptance | +| `FACTS_PROPERTY_ALIAS_MIN_RATIO` | `0.95` | Tie-break floor for deterministic near-miss property rewrites in catalog namespaces. Only token containment or folded equality qualifies a candidate (`qudt:value` → `qudt:numericValue`); the ratio decides between several qualifying candidates and never licenses a rewrite alone. Predicates present anywhere in the full catalog are left untouched | | `FACTS_MERGE_REPAIR_PASSES` | `1` | Un-merge budget at the post-aggregation `VALIDATE_FACTS` gate: *merge-signature* error findings (functional violation, suspect multi-value, degenerate coreference) on merged subjects become full-cluster pair vetoes and the facts units are re-aggregated. `0` records findings without repairing. SHACL findings never drive it | | `FACTS_CODE_PREDICATES` | `qudt:ucumCode`, `qudt:symbol`, `skos:notation` | Predicates whose literal objects are machine-resolvable codes. A node carrying `qudt:ucumCode "d"` but no unit link gains the object property pointing at the catalog individual declaring that code, when exactly one does. Exact and case-sensitive — these are codes, not labels | | `FACTS_SUSPECT_MULTI_VALUE_SEVERITY` | `error` | Severity of SUSPECT_MULTI_VALUE gate findings (multiple distinct numeric values on one predicate; mutually irreconcilable short string values on a dominantly string-single-valued predicate; or multiple objects on a dominantly single-valued predicate); only `error` findings drive the un-merge repair | | `FACTS_LITERAL_VARIANT_DEDUPE` | `true` | Collapse duplicate literals differing only in language tag or datatype on one (subject, predicate) before validation — `"X"@en` alongside `"X"^^xsd:string` alongside `"X"`. The language-tagged form wins, then the plain form; reified provenance follows the survivor. Each removal is a `literal_variant_pruned` repair record | -| `FACTS_SHAPES_DIR` | — | **Seed** directory of SHACL shape files (searched recursively), materialized at startup into the tenant's `{tenant}--{project}--shapes` partition — the same read-only bootstrap contract `ONTOCAST_ONTOLOGY_DIRECTORY` has. The gate validates against the partition, so shapes uploaded over `POST /shapes` are equally in force and a container needs no shapes directory. `sh:NodeShape` triples inlined in the ontology context are picked up automatically. SHACL runs only when `pyshacl` is installed (`uv sync --extra shacl`). Setting this without the extra, or pointing it at a missing/empty directory, logs a **warning** — it never passes silently | +| `FACTS_SHAPES_DIR` | — | **Seed** directory of SHACL shape files (searched recursively), materialized at startup into the tenant's `{tenant}--{project}--shapes` partition — the same read-only bootstrap contract `ONTOCAST_ONTOLOGY_DIRECTORY` has. The gate validates against the partition, so shapes uploaded over `POST /shapes` are equally in force and a container needs no shapes directory. `sh:NodeShape` triples inlined in the ontology context are picked up automatically. SHACL runs only when `pyshacl` is installed (`uv sync --extra shacl`). Setting this without the extra, or pointing it at an empty directory, logs a **warning** — it never passes silently; a directory that does not exist stops `ontocast process` outright. `--shapes-dir` overrides it per run | | `FACTS_SHACL_INFERENCE` | `rdfs` | Pre-inference for the SHACL run: `none`, `rdfs`, `owlrl`. RDFS by default because SHACL property paths carry no `rdfs:subPropertyOf` entailment, so a shape naming a superproperty reports the specialised predicate the renderer emitted as missing | | `FACTS_SHACL_ADVANCED` | `true` | Enable SHACL Advanced Features (`sh:sparql` constraints, node expressions) | | `FACTS_SHACL_MAX_TRIPLES` | `200000` | Skip SHACL with a warning above this graph size; `0` disables the guard | @@ -592,6 +711,8 @@ VECTOR_STORE_INDUCED_SUBGRAPH_ESTIMATED_TRIPLES_PER_QUERY=24 | `FACTS_SHACL_AUTOFIX_PASSES` | `1` | Bounded validate → repair → revalidate rounds; a pass that does not strictly reduce violations is reverted | | `FACTS_FUNCTIONAL_MIN_SINGLE_SUPPORT` | `3` | Distinct single-valued subjects a predicate needs before the gate treats it as empirically functional. Below this the evidence is too thin to call a second value a violation | | `FACTS_QUANTITY_FALLBACK_VOCABULARY` | QUDT | Role → term mapping the facts prompt names as the fallback for bounded/approximate quantities when retrieval supplied no suitable class. Roles: `value_class`, `numeric_value`, `unit`, plus optional `lower_bound`/`upper_bound` (and roles containing `inclusive`) naming the catalog's range properties. Override for catalogs modelling quantities with another vocabulary; set to `{}` to forbid the fallback entirely and keep the renderer inside the provided context. Terms in a configured fallback namespace are reported by `NON_CATALOG_VOCABULARY` as a *deliberate* fallback, and the configured terms are exempt from `UNKNOWN_TERM`. When all of `numeric_value`/`lower_bound`/`upper_bound` are set, equal-bound pairs are promoted to a single scalar at parse time; the `unit` role drives the `LABEL_ONLY_NUMBER` finding — see [Validation](validation.md#which-terms-count-as-unknown) | +| `FACTS_DOMAIN_ADHERENCE_MIN_SHARE` | `0.15` | Floor on the fraction of a render's distinct schema terms (predicates and `rdf:type` objects, excluding minted instances and RDF/RDFS/OWL/XSD/SKOS/DC/PROV plumbing) that must come from the unit's ontology context. Below it a mandatory `DOMAIN_ADHERENCE` finding asks for a rewrite. Catches what no per-triple check can see: a render that says everything in a generic vocabulary is well-formed term by term, exempt from `UNKNOWN_TERM`, and matched by no shape — it reads as extracted while answering nothing. `0` disables; leave it disabled if you extract without a catalog | +| `FACTS_DOMAIN_ADHERENCE_MIN_TERMS` | `4` | Fewest distinct schema terms a render must use before its catalog share is judged at all. A share over one or two terms is noise — a front-matter unit typing an identifier and an author with generic vocabulary has not abandoned the catalog. `0` judges every non-empty render | | `FACTS_ADDITIONAL_STANDARD_NAMESPACES` | schema.org | Namespaces exempt from `UNKNOWN_TERM` beyond the RDF/OWL substrate and annotation/provenance terms. Only meta-vocabularies are built in; a domain vocabulary shared across catalogs (SOSA/SSN, CSVW, FOAF, Dublin Core profiles) is exempted here. schema.org is the default because the shipped citation vocabulary uses it | | `CHUNK_CITATION_VOCABULARY` | schema.org | Role → term mapping used by the citation-metadata prompt in `citations_only` mode. Bibliographic entries are not domain content, so unlike the rest of the pipeline these terms are configuration rather than retrieval. Roles: `work_class`, `fallback_class`, `title`, `author`, `author_name`, `date_published`, `venue`, `identifier`, `cites` | @@ -651,14 +772,15 @@ Budget behavior: - `VECTOR_STORE_INDUCED_SUBGRAPH_MAX_TOTAL_TRIPLES` is the global upper bound returned to the LLM. - `VECTOR_STORE_INDUCED_SUBGRAPH_ESTIMATED_TRIPLES_PER_QUERY` shapes per-entity allocation during retrieval. -!!! warning "Retrieval defaults are single-corpus fits" +!!! warning "Retrieval defaults are a starting point, not a fit to your catalog" The shipped retrieval defaults — notably - `VECTOR_STORE_INDUCED_SUBGRAPH_MAX_TOTAL_TRIPLES` (raised 550 → 1200), + `VECTOR_STORE_INDUCED_SUBGRAPH_MAX_TOTAL_TRIPLES`, `PER_ONTOLOGY_ATOM_FLOOR`, `PER_ROLE_ATOM_FLOOR`, and - `SCHEMA_CLOSURE_MAX_ENTITIES` — were tuned one axis at a time against a - single catalog, and the triple budget more than doubles prompt-context cost - per unit. Treat them as a starting point and re-sweep for your own catalog; + `SCHEMA_CLOSURE_MAX_ENTITIES` — were set one axis at a time, and the right + value depends on how large and how overlapping a catalog is. The triple + budget is also the main driver of prompt-context cost per unit. Re-sweep + them for your own catalog; each field's description in `ontocast/config/settings.py` records what it controls. @@ -668,7 +790,7 @@ See [Ontology Context](ontology_context.md) for vector-search mode requirements. Post-vector scoring and capping (backend-agnostic; prefix `ONTOLOGY_PATCH_`). Applied after hybrid dense + BM25 retrieval, before induced-subgraph expansion. -**Default path** (simple): max-score IRI dedupe → global score order → window-scaled hard cap. A non-zero `ONTOLOGY_PATCH_PER_ONTOLOGY_SEED_QUOTA` inserts per-ontology round-robin (best-scoring ontologies first) instead of plain score order. Relative floors, hybrid tier merge, merged-score ratio, and MMR are advanced opt-in. +**The path** (simple): max-score IRI dedupe → global score order → window-scaled hard cap. A non-zero `ONTOLOGY_PATCH_PER_ONTOLOGY_SEED_QUOTA` inserts per-ontology round-robin (best-scoring ontologies first) instead of plain score order. Merged-score ratio and MMR are advanced opt-in. ```bash ONTOLOGY_PATCH_CROSS_QUERY_MERGE_MODE=max_score @@ -679,35 +801,23 @@ ONTOLOGY_PATCH_MAX_ATOMS=96 ONTOLOGY_PATCH_MIN_MERGED_MAX_SCORE=0.18 ONTOLOGY_PATCH_MMR_LAMBDA=1.0 # Advanced (off by default): -# ONTOLOGY_PATCH_PER_QUERY_CORE_SCORE_RATIO=0.0 -# ONTOLOGY_PATCH_PER_QUERY_NEIGHBORHOOD_SCORE_RATIO=0.0 -# ONTOLOGY_PATCH_PER_QUERY_BM25_SCORE_RATIO=0.0 # ONTOLOGY_PATCH_MERGED_SCORE_RATIO=0.0 -# ONTOLOGY_PATCH_CROSS_QUERY_MERGE_MODE=hybrid -# ONTOLOGY_PATCH_MAX_ATOMS_TIER1=12 -# ONTOLOGY_PATCH_MIN_ENTITY_SCORE=0.3 +# ONTOLOGY_PATCH_CROSS_QUERY_MERGE_MODE=sum_score ``` | Variable | Default | Role | |----------|---------|------| -| `ONTOLOGY_PATCH_CROSS_QUERY_MERGE_MODE` | `max_score` | Default merge; `sum_score` sums per-window scores; `hybrid` is tier-1 + tier-2 | -| `ONTOLOGY_PATCH_PER_ONTOLOGY_SEED_QUOTA` | `0` | Max seeds per ontology in round-robin fill; `0` (default) means global score order, which measured better on both recall and precision | +| `ONTOLOGY_PATCH_CROSS_QUERY_MERGE_MODE` | `max_score` | Default merge; `sum_score` sums per-window scores, so a term several windows agree on outranks one window's top hit | +| `ONTOLOGY_PATCH_PER_ONTOLOGY_SEED_QUOTA` | `0` | Max seeds per ontology in round-robin fill; `0` (default) means global score order, which ranks every candidate on one scale; a quota caps each ontology's share so one cannot take the whole budget, at the cost of admitting lower-scoring terms from the others | | `ONTOLOGY_PATCH_SEEDS_PER_WINDOW` | `4` | Scales effective cap with proposition window count | | `ONTOLOGY_PATCH_MAX_ATOMS_BASE` | `96` | Floor for effective atom cap; below the candidate pool it silently clips seeds on multi-ontology catalogs | | `ONTOLOGY_PATCH_MAX_ATOMS` | `96` | Hard cap: `min(max_atoms, max(base, seeds_per_window × n_queries))` (`0` = unlimited) | -| `ONTOLOGY_PATCH_MIN_MERGED_MAX_SCORE` | `0.18` | Empty patch when the best **per-window** fused score is below this (`0` disables); evaluated before cross-window merge | +| `ONTOLOGY_PATCH_MIN_MERGED_MAX_SCORE` | `0.18` | Empty patch when the best **per-window** fused score falls below this **fraction of the best attainable** fused score (`0` disables); evaluated before cross-window merge | | `ONTOLOGY_PATCH_MMR_LAMBDA` | `1.0` | `1.0` skips MMR; lower values enable diversity rerank | -| `ONTOLOGY_PATCH_PER_QUERY_CORE_SCORE_RATIO` | `0.0` | Advanced: per-window core relative floor (`0` disables) | -| `ONTOLOGY_PATCH_PER_QUERY_NEIGHBORHOOD_SCORE_RATIO` | `0.0` | Advanced: per-window neighborhood relative floor | -| `ONTOLOGY_PATCH_PER_QUERY_BM25_SCORE_RATIO` | `0.0` | Advanced: per-window BM25 relative floor | -| `ONTOLOGY_PATCH_MIN_CORE_QUERY_BEST_SCORE` | `0.0` | If `> 0`, windows whose top core score is below this contribute no core hits | -| `ONTOLOGY_PATCH_MIN_NEIGHBORHOOD_QUERY_BEST_SCORE` | `0.0` | If `> 0`, windows whose top neighborhood score is below this contribute no neighborhood hits | -| `ONTOLOGY_PATCH_MIN_BM25_QUERY_BEST_SCORE` | `0.0` | If `> 0`, windows whose top BM25 score is below this contribute no BM25 hits | | `ONTOLOGY_PATCH_MERGED_SCORE_RATIO` | `0.0` | Advanced: drop seeds below `top_score × ratio` (`0` disables) | -| `ONTOLOGY_PATCH_MAX_ATOMS_TIER1` | `12` | Hybrid only: global tier-1 cap (`0` = no cap) | -| `ONTOLOGY_PATCH_MIN_ENTITY_SCORE` | `0.3` | Hybrid only: tier-2 minimum fused score | | `ONTOLOGY_PATCH_PER_ONTOLOGY_ATOM_FLOOR` | `2` | Reserve pass before the global fill: every contributing ontology is guaranteed `min(floor, its candidates)` seed slots (round-robin). Unlike the quota (a ceiling), the floor protects small modules from starvation at the atom cap. `0` disables | | `ONTOLOGY_PATCH_SMALL_MODULE_CLOSURE_MAX_TRIPLES` | `300` | Include a source ontology's whole header-stripped graph in the snapshot when it has ≥ 1 admitted atom and at most this many triples (prevents near-miss property improvisation on tiny vocabularies). `0` disables | +| `ONTOLOGY_PATCH_SMALL_MODULE_CLOSURE_MAX_TOTAL_TRIPLES` | *(unset)* | Ceiling on what *all* whole-module closures together may contribute to one snapshot. Unset is unlimited — every module that fits `SMALL_MODULE_CLOSURE_MAX_TRIPLES` and won a seed is included. When set, candidates are admitted in order of retrieval relevance until the budget is spent, and a module too large for the remainder is skipped rather than ending the pass, so a smaller one behind it can still get in | | `ONTOLOGY_PATCH_PER_ROLE_ATOM_FLOOR` | `12` | Reserve pass for predicate-role atoms before the global fill. Prose reads as noun phrases, so classes out-score the properties that link them in a shared ranking. `0` disables | | `ONTOLOGY_PATCH_SCHEMA_CLOSURE_MAX_ENTITIES` | `32` | Cap on terms admitted by `rdfs:domain`/`rdfs:range` closure over the seeds: properties whose domain/range names an admitted class (or an ancestor), plus the domain/range classes of admitted properties. `0` disables | | `ONTOLOGY_PATCH_SCHEMA_CLOSURE_ANCESTOR_DEPTH` | `2` | How far to walk `rdfs:subClassOf` upward when matching a property's declared domain/range against an admitted class | @@ -726,9 +836,9 @@ what to read while doing so. | `ONTOLOGY_PATCH_PER_ONTOLOGY_ATOM_FLOOR` | `2` | 2–4 | Saturates quickly. **The closure above is inert without this** — a module must win at least one seed before its graph is considered | | `ONTOLOGY_PATCH_SCHEMA_CLOSURE_MAX_ENTITIES` | `32` | 16–48 | Admits properties whose domain/range names an admitted class. Saturates well before the top of the range | | `ONTOLOGY_PATCH_PER_ROLE_ATOM_FLOOR` | `12` | 8–16 | Weak on its own; contributes once the schema closure is on | -| `ONTOLOGY_PATCH_MAX_ATOMS` / `_BASE` | `96` | 96–192 | Trades directly against context size once the triple budget is not binding | -| `VECTOR_STORE_TOP_K` | `20` | — | **Insensitive** (10–40 all within 1 point). Effectively capped by `MAX_ATOMS_BASE`; leave alone | -| `ONTOLOGY_PATCH_MMR_LAMBDA` | `1.0` | — | **Insensitive** (0.5–1.0 identical on this corpus). Leave alone unless you see near-duplicate terms crowding the snapshot | +| `ONTOLOGY_PATCH_MAX_ATOMS` / `_BASE` | `96` | 96–192 | Trades directly against context size once the triple budget is not binding. **Raise `_BASE`, not `MAX_ATOMS`:** the effective cap is `min(MAX_ATOMS, max(MAX_ATOMS_BASE, SEEDS_PER_WINDOW × windows))` and `windows` is capped by `VECTOR_STORE_PROPOSITION_MAX_WINDOWS`, so at the defaults nothing above 96 can bind and every larger value is the same run. A warning at startup names the ceiling | +| `VECTOR_STORE_TOP_K` | `40` | 20–40 | How many candidates each window **offers** selection — not how many survive it. `MAX_ATOMS_BASE` caps the retained set, so raising this fills the same budget from a wider field rather than enlarging the snapshot; it buys the terms a shallower list ranked just out of reach, most of all the scaffolding vocabulary that arrives through expansion rather than as a seed. Costs vector-search time, not prompt budget | +| `ONTOLOGY_PATCH_MMR_LAMBDA` | `1.0` | — | Diversity trade-off: `1.0` ranks by relevance alone, lower values penalise terms similar to ones already selected. Leave alone unless you see near-duplicate terms crowding the snapshot. Below 1.0 it selects the whole budget itself and cannot honour the two atom floors, so the combination is **rejected at startup**: zero the floors to choose MMR, or leave this at 1.0 to keep them | Combined at the defaults: needed-term recall 11/11, declared-property coverage 82%, at roughly 2.7× the snapshot size of the old settings. That size increase is @@ -748,7 +858,7 @@ VECTOR_STORE_INDUCED_SUBGRAPH_MAX_TOTAL_TRIPLES=600 ```bash CURRENT_DOMAIN=https://example.com # base for minted IRIs; also the default facts namespace -ONTOCAST_ONTOLOGY_DIRECTORY=/path/to/ontology/files # seed .ttl files, synced to the catalog on startup +ONTOCAST_ONTOLOGY_DIRECTORY=/path/to/ontology/files # seed .ttl files, synced to the catalog on startup (CLI: --ontology-dir) ONTOCAST_CACHE_DIR=/path/to/cache/directory # LLM + converter disk cache root # Cache eviction. The cache bounds itself: once it exceeds the ceiling, @@ -777,6 +887,7 @@ AGG_LITERAL_CONFLICT_GUARD=true AGG_INITIALS_DISTINCT_GUARD=true AGG_NATURAL_KEY_MERGE=true AGG_TYPE_GUARD_UNTYPED=permissive +AGG_UNIT_SCOPED_FACT_IRIS=true # identity across units is decided by the aggregator, not by name ``` See [Aggregation](aggregation.md) for what each threshold and guard does. @@ -821,12 +932,13 @@ describe a lane that does not run until you turn it on. | `WEB_SEARCH_REGION` | `wt-wt` | DuckDuckGo region code | | `WEB_SEARCH_SAFESEARCH` | `moderate` | DuckDuckGo safesearch mode | -!!! note "Enabling search is what makes the critic loop expensive" +!!! note "Enabling search adds a second critic call per pass" - The critic breaks out of its loop immediately when it fails *without* - requesting external evidence, so with search off it runs at most once per - render whatever `MAX_VISITS` says. Turning search on is what opens the - quadratic path — bound it with `MAX_CRITIC_VISITS_PER_NODE`. + With search off, each critic pass is one provider call. With search on, a + critique that fails *and* asks for external evidence triggers one search + and one re-critique within the same pass, so a pass costs at most two + critic calls. The number of passes stays bounded by `FACTS_CRITIC_PASSES` + and `ONTOLOGY_CRITIC_PASSES`; lower those to bound the search cost. ### Other @@ -844,6 +956,74 @@ LOGGING_LEVEL=info # debug | info | warning | error Overridable per request as `llm_graph_format`, with the same precedence and the same 400-on-typo contract as [`RENDER_MODE`](#render-mode-render_mode). +`ONTOLOGY_CHAPTER_FORMAT` (`auto` default, or `inherit`, `turtle`, +`term_sheet`) narrows the choice to one chapter. With `turtle` the `# ONTOLOGY` +chapter of the facts render and critic prompts is serialized as canonical +Turtle while everything else keeps the wire format above; that chapter is the +bulk of a facts prompt and JSON-LD spends materially more characters per +triple, so this is a context lever that leaves parsing untouched. With +`term_sheet` the chapter stops being a serialized graph and becomes a +line-per-term listing — name, surface forms, type, hierarchy, domain/range and +one prose field (the scope note where a term has one, `rdfs:comment` +otherwise), without the per-statement RDF scaffolding — which is by a wide +margin the cheapest of the four. See [Performance](performance.md) for what +each keeps and drops. + +`auto` picks the cheapest chapter the render mode can legally read: +`term_sheet` under `RENDER_MODE=facts`, `inherit` otherwise. It is resolved +when the configuration is built, so the run manifest and the LLM cache key +carry the chapter actually sent, never the word `auto`. + +Because it is resolved against the *deployment's* `RENDER_MODE`, a per-request +`render_mode` override does not re-resolve it: a request that switches an +ontology-mode deployment to facts gets a graph chapter rather than a term sheet +— cheaper was available and not taken, which costs tokens and nothing else. The +converse cannot go wrong: the chapter format reaches the facts loop only, so an +ontology pass never receives a listing whatever a request asks for. + +All of them apply to the facts loop only, are not overridable per request, and +change the LLM cache key for facts calls when the resolved chapter changes. +An **explicit** `term_sheet` additionally requires `RENDER_MODE=facts`: the +ontology loop emits a patch against the statements in its chapter, so that +chapter has to be a graph, and a configuration that asks for both is rejected +at startup rather than silently falling back. `auto` never fails that way — +where a listing is illegal it simply yields a graph. + +### Ontology context scope (`ONTOLOGY_CONTEXT_SCOPE`) + +`unit` (default) has each content unit retrieve its own context: the smallest +chapter per unit, and a different one for each, so no two calls in a document +share a prompt prefix. `document` resolves each unit's context as before and +shows every unit the union — larger per call, identical across the fan-out, and +therefore chargeable at a provider's cached rate on every call after the first. +Recall-safe by construction: the union contains every atom each unit's own +retrieval selected. What it costs is precision, since a unit also sees its +siblings' terms. + +Pair it with `FANOUT_WARMUP_UNITS=1`. A prefix cache is populated by a request +that has already *completed*, and the fan-out issues every unit's call at once, +so without a warm-up all of them miss a prefix they all share. The two settings +are worth nothing apart. On OpenAI, `LLM_PROMPT_CACHE_KEY` additionally keeps +those requests routed to one cache shard; any stable string works, and it must +not vary per request. See [Performance](performance.md). + +### Ontology chapter text caps + +`ONTOLOGY_TEXT_MAX_CHARS_NAMING`, `ONTOLOGY_TEXT_MAX_CHARS_CONTRACT`, +`ONTOLOGY_TEXT_MAX_CHARS_PROSE` and `ONTOLOGY_TEXT_TOTAL_BUDGET` bound the text +literals in the ontology chapter, in whichever of the three formats it is built. +`ONTOLOGY_CONTEXT_MAX_TRIPLES` below is a *count* and says nothing about how +long any one literal may be, so a chapter well inside it can still be unbounded. + +All four are unset by default and are inert when unset — a catalog whose labels +and comments are already short sees byte-identical prompts and cache keys. +Clipping is on a word boundary with a visible marker, and **only ever +shortens** — no statement is removed to meet a budget. Over the total budget, +prose is fitted first, then contracts, then names, each to the largest cap that +still meets it. The run manifest's `budget.counters` reports +`chapter/text_chars_before`, `chapter/text_chars_after`, +`chapter/literals_clipped` and `chapter/text_over_budget`. + ## Ontology Context Size (`ONTOLOGY_CONTEXT_MAX_TRIPLES`) How much ontology is serialized into each prompt. This is the knob for context @@ -910,6 +1090,32 @@ system: two of the three values skip an entire half of the graph. catalog first (see [Ontology Context](ontology_context.md#seeding-the-catalog)), or run `ontology_and_facts` once. + This is the one mode `ontocast process` **refuses to start** in with an + empty catalog, and the only mode `ONTOLOGY_CONTEXT_REQUIRED` applies to. + The other two answer an empty context by creating vocabulary, so for them + it is a starting point rather than a fault — see + [Ontology Context](ontology_context.md#what-if-the-context-is-empty). + +!!! tip "Recommended companions for `RENDER_MODE=facts`" + + Three settings earn their keep in facts-only runs and belong together in a + facts-extraction environment (the first is on by default; it is listed so a + preset that pins it off stands out): + + ```bash + FACTS_CONTEXT_FROM_UNITS=true # gate + aggregator see the units' resolved + # ontology context instead of an empty graph + LLM_JSON_MODE=true # constrained decoding (OpenAI): removes the + # malformed-JSON retry failure mode + FACTS_SHAPES_PROMPT_CONTRACT=auto # default; shows the renderer the shapes + # rulebook it is validated against + ``` + + With `FACTS_CONTEXT_FROM_UNITS=false` the SHACL gate validates against an + empty ontology and overstates class violations (recorded as + `validated_without_ontology_context`) — see + [Validation](validation.md#how-the-validation-is-set-up). + Which stages that corresponds to is drawn out in [Workflow](workflow.md). **Per request.** `render_mode` is overridable on both `/process` and @@ -970,7 +1176,7 @@ Entity alignment and evaluation endpoints are documented in [API Endpoints](api. through — the provider validates it, not OntoCast. - `MAX_VISITS_PER_NODE` is the canonical name; `MAX_VISITS` is an accepted alias for it. Set one, not both. - `RENDER_MODE`, `ONTOLOGY_CONTEXT_MODE` and `LLM_GRAPH_FORMAT` reject an unrecognised per-request value with `400` rather than falling back to the environment default. -- `RECURSION_LIMIT` was renamed to `BASE_RECURSION_LIMIT`. +- `RECURSION_LIMIT`, later `BASE_RECURSION_LIMIT`, and `ESTIMATED_CHUNKS` are removed: the document graph is a DAG whose depth does not vary with the document, so neither knob ever changed a run. - `WEB_SEARCH_ALLOWED_DOMAINS` and `WEB_SEARCH_BLOCKED_DOMAINS` accept comma-separated values. - `LLM_CACHE_ENABLED` and `LLM_CACHE_READ_ONLY` control disk cache read/write behavior. - `LLM_MAX_INFLIGHT` must be ≥ 1; `MAX_CONCURRENT_PROCESSES` must be ≥ 1 when set. diff --git a/docs/user_guide/llm_caching.md b/docs/user_guide/llm_caching.md index 83b309b6..d70766b0 100644 --- a/docs/user_guide/llm_caching.md +++ b/docs/user_guide/llm_caching.md @@ -15,6 +15,21 @@ The LLM caching system automatically caches responses from language model provid --- +!!! note "This cache is not the provider's prompt cache" + + Everything on this page is OntoCast's own on-disk cache: an identical + request is answered locally and never reaches the provider. A provider's + *prefix* cache is a different mechanism — the request is sent and billed, + but a repeated prompt prefix is billed at a discount. OntoCast shapes its + prompts so that prefixes can repeat, and `LLM_PROMPT_CACHE_KEY` keeps an + OpenAI fan-out on one cache shard; see + [One chapter per document](performance.md#one-chapter-per-document-and-warming-the-cache-that-serves-it). + + The prompt is part of this cache's key, so anything that changes the prompt + text is a miss here too. A default whose prompt changed — as the facts + ontology chapter did in 0.6.4 — re-bills the first run after upgrading, + even with a warm cache. + ## Configuration | Setting | Env | Default | Description | @@ -104,6 +119,7 @@ Cache keys hash **normalized prompt text** (LangChain prompt values use `to_stri - provider, model name, temperature, base URL - the Ollama generation knobs `think`, `num_predict`, `num_ctx` — these bound reasoning and output length, so the same prompt under a different `num_ctx` is a different response +- the cloud reasoning knobs `reasoning_effort` and `thinking_budget`, **only when set** — an unset knob means "provider default", which is what every entry written before the knobs existed was produced under, so leaving it unset keeps those entries valid while setting it re-issues every prompt - the output schema name, for structured `extract` calls - a `cache_format_version` constant, bumped whenever the entry shape or the set of key inputs changes @@ -347,15 +363,14 @@ On a running server, the same counters are available from `GET /info` (`llm_cach Caching works seamlessly with the ToolBox through a shared Cacher instance: ```python -from ontocast.toolbox import ToolBox -from ontocast.config import Config +from ontocast import Config, ToolBox -# ToolBox automatically creates and uses a shared Cacher -config = Config() -tools = ToolBox(config) +# ToolBox creates one Cacher and hands it to every tool +tools = await ToolBox.acreate(Config()) +await tools.initialize() -# All tools (LLM, Converter, Chunker) share the same cache instance -result = tools.llm("Process this document") +# LLM, converter and chunker all read and write the same cache +result = await tools.llm("Process this document") converted = tools.converter(document_file) chunks = tools.chunker(text) ``` @@ -402,12 +417,18 @@ The `ConverterTool` automatically caches document conversion results based on th ### Chunker Caching -The `ChunkerTool` caches chunking results based on: -- **Input text content**: The exact text being chunked -- **Chunking configuration**: All chunking parameters (max_size, min_size, model, etc.) -- **Chunking mode**: Whether semantic or naive chunking is used - -This ensures that identical text with identical chunking parameters will return cached results. +The `ChunkerTool` caches chunking results keyed on exactly: +- **Input text content**: the exact text being chunked +- **`CHUNK_EMBEDDING_MODEL`**, **`CHUNK_MAX_SIZE`** and **`CHUNK_MIN_SIZE`** +- **Chunking mode**: whether semantic or naive chunking ran +- **`cache_format_version`**: bumped whenever the split algorithm changes its + output for the same inputs, so entries from an older algorithm are not served + +Section detection, segmenter choice and unit filtering are not in the key: +they run outside the cached call, on whole section blocks, and only an +oversized block reaches the chunker. Because semantic chunking is seeded, a +cache miss recomputes exactly what the hit would have returned; the cache +saves time, not consistency. ### Cache Organization diff --git a/docs/user_guide/observability.md b/docs/user_guide/observability.md index 61653175..b7f4d967 100644 --- a/docs/user_guide/observability.md +++ b/docs/user_guide/observability.md @@ -25,6 +25,12 @@ problem lives in the prompt or in the model's thinking budget. ### Retrieval metrics +The ontology snapshot's whole-module closures are reported in +`retrieval_metrics.patch_retrieval`: `module_closure_iris` (admitted whole), +`module_closure_triples` (what they cost), and `module_closure_declined_iris` +(kept out by `ONTOLOGY_PATCH_SMALL_MODULE_CLOSURE_MAX_TOTAL_TRIPLES`, named +rather than merely counted so a missing-term question is answerable). + Alongside the budget, a run accumulates `retrieval_metrics`: counters covering how ontology context was assembled, what the facts fan-out produced, and what the validation gate found. It is returned as `metadata.retrieval_metrics` on @@ -42,9 +48,10 @@ three modules, where a typo used to mean a silently missing metric. | `ontology_writable_count` / `ontology_primary_units` | Writable anchors, and units assigned a primary anchor | | `ontology_snapshot_triples` | Size of the ontology snapshot a unit was actually shown, written in **every** context mode. Previously only the vector resolver recorded a size, nested under `patch_retrieval` — so the two modes that bound nothing also reported nothing. Read it against `ONTOLOGY_CONTEXT_MAX_TRIPLES` to tell a condensed snapshot from an unbounded one | | `facts_anchor_count` / `facts_anchor_units` | Same for the facts fan-out | -| `facts_llm_repair_renders_total` / `_failed` | Finding-driven repair renders attempted, and those that crashed (a failed repair leaves the pre-repair graph and the unit still reports success) | +| `facts_critic_fixes_applied` / `_residual` / `_noop` | Proposed fixes that reached the graph, that still need judgement, and that removed exactly what they re-added. A critique dominated by the last is a critic producing motion rather than corrections | +| `facts_critic_patches_rolled_back` | Passes undone for leaving the unit worse. Non-zero means the critique is provoking data-destroying edits | | `facts_repair_delete_only` | Repair renders rolled back for answering the findings prompt with deletions instead of an in-place rewrite. Non-zero means the findings prompt or the validator is provoking data-destroying responses — treat it as a release blocker, not a curiosity | -| `facts_findings_residual` / `facts_mandatory_residual` | Deterministic findings still open after the last repair render, over every unit; the mandatory subset is the number that tracks defects | +| `facts_findings_residual` / `facts_mandatory_residual` | Deterministic findings still open after the last critic pass, over every unit; the mandatory subset is the number that tracks defects | | `facts_critic_calls` / `facts_critic_accepted` | The facts critic's ledger: calls billed, and calls whose verdict let the unit exit the loop | | `ontology_findings_residual` / `ontology_mandatory_residual` | Same residuals for the ontology loop's delta validator (shadow mode — recorded, not yet gating) | | `ontology_critic_calls` / `ontology_critic_accepted` | The ontology critic's ledger under its incumbent `success or score > 90` gate | @@ -72,9 +79,15 @@ pass actually ran — absent means "did not run", which is not the same as zero. "source": "paper.pdf", "ontocast_version": "0.6.0", "render_mode": "ontology_and_facts", - "loops": {"max_visits": 1, "max_critic_visits": null, "llm_repair_visits": 1}, + "loops": {"max_visits": 1, "facts_critic_passes": 1, "ontology_critic_passes": 0}, "graph_metrics": {"nodes": 130, "edges": 112, "components": 24, "largest_component": 61, "isolated_nodes": 18}, - "llm": {"provider": "ollama", "model_name": "qwen3.6", "temperature": 0.0, "think": true}, + "llm": {"provider": "ollama", "model_name": "qwen3.6", "temperature": 0.0, "think": true, + "reasoning_effort": null, "thinking_budget": null, + "prompt_cache_key": null, "max_inflight": 16}, + "prompting": {"llm_graph_format": "jsonld", "ontology_chapter_format": "inherit", + "ontology_context_scope": "unit", "fanout_warmup_units": 0, + "parallel_workers": 16, + "embedding_model_name": "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"}, "budget": { "calls_count": 42, "cache_hits": 0, "input_tokens": 380174, "output_tokens": 51203, "reasoning_tokens": 39880, @@ -83,7 +96,11 @@ pass actually ran — absent means "did not run", which is not the same as zero. "counters": {"llm/parse_retry": 3, "llm/json_bracket_repair": 1, "llm/parse_abandoned": 0} }, "selection": {"target_sections": null, "exclude_sections": ["references"], - "summarize_sections": null, "summary_max_sentences": null, + "summarize_sections": null, + "labeled_units": 9, "unlabeled_units": 4, + "section_label_histogram": {"results": 5, "methods": 4, "(unlabeled)": 4}, + "non_content_mode": "extract", "undersized_units_skipped": 0, + "bibliography_units_skipped": 1, "non_content_units_skipped": 0, "bibliography_mode": "exclude"}, "critic": {"calls": 34, "accepted": 6, "score_min": 55, "score_median": 79, "score_max": 98, @@ -92,6 +109,9 @@ pass actually ran — absent means "did not run", which is not the same as zero. "ontology_critic": {"calls": 0, "accepted": 0, "score_min": null, "score_median": null, "score_max": null, "score_histogram": {}, "fix_severity_histogram": {}}, + "completion": {"calls": 0, "units": 0, "subjects_inserted": 0, + "subjects_rolled_back": 0, "triples_inserted": 0, + "measurements_recovered": 0}, "facts_triples": 1204, "facts_triples_serialized": 557, "ontology_triples": 318, "retrieval_metrics": { "ontology_context_mode": "selected_vector_search_ontology", @@ -102,6 +122,17 @@ pass actually ran — absent means "did not run", which is not the same as zero. } ``` +`prompting` records the settings that change what a run costs without changing +any of the generation settings beside them. Two runs that agree on `llm` and +disagree here can differ several-fold in tokens, and before these were recorded +nothing in either manifest said why. `ontology_chapter_format: "inherit"` follows +`llm_graph_format`, so the two are not independent — a run that changed the wire +changed the chapter too unless it pinned the chapter. `ontology_context_scope` +and `fanout_warmup_units` together decide whether a prompt prefix can be cached +at all, and `parallel_workers` decides how much of the fan-out issues before the +first response has populated it, which is why a low `prefix_cache_hit_rate` on a +wide fan-out is call sequencing rather than configuration. + `budget.counters` holds named event counts, summed across unit workers. Three of them cover how the LLM's JSON survived parsing, and they are worth reading on any run against a new model: @@ -111,10 +142,38 @@ any run against a new model: | `llm/parse_retry` | Renders re-issued because the previous response would not parse or validate. Each is a full re-extraction, so a non-trivial count is a real share of the bill. | | `llm/json_bracket_repair` | Responses recovered by rewriting mismatched closing brackets. Non-zero means the model is emitting structurally broken JSON that the deterministic repair caught — the run is correct, but the prompt or the schema shape is provoking it. | | `llm/parse_abandoned` | Calls given up on: retries exhausted, or the same JSON syntax error recurred and further attempts were judged pure spend. Every one of these is a content unit that contributed nothing. | +| `llm/calls_failed` | Every provider call that raised; `llm/timeouts`, `llm/rate_limited` and `llm/calls_rejected` are its subsets. A timed-out call is charged its prompt characters, so `calls_count = llm/calls_timed + llm/timeouts` — a run whose timeouts cost tokens no longer hides them. | +| `llm/calls_rejected` | Calls the provider refused as configured — an unsupported parameter value, a model the account cannot reach, a bad key. Non-zero at all means the run was aborted: the request is one no retry and no other unit would change (see below). | A silent `llm/parse_abandoned` used to be visible only as a `failed without usable output` warning scrolling past in the logs; it is now in the manifest. +Five more describe the ontology chapter, which is the bulk of a facts prompt. +They are recorded once per chapter actually built — the snapshot is shared +across the unit fan-out and the chapter is memoised on it — so they describe the +context, not the traffic that reads it. + +| Counter | Meaning | +|---|---| +| `chapter/text_chars_before` | Summed length of the chapter's text literals as the catalog authored them. Reported whether or not a cap is set: this is the number that says what an uncapped chapter costs. | +| `chapter/text_chars_after` | The same sum after the [text caps](configuration.md#ontology-chapter-text-caps). Equal to `before` when the caps are unset or inert — the common case on a tersely authored catalog, and worth confirming rather than assuming. | +| `chapter/literals_clipped` | Literals shortened to a per-role cap. Each keeps its statement and its leading text; only the tail is gone. | +| `chapter/text_over_budget` | Chapters that still exceeded `ONTOLOGY_TEXT_TOTAL_BUDGET` with every role at its shortest cap. Passed through oversized rather than having statements removed, so a non-zero count is a budget to raise or a catalog to narrow — and specifically a signal to send *fewer terms*, since what remains at that point is the vocabulary itself. | + +!!! warning "A rejected request stops the run rather than emptying it" + A request the provider refuses — an unsupported parameter value, a model + the account cannot reach, a missing key — describes the deployment, not the + unit: every sibling unit and every retry would be refused identically. + Isolating it the way a bad render is isolated let the fan-out finish, write + a zero-triple manifest and a validation report next to no facts, and exit + successfully. It now propagates out of the fan-out, aborts the batch, and + exits `78` with a message naming the provider, the model and the rejected + parameter — and writes no dumps at all, so the absence of output is the + signal rather than a plausible-looking empty one. Throttling (`429`) and an + over-long chunk stay per-unit faults. Separately, a document whose *every* + unit failed for any other reason is now recorded as a failed file, so the + CLI exits non-zero there too. + This is the offline option: no service, no account, and it survives the process. Two runs are comparable by diffing their manifests — which model, which generation settings, how many tokens, how long per node, and now what retrieval @@ -126,7 +185,10 @@ histogram, fix-severity histogram — the evidence a gate recalibration reads), `selection` records which sections the run was actually given — a `--target-sections` typo that matched nothing is otherwise indistinguishable from a document that genuinely had no -such section — and `graph_metrics` summarizes the connectivity of the +such section — and its `labeled_units` / `unlabeled_units` / +`section_label_histogram` census says whether a section filter had anything to +act on at all (a body the classifier could not label passes every denylist +untouched; `summary_max_sentences` appears only when summarization ran) — and `graph_metrics` summarizes the connectivity of the serialized facts graph so fragmentation regressions surface per document. It is also what makes a sweep of runs auditable after the fact, rather than something to be re-run. @@ -140,7 +202,7 @@ to be re-run. volume — mixing them silently compares a graph with its own subset. Read `critic.calls` before `critic.accepted`. At the default - `MAX_VISITS=1` the critic never runs and `summarize_loop` returns an + `FACTS_CRITIC_PASSES=0` the critic never runs and `summarize_loop` returns an all-zero record, so `accepted: 0` there means *nothing was judged*, not *everything was rejected*. Score buckets are decade ranges keyed `"70-79"`, so an empty `score_histogram` alongside `calls > 0` means the @@ -151,6 +213,43 @@ batch run is no longer the blind path: before this, retrieval telemetry existed only over HTTP, which left `ONTOLOGY_PATCH_DUMP_ONTOLOGY_RANKS` with no reader for anyone running `ontocast process`. +Two blocks make an arm self-describing, so a sweep of output directories can be +compared without reconstructing each run's environment: + +- **`validation_config`** records the validation-facing knobs the run actually + used — `context_from_units`, `json_mode`, `shapes_prompt_contract`, + `shapes_triples` (size of the merged shapes partition; 0 means neither the + gate nor the prompt contract had shapes), `shacl_inference`, + `numeric_coverage_mandatory`, and `facts_user_instruction_chars` (length + only; the text can carry deployment secrets and the dump is shareable). +- **`selection.labeled_units` / `unlabeled_units` / + `section_label_histogram`** record whether section filters could act at + all: `--exclude-sections` is a denylist over *labels*, so against mostly + unlabeled units it is a no-op that the arm's name — and previously its + manifest — would never reveal. +- **`selection.non_content_mode` and the `*_units_skipped` counters** + (`undersized`, `bibliography`, `non_content`) say what the pre-fan-out + routing dropped, in rule order; a document that lost half its units to a + routing rule otherwise reads as one that extracted little. +- **`completion`** summarizes the insert-only completion pass (see + [Validation](validation.md#completion-pass-insert-only-recovery)), read off + the same per-unit attempt log `critic` is: all-zero at the default + `FACTS_COMPLETION_PASSES=0`, which is what a zero pass budget buys. + `measurements_recovered` is missed measurements the numeric inventory + stopped listing after the inserts that stayed — read it against + `retrieval_metrics.facts_mandatory_residual` and the `NUMERIC_COVERAGE` + finding count to see how much of the coverage gap the pass closed; + `subjects_rolled_back` non-zero means some proposed subjects made the unit + worse and were undone, the same as a rolled-back critic fix. + +!!! note "Salvaged-unit counts reflect the post-patch verdict" + The reduce warning `Parallel facts map salvaged output from non-converged + loop(s)` counts units that exited their loop `FAILED`. Unit status is + re-evaluated after the critic's patch is applied (and after a rollback), + so a unit whose patch resolved every material defect exits `SUCCESS` and + is not counted. Earlier builds set the status from the critic's + *pre-patch* verdict, which overstated non-convergence. + The HTTP path already returns the same `budget` in its response, so no manifest is written there. diff --git a/docs/user_guide/ontology_context.md b/docs/user_guide/ontology_context.md index 9ca5f0af..93820f0c 100644 --- a/docs/user_guide/ontology_context.md +++ b/docs/user_guide/ontology_context.md @@ -23,6 +23,83 @@ Context is assembled **per unit** inside the ontology loop, not at document leve [`ONTOLOGY_CONTEXT_MAX_TRIPLES`](#how-large-is-the-context), like every other prompt. +## What if the context is empty? + +**It depends on who is asking.** An empty context means opposite things to the +two loops, and treating them alike is what makes an empty catalog either a +silent disaster or a spurious failure. + +### For an ontology unit: this is the starting point + +Nothing stops. An ontology unit with no context is handed to +`render_ontology_fresh`, which invents a new catalog ontology from the text and +mints its IRI under `current_domain`. That is the whole bootstrap journey — a +corpus with no ontology yet is the *input* to an ontology-building run, not a +fault — and it is also what happens mid-run when the selector honestly reports +that no catalog ontology fits a particular unit. + +`ONTOLOGY_CONTEXT_REQUIRED` never applies here, whatever it is set to. + +### For a facts unit: the run stops, if you asked it to + +A facts unit cannot answer an empty context. The renderer is instructed to +extract "based on provided domain ontology"; handed nothing, it does not fail — +it falls back on whatever standard vocabulary the prompt names. What comes out +is well-formed triple by triple, exempt from `UNKNOWN_TERM` (standard +namespaces are exempt by default), and matched by no shape, since none of its +subjects are in any shape's target class. So the conformance gate reports +**zero violations** on it. A run with no vocabulary at all can therefore look, +at every checkpoint, like the cleanest run in a series. + +`ONTOLOGY_CONTEXT_REQUIRED` (default `false`) raises +`EmptyOntologyContextError` on that, and the error is *not* caught as a +per-unit failure: it describes the deployment, so every sibling unit has it +too, and recording it per unit let the run finish, write a zero-triple manifest +and exit successfully — the outcome the setting exists to prevent, with one +traceback per unit burying the cause. + +It is **off by default** because the default render mode builds ontologies as +well as facts, so a catalog that starts empty is ordinary. Turn it on for a +deployment that extracts against a curated catalog, where an empty context can +only mean the catalog failed to load. With it off, the run continues and +records `retrieval_metrics.empty_snapshot_reason`, which names the subsystem at +fault — catalog-side causes first, because a catalog that resolved to no graphs +and a retrieval threshold that matched nothing are different problems with +different fixes. + +!!! tip "Facts-only runs stop at startup instead" + + `ontocast process` under `RENDER_MODE=facts` refuses to start when the + catalog resolves to zero ontologies — before a document is converted or a + provider call is billed. Under a render mode that creates ontologies it + starts and says so. `ontocast serve` never refuses on this: it is filled + through `POST /ontologies`. + +### Regardless of either: the two halves must agree + +Two startup checks are about *integrity*, not preference, so they do not +consult `ONTOLOGY_CONTEXT_REQUIRED` and are not waived by any render mode. They +fail in opposite directions, and each hides the other: + +- A populated vector index beside an **empty catalog** gives *healthy-looking* + retrieval metrics — the expected seeds, the expected atom count — and an + empty graph. +- An **empty index** beside a populated catalog is what `--wipe-vector-store` + leaves behind when materialization put nothing back; the wipe is + unconditional, the refill is not. + +Neither fires during a legitimate bootstrap, where catalog and index are both +empty. + +Two companion signals for a context that arrived and was wasted, both on by +default: + +- **`DOMAIN_ADHERENCE`** (`FACTS_DOMAIN_ADHERENCE_MIN_SHARE`, default `0.15`) — + a mandatory finding when a render used almost none of the catalog it *did* + get. Covers the case where the context arrived but was ignored. +- **`shacl_vacuous` / `shacl_focus_nodes`** in the conformance summary — + `conforms` is `null`, never `true`, when the shapes matched no node. + ## How large is the context? Only vector mode bounds retrieval itself; every mode is bounded at @@ -86,6 +163,9 @@ On `ToolBox.initialize` in this mode: - Orphan ontology IRIs (indexed but absent from the synchronized catalog) are pruned by default (`VECTOR_STORE_PRUNE_ORPHAN_IRIS_ON_INIT=true`) so renamed ontologies do not linger in retrieval. - Optional clean slate: `VECTOR_STORE_WIPE_ON_INIT=true` or CLI `--wipe-vector-store` drops the current partition before recreate+reindex. + The wipe itself is not conditional on the context mode -- it runs whenever a vector store is configured, so the flag never accepts a + destructive request and silently does nothing. Whether the partition is then recreated and reindexed *is* the mode's decision, so under + another mode the flag leaves the partition dropped and empty until a vector-search run rebuilds it. `QDRANT_URI` and `LANCEDB_ENABLED=true` are mutually exclusive. @@ -97,7 +177,7 @@ Default path: per-window channel fusion → max-score IRI dedupe → global scor | Variable | Default | Role | |----------|---------|------| -| `VECTOR_STORE_TOP_K` | `20` | Hits per channel per proposition window | +| `VECTOR_STORE_TOP_K` | `40` | Hits per channel per proposition window | | `VECTOR_STORE_INDUCED_SUBGRAPH_MAX_TOTAL_TRIPLES` | `1200` | Global triple cap for context | | `VECTOR_STORE_INDUCED_SUBGRAPH_DEPTH` | `2` | BFS depth for hub seed expansion | | `VECTOR_STORE_INDUCED_SUBGRAPH_HUB_SEED_COUNT` | `16` | Top seeds receiving full BFS budget | @@ -105,15 +185,23 @@ Default path: per-window channel fusion → max-score IRI dedupe → global scor | `VECTOR_STORE_INDUCED_SUBGRAPH_ESTIMATED_TRIPLES_PER_QUERY` | `24` | Per-entity BFS quota hint | | `VECTOR_STORE_INDUCED_SUBGRAPH_CANDIDATE_PUSHDOWN` | `false` | Opt-in SPARQL neighborhood CONSTRUCT (see below) | | `VECTOR_STORE_PROPOSITION_MAX_WINDOWS` | `16` | Window cap; long chunks sample evenly across the text | -| `ONTOLOGY_PATCH_CROSS_QUERY_MERGE_MODE` | `max_score` | Default merge; `sum_score` (rewards multi-window agreement) and `hybrid` are opt-in | +| `ONTOLOGY_PATCH_CROSS_QUERY_MERGE_MODE` | `max_score` | Default merge; `sum_score` (rewards multi-window agreement) is opt-in | | `ONTOLOGY_PATCH_PER_ONTOLOGY_SEED_QUOTA` | `0` | Max seeds per ontology; `0` (default) uses global score order | | `ONTOLOGY_PATCH_SEEDS_PER_WINDOW` | `4` | Scales effective atom cap with proposition windows | | `ONTOLOGY_PATCH_MAX_ATOMS_BASE` | `96` | Floor for the effective atom cap | | `ONTOLOGY_PATCH_MAX_ATOMS` | `96` | Hard cap: `min(max_atoms, max(base, seeds_per_window × n_queries))` | -| `ONTOLOGY_PATCH_MIN_MERGED_MAX_SCORE` | `0.18` | Empty patch when the best per-window fused score is below this | -| `ONTOLOGY_PATCH_MMR_LAMBDA` | `1.0` | `1.0` skips MMR (default); lower enables diversity rerank | - -Advanced (off by default): `ONTOLOGY_PATCH_PER_QUERY_*_SCORE_RATIO`, `ONTOLOGY_PATCH_MERGED_SCORE_RATIO`, hybrid tier-1/tier-2 (`MAX_ATOMS_TIER1`, `MIN_ENTITY_SCORE`). +| `ONTOLOGY_PATCH_MIN_MERGED_MAX_SCORE` | `0.18` | Empty patch when the best per-window fused score is below this **fraction of the best attainable** fused score | +| `ONTOLOGY_PATCH_MMR_LAMBDA` | `1.0` | `1.0` skips MMR (default); lower enables diversity rerank. Below 1.0 it is quadratic in the candidate set, so it is costly at a wide `TOP_K` | +| `VECTOR_STORE_BM25_TOP_K` | unset | Sparse-lane depth when it should differ from `TOP_K`; unset means both are the same | +| `VECTOR_STORE_FUSION_RANK_CONSTANT` | `0.0` | Smoothing in `weight / (constant + rank)`. See the warning below before raising it | +| `VECTOR_STORE_PROPOSITION_WINDOW_MAX_CHARS` | unset | Characters per window; replaces the sentence bound when set | +| `VECTOR_STORE_PROPOSITION_WINDOW_MAX_TOKENS` | unset | Encoder word pieces per window; replaces both bounds above, and rules truncation out rather than approximating it | +| `VECTOR_STORE_PROPOSITION_WINDOW_STRIDE` | unset | Sentences advanced between windows; unset strides by the window size, so windows are disjoint | +| `VECTOR_STORE_PROPOSITION_WINDOW_OVERLAP` | `0.0` | Fraction of a window repeated at the start of the next, under a budget | +| `VECTOR_STORE_PROPOSITION_ABBREVIATION_AWARE` | `false` | Rejoin fragments split inside an abbreviation, an initial or a citation run | +| `VECTOR_STORE_PROPOSITION_MEASUREMENT_AWARE` | `false` | Never break between a number and its unit, or inside a range | + +Advanced (off by default): `ONTOLOGY_PATCH_MERGED_SCORE_RATIO`. ### Recommended preset for dense scientific text @@ -137,9 +225,12 @@ in other catalog ontologies via `rdfs:subClassOf`, `rdfs:domain`, or `rdfs:range ### Diagnostics -There is no in-repo recall harness. Retrieval quality is measured out of -repo, in `ontocast-validation`; this page describes the mechanisms and the -per-run telemetry, not their measured quality. +`test/test_retrieval_recall.py` is the in-repo recall harness: point +`ONTOCAST_RECALL_CORPUS` at a corpus directory and it reports the retrieval +funnel for your own catalog (setup in +[Contributing](../contributing.md)). It ships no corpus; reference corpora and +their measured results live in `ontocast-validation`. This page describes the +mechanisms and the per-run telemetry, not their measured quality. When comparing configurations there, note two things about recall measurement: the **case**-level figures saturate as soon as cases carry several expected @@ -150,9 +241,15 @@ treat sub-percentage-point differences as run-to-run noise. in-repo. Per-run metrics are available in production on -`state.retrieval_metrics["patch_retrieval"]`: `atoms_after_dedupe`, `atoms_final`, -`seed_iris`, `seeds_by_ontology`, `snapshot_triple_count`, `snapshot_pruned_uri_count`, -`snapshot_uri_components`. +`state.retrieval_metrics["patch_retrieval"]`: `candidate_hits`, `threshold_rejected`, +`atoms_after_dedupe`, `atoms_final`, `seed_iris`, `seeds_by_ontology`, +`snapshot_triple_count`, `snapshot_pruned_uri_count`, `snapshot_uri_components`. + +`candidate_hits` and `threshold_rejected` are counted *before* the score gate; +`atoms_after_dedupe` is counted after it. Only the first pair can distinguish +"search returned nothing" from "a threshold rejected everything" — a threshold +rejection records zero atoms after dedupe, so reading that number alone reports +the wrong cause. #### Catalog I/O @@ -307,6 +404,84 @@ This is what makes a **small** vocabulary findable by symbol in the sparse lane. thousands of near-identical unit embeddings cluster together and displace domain terms under the global atom cap. +### Lane depth, and the fusion scale + +Lanes are combined by weighted reciprocal rank, so a lane's **depth is a weight in +disguise**: a list of length N contributes ranks 1..N at full lane weight however weak +its tail is. `VECTOR_STORE_BM25_TOP_K` exists because the lanes fail differently — +dense retrieval degrades into topical near-misses, lexical retrieval into unrelated text +sharing a token — so the depth at which each stops paying is not the same number. + +!!! note "Why the relevance gate is a fraction, not a score" + `ONTOLOGY_PATCH_MIN_MERGED_MAX_SCORE` is a fraction of the best score a window + could possibly achieve — an atom ranked first in every lane. It has to be, + because the lane weights and the rank constant both **rescale the fused + score**: a rank-1 hit worth `w` unsmoothed is worth `w / (1 + constant)` + smoothed. Read as an absolute number, a gate calibrated without smoothing + rejected *every* candidate once smoothing was on, and the symptom — an empty + ontology context — reads as a retrieval failure rather than a miscalibration. + Expressed as a fraction it means one thing at every constant, which is what + lets smoothing be evaluated on its own merits. + +### Query windows + +Retrieval queries are proposition windows over the content unit, not the whole unit. +Two properties are easy to be caught by: + +- **A sentence count is not a bound on how much text a query carries.** Two + sentences of technical prose span an order of magnitude in length, and the + splitter breaks on every period with no abbreviation handling — so + `J. Phys. Chem. Lett.` is four "sentences", and a two-sentence window over a + citation is a fragment with nothing to retrieve. + `VECTOR_STORE_PROPOSITION_WINDOW_MAX_CHARS` bounds by length instead: it caps + the long windows that the encoder would truncate *and* coalesces the short + fragments, because it keeps taking sentences until the budget is met. It + replaces the sentence bound rather than joining it. Set it below the encoder's + sequence limit and truncation becomes impossible by construction; read the + limit and the observed characters-per-token from the retrieval metrics rather + than assuming a ratio. +- **A character budget is still a proxy.** Characters per token drift with + notation — formulae, symbols and digits cost more pieces per character than + prose — so a budget that fits one passage truncates the next. + `VECTOR_STORE_PROPOSITION_WINDOW_MAX_TOKENS` bounds a window in the unit the + encoder itself counts in, and replaces both of the bounds above. Below the + sequence limit it rules truncation out rather than approximating it, and it is + the only bound that *cuts* a sentence longer than the budget, at a whitespace + boundary: emitted whole, such a sentence is cut by the encoder anyway and its + tail reaches no lane at all. It needs a provider that exposes a tokenizer; + without one it degrades to a character approximation and logs that it did. +- **Citation runs are not sentences.** + `VECTOR_STORE_PROPOSITION_ABBREVIATION_AWARE` rejoins the fragments the + period-splitter creates inside an abbreviation, an initial or a citation run, + so a packing bound counts prose rather than shards. General English and + bibliographic shapes only — a domain vocabulary here would make retrieval + quality a property of the corpus it was written against. Off by default, + because it changes the windows any earlier measurement was taken on. +- **A break between a number and its unit costs the pair.** A window ending on + "a red shift of ∼10" retrieves nothing that the number and its unit together + would. `VECTOR_STORE_PROPOSITION_MEASUREMENT_AWARE` moves such a break + backwards, using number/unit *shapes* rather than a unit list. It only binds + where a cut inside a sentence is possible, which today means a token budget. +- **Windows are disjoint by default.** A statement whose subject and value straddle a + window boundary appears in no window at all, and neither half retrieves what the pair + together names. `VECTOR_STORE_PROPOSITION_WINDOW_STRIDE` overlaps them by whole + sentences; `VECTOR_STORE_PROPOSITION_WINDOW_OVERLAP` is the fractional form the + budgets take, since a sentence says nothing about how much text a budgeted window + shares. Either costs queries — and, once `PROPOSITION_MAX_WINDOWS` binds, the extra + queries are bought by subsampling, so overlap and the cap work against each other. +- **`PROPOSITION_MAX_WINDOWS` drops text when it binds.** Over the cap, windows + are subsampled evenly across the unit — so coverage is preserved but *density* + is not, and the text in a dropped window reaches no dense or sparse lane at + all. Whether it binds is a property of how long your content units are, which + is why it can be inert on short units and lossy on long ones. +- **The embedding model truncates, silently.** A window longer than the checkpoint's + sequence limit is cut by the encoder before the model sees it; the vector comes back + the right shape for a prefix of the text and nothing downstream can tell. Widening the + window past that limit discards query text rather than matching more of it. Read + `queries_truncated` and `query_sequence_limit` from `retrieval_metrics` before + reaching for a wider window — several widely used checkpoints stop at 128 word pieces, + well below what a two-sentence window of technical prose costs. + ### Lexical-trigger lane (exact-match codes) Some catalog terms are identified by a **literal token** in source text — unit symbols @@ -330,6 +505,21 @@ paraphrase similarity. Those are handled by a separate **lexical-trigger** lane: Requires a **reindex** after upgrading: the embedding contract fingerprint bumps to `sf3`. +### Number-adjacent query signals + +`VECTOR_STORE_QUERY_UNIT_SIGNALS_ENABLED` (default **on**) adds a query-time lane that +takes the tokens sitting immediately after a number — the `days` in "4-15 days", the +`kV` in "200 kV" — and matches them case-insensitively, with a singular/plural variant, +against catalog surface forms. Matched entities join the seeds outside the semantic +budget. + +The mechanism fits quantitative extraction exactly: that shape of text is what it +keys on, and the terms it recovers are the units and qualifiers a value node needs. +It is narrow by construction and Latin-script/English-centric, so turn it off for a +catalog whose surface forms are neither, or one whose facts are not quantities. +Costs no reindex either way — it is query-time only, so it can be A/B'd against an +index already built. + ### `fixed_single_ontology` Always uses one catalog ontology identified by `ontology_context_fixed_ontology_id` (env: `ONTOLOGY_CONTEXT_FIXED_ONTOLOGY_ID` or per-request parameter). @@ -347,8 +537,9 @@ Returns **400** if the mode is fixed but no ontology id is provided. `ontocast s A *missing* id is a 400. An id that is present but matches no catalog entry is **not** an error: it logs a warning and the unit renders against an **empty ontology snapshot**, which usually looks like a bad extraction - rather than a misconfiguration. Check the catalog - (`GET /ontologies`) if a fixed-mode run suddenly produces sparse output. + rather than a misconfiguration. If a fixed-mode run suddenly produces + sparse output, look for that warning in the server log: it names the id + that matched nothing. ## Ontology identity @@ -373,8 +564,7 @@ catalog terminals. The prompts say so explicitly (a PARTIAL CONTEXT notice in the render intro and critic criteria), and three reduce-time policies close the gap that partiality opens: minted-duplicate reconciliation against the full terminals, a redeclare-only delete policy, and fresh-path union merging. -See [Validation → Reduce-time policies](validation.md#reduce-time-policies-the-terminal-is-the-authority) -and the workspace design note `planning/ontology-update-semantics.md`. +See [Validation → Reduce-time policies](validation.md#reduce-time-policies-the-terminal-is-the-authority). ## Per-Request Overrides diff --git a/docs/user_guide/performance.md b/docs/user_guide/performance.md index b595e928..eab96ef0 100644 --- a/docs/user_guide/performance.md +++ b/docs/user_guide/performance.md @@ -14,10 +14,42 @@ single provider token. | Documents | `MAX_CONCURRENT_PROCESSES` | unset | Concurrent `/process` and `/process_unit` handlers | A unit never issues two LLM calls at once, so within a *single* document the -effective provider concurrency is `PARALLEL_WORKERS`. Across `K` concurrent +effective provider concurrency is `min(PARALLEL_WORKERS, LLM_MAX_INFLIGHT)`; +workers above the in-flight cap only queue on the semaphore, which shows up as +`llm/inflight_wait` in the node durations, and startup now warns when the +worker count exceeds the cap. Across `K` concurrent documents it is `min(K x PARALLEL_WORKERS, LLM_MAX_INFLIGHT)` — which is why a busy server can stop scaling with `PARALLEL_WORKERS` alone. +## Provider rate limits + +Concurrency caps are not rate caps: a fan-out of short calls can exceed a +provider tier's requests-per-minute while never holding many connections at +once. Three knobs, three roles: + +- **`LLM_REQUESTS_PER_SECOND`** (unset = unpaced) — the sustained rate, paced + by a per-process token bucket on request *starts*. Set it from your + provider tier with headroom (a 500 RPM tier is ~8 RPS; leave a margin for + the SDK's own retries). The limiter is acquired inside an + `LLM_MAX_INFLIGHT` slot, so aggressive pacing also lowers effective + concurrency — that is the point. +- **`LLM_MAX_RETRIES`** (unset = each SDK's default: OpenAI 2, Anthropic 2, + Google 6) — the provider SDK's transport-retry budget. The SDKs back off + and honour `Retry-After`, so raising this is the correct response to + residual throttling. The pipeline itself deliberately never retries + transport failures: retrying at this layer multiplies the request rate + exactly when the provider is asking for less. +- **`LLM_MAX_INFLIGHT`** — the burst ceiling, as above. + +**What a throttle looks like.** A rate-limit error that survives the SDK's +retries fails the unit's render (one loop visit burned) and increments +`llm/rate_limited` in `budget.counters` — beside `llm/timeouts`, both in the +run manifest. **Read those counters before reading a run's cost or quality +figures**: a throttled run's token totals and failure counts describe the +throttle, not the pipeline. Both pacing knobs are recorded in the manifest's +`llm` block, so a paced run is distinguishable from an unpaced one after the +fact. + ## Reading the metrics Every run reports `budget.node_durations` (seconds) and `budget.counters` @@ -85,7 +117,7 @@ at zero, which is not the same as a run that used no tokens. |---|---| | `input_tokens` / `output_tokens` | **Billed**: live provider calls only. | | `cached_input_tokens` / `cached_output_tokens` | Replayed from the OntoCast disk cache. Deliberately *not* added to the billed totals — a replay pays nothing — so these are what the workload would cost cold. | -| `reasoning_tokens` | Thinking tokens, counted **inside** the output totals. Dominates output cost for reasoning models (`LLM_THINK`). | +| `reasoning_tokens` | Thinking tokens, counted **inside** the output totals. Dominates output cost for reasoning models; bounded by `LLM_THINK` (Ollama), `LLM_REASONING_EFFORT` (OpenAI and Gemini 3+) or `LLM_THINKING_BUDGET` (Gemini 2.5). When `reasoning_share_of_output` is large that knob is the first cost lever, not the prompt. | | `cache_read_input_tokens` | Served from the **provider's** prompt cache, counted inside the input totals and billed at a reduced rate. Unrelated to OntoCast's disk cache. | | `cache_creation_input_tokens` | Written to the provider's prompt cache. | @@ -110,6 +142,12 @@ accumulate on billed *and* replayed calls, while `input_tokens` counts billed only, so dividing by `input_tokens` alone can exceed 100% on a partly replayed run. +The two calls a facts unit makes — render, then critic — carry the same +ontology chapter, and both prompts now open with the constant chapters +(preamble, conformance requirements) followed by that chapter, byte-identical, +before anything unit-specific. The critic's call can therefore be served the +prefix the render populated; `prefix_cache_hit_rate` is where that shows. + **A low `prefix_cache_hit_rate` on a wide fan-out is expected, not a bug — and it is money.** A provider cache entry only becomes readable once the first response has begun, so `PARALLEL_WORKERS` units that issue together all miss the @@ -134,7 +172,9 @@ reintroduced O(N) full rdflib merges into the fan-out. The `llm/*` counters in the same map are about *spend* rather than concurrency — `llm/parse_retry` is a re-issued render, `llm/parse_abandoned` is a unit that -contributed nothing. They are tabulated in +contributed nothing, `llm/calls_failed` is every call that raised (timeouts and +rate limits are its subsets), and a timed-out call is charged its prompt +characters so `calls_count = llm/calls_timed + llm/timeouts` holds. They are tabulated in [Observability](observability.md#2-the-run-manifest), and the mechanism behind them in [Configuration](configuration.md#what-happens-to-a-response-that-will-not-parse). @@ -192,7 +232,9 @@ between one checkpoint in memory and two. Read [Configuration](configuration.md) first: changing `CHUNK_EMBEDDING_MODEL` invalidates the chunk cache, shifts chunk boundaries, and affects the calibrated -schema-detection thresholds. +schema-detection thresholds. With the model fixed, boundaries are reproducible: +the chunker's reduction is seeded, so repeated runs of a document see the same +units. Inference on a shared model is **serialised per model**. That bounds peak memory, which is what matters when `PARALLEL_WORKERS` units and several @@ -215,13 +257,13 @@ better — the extra units queue behind the same synchronous section. Other knobs that change cost rather than concurrency: -- `MAX_VISITS` (default 1) — at 1 the LLM critic never runs. Raising it to 2 - adds **one** call per unit, not a second render: a rejecting facts critic now - buys a bounded repair pass rather than a full re-extraction, so the per-unit - ledger is flat in `MAX_VISITS`. A facts unit costs one render plus - `FACTS_LLM_REPAIR_VISITS` (default 1) at either setting, plus the critique at - 2 — so 2 calls becomes 3, not 4. The ontology loop has no repair lane, so - there it genuinely doubles, 1 to 2. See +- `MAX_VISITS` (default 1) — retries of a **failed** render, nothing else. + Raising it costs nothing on units that render successfully, which is most of + them. +- `FACTS_CRITIC_PASSES` (default 1) — the budget that actually buys critic + calls. Each pass is one call and applies its own fixes, so a facts unit costs + two calls at the defaults; each extra pass adds one. Passes stop early once + one changes nothing. `ONTOLOGY_CRITIC_PASSES` defaults to `0`. See [Validation](validation.md#how-many-llm-calls-a-facts-unit-really-costs). - `CONVERTER_PROFILE=born_digital` — skips OCR on digital PDFs. - `ONTOLOGY_CONTEXT_MAX_TRIPLES` (default `4000`) — the budget for the ontology @@ -231,15 +273,209 @@ Other knobs that change cost rather than concurrency: ## How much a triple costs -A triple's cost in the prompt is set by the wire format, and the two differ by -about a factor of two: +A triple's cost is set by the wire format. `LLM_GRAPH_FORMAT=turtle` spends +meaningfully fewer characters per triple than the `jsonld` default — it changes +no extraction semantics, only the encoding — and it invalidates the LLM cache. + +Two things bound that saving, and both matter more than the ratio: + +- **The encodings do not fail the same way.** JSON-LD is the default because it + is parsed more reliably out of structured output; a nested payload that + arrives with mismatched brackets is a lost render. Turtle removes that failure + mode and introduces another — an IRI a model mints from a phrase containing a + delimiter is a legal JSON string and not a legal Turtle IRI. Which one costs + you more is a property of your model and your text, and the only way to know + is to run both and compare `llm/parse_retry` and `llm/parse_abandoned`. +- **On a reasoning model, most of the output is not the graph.** Read + `reasoning_share_of_output` first. Where it is high the encoding governs a + minority of the output tokens, and the thinking budget is the larger lever. + +`ONTOLOGY_CHAPTER_FORMAT=turtle` takes the density for the ontology chapter +alone, leaving the output wire as JSON-LD. Prefer `term_sheet` below: it is +cheaper still, and unlike the Turtle chapter it does not trade quality for the +saving. + +### The ontology chapter as a term sheet + +`ONTOLOGY_CHAPTER_FORMAT=term_sheet` goes further than a denser serialization +by not serializing the graph at all. **A facts-only run gets it by default** — +that is what the shipped `auto` resolves to; the rest of this section is what +that default buys, and how to opt out of it. The chapter becomes one line per term — +its name, the surface forms a document might spell it with, what it is, where +it sits in the hierarchy, what it connects, and the scope note saying when it +applies: + +``` +## Classes + ex:PowderSample "Powder sample" < ex:Sample +## Properties (domain -> range) + ex:hasThickness "has thickness" ex:Sample -> ex:QuantityValue +## Individuals (units, vocabulary values) + ex:Approximate "approximately" : ex:EpistemicQualifier ~ ~; ∼; ≈; ca.; about +``` -**`LLM_GRAPH_FORMAT=jsonld` roughly doubles chars per triple against `turtle`.** -That is the cost of the default: JSON-LD is more reliably parsed out of -structured output, and it buys that with context. If you are context-bound -rather than parse-bound, switching to `turtle` is the largest single lever -available — larger than any retrieval knob — and it changes no extraction -semantics, only the encoding. It does invalidate the LLM cache. +The `~` list is why a term sheet can be cheaper without being poorer: those +surface forms are what a document match actually has to work with, they cost a +couple of dozen characters each, and a serialized chapter buries them under a +`skos:altLabel` predicate IRI per entry. + +What that drops is the per-statement RDF scaffolding — a node wrapper or +subject block per term, a repeated predicate IRI per statement — which carries +nothing a reader of the sheet loses. What it keeps is everything that lets a +model pick a term, including one prose field each: the usage contract +(`skos:scopeNote`, `skos:definition`) where a term has one, and `rdfs:comment` +otherwise. + +That fallback is deliberate and is not free — it is the difference between a +listing that re-encodes the chapter and one that cuts it. On a catalog where +most terms carry a comment and few carry a scope note, dropping comments would +reduce those terms to a name and a parent, and nothing downstream can recover a +description that was never shown. The caps below bound how long that prose may +be; nothing bounds it to zero. + +This is admissible only because a facts prompt reads its ontology and writes an +unrelated graph. The ontology loop writes a *patch against the statements in +its chapter*, which a listing cannot express, so an explicit `term_sheet` +requires `RENDER_MODE=facts` and is rejected outright otherwise rather than +falling back to a graph. That restriction is also why the default is `auto` +rather than `term_sheet`: a mode-aware default can be the cheapest legal +chapter everywhere, where a fixed one would either fail on the ontology path or +overpay on the facts path. Set `ONTOLOGY_CHAPTER_FORMAT=inherit` to go back to +a chapter in the wire format. + +### One chapter per document, and warming the cache that serves it + +!!! warning "Opt-in and unverified end to end" + Everything in this section is off by default and should be treated as + unproven for your deployment until you have measured it. The prompt side is + pinned by test — with these settings the render calls of a document really + do share a byte-identical prefix — but that is only half the mechanism. The + saving exists only where the provider *reads* its cache and says so; a + provider that reports cache **writes** and zero reads gives you the extra + tokens of a union chapter and none of the discount. Turn it on, read + `budget.prefix_cache_hit_rate`, and keep it only if that number moves. + +Even a cheap chapter is paid once per LLM call, and the facts pipeline makes +`units + critic passes + completion passes` of them per document. A provider's +prefix cache is the mechanism for paying for a repeated prefix once — but it can +only serve a prefix that actually repeats, and by default no two calls in a +document share one: with `ONTOLOGY_CONTEXT_MODE=selected_vector_search_ontology` +every unit retrieves its *own* context, so every unit gets a different chapter. + +`ONTOLOGY_CONTEXT_SCOPE=document` resolves each unit's context as before and +then shows every unit the union. It is **recall-safe by construction** — the +union contains every atom each unit's own retrieval selected, so no unit is +shown less than it would have been. What it costs is precision, because a unit +also sees its siblings' terms, and per-call tokens, because the union is larger +than any one unit's slice. What it buys is that the chapter repeats. + +That alone is not enough. A prefix cache is populated by a request that has +already *completed*, and the unit fan-out issues every call at once — so N calls +sharing a prefix all miss it, having each arrived before any of them wrote the +entry. `FANOUT_WARMUP_UNITS=1` runs the first unit to completion before fanning +out the rest, turning the other N−1 misses into hits at the cost of one +serialized call's wall-clock. The two settings are worth nothing apart and +should be set together. + +`LLM_PROMPT_CACHE_KEY` completes the picture on OpenAI. The provider caches by +prefix regardless, but without a routing hint a wide simultaneous fan-out can be +spread across machines that each build their own entry. Any stable string works; +it must **not** vary per request, or it defeats itself. + +Read the result from `budget.prefix_cache_hit_rate` — and read it **before +adopting any of this**, because on a provider that never reports a cache read +the three settings cost input tokens and return nothing. Note that a run with +the critic on already shows a non-trivial rate for an unrelated reason — the +critic re-reads the chapter its own render just built — so compare arms of the +same shape, and expect the change here to show up on the *render* fan-out. If +the render fan-out's rate stays at zero, the provider is not serving the prefix +and the union chapter is pure overhead: leave all three unset. + +### Capping what whole-module inclusion costs + +`ONTOLOGY_PATCH_SMALL_MODULE_CLOSURE_MAX_TRIPLES` pulls a module's *whole* graph +into the snapshot once any of its atoms is admitted, because a +qualified-quantity or observation vocabulary is only useful whole: showing a +model `hasLowerBound` but not `hasUpperBound` is what makes it invent near-miss +property names. Whole-module inclusion can be most of the chapter, and the +chapter is paid on every call of every unit. + +`ONTOLOGY_PATCH_SMALL_MODULE_CLOSURE_MAX_TOTAL_TRIPLES` caps what all closures +together may contribute. It is unset by default (unlimited, the historical +behaviour). When set, candidate modules are admitted **in order of retrieval +relevance** — the best score any of their atoms achieved — until the budget is +spent; a module too large for what remains is skipped rather than ending the +pass, so a smaller one behind it still gets in. + +The ordering is relevance and deliberately **not** how many atoms a module won. +A module can win at most as many seeds as it has terms, so counting them ranks +modules by size, and would exclude precisely the case this closure exists for: a +small, sharply relevant vocabulary that is the document's actual subject can +never out-count a large peripheral one. Nothing is excluded for being small or +for being unpopular — a budget filled best-first also keeps the ordinary +situation working, which is a *combination* of modules rather than a single +winner. + +Read it back from `module_closure_iris`, `module_closure_declined_iris` and +`module_closure_triples` in the run manifest: a question about a missing term is +answered by knowing which module the budget kept out. + +!!! note "`ONTOLOGY_CONTEXT_MAX_TRIPLES` is not the lever it looks like here" + Lowering the triple budget to force condensing is counterproductive on a + term-sheet chapter. The condenser's noise and structural passes remove + `owl:imports`, `dcterms:*` and stub restriction nodes — none of which a term + sheet renders in the first place, so they save nothing. Its next pass drops + `GLOSS_PREDICATES`, which includes `skos:altLabel` and `skos:scopeNote`: + the surface forms and usage contracts the sheet deliberately keeps. Bound + those with the text caps below, which shorten rather than remove them. + +### Bounding the chapter's text + +Nothing else in the pipeline caps a single literal, so without a cap the +chapter costs what a catalog's authors chose to write rather than what it +declares — one rambling `rdfs:comment` is paid for on every call of every unit, +and a catalog with kilobyte-long definitions has no ceiling at all. Four +settings put one in place: + +| setting | governs | +|---|---| +| `ONTOLOGY_TEXT_MAX_CHARS_NAMING` | `rdfs:label`, `skos:prefLabel`, `skos:altLabel` | +| `ONTOLOGY_TEXT_MAX_CHARS_CONTRACT` | `skos:scopeNote`, `skos:definition` | +| `ONTOLOGY_TEXT_MAX_CHARS_PROSE` | `rdfs:comment` and the remaining SKOS notes | +| `ONTOLOGY_TEXT_TOTAL_BUDGET` | the summed length of all of them | + +They apply to every chapter the facts loop builds, term sheet and serialized +graph alike, and are unset by default: on a tersely authored catalog they are a +no-op, which is the intended shape. These are bounds, not reductions. Clipping +happens on a word boundary and leaves a visible marker, so the model can tell a +clipped definition from a complete one — worth preferring to dropping the +statement, because a scope note's first sentence usually carries the contract +and the rest elaborates. + +Over the total budget, prose is tightened first, then contracts, then names — +each fitted to the *largest* cap that still meets the budget rather than stepped +down through preset tiers, because every character under the budget is context +it never asked to lose. Nothing is ever removed: a clipped definition still says +the term has one and still carries the words that say when it applies, while a +removed one says nothing at all. Shedding whole statements is +`ONTOLOGY_CONTEXT_MAX_TRIPLES`'s job, on the axis where statements are what is +over budget. + +Names have the highest floor of the three, and on a catalog whose labels are +already shorter than it that role is reached and does nothing. A chapter that +still does not fit is then passed through with a warning — correctly, because +what remains at that point is the vocabulary itself, and the fix is to send +fewer terms, not shorter names. + +Read the effect back from the run manifest's `budget.counters`: +`chapter/text_chars_before` and `chapter/text_chars_after`, +`chapter/literals_clipped`, `chapter/literals_dropped`, and +`chapter/text_over_budget` when a chapter could not be made to fit. They are +recorded once per chapter built rather than per call that reads it, and the +before/after is reported even when nothing was clipped — which is the case that +tells you your caps are inert on this catalog. Sizing a cap is not something a +default can do for you: whether a catalog reaches one is a property of that +catalog. See [Configuration](configuration.md) for the full list and [LLM Caching](llm_caching.md) for cache behavior. diff --git a/docs/user_guide/playbooks.md b/docs/user_guide/playbooks.md index d9fe669e..020ff4c5 100644 --- a/docs/user_guide/playbooks.md +++ b/docs/user_guide/playbooks.md @@ -68,7 +68,7 @@ what comes out, iterate on the seed catalog. ```bash RENDER_MODE=ontology # writes NO facts — this is the point -MAX_VISITS_PER_NODE=2 # enable the LLM critic; schema quality is the deliverable +ONTOLOGY_CRITIC_PASSES=1 # enable the LLM critic; schema quality is the deliverable ONTOLOGY_CONTEXT_MODE=selected_single_ontology ``` @@ -77,16 +77,30 @@ Why these: - **`RENDER_MODE=ontology`** skips the entire facts block, so you are not paying to instantiate against a schema you are still changing. Nothing is written to the facts partition at all. -- **`MAX_VISITS_PER_NODE=2`** turns on `criticise_ontology`, which is off at the - default of `1`. This is the one place the extra cost is usually justified — - a bad term propagates into every downstream fact. It costs roughly one extra - call per unit, not the squared worst case the nested loops suggest, because - the critic exits immediately when it fails without requesting external - evidence. See [Configuration](configuration.md#server). +- **`ONTOLOGY_CRITIC_PASSES=1`** turns on `criticise_ontology`, which is off by + default. This is the one place the extra cost is usually justified — a bad + term propagates into every downstream fact. It costs exactly one extra call + per unit: the pass applies its own fixes rather than describing them to a + second call, and it stops early once a pass changes nothing. Deletes are + scoped to the unit's own delta, so a critique cannot remove retrieved catalog + content. See [Configuration](configuration.md#server). + +Starting from **nothing** is a supported first step, not a misconfiguration. +With no seed catalog, each unit's context resolves empty, and that is precisely +the signal the renderer reads to mint a new ontology from the text under +`current_domain`: -Iterate: run, inspect the emitted ontology, fold the good parts back into -`ONTOCAST_ONTOLOGY_DIRECTORY`, run again. The catalog is the input that most -improves the next run. +```bash +ontocast process --input-path ./docs --ontology-dir '' --ontology-output-dir ./out +``` + +The empty string is how a run declares "no seed ontologies" over a configured +`ONTOCAST_ONTOLOGY_DIRECTORY`. (A path that does not *exist* is a different +thing entirely, and `process` refuses to start on it.) + +Iterate: run, inspect the emitted ontology, fold the good parts back into your +seed directory, run again. The catalog is the input that most improves the next +run. **Next:** once the schema stops changing, switch to [Populate facts](#3-populate-facts). @@ -101,8 +115,11 @@ you already have rather than inventing near-duplicates. RENDER_MODE=facts ONTOLOGY_CONTEXT_MODE=fixed_single_ontology ONTOLOGY_CONTEXT_FIXED_ONTOLOGY_ID=my_schema # IRI, ontology_id, or author prefix +FACTS_CONTEXT_FROM_UNITS=true # gate + aggregator see the resolved + # context, not an empty graph FACTS_SHACL_AUTOFIX=prune AGG_CANDIDATE_SIMILARITY_THRESHOLD=0.70 +LLM_JSON_MODE=true # OpenAI: constrained decoding ``` Why these: @@ -112,6 +129,11 @@ Why these: - **`fixed_single_ontology`** removes the per-unit selection call, which is both cheaper and more consistent than letting the model re-pick a schema for every chunk. +- **`FACTS_CONTEXT_FROM_UNITS=true`** (the default) matters specifically in this mode: with + no ontology stage there are no reduced artifacts to merge, so without it the + aggregator's merge guards and the SHACL gate run against an *empty* + vocabulary — the gate then overstates class violations and records + `validated_without_ontology_context`. !!! warning "Two failure modes that look identical to bad extraction" @@ -121,8 +143,9 @@ Why these: **A fixed id that matches nothing.** A *missing* id is a clean 400. An id that matches no catalog entry is **not** an error — it logs a warning and - renders against an empty snapshot. Check `GET /ontologies` if output goes - sparse after a rename. + renders against an empty snapshot. If output goes sparse after a rename, + look for that warning in the server log: it names the id that matched + nothing. Tune `AGG_CANDIDATE_SIMILARITY_THRESHOLD` on what you actually see: raise it when distinct entities are being merged, lower it when duplicates survive @@ -130,6 +153,12 @@ when distinct entities are being merged, lower it when duplicates survive [Entity Disambiguation](aggregation.md), and the validation gate is described in [Facts Validation](validation.md). +What each call costs is mostly the ontology chapter. On a facts run it already +defaults to a term sheet — one line per term instead of a serialized graph +(`ONTOLOGY_CHAPTER_FORMAT=auto`); leave it there unless the model needs the +graph's axioms. See +[The ontology chapter as a term sheet](performance.md#the-ontology-chapter-as-a-term-sheet). + --- ## 4. Scale the catalog @@ -151,7 +180,7 @@ LANCEDB_ENABLED=true # embedded; or QDRANT_URI for a server. Nev EMBEDDING_MODEL_NAME=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 EMBEDDING_DIMENSION=384 -VECTOR_STORE_TOP_K=20 +VECTOR_STORE_TOP_K=40 VECTOR_STORE_INDUCED_SUBGRAPH_MAX_TOTAL_TRIPLES=1200 ``` @@ -172,6 +201,16 @@ Tune in this order, one at a time: 3. `ONTOLOGY_PATCH_MAX_ATOMS` — **lower** it for noisy catalogs, where the problem is irrelevant terms crowding out correct ones. +Retrieval gives every unit its own chapter, so no two calls in a document share +a prompt prefix and a provider's prefix cache has nothing to serve. To make the +chapter repeat, set `ONTOLOGY_CONTEXT_SCOPE=document` (every unit sees the +union of the document's retrieved context) together with +`FANOUT_WARMUP_UNITS=1` (the first unit completes and populates the cache +before the rest fan out), and on OpenAI a stable `LLM_PROMPT_CACHE_KEY`. All +three are off by default and pay off only if `budget.prefix_cache_hit_rate` +rises on your provider — see +[One chapter per document](performance.md#one-chapter-per-document-and-warming-the-cache-that-serves-it). + !!! warning "The retrieval defaults are fitted to one corpus" They are a starting point, not a recommendation. The full budget surface is @@ -274,7 +313,7 @@ separates "the model did badly" from "that stage never ran". | Bibliography extracted as domain facts | Reference routing | `CHUNK_BIBLIOGRAPHY_MODE=skip` | | Ligature gaps in PDF text (`di ff usion`) | Converter profile | `CONVERTER_PROFILE=born_digital` | -## Where the other ~175 variables are +## Where the other variables are [Configuration System](configuration.md) is the complete reference, and `.env.example` documents every variable with its default. Areas deliberately diff --git a/docs/user_guide/tenancy.md b/docs/user_guide/tenancy.md index 0f37a287..9943f501 100644 --- a/docs/user_guide/tenancy.md +++ b/docs/user_guide/tenancy.md @@ -35,7 +35,7 @@ Tenant and project are **runtime parameters**, not environment variables. They m When `tenant` or `project` appears in the **query string**, the request is served by a `ToolBox` bound to that scope. Requests without tenancy query parameters use the server's active tenant/project from startup (defaults: `ontocast` / `test`). -Seed TTLs from `ONTOCAST_ONTOLOGY_DIRECTORY` are **not** replayed into a new tenant — they are startup bootstrap only. Details: [Ontology Catalog](../architecture/ontology_catalog.md#why-it-resets-on-a-tenancy-switch). The same holds for `FACTS_SHAPES_DIR`: a new tenant's shapes are whatever its own partition already holds, which may be nothing — correctly read downstream as "SHACL never checked" rather than "conforms". See [Validation](validation.md#where-shapes-come-from). +Seed TTLs from `ONTOCAST_ONTOLOGY_DIRECTORY` are replayed into a new tenant, but only for ontologies its partition serves no terms for — an ontology the new scope already defines is never overwritten by the on-disk copy. Details: [Ontology Catalog](../architecture/ontology_catalog.md#why-it-resets-on-a-tenancy-switch). `FACTS_SHAPES_DIR` is seeded on the same switch; a tenant with neither a shapes partition nor a shapes directory has no shapes, which reads downstream as "SHACL never checked" rather than "conforms". See [Validation](validation.md#where-shapes-come-from). ### One ToolBox per scope diff --git a/docs/user_guide/triple_stores.md b/docs/user_guide/triple_stores.md index 299b4495..8c393267 100644 --- a/docs/user_guide/triple_stores.md +++ b/docs/user_guide/triple_stores.md @@ -104,9 +104,11 @@ the store. ## Seed Ontologies and Shapes -Place `.ttl` files in `ONTOCAST_ONTOLOGY_DIRECTORY`. On startup, `ToolBox` scans that directory and materializes any ontologies not already present in the triple store. This is a one-way bootstrap path — ongoing persistence is through the triple store. +Place `.ttl` files in `ONTOCAST_ONTOLOGY_DIRECTORY` (or pass `--ontology-dir` for one run). On startup, `ToolBox` scans that directory and materializes any ontologies the triple store does not already serve. This is a one-way bootstrap path — ongoing persistence is through the triple store. -`FACTS_SHAPES_DIR` works the same way for SHACL shapes, into the shapes partition, except that the scan is **recursive** and each document is written on every startup rather than only when absent — so editing a seed shapes file takes effect on restart. Neither directory is ever written to: `POST /ontologies` and `POST /shapes` mutate the store alone, and the matching `DELETE` routes leave your files untouched. See [Validation](validation.md#where-shapes-come-from) for why shapes get a partition of their own. +Naming a directory that does not exist is a configuration error, not an empty catalog: `ontocast process` refuses to start on it, since nothing downstream can tell it apart from having named none. Naming none is fine — `--ontology-dir ''` says so explicitly, and an ontology-rendering run then creates the first ontology from the corpus. + +`FACTS_SHAPES_DIR` (or `--shapes-dir`) works the same way for SHACL shapes, into the shapes partition, except that the scan is **recursive** and each document is written on every startup rather than only when absent — so editing a seed shapes file takes effect on restart. Neither directory is ever written to: `POST /ontologies` and `POST /shapes` mutate the store alone, and the matching `DELETE` routes leave your files untouched. See [Validation](validation.md#where-shapes-come-from) for why shapes get a partition of their own. ## Why Shapes Are Not in the Ontologies Dataset diff --git a/docs/user_guide/user_instructions.md b/docs/user_guide/user_instructions.md index bf236b7d..a04d1642 100644 --- a/docs/user_guide/user_instructions.md +++ b/docs/user_guide/user_instructions.md @@ -90,7 +90,22 @@ curl -X POST http://localhost:8999/process \ -F "facts_user_instruction=Extract financial data and numerical values" ``` -### 3. Programmatic Usage +### 3. Batch CLI + +`ontocast process` accepts the facts slot directly, so a batch run carries the +same deployment guidance a server request would: + +```bash +ontocast process --input-path ./docs \ + --facts-user-instruction "Extract financial data and numerical values" \ + --output-dir ./out +``` + +The run manifest records only the instruction's length +(`validation_config.facts_user_instruction_chars`), never its text — the dump +is shareable, and deployment guidance can carry sensitive detail. + +### 4. Programmatic Usage When using OntoCast programmatically, set user instructions in the AgentState: diff --git a/docs/user_guide/validation.md b/docs/user_guide/validation.md index cf5dff07..4c31d4ea 100644 --- a/docs/user_guide/validation.md +++ b/docs/user_guide/validation.md @@ -15,44 +15,91 @@ graph, and does not yet gate — is | # | Layer | Where | LLM calls | |---|-------|-------|-----------| | 1 | **Machine repair, at parse time** | `agent/render_facts.py::_normalize_and_repair_graph`, per unit, on every rendered graph | **none** | -| 2 | **Finding-driven repair render** | `stategraph/atomic.py::_run_finding_driven_repair`, per unit — fed by deterministic findings *and* the critic's blocking fixes | **up to `FACTS_LLM_REPAIR_VISITS`** | +| 2 | **Critic pass** | `stategraph/atomic.py::run_unit_loop`, per unit — the critique itself costs a call; compiling and applying it costs none | **one per `FACTS_CRITIC_PASSES`** | | 3 | **Post-merge gate** | `VALIDATE_FACTS` node, once per document | **none** | ### How many LLM calls a facts unit really costs -At the default `MAX_VISITS=1` the critic never runs, but extraction is **not** -one call per unit: +At the defaults, two: ``` render_facts 1 provider call - ↓ critic skipped (MAX_VISITS reached) -finding-driven repair render 1 more, if mandatory findings remain - (up to FACTS_LLM_REPAIR_VISITS, default 1) + ↓ +deterministic checks free — re-run before every critique + ↓ +criticise_facts 1 per pass (FACTS_CRITIC_PASSES, default 1) + ↓ the critique is compiled and applied here, no LLM call ``` -The *trigger* is deterministic — quarantined literals, unknown terms, alias -leftovers — but the *fix* is bought from the model. Set -`FACTS_LLM_REPAIR_VISITS=0` to pin extraction at exactly one call per unit and -leave the residue to layers 1 and 3. - -The ontology loop has no repair stage, so at `MAX_VISITS=1` it is genuinely one -call per unit. It does run its own deterministic validator — see -[Ontology delta validation](#ontology-delta-validation-shadow-mode) — but in -shadow mode: findings are recorded and shown to the critic, and cost nothing. +The two budgets are independent and mean different things: -Above `MAX_VISITS=1` the facts critic runs, and a **rejection costs one repair -render, not another full render**. So the ceiling is: +- **`MAX_VISITS`** retries a render that *failed*. A render that succeeded is + never repeated — re-extracting a whole unit to fix a local defect is the + expensive answer to a cheap question, and reliably introduces new defects of + its own. Raise it only if renders are failing outright. +- **`FACTS_CRITIC_PASSES`** buys review-and-patch passes. Each one re-runs the + deterministic checks for free, sends the graph and its findings to the critic, + and applies what comes back. -``` -render_facts 1 provider call - ↓ -criticise_facts 1 more (MAX_VISITS > 1 only) - ↓ rejected -> its blocking fixes become findings -finding-driven repair render 1 more, up to FACTS_LLM_REPAIR_VISITS -``` +Set `FACTS_CRITIC_PASSES=0` to pin extraction at exactly one call per unit and +leave the residue to layers 1 and 3. -That total does not grow with `MAX_VISITS`; the bound now governs render -*failure* retries only. +A pass ends the loop early when it changed nothing, or when it was rolled back: +a second critique of an unchanged graph is the same answer billed twice. + +The ontology loop is the same loop. It defaults to `ONTOLOGY_CRITIC_PASSES=0`, +so it is one call per unit unless you turn its critic on. It does run its own +deterministic validator — see +[Ontology delta validation](#ontology-delta-validation-shadow-mode) — which +costs nothing. + +### How a critique reaches the graph + +The critic does not describe the statement it wants changed; it **cites the +statement's id**. Every graph chapter in a critic prompt carries a number per +statement — inline in Turtle, in a `TRIPLE INDEX` table under JSON-LD — and a +fix names those numbers in `triple_ids`. + +That is the difference between a critique that lands and one that does not. The +previous contract asked the critic to retype the offending statement into +`incorrect_value`, and to be applied it had to reproduce the stored triple +exactly: same prefix form, same predicate, same literal shape. Models reproduce +graph content from memory poorly, and a single wrong predicate in a node-shaped +quote discarded the whole fix. Authoring *new* content has no such problem, +because nothing has to match — so `correct_value` is still free-hand. + +The payload is parsed **format-tolerantly**, not by dispatch. `LLM_GRAPH_FORMAT` +names one syntax for fix payloads and models do not reliably use it — under a +JSON-LD deployment a large share of authored payloads come back as Turtle, often +as a Turtle body with a JSON-LD term object where the object belongs +(`rdfs:label {"@value": "x", "@language": "en"}`). Those are mapped onto the +equivalent Turtle literal forms, `@id` is unwrapped, and bare absolute IRIs are +bracketed. Nothing guesses at meaning: a payload that is still not a statement +afterwards — a bare literal, a predicate with no subject — stays unparseable and +is reported as residual. + +On the ontology side the ids are scoped to the unit's own delta. The retrieved +catalog is still shown, because a term choice cannot be judged without it, but +those statements carry no id — so a delete that would propagate onto a shared, +versioned terminal is not expressible rather than merely reported afterwards. + +### What a pass may not destroy + +Both halves of a compiled patch are known before anything is touched, so the +limits are enforced by withholding, not by inspecting the damage: + +| Rule | Why | +|------|-----| +| Deletes are by cited id only | A statement the critic was never shown cannot be removed | +| `FACTS_CRITIC_MAX_DELETE_SHARE`, with a `FACTS_CRITIC_MIN_DELETES` floor | Past the share a critique has stopped correcting and started rewriting. The floor keeps short units correctable, where one legitimate fix is already a large fraction of the graph | +| A `REMOVE` may not empty a subject | Removing the last statement about a node deletes the node, which is not a correction | +| A `REPLACE` may not write about a different subject than it deletes | That is a rename: it orphans the old node and leaves the new one bare. `FACTS_CRITIC_ALLOW_SUBJECT_RENAME` opts in | +| A blank node goes whole or not at all | A partial delete leaves the degenerate stub the validator exists to flag | +| A fix that re-adds exactly what it removes is dropped and counted | It asks for no change; counting it as applied overstates what the critique bought | + +After the patch, a pass is rolled back whole if it deleted without writing, +shrank the unit's product without resolving anything, or *created* mandatory +findings. ### What makes a render acceptable @@ -74,6 +121,40 @@ computed played no part at all. Set Per-document critic telemetry — call count, accept count, score histogram, fix severity histogram — is written to the run manifest under `critic`. +### Completion pass: insert-only recovery + +The critic answers *conformance* — is what was rendered correct? — not +*completeness*. `NUMERIC_COVERAGE` findings tell it about measurements the +render missed, but the critic's own guideline is to describe them in +`systemic_critique_summary` rather than mint a fix for each one: a fix +compiled from a bare number with no place to attach it is exactly the +placeholder-subject failure mode the compiler's junk check exists to refuse. + +`FACTS_COMPLETION_PASSES` (default `0`, off) adds a second, narrower pass for +this gap. Once the critic loop above is done, and only while the unit's +numeric-coverage inventory still lists a *measurement* — a number with its +unit — absent from the graph, up to this many completion passes run. Each +one: + +- is shown a compact **term sheet** (the unit's quantity/observation/condition + classes and unit individuals) instead of the full ontology chapter, plus the + unit's existing catalog-typed subjects, so a recovered measurement attaches + to what is already there instead of minting a duplicate; +- proposes **insert-only** fixes — `action=ADD` only, never `REMOVE` or + `REPLACE` — one per new subject closure; anything else the model returns is + dropped rather than trusted; +- applies each proposed subject through the **same per-subject regression + check** a critic fix goes through (see + [What a pass may not destroy](#what-a-pass-may-not-destroy)): an insert that + leaves the unit worse — creates a mandatory finding, for instance — is + rolled back on its own, the rest of the pass stands. + +The loop stops early once the inventory is empty, so a unit missing nothing +never pays for a pass it does not need. Per-document telemetry — calls, +subjects inserted and rolled back, triples inserted, measurements recovered — +is written to the run manifest under `completion` +(see [Observability](observability.md)). + ## Layer 1 — machine repair at parse time Applied to every rendered graph before it is accepted. Each repair is recorded @@ -84,13 +165,13 @@ can always tell machine-altered triples from what the model asserted. |--------|--------------| | Numeric literal retyping | `qudt:numericValue 230` → `"230"^^xsd:decimal` when the schema declares a numeric range | | `rdf:type` literal coercion | A type emitted as a string becomes an IRI when it resolves unambiguously | -| Near-miss predicate rewrite | `qudt:value` → `qudt:numericValue` when exactly one catalog term is a near match (`FACTS_PROPERTY_ALIAS_MIN_RATIO`) | +| Near-miss predicate rewrite | `qudt:value` → `qudt:numericValue` when exactly one catalog term **contains** the written name's tokens (or equals it once case and separators are folded). String similarity never licenses a rewrite on its own — a site letter or a negating prefix is one character and an opposite meaning — it only breaks ties among containing candidates above `FACTS_PROPERTY_ALIAS_MIN_RATIO`. A predicate declared anywhere in the full catalog is never treated as a near-miss, even when this unit's snapshot did not retrieve it | | **Code resolution** | A node carrying `qudt:ucumCode "d"` but no unit link gains `qudt:unit unit:DAY` when exactly one catalog individual declares that code (`FACTS_CODE_PREDICATES`) | | Degenerate-bound promotion | Equal lower/upper bounds collapse to a single scalar on the configured numeric-value property — active only when the quantity fallback vocabulary names `numeric_value`, `lower_bound` **and** `upper_bound` roles | ### Which terms count as unknown -`UNKNOWN_TERM` findings drive mandatory repair renders, so the closure rule +`UNKNOWN_TERM` findings are mandatory, so the closure rule behind them matters. A namespace is *closed* — members the catalog does not list get flagged — only when the catalog **declares** terms in it (subject-position statements). A namespace the catalog merely *references* @@ -110,13 +191,26 @@ mandatory finding must be resolved by *rewriting in place* — a repair response that only deletes statements is flagged as data destruction, not a fix. The gate additionally cross-checks the SHACL shapes against these rules at load time and logs an error for any property the shapes require but the term -validator would flag — data cannot satisfy both sides. +validator would flag — data cannot satisfy both sides. The cross-check +judges the shapes the way the unit validator does: against the whole +catalog, not the retrieved snapshot, and with the shapes-contract exemptions +applied, so a required property a unit did not retrieve is not a +contradiction. A companion mandatory finding, `LABEL_ONLY_NUMBER`, fires when a node carries the fallback vocabulary's unit property but no numeric literal on any property while its label holds a number as prose — a measurement that is invisible to every query. +`UNIT_SYMBOL_CASE_MISMATCH` guards the other silent numeric error: a value +node whose unit's declared symbol (from the catalog's own `qudt:symbol`, +`skos:notation` or code literals) matches the unit written next to that +number in the text only after case folding, while a *different* catalog unit +declares the written form exactly. Symbols are case-significant, and two +units a case apart are typically a prefix apart in magnitude; the exact-match +unit is offered as the suggestion. A case variant with no competing unit is +a spelling, not a finding. + Code resolution is schema-driven, not vocabulary-specific: the connecting property is whichever object property the ontology context declares with a range the resolved individual is typed as and a domain the subject satisfies. Where @@ -236,6 +330,79 @@ reported violations. and reports a statement that is present as missing. Turning inference off therefore raises the violation count rather than lowering it. +!!! warning "In facts-only runs, the mixed-in context can be empty" + With `RENDER_MODE=facts` there is no ontology stage, so the document-level + context the gate mixes in is empty unless `FACTS_CONTEXT_FROM_UNITS` (on by default) + seeds it from the units' retrieved snapshots. An empty context means no + `rdfs:subClassOf` axioms, so `sh:class` constraints fire on every node + typed with a subclass of what a shape names — the gate then overstates + violations and misattributes them to typing. The run records this as + `validated_without_ontology_context` in the retrieval metrics; treat the + conformance figure of such a run as an upper bound, not a measurement. + +### The shapes-driven prompt contract + +The shapes judge every extracted graph — and, with +`FACTS_SHAPES_PROMPT_CONTRACT=auto` (the default), they also *inform* it: the +loaded shapes are rendered once per tenancy into a `# CONFORMANCE +REQUIREMENTS` chapter that both the facts renderer and the facts critic see. +Without it the model is graded against a rulebook it is never shown, and the +dominant violation classes are reliably the rules no prompt states. + +The chapter is derived from the shapes at run time, so the library prompt +stays domain-neutral — domain knowledge enters only through the deployment's +artifacts: + +- Each constraint renders its **`sh:message` verbatim** when the shape author + wrote one (SPARQL constraints included — a message states the rule in + prose). Message-less property constraints get one synthesized structural + line (`path: at least 1, of type C`); a message-less SPARQL constraint + cannot be summarized and is omitted with a startup warning. +- The chapter is **capped** (`FACTS_SHAPES_PROMPT_MAX_LINES`, default 60) and + notes in-text when rules were truncated. +- With no shapes loaded, or `FACTS_SHAPES_PROMPT_CONTRACT=off`, the prompt is + byte-identical to before. +- **Terms the shapes require are exempt from `UNKNOWN_TERM`**, for the same + reason the quantity fallback vocabulary is: the unit's retrieved ontology + context is a subset, and flagging a term the contract instructed the + renderer to emit would have the repair lane delete exactly what the + contract required. The exemption is always the **full catalog's** terms, + whatever chapter selection (below) does — they are catalog IRIs either + way, and the gate validates against every shape. + +**How the chapter scales — selection by context join.** A small catalog is +rendered whole: run-constant, memoized once per tenancy, shared by every +unit. A large catalog must not be blind-truncated in document order, so above +the line cap the chapter is **selected per unit**: a shape is included iff +its own terms (its `sh:targetClass`, target predicates, `sh:path`, +`sh:class`) intersect the IRIs of the unit's *resolved ontology snapshot*. +Shape relevance is derivative of ontology-term relevance — a unit can only +instantiate the classes its context carries — so the snapshot the retrieval +already produced is the join key, and no second retrieval decision exists to +get wrong. The join reads every IRI of the snapshot (subjects, predicates +and objects: the schema closure carries superclass IRIs as objects, which is +how a shape targeting a superclass reaches a unit typed with the subclass). +A unit with an empty context gets no chapter — it has no classes to hold to +their rules. + +`FACTS_SHAPES_PROMPT_CONTRACT` modes: `full` (whole catalog always), +`context` (per-unit join always), `auto` (default — `full` while the catalog +fits `FACTS_SHAPES_PROMPT_MAX_LINES`, `context` once it outgrows it), `off`. +The run manifest records the resolved behavior as +`validation_config.shapes_prompt_selection`. + +Shapes are deliberately **not** indexed in the vector store: the whole index +lifecycle (tenancy collections, wipe, orphan pruning, the startup +index↔catalog consistency checks) is keyed on ontology identity, and shape +relevance needs no similarity search — the context join answers it exactly, +from a retrieval that already happened. This extends the standing rule that +shapes live in their own partition precisely so they are never mistaken for +catalog schema. + +Write `sh:message` for every constraint you author: it is simultaneously the +validation report's diagnostic and the renderer's instruction, one string +maintained in one place. + ### LLM-free autofix `FACTS_SHACL_AUTOFIX` repairs violations in code, in a bounded @@ -311,7 +478,12 @@ Grouping by constraint component is what makes a residue diagnosable: 36 36 problems to triage. Batch runs (`ontocast process --output-dir …`) write the same payload beside the -facts Turtle as `.facts.validation.json`. +facts Turtle as `.facts.validation.json`, plus two blocks the HTTP response +splits out elsewhere: `unit_repairs` (the layer-1 `GraphRepairRecord`s per unit — +kind, source, target, triple count — so a machine rewrite of a model's statement +is auditable from the dump alone) and `unit_failures` (unit index, phase, stage, +reason). The file is written whenever any of its blocks is non-empty, so a run +whose units all failed still leaves a record. ## Ontology delta validation (shadow mode) @@ -366,8 +538,7 @@ Under `ONTOLOGY_CONTEXT_MODE=selected_vector_search_ontology` the per-unit snapshot is a **retrieved subset** of the catalog, so judgments that require knowing what the catalog contains cannot be made inside the unit. Three policies therefore run at the reduce step, where the full catalog terminals -are already in hand (design note: -`planning/ontology-update-semantics.md` at the workspace root): +are already in hand: | Policy | Behavior | Metric | |---|---|---| @@ -399,7 +570,7 @@ simply be unretrieved rather than missing. | Variable | Default | Description | |----------|---------|-------------| -| `FACTS_SHAPES_DIR` | — | **Seed** directory of SHACL shape files (recursive), materialized into the shapes partition at startup; inline `sh:NodeShape` in the ontology context is picked up automatically | +| `FACTS_SHAPES_DIR` | — | **Seed** directory of SHACL shape files (recursive), materialized into the shapes partition at startup; inline `sh:NodeShape` in the ontology context is picked up automatically. `--shapes-dir` overrides it per run | | `FUSEKI_SHAPES_DATASET` | derived from tenant/project | Fuseki dataset backing the shapes partition | | `FACTS_SHACL_INFERENCE` | `rdfs` | `none`, `rdfs` or `owlrl` pre-inference | | `FACTS_SHACL_ADVANCED` | `true` | Enable SHACL Advanced Features | @@ -407,13 +578,52 @@ simply be unretrieved rather than missing. | `FACTS_SHACL_AUTOFIX` | `prune` | `off`, `rewrite`, or `prune` | | `FACTS_SHACL_AUTOFIX_PASSES` | `1` | Bounded validate → repair → revalidate rounds | | `FACTS_CODE_PREDICATES` | `qudt:ucumCode`, `qudt:symbol`, `skos:notation` | Predicates whose literals are machine-resolvable codes | -| `FACTS_LLM_REPAIR_VISITS` | `1` | Finding-driven repair renders per unit — **each one is a provider call** | +| `FACTS_CRITIC_PASSES` | `1` | Review-and-patch passes per unit — **each one is a provider call** | | `FACTS_MERGE_REPAIR_PASSES` | `1` | Un-merge passes at the gate | | `FACTS_SUSPECT_MULTI_VALUE_SEVERITY` | `error` | Severity of `SUSPECT_MULTI_VALUE` findings | +| `FACTS_SUSPECT_MULTI_VALUE_REQUIRE_CROSS_UNIT` | `false` | Report an IRI-branch `SUSPECT_MULTI_VALUE` as an error only when its objects came from different units | +| `FACTS_NUMERIC_IDENTIFIER_GUARD` | `true` | Keep identifier digit groups out of the numeric-coverage inventory | +| `FACTS_CONTEXT_FROM_UNITS` | `true` | In facts-only runs, seed the merge/validate ontology context from the snapshots the units resolved | | `FACTS_FUNCTIONAL_MIN_SINGLE_SUPPORT` | `3` | Subjects needed before a predicate counts as empirically functional | | `FACTS_ACCEPT_BLOCKING_SEVERITY` | `critical` | Which critic-proposed fix severities block a unit from leaving the loop; `never` lets deterministic findings gate alone. A `REMOVE` fix never blocks at any setting | | `FACTS_LITERAL_VARIANT_DEDUPE` | `true` | Collapse duplicate literals differing only in language tag or datatype before the gate validates | | `ONTOLOGY_RECONCILE_MINTED_TERMS` | `detect` | Reduce-time minted-duplicate scan: `off`, `detect` (count only), `rewrite` (substitute the catalog IRI) | +The last three default to off because each changes what the pipeline extracts or +reports, and the right setting depends on the corpus: + +- **`FACTS_SUSPECT_MULTI_VALUE_REQUIRE_CROSS_UNIT`.** The IRI branch of + `SUSPECT_MULTI_VALUE` flags any subject carrying two objects on a predicate + that is single-valued elsewhere in the graph, and error findings drive the + un-merge repair. Frequency alone is weak evidence: a genuinely multi-valued + statement is rare by construction, so a correct one — two agents named in one + sentence — looks exactly like a bad merge and gets repaired away. Provenance + separates them, because only objects arriving from *different* units could + have been brought together by an identity decision. With this set, a pair + asserted within a single unit is reported as a warning and never vetoes a + cluster. The numeric and string branches are unaffected: two distinct + quantities on one node are a defect whatever their provenance. +- **`FACTS_NUMERIC_IDENTIFIER_GUARD`.** The numeric-coverage finding lists + numbers present in the source text but absent from the graph, and the repair + / completion passes act on it. Measurements — a number written next to its + unit — are judged against structured `(number, unit)` pairs in the graph: a + bare numeric literal, or the same number under a different unit, does not + clear a unit-adjacent mention. Bare numbers stay on the number-only presence + set. Digit groups sitting against an identifier separator (`600/92`, + `10.1234/example`) are parts of one identifier, not quantities; offering + them invites the repair to structure a file number into numeric properties, + which the post-merge multi-value check then flags. A digit group standing + alone as its own token is deliberately *not* covered — nothing around it + distinguishes a file-number component from a small quantity — and a value + with its unit (`8.5 nm`) or a range (`10-15 meV`) is untouched. +- **`FACTS_CONTEXT_FROM_UNITS`** (on by default). With `RENDER_MODE=facts` no ontology stage + runs, so there are no reduced artifacts to merge and the document-level + ontology context is empty unless it is seeded from the units' snapshots — even though every unit resolved and rendered + against a real one. Turned off, both consumers see that: the aggregator loses the type and + functionality declarations its guards read, and the gate skips every check + that needs a vocabulary. Watch `validated_without_ontology_context` and + `ontology_snapshot_triples` in the retrieval metrics to see which side you are + on. + See [Configuration System](configuration.md) for the full surface and [Entity Disambiguation](aggregation.md) for the merge stage this gate sits behind. diff --git a/docs/user_guide/workflow.md b/docs/user_guide/workflow.md index 68215d76..0b420444 100644 --- a/docs/user_guide/workflow.md +++ b/docs/user_guide/workflow.md @@ -82,7 +82,7 @@ Notes: - Core diagrams show the default path: render/critic retries without web search. When a node sets `initiate_search`, plan/fetch/retry branches apply — see `*_evidence.mmd` (and matching PNG/SVG). - First render/critic pass always runs **without** web search; search runs only when the node sets `initiate_search`. -- On the **last allowed render attempt**, the critic is skipped (no further extract to critique). The facts loop also surfaces unresolved quarantined literals on that path. +- `MAX_VISITS` retries a **failed** render only. A successful render is never repeated: improving it is what the critic passes are for. - In the **facts** loop a rejecting critic does not escalate to another render. Its blocking fixes are converted to findings and applied by the bounded rewrite-in-place repair pass, so the outer loop retries only on render *failure* and a unit's worst-case call count is flat in `MAX_VISITS`. Acceptance is decided by `material_defects()` — mandatory deterministic findings plus critic fixes at `FACTS_ACCEPT_BLOCKING_SEVERITY` — not by the critic's score. The ontology loop still gates on the critic's score. It now has a deterministic finding lane of its own (`tool/ontology_validation/unit_findings.py`), but that lane runs in **shadow mode**: findings are collected, injected into the critic prompt as MANDATORY items and reported as `ontology_findings_residual` / `ontology_mandatory_residual`, and the gate itself is unchanged until a sampling run yields the incumbent's distribution — the same order the facts gate change followed. See [Validation](validation.md#ontology-delta-validation-shadow-mode). - `/process_unit` runs this loop on a single unit via `unit_pipeline.py` (no chunking or document-level reduce). @@ -115,6 +115,7 @@ every unit wait for the slowest summary before any extraction could start. - Section LLM tagging during Chunk uses **parallel** workers up to `PARALLEL_WORKERS` - Use `--head-chunks N` on the CLI to process only the first N units (testing) - Without section parameters, Chunk uses layout/simple sizing only (no tag/filter) +- Routing before the fan-out runs `CHUNK_MIN_UNIT_CHARS` → bibliography (`CHUNK_BIBLIOGRAPHY_MODE`) → non-content (`CHUNK_NON_CONTENT_MODE`); in `extract` mode a non-content unit carries `is_non_content` instead of being dropped. `CHUNK_MAX_MEASUREMENTS_PER_UNIT` then splits units dense in unit-adjacent numbers at sentence boundaries. ### 3. Per-Unit Ontology Loop @@ -147,12 +148,37 @@ Provenance triples (`prov:`, reification, chunk metadata) are kept in `ontology_ ### 5. Per-Unit Facts Loop -When facts rendering is enabled, each unit runs a **facts loop** (render → critic, with optional web evidence), then **merge facts** applies cross-chunk entity disambiguation and aggregation, and **validate facts** checks post-merge invariants (functional violations, suspect multi-values, degenerate coreference, optional SHACL). Merge-signature error findings (functional violation, suspect multi-value, degenerate coreference — never SHACL) on merged subjects trigger a deterministic un-merge: the offending cluster's pairs are vetoed and the retained facts units are re-aggregated (`FACTS_MERGE_REPAIR_PASSES`). Residual findings land in `facts_validation_findings` and the retrieval metrics. +When facts rendering is enabled, each unit runs a **facts loop** (render once, then bounded review-and-patch passes, with optional web evidence), then **merge facts** applies cross-chunk entity disambiguation and aggregation, and **validate facts** checks post-merge invariants (functional violations, suspect multi-values, degenerate coreference, optional SHACL). Merge-signature error findings (functional violation, suspect multi-value, degenerate coreference — never SHACL) on merged subjects trigger a deterministic un-merge: the offending cluster's pairs are vetoed and the retained facts units are re-aggregated (`FACTS_MERGE_REPAIR_PASSES`). Residual findings land in `facts_validation_findings` and the retrieval metrics. -Chunks detected as bibliography/reference lists are routed by `CHUNK_BIBLIOGRAPHY_MODE`: by default they are dropped before extraction (`skip`); `citations_only` yields citation metadata only (`schema:ScholarlyArticle` + `schema:citation`), never domain facts mined from citation titles. +Chunks detected as bibliography/reference lists are routed by `CHUNK_BIBLIOGRAPHY_MODE`: by default they are dropped before extraction (`skip`); `citations_only` yields citation metadata only (`schema:ScholarlyArticle` + `schema:citation`), never domain facts mined from citation titles. Front and back matter (author information, notes, licence, data availability, …) is routed the same way by `CHUNK_NON_CONTENT_MODE`. ![Facts loop](../assets/facts_loop.png) +The critique is **applied**, not described to a second call. The critic is +shown a numbered graph and cites statement ids; the loop resolves them by +lookup, screens what the deployment allows a pass to destroy, and applies the +result as a validated `GraphUpdate` with **no LLM call**. Both outcomes of the +critique go there — an accepted render's fixes are applied too, rather than +discarded — so accept/reject governs whether the unit may *leave*, not whether +the critique is worth acting on. + +Every mutation is still a compiled, validated `GraphUpdate`, exactly as a render +produces. What changed is that producing one no longer requires an LLM. + +A pass ends the loop early when it changed nothing or was rolled back, and is +undone whole if it deleted without writing, shrank the unit's product without +resolving anything, or created new mandatory findings. See +[Validation](validation.md#how-a-critique-reaches-the-graph). + +When `FACTS_COMPLETION_PASSES` is set above its default of `0`, an +insert-only **completion pass** runs once the critic loop above is done, +while the numeric-coverage inventory still lists a measurement — a number +with its unit — the render missed. Each pass proposes new subjects to add, +never a removal or a rewrite, and its inserts go through the same +per-subject regression check a critic fix goes through: an insert that +leaves the unit worse is rolled back on its own. See +[Validation](validation.md#completion-pass-insert-only-recovery). + Facts output uses the **`cd:` namespace** for text-derived instances; domain ontology IRIs are read-only schema and pre-declared reference individuals (see [Facts extraction model](concepts.md#facts-extraction-model)). Optional `facts_user_instruction` adds focus on top of these built-in guidelines. ### 6. Output @@ -169,10 +195,11 @@ Facts output uses the **`cd:` namespace** for text-derived instances; domain ont | `PARALLEL_WORKERS` | Max concurrent unit workers | | `LLM_MAX_INFLIGHT` | Max concurrent provider LLM requests (shared across units) | | `MAX_CONCURRENT_PROCESSES` | Optional cap on simultaneous `/process` pipelines | -| `MAX_VISITS` / `max_visits` | Render/critic retry budget per loop (at `1`, the default, the LLM critic never runs — the critic is skipped after the final render). In the facts loop it bounds render-*failure* retries: a rejecting critic no longer consumes an attempt | +| `MAX_VISITS` / `max_visits` | Retries of a **failed** fresh extraction, and nothing else. A render that succeeded is never repeated, and a rejecting critic does not consume an attempt | | `FACTS_ACCEPT_BLOCKING_SEVERITY` | Which critic fix severities block a facts unit from leaving the loop (`critical` default, or `important` / `never`). Mandatory deterministic findings always block — see [Validation](validation.md) | -| `MAX_CRITIC_VISITS_PER_NODE` | Critic attempts per render attempt. Unset couples it to `MAX_VISITS`; set to `1` for one critique per render. Only bites when the critic keeps requesting external evidence — see [Configuration](configuration.md) | -| `FACTS_LLM_REPAIR_VISITS` | Finding-driven repair budget per facts unit, **in provider calls**: bounded update renders driven by machine-found violations; fires even at `MAX_VISITS=1`, so the default costs up to two calls per unit. See [Validation](validation.md#how-many-llm-calls-a-facts-unit-really-costs) | +| `FACTS_CRITIC_PASSES` | Review-and-patch passes per facts unit, **in provider calls**. Each pass re-runs the deterministic checks for free, then buys one critique and applies it. `0` is extraction only. See [Validation](validation.md#how-many-llm-calls-a-facts-unit-really-costs) | +| `ONTOLOGY_CRITIC_PASSES` | The same for ontology units. Defaults to `0`: the ontology critic was previously unreachable at default settings, so enabling it is a real cost increase and is opt-in | +| `FACTS_CRITIC_MAX_DELETE_SHARE` / `_MIN_DELETES` / `_ALLOW_SUBJECT_RENAME` | Limits on what one pass may destroy — see [Validation](validation.md#what-a-pass-may-not-destroy) | | `FACTS_MERGE_REPAIR_PASSES` | Un-merge budget at the post-aggregation validation gate (merge-signature error findings → cluster pair vetoes → re-aggregation) | | `FACTS_SHACL_AUTOFIX` | LLM-free repair of SHACL violations at the gate: `off`, `rewrite`, or `prune` (default). See [Validation](validation.md#llm-free-autofix) | | `CHUNK_SEGMENTER` | `semantic` (sections-first, default) or `docling` structural segments | @@ -189,6 +216,8 @@ Facts output uses the **`cd:` namespace** for text-derived instances; domain ont | `ONTOLOGY_CONTEXT_MODE` | How per-unit ontology context is sourced; also gates the consistency critic — see [Configuration](configuration.md#ontology-context-mode-ontology_context_mode) | | `LLM_GRAPH_FORMAT` | LLM wire encoding: `jsonld` (default) or `turtle` (legacy) | | `--max-visits` | CLI override for `MAX_VISITS` (batch mode and server default) | +| `--ontology-dir` | CLI override for `ONTOCAST_ONTOLOGY_DIRECTORY`; empty string means "no seed ontologies" | +| `--shapes-dir` | CLI override for `FACTS_SHAPES_DIR`; empty string means "no seed shapes" | | `--wipe-vector-store` | Drop the current vector partition before recreate+reindex | | `--head-chunks` | CLI limit on units processed | | `target_sections` / `summarize_sections` / `summary_max_sentences` | Per-request structured-document preprocessing (not env vars) | @@ -200,7 +229,7 @@ Full reference: [Configuration System](configuration.md). 1. **Start with defaults** — `MAX_VISITS=1`, `ontology_and_facts`, consolidation off; tune after inspecting output. 2. **Use `--head-chunks`** for large documents during development. 3. **Monitor budget summaries** to estimate LLM cost at scale. -4. **Provide seed ontologies** in `ONTOCAST_ONTOLOGY_DIRECTORY` for catalog selection modes. +4. **Provide seed ontologies** in `ONTOCAST_ONTOLOGY_DIRECTORY` (or `--ontology-dir`) for catalog selection modes — or start with none and let an ontology-rendering run build the first one. 5. **Enable vector mode** only when Qdrant or LanceDB and embeddings are configured. ## Next Steps diff --git a/ontocast/agent/__init__.py b/ontocast/agent/__init__.py index f4f66411..224c9e2a 100644 --- a/ontocast/agent/__init__.py +++ b/ontocast/agent/__init__.py @@ -12,6 +12,7 @@ """ from .chunk_text import chunk_text +from .complete_facts import complete_facts from .convert_document import convert_document from .criticise_facts import criticise_facts from .criticise_ontology import criticise_ontology @@ -24,6 +25,7 @@ __all__ = [ "chunk_text", + "complete_facts", "convert_document", "criticise_facts", "criticise_ontology", diff --git a/ontocast/agent/chunk_text.py b/ontocast/agent/chunk_text.py index e940f336..7c542bf2 100644 --- a/ontocast/agent/chunk_text.py +++ b/ontocast/agent/chunk_text.py @@ -10,6 +10,7 @@ from ontocast.onto.enum import Status from ontocast.onto.state import AgentState from ontocast.tool.chunk.bibliography import is_bibliography_unit +from ontocast.tool.chunk.non_content import first_line, is_non_content_unit from ontocast.tool.chunk.prepare import PrepareOptions, prepare_content_units from ontocast.toolbox import ToolBox @@ -49,9 +50,26 @@ async def chunk_text(state: AgentState, tools: ToolBox) -> AgentState: ) bibliography_mode = tools.chunker.config.bibliography_mode + non_content_mode = tools.chunker.config.non_content_mode + min_unit_chars = tools.chunker.config.min_unit_chars skipped_bibliography = 0 + skipped_undersized = 0 + skipped_non_content = 0 index = 0 for chunk in prepared: + if min_unit_chars and len(chunk.text) < min_unit_chars: + # Same discipline as the bibliography route above: dropping a unit + # changes what can be extracted, so each decision is logged rather + # than only counted. + logger.info( + "Chunk (%d chars, section_label=%r) dropped as undersized " + "(CHUNK_MIN_UNIT_CHARS=%d)", + len(chunk.text), + chunk.section_label, + min_unit_chars, + ) + skipped_undersized += 1 + continue is_bibliography = bibliography_mode != "domain_facts" and is_bibliography_unit( chunk.text, chunk.section_label ) @@ -72,6 +90,26 @@ async def chunk_text(state: AgentState, tools: ToolBox) -> AgentState: if is_bibliography and bibliography_mode == "skip": skipped_bibliography += 1 continue + # A reference list already has its route; only prose-shaped units are + # tested for front/back matter. Same trace discipline: a false positive + # drops a section, so the decision is logged in both modes. + is_non_content = not is_bibliography and is_non_content_unit( + chunk.text, chunk.headings, chunk.section_label + ) + if is_non_content: + logger.info( + "Chunk %d (%d chars, section_label=%r, first line=%r) routed as " + "non-content (CHUNK_NON_CONTENT_MODE=%s): %s", + index, + len(chunk.text), + chunk.section_label, + first_line(chunk.text)[:60], + non_content_mode, + "dropped" if non_content_mode == "skip" else "kept, flagged", + ) + if is_non_content and non_content_mode == "skip": + skipped_non_content += 1 + continue state.content_units.append( ContentUnit( text=chunk.text, @@ -83,6 +121,7 @@ async def chunk_text(state: AgentState, tools: ToolBox) -> AgentState: section_label_source=chunk.section_label_source, section_label_confidence=chunk.section_label_confidence, is_citation_metadata=is_bibliography, + is_non_content=is_non_content, ) ) index += 1 @@ -91,6 +130,22 @@ async def chunk_text(state: AgentState, tools: ToolBox) -> AgentState: "Dropped %d bibliography chunk(s) (CHUNK_BIBLIOGRAPHY_MODE=skip)", skipped_bibliography, ) + if skipped_undersized: + logger.info( + "Dropped %d undersized chunk(s) (CHUNK_MIN_UNIT_CHARS=%d)", + skipped_undersized, + min_unit_chars, + ) + if skipped_non_content: + logger.info( + "Dropped %d non-content chunk(s) (CHUNK_NON_CONTENT_MODE=skip)", + skipped_non_content, + ) + # Recorded on the state so the run manifest's selection block can say how + # many units each routing knob removed, not only how many survived. + state.bibliography_units_skipped = skipped_bibliography + state.undersized_units_skipped = skipped_undersized + state.non_content_units_skipped = skipped_non_content logger.info( "Created %s content units: %s", diff --git a/ontocast/agent/common.py b/ontocast/agent/common.py index 7b813989..3a0c006d 100644 --- a/ontocast/agent/common.py +++ b/ontocast/agent/common.py @@ -15,9 +15,6 @@ suggestion_concrete_template, suggestion_general_template, ) -from ontocast.prompt.render_facts import ( - improvement_instruction_template as facts_template, -) from ontocast.prompt.render_ontology import ( improvement_instruction_template as ontology_template, ) @@ -368,12 +365,9 @@ def render_suggestions_prompt(suggestions: Suggestions, stage: WorkflowNode) -> suggestion_str=suggestions.to_markdown() ) - if stage == WorkflowNode.TEXT_TO_FACTS: - template = facts_template - elif stage == WorkflowNode.TEXT_TO_ONTOLOGY: - template = ontology_template - else: + if stage != WorkflowNode.TEXT_TO_ONTOLOGY: raise ValueError(f"Stage {stage} not supported") + template = ontology_template if general_template or concrete_template: final_prompt = template.format( suggestions_instruction=f"\n\n{general_template}\n\n{concrete_template}" @@ -412,6 +406,14 @@ async def call_llm_with_retry( visits — so a single identical re-issue (per outer call) is allowed before the timeout propagates. + :class:`~ontocast.tool.llm.LLMConfigurationError` propagates on the first + occurrence and consumes nothing: it is deliberately not a subclass of + ``LLMRequestTimeoutError``, and the provider call sits outside the parse + ``try`` below, so a request the provider refuses is never re-sent with + parse-error feedback attached and never spends the timeout re-issue. Both + properties come from statement placement rather than an explicit clause; + test_llm_resilience.py pins them. + Retries back off exponentially with jitter, so N units failing to parse simultaneously do not re-issue in lockstep. diff --git a/ontocast/agent/complete_facts.py b/ontocast/agent/complete_facts.py new file mode 100644 index 00000000..18d8dd4d --- /dev/null +++ b/ontocast/agent/complete_facts.py @@ -0,0 +1,175 @@ +"""Insert-only facts completion agent. + +Runs after the facts render/critic loop, only when the numeric-coverage +inventory still lists measurements the render missed (see +:func:`ontocast.tool.facts_validation.unit_findings.unit_numeric_inventory`). +Mirrors :mod:`ontocast.agent.criticise_facts` in shape -- one LLM call parsed +into :class:`~ontocast.onto.model.TripleFix` fixes -- but proposes insertions +instead of judging what is already there. + +The fixes returned here are not applied by this module: the unit loop +(``ontocast.stategraph.atomic``) compiles and applies them through +``compile_critic_fixes`` / ``_apply_patches``, the same per-subject +regression check a critic fix goes through, so a fix that leaves the unit +worse is rolled back exactly the way a bad critic fix would be. +""" + +import logging + +from langchain_core.output_parsers import PydanticOutputParser +from langchain_core.prompts import PromptTemplate + +from ontocast.agent.common import call_llm_with_retry +from ontocast.onto.model import FactsCompletionReport, TripleFix +from ontocast.onto.unit_states import UnitFactsState +from ontocast.prompt.common import text_template, user_template +from ontocast.prompt.complete_facts import ( + build_catalog_subjects_chapter, + build_missing_measurements_chapter, + build_term_sheet, + completion_instruction, + output_instruction_for, + preamble, + template_prompt, +) +from ontocast.prompt.graph_format import get_graph_format_profile +from ontocast.tool.atomic import AtomicToolBox +from ontocast.tool.facts_validation import ( + collect_catalog_terms, + expand_vocabulary_terms, +) +from ontocast.tool.llm import LLMConfigurationError +from ontocast.util.numeric_inventory import NumericInventory + +logger = logging.getLogger(__name__) + + +def _unit_role_property(atomic: AtomicToolBox, *graphs) -> set[str]: + """The configured unit-role property, expanded to an IRI, or empty. + + ``atomic.quantity_fallback_vocabulary`` names the deployment's roles for + bounded quantities (``value_class``, ``numeric_value``, ``unit``); only + ``unit`` locates unit individuals, so the other roles are dropped before + expansion rather than reused from ``expand_vocabulary_terms`` verbatim. + """ + vocabulary = atomic.quantity_fallback_vocabulary or {} + unit_term = vocabulary.get("unit") + if not unit_term: + return set() + return expand_vocabulary_terms({"unit": unit_term}, *graphs) + + +def _build_prompt() -> PromptTemplate: + return PromptTemplate( + template=template_prompt, + input_variables=[ + "preamble", + "conformance_chapter", + "term_sheet", + "catalog_subjects_chapter", + "completion_instruction", + "user_instruction", + "text_chapter", + "missing_measurements_chapter", + "output_instruction", + "format_instructions", + ], + ) + + +async def complete_facts( + state: UnitFactsState, + atomic: AtomicToolBox, + inventory: NumericInventory, +) -> list[TripleFix]: + """Propose insert-only fixes recovering measurements the render missed. + + Args: + state: The unit's current facts state. Read-only here -- the unit + loop applies whatever this returns. + atomic: Toolbox for the LLM call and the unit's quantity vocabulary. + inventory: The unit's missing-measurement inventory, already computed + by the caller so the completion pass and the NUMERIC_COVERAGE + finding agree on what is missing. An empty inventory short- + circuits with no call. + + Returns: + Proposed fixes, action ``ADD`` only. Anything else the model returns + is dropped defensively rather than trusted -- this pass is + insert-only by contract, not by the model's cooperation. + """ + if not inventory.measurements: + return [] + + llm_tool = await atomic.get_llm_tool(state.budget_tracker) + profile = get_graph_format_profile( + state.llm_graph_format, + ontology_chapter_format=state.ontology_chapter_format, + ) + parser = PydanticOutputParser(pydantic_object=FactsCompletionReport) + + ontology_graph = state.ontology_snapshot.graph + fact_graph = state.content_unit.graph + + unit_properties = _unit_role_property(atomic, ontology_graph, fact_graph) + term_sheet = build_term_sheet(ontology_graph, unit_properties) + catalog_subjects_chapter = build_catalog_subjects_chapter( + fact_graph, collect_catalog_terms(ontology_graph) + ) + missing_measurements_chapter = build_missing_measurements_chapter(inventory) + + user_instruction = ( + user_template.format(user_instruction=state.facts_user_instruction) + if state.facts_user_instruction + else "" + ) + text_chapter = text_template.format(text=state.content_unit.extraction_text) + + prompt_data = { + "preamble": preamble, + "conformance_chapter": state.conformance_chapter, + "term_sheet": term_sheet, + "catalog_subjects_chapter": catalog_subjects_chapter, + "completion_instruction": completion_instruction, + "user_instruction": user_instruction, + "text_chapter": text_chapter, + "missing_measurements_chapter": missing_measurements_chapter, + "output_instruction": output_instruction_for(profile.format), + "format_instructions": profile.format_instructions( + FactsCompletionReport, web_search_enabled=False + ), + } + + try: + report: FactsCompletionReport = await call_llm_with_retry( + llm_tool=llm_tool, + prompt=_build_prompt(), + parser=parser, + prompt_kwargs=prompt_data, + llm_graph_format=state.llm_graph_format, + ) + except LLMConfigurationError: + # Best-effort is the right call for a failed completion pass, but + # not for a request the provider will never accept: swallowing it + # here hides the fault behind a pass that is allowed to add + # nothing. + raise + except Exception as exc: + # A failed completion call costs nothing beyond itself: the render + # and critic loop already stand, accepted or not, and this pass only + # ever adds to it. Propagating would fail the whole unit for a + # best-effort improvement pass that never touched its graph. + logger.warning("Facts completion pass call failed: %s", exc) + return [] + + fixes: list[TripleFix] = [] + for fix in report.proposed_fixes: + if fix.action != "ADD": + logger.warning( + "Completion pass proposed a %s fix; dropping it -- this pass " + "is insert-only and the model's action choice is not trusted", + fix.action, + ) + continue + fixes.append(fix) + return fixes diff --git a/ontocast/agent/criticise_facts.py b/ontocast/agent/criticise_facts.py index f1ebb455..51579855 100644 --- a/ontocast/agent/criticise_facts.py +++ b/ontocast/agent/criticise_facts.py @@ -33,6 +33,7 @@ accept_reason, material_defects, ) +from ontocast.tool.llm import LLMConfigurationError logger = logging.getLogger(__name__) @@ -81,7 +82,13 @@ async def criticise_facts( ) llm_tool = await tools.get_llm_tool(state.budget_tracker) - profile = get_graph_format_profile(state.llm_graph_format) + # Same profile the renderer used, chapter override included: the memoised + # ontology chapter is only shared between the two calls when both ask for + # the same syntax. + profile = get_graph_format_profile( + state.llm_graph_format, + ontology_chapter_format=state.ontology_chapter_format, + ) parser = PydanticOutputParser(pydantic_object=FactsCritiqueReport) ctx = ontology_access_for_unit_facts(state).effective_ontology_for_prompt() @@ -91,11 +98,17 @@ async def criticise_facts( # judged term choices it could not read. Also memoised on the shared # snapshot, so this stops re-serialising the ontology on every visit. ontology_chapter = ctx.prompt_chapter( - profile, max_triples=state.ontology_context_max_triples + profile, + max_triples=state.ontology_context_max_triples, + text_caps=state.ontology_text_caps, ) - facts_chapter = profile.format_facts_chapter( - state.content_unit.graph - ) + _build_quarantine_chapter(state) + # Every statement gets a citable id, and the index is kept on the state so + # the fixes that come back can be resolved by lookup. The critic used to be + # asked to requote the statements it wanted changed, which it reproduces + # correctly only a minority of the time -- for a bare removal, almost never. + indexed_facts = profile.format_facts_chapter_indexed(state.content_unit.graph) + state.prompt_triple_index = indexed_facts.index + facts_chapter = indexed_facts.text + _build_quarantine_chapter(state) text_chapter = text_template.format(text=state.content_unit.extraction_text) @@ -112,6 +125,7 @@ async def criticise_facts( "evaluation_instruction", "user_instruction", "ontology_chapter", + "conformance_chapter", "facts_chapter", "text_chapter", "graph_format_instruction", @@ -135,6 +149,9 @@ async def criticise_facts( "evaluation_instruction": evaluation_instruction_str, "user_instruction": user_instruction, "ontology_chapter": ontology_chapter, + # Same rulebook the gate validates against; critique and render + # share one contract. + "conformance_chapter": state.conformance_chapter, "facts_chapter": facts_chapter, "text_chapter": text_chapter, "graph_format_instruction": graph_format_instruction, @@ -158,6 +175,7 @@ async def criticise_facts( critique.external_evidence_request, web_search_enabled, ) + state.critic_outcome = "reviewed" logger.debug( f"Parsed critique report - success: {critique.success}, " @@ -188,6 +206,10 @@ async def criticise_facts( severity_counts=Counter( fix.severity for fix in critique.actionable_triple_fixes ), + action_severity_counts=Counter( + f"{fix.action}:{fix.severity}" + for fix in critique.actionable_triple_fixes + ), n_deterministic_findings=len(state.deterministic_findings), n_mandatory_findings=sum( 1 for finding in state.deterministic_findings if finding.mandatory @@ -199,13 +221,13 @@ async def criticise_facts( if not defects: state.status = Status.SUCCESS state.set_node_status(WorkflowNode.CRITICISE_FACTS, Status.SUCCESS) - # An accepting critic has no outstanding requests. Clearing here is - # not redundant with the reset in render_facts_update: the loop can - # accept on a *later* critic attempt of the same render (after an - # external-evidence search), with no render in between to consume - # the suggestions the earlier, rejecting attempt left behind. The - # finding-driven repair then runs next, and must see only findings. - state.suggestions = Suggestions() + # Accepting means "no defect worth another render", NOT "the + # critique was empty". The fixes are kept: the repair lane compiles + # the mechanical ones for free and records the rest as residual. + # Clearing them here used to discard the entire critique of every + # accepted render -- the bulk of everything the critic produced, + # since a REMOVE fix can never make a render blocking. + state.suggestions = Suggestions.from_critique_report(critique) logger.info( "Facts critique passed (score %s, no material defect)", critique.score, @@ -225,8 +247,34 @@ async def criticise_facts( return state + except LLMConfigurationError: + # A rejected request is not a critic that failed to answer: the + # next unit's critic will be rejected the same way. + raise except Exception as e: + # A critic that did not answer -- timeout, transport error, a response + # that never parsed -- is not a critic that accepted. The unit leaves + # the loop FAILED at the critique stage with its render intact, and + # the attempt is on the record as a billed call that produced no + # critique; the loop reads ``critic_outcome`` and applies no patch. logger.error(f"Failed to criticize facts: {str(e)}") + state.critic_outcome = "unavailable" + state.attempt_log.append( + LoopAttempt( + render_attempt=state.node_visits[WorkflowNode.TEXT_TO_FACTS], + critic_attempt=state.node_visits[WorkflowNode.CRITICISE_FACTS], + kind="critic", + success=False, + accept_reason="critic_unavailable", + failure_stage=str(FailureStage.FACTS_CRITIQUE), + failure_reason=str(e), + n_deterministic_findings=len(state.deterministic_findings), + n_mandatory_findings=sum( + 1 for finding in state.deterministic_findings if finding.mandatory + ), + triple_count=len(state.content_unit.graph), + ) + ) state.set_failure(FailureStage.FACTS_CRITIQUE, str(e)) state.set_node_status(WorkflowNode.CRITICISE_FACTS, Status.FAILED) return state diff --git a/ontocast/agent/criticise_ontology.py b/ontocast/agent/criticise_ontology.py index 784ab7c0..0a0f9e85 100644 --- a/ontocast/agent/criticise_ontology.py +++ b/ontocast/agent/criticise_ontology.py @@ -40,6 +40,8 @@ from ontocast.prompt.web_grounding import persist_search_request, search_guidelines_for from ontocast.tool import LLMTool from ontocast.tool.atomic import AtomicToolBox +from ontocast.tool.facts_validation import accept_reason, material_defects +from ontocast.tool.llm import LLMConfigurationError from ontocast.tool.ontology_validation import count_fixes_targeting_snapshot logger = logging.getLogger(__name__) @@ -82,11 +84,18 @@ async def criticise_ontology( # With the index appendix, as the renderer sends it: a critic shown bare # opaque IRIs cannot judge the term choices it is asked about. No memo here # -- effective_graph_for_prompt returns a bare graph, not a snapshot. - ontology_chapter = profile.format_ontology_chapter( + # Ids are scoped to this unit's own delta. The chapter still shows the + # retrieved catalog -- the critic cannot judge a term choice without it -- + # but those statements carry no id, so a delete that would propagate onto a + # shared, versioned terminal is not expressible rather than merely flagged. + indexed_ontology = profile.format_ontology_chapter_indexed( current_graph, + scope=state.build_delta().inserts, suffix=build_ontology_index(current_graph), max_triples=state.ontology_context_max_triples, ) + state.prompt_triple_index = indexed_ontology.index + ontology_chapter = indexed_ontology.text if state.deterministic_findings: # Machine-found delta defects, presented exactly the way the facts # critic receives its findings block. @@ -169,13 +178,24 @@ async def criticise_ontology( f"score: {critique.score}, n fixes: {len(critique.actionable_ontology_fixes)}." ) - # Incumbent gate, deliberately unchanged: `success or score > 90` is - # the top band of the prompt's own scoring rubric ("Excellent - minor - # refinements only"). Whether that demand for perfection is the right - # operating point is exactly what this record exists to measure -- the - # ontology critic has never run on recorded data, so unlike the - # facts gate there is no distribution to replace it from yet. - accepted = critique.success or critique.score > 90 + # The gate is the deterministic findings, as on the facts side. The + # incumbent rule was `success or score > 90` -- the top band of the + # prompt's own rubric, which calls 70-89 "Good", so it rejected + # ontologies its own instructions considered good. Its verdict is still + # recorded, because replacing a gate deserves a distribution rather than + # an argument, and the ontology critic has never run on recorded data. + # + # The blocking set is the destructive-or-lossy subset only. Blocking on + # every mandatory finding would include `missing_label`, which fires + # whenever a render mints an unlabelled term: routine, and a permanent + # per-unit tax rather than a defect signal. + incumbent_accepted = critique.success or critique.score > 90 + defects = material_defects( + state.deterministic_findings, + critique.actionable_ontology_fixes, + tools.ontology_acceptance_policy, + ) + accepted = not defects delta = state.build_delta() state.attempt_log.append( LoopAttempt( @@ -184,13 +204,8 @@ async def criticise_ontology( kind="critic", score=critique.score, success=accepted, - accept_reason=( - "incumbent_success" - if critique.success - else "incumbent_score" - if critique.score > 90 - else "incumbent_rejected" - ), + accept_reason=accept_reason(defects), + incumbent_accepted=incumbent_accepted, n_actionable_fixes=len(critique.actionable_ontology_fixes), severity_counts=Counter( fix.severity for fix in critique.actionable_ontology_fixes @@ -218,24 +233,30 @@ async def criticise_ontology( if accepted: state.status = Status.SUCCESS state.set_node_status(WorkflowNode.CRITICISE_ONTOLOGY, Status.SUCCESS) - # An accepting critic has no outstanding requests. Not redundant - # with the reset in render_ontology_update: the loop can accept on a - # *later* critic attempt of the same render, after an - # external-evidence search, with no render in between to consume - # what the earlier rejecting attempt left behind. - state.suggestions = Suggestions() + # Kept, not cleared. Acceptance decides whether the unit may leave + # the loop; it does not decide whether the critique is worth + # applying. Clearing here discarded every fix attached to an + # accepted render -- which, since a REMOVE can never by itself + # cause a rejection, was most of them. + state.suggestions = Suggestions.from_critique_report(critique) logger.info("Ontology critique passed") else: state.status = Status.FAILED state.failure_stage = FailureStage.ONTOLOGY_CRITIQUE state.set_node_status(WorkflowNode.CRITICISE_ONTOLOGY, Status.FAILED) state.suggestions = Suggestions.from_critique_report(critique) - state.failure_reason = "Ontology Critic suggests improvements" + state.failure_reason = ( + f"Ontology unit has {len(defects)} material defect(s)" + ) logger.info( f"Ontology critique failed: {critique.systemic_critique_summary}" ) return state + except LLMConfigurationError: + # A rejected request is the deployment, not this critique: every + # other unit is about to be rejected identically. + raise except Exception as e: logger.error(f"Failed to critique ontology: {str(e)}") state.set_failure(FailureStage.ONTOLOGY_CRITIQUE, str(e)) diff --git a/ontocast/agent/external_evidence.py b/ontocast/agent/external_evidence.py index 689bb7bd..4e55fefd 100644 --- a/ontocast/agent/external_evidence.py +++ b/ontocast/agent/external_evidence.py @@ -17,6 +17,7 @@ ) from ontocast.onto.unit_states import UnitFactsState, UnitOntologyState from ontocast.tool.atomic import AtomicToolBox, SearchHit +from ontocast.tool.llm import LLMConfigurationError logger = logging.getLogger(__name__) UnitStateT = TypeVar("UnitStateT", UnitFactsState, UnitOntologyState) @@ -274,6 +275,11 @@ async def plan_external_evidence_for_node( "format_instructions": parser.get_format_instructions(), }, ) + except LLMConfigurationError: + # Skipping external evidence is a sound fallback for a planner + # that failed; it is not one for a provider that rejects every + # request, which the rest of the unit is about to hit too. + raise except Exception as error: logger.warning( "Evidence planner failed for %s; skipping external evidence (%s).", diff --git a/ontocast/agent/render_facts.py b/ontocast/agent/render_facts.py index c3ac4341..37fc4746 100644 --- a/ontocast/agent/render_facts.py +++ b/ontocast/agent/render_facts.py @@ -12,16 +12,12 @@ from langchain_core.output_parsers import PydanticOutputParser from langchain_core.prompts import PromptTemplate -from ontocast.agent.common import call_llm_with_retry, render_suggestions_prompt -from ontocast.agent.update_common import finalize_update_report, log_quarantine +from ontocast.agent.common import call_llm_with_retry from ontocast.onto.constants import DEFAULT_IRI from ontocast.onto.enum import FailureStage, Status, WorkflowNode from ontocast.onto.model import ( FactsRenderReport, GraphRepairRecord, - GraphUpdateRenderReport, - Suggestions, - format_findings_for_prompt, ) from ontocast.onto.ontology import Ontology from ontocast.onto.ontology_access import ( @@ -29,9 +25,9 @@ build_llm_prefix_map, ontology_access_for_unit_facts, ) +from ontocast.onto.ontology_condense import CondenseReport from ontocast.onto.rdfgraph import ( RDFGraph, - RejectedLiteralTriple, finalize_llm_graph, ) from ontocast.onto.state import BudgetTracker @@ -57,6 +53,7 @@ repair_property_aliases, resolve_code_literals, ) +from ontocast.tool.llm import LLMConfigurationError from ontocast.tool.validate import partition_object_property_literal_triples logger = logging.getLogger(__name__) @@ -77,14 +74,17 @@ def _normalize_and_repair_graph( ``qudt:numericValue``), and links nodes to the catalog individual whose code they carry (``qudt:ucumCode "d"`` -> ``qudt:unit unit:DAY``). Ambiguous near-misses and unresolvable type literals are left for findings - collection. + collection. The alias rewrite checks membership against the scope's *full* + catalog, not only the unit's snapshot: a predicate the catalog declares + but retrieval did not bring into this unit's context is a real term and + is never rewritten toward a look-alike the snapshot happens to carry. Args: graph: Rendered facts graph, repaired in place. ontology_context_graph: Read-only schema the repairs are checked against. - tools: Supplies the alias-rewrite similarity floor, code predicates, - and the quantity fallback vocabulary (alias exemptions and the - degenerate-bound promotion roles). + tools: Supplies the alias tie-break floor, the full-catalog term set, + code predicates, and the quantity fallback vocabulary (alias + exemptions and the degenerate-bound promotion roles). budget_tracker: Charged ``"repair/deterministic"``. Both scans here walk the whole ontology graph per call, so this is timed to show how much of it is per-unit-invariant work. @@ -102,6 +102,7 @@ def _normalize_and_repair_graph( exempt_terms=expand_vocabulary_terms( tools.quantity_fallback_vocabulary, graph, ontology_context_graph ), + full_catalog_terms=tools.catalog_terms(), ) resolved, code_records = resolve_code_literals( graph, ontology_context_graph, tools.code_predicates @@ -125,43 +126,61 @@ def _normalize_and_repair_graph( return graph, [*type_records, *alias_records, *code_records] -def _findings_instruction(state: UnitFactsState) -> str: - """Render pending deterministic findings as a prompt block, if any.""" - if not state.deterministic_findings: - return "" - return "\n\n" + format_findings_for_prompt(state.deterministic_findings) - - async def render_facts( state: UnitFactsState, tools: AtomicToolBox, supplemental_ontologies: Sequence[Ontology] | None = None, ) -> UnitFactsState: - """Structured hybrid facts renderer: fresh Turtle or structured graph updates. + """Extract a unit's facts from its text. + + There is one renderer because there is one render. The loop retries this + only when it *fails*; a unit that rendered successfully is improved by the + critic passes, which apply a compiled patch rather than asking for the graph + to be written again. The update-mode renderer this used to dispatch to had + become unreachable: it keyed on the unit graph being non-empty, and nothing + populates that except a successful render, after which the render loop is + already done. - This function decides between generating bare Turtle for fresh facts - and structured TripleOp graph patches for updates based on whether facts exist. + The ontology renderer still has both modes, and for a reason that does not + apply here: it keys on the retrieved *snapshot*, a different field from the + one it writes, so its update mode is the normal path whenever a catalog + exists. Args: - state: The current unit facts state - tools: The toolbox containing necessary tools + state: The current unit facts state. + tools: The toolbox containing necessary tools. + supplemental_ontologies: Extra ontologies for prefix resolution. Returns: - UnitFactsState: Updated state with rendered facts + UnitFactsState: Updated state with rendered facts. """ + logger.info(f"Render facts for {state.get_content_unit_progress_string()}") + return await render_facts_fresh( + state, tools, supplemental_ontologies=list(supplemental_ontologies or ()) + ) - is_fresh_facts_graph = len(state.content_unit.graph) == 0 - progress_info = state.get_content_unit_progress_string() - logger.info(f"Render facts for {progress_info}") +def _record_chapter_report( + report: CondenseReport, budget_tracker: BudgetTracker | None +) -> None: + """Publish what condensing and capping did to the ontology chapter. - extras = list(supplemental_ontologies or ()) - if is_fresh_facts_graph: - logger.info("Generating fresh facts as Turtle") - return await render_facts_fresh(state, tools, supplemental_ontologies=extras) - else: - logger.info("Generating facts update") - return await render_facts_update(state, tools, supplemental_ontologies=extras) + Fires once per chapter actually built, not once per call that reads it: the + snapshot is shared across the fan-out and the chapter is memoised on it, so + these describe the chapter, not the traffic. + + Sizing a text cap is not something a default can do in advance -- whether a + catalog reaches one is a property of that catalog. So the counters carry the + before/after even when nothing was clipped, which is the case that tells a + deployment its caps are inert. + """ + if budget_tracker is None or not report.text_chars_before: + return + budget_tracker.incr("chapter/text_chars_before", report.text_chars_before) + budget_tracker.incr("chapter/text_chars_after", report.text_chars_after) + budget_tracker.incr("chapter/literals_clipped", report.literals_clipped) + if report.text_over_budget: + budget_tracker.incr("chapter/text_over_budget") def _prepare_prompt_data( @@ -201,13 +220,26 @@ def _prepare_prompt_data( # Memoised on the snapshot, which the whole fan-out shares: serialising # the same ontology once per unit dominated facts prompt construction. ontology_chapter = ctx.prompt_chapter( - profile, max_triples=state.ontology_context_max_triples + profile, + max_triples=state.ontology_context_max_triples, + text_caps=state.ontology_text_caps, + on_report=lambda report: _record_chapter_report( + report, state.budget_tracker + ), ) else: ontology_chapter = profile.format_ontology_chapter( ontology_graph, - suffix=build_ontology_index(ontology_graph), + suffix=( + "" + if profile.renders_term_sheet + else build_ontology_index(ontology_graph) + ), max_triples=state.ontology_context_max_triples, + text_caps=state.ontology_text_caps, + on_report=lambda report: _record_chapter_report( + report, state.budget_tracker + ), ) state.budget_tracker.add_duration( "prompt/ontology_chapter", time.perf_counter() - chapter_start @@ -222,8 +254,6 @@ def _prepare_prompt_data( text_chapter = text_template.format(text=state.content_unit.extraction_text) - fact_chapter = "" - user_instruction = ( user_template.format(user_instruction=state.facts_user_instruction) if state.facts_user_instruction @@ -237,10 +267,12 @@ def _prepare_prompt_data( return { "ontology_chapter": ontology_chapter, + # Rendered once per tenancy from the shapes partition; "" for + # shape-less deployments, keeping their prompt byte-identical. + "conformance_chapter": state.conformance_chapter, "user_instruction": user_instruction, "facts_instruction": facts_instruction_str, "text_chapter": text_chapter, - "fact_chapter": fact_chapter, } @@ -257,8 +289,8 @@ def _create_prompt_template() -> PromptTemplate: "facts_instruction", "user_instruction", "ontology_chapter", + "conformance_chapter", "text_chapter", - "improvement_instruction", "output_instruction", "format_instructions", ], @@ -301,7 +333,10 @@ async def render_facts_fresh( logger.info("Rendering fresh facts") state.quarantined_literal_triples = [] llm_tool = await tools.get_llm_tool(state.budget_tracker) - profile = get_graph_format_profile(state.llm_graph_format) + profile = get_graph_format_profile( + state.llm_graph_format, + ontology_chapter_format=state.ontology_chapter_format, + ) parser = PydanticOutputParser(pydantic_object=FactsRenderReport) access = ontology_access_for_unit_facts(state) @@ -326,13 +361,13 @@ async def render_facts_fresh( ) prompt_data_fresh = { "preamble": preamble, - "improvement_instruction": "", "output_instruction": profile.render_fresh_output_instruction(target="facts"), } prompt_data.update(prompt_data_fresh) prompt = _create_prompt_template() + previous_prefixes = RDFGraph.get_known_prefixes() try: # Set known prefixes in context before parsing RDFGraph.set_known_prefixes(known_prefixes if known_prefixes else None) @@ -388,140 +423,16 @@ async def render_facts_fresh( state.set_node_status(WorkflowNode.TEXT_TO_FACTS, Status.SUCCESS) return state + except LLMConfigurationError: + # The provider rejects the request itself, not this attempt at + # it: every other unit is about to be rejected identically. + # Failing one unit here turns a configuration fault into an + # empty run that reports success. + raise except Exception as e: return _handle_rendering_error(state, e, FailureStage.GENERATE_TTL_FOR_FACTS) finally: - # Clear the context after parsing - RDFGraph.set_known_prefixes(None) - - -async def render_facts_update( - state: UnitFactsState, - tools: AtomicToolBox, - supplemental_ontologies: Sequence[Ontology] | None = None, -) -> UnitFactsState: - """Render facts updates using structured graph patch operations. - - Args: - state: The current unit facts state containing the chunk to render. - tools: The toolbox instance providing utility functions. - - Returns: - UnitFactsState: Updated state with rendered facts. - """ - logger.info("Rendering updates for facts") - state.quarantined_literal_triples = [] - llm_tool = await tools.get_llm_tool(state.budget_tracker) - profile = get_graph_format_profile(state.llm_graph_format) - parser = PydanticOutputParser(pydantic_object=GraphUpdateRenderReport) - - access = ontology_access_for_unit_facts(state) - web_search_enabled = tools.web_grounding_enabled_for_node( - WorkflowNode.TEXT_TO_FACTS - ) - prompt_data = _prepare_prompt_data( - state, - access, - profile, - citation_vocabulary=tools.citation_vocabulary, - quantity_fallback_vocabulary=tools.quantity_fallback_vocabulary, - search_guidelines=search_guidelines_for( - WorkflowNode.TEXT_TO_FACTS, web_search_enabled - ), - ) - prompt_data_update = { - "preamble": preamble, - "improvement_instruction": render_suggestions_prompt( - state.suggestions, WorkflowNode.TEXT_TO_FACTS - ) - + _findings_instruction(state), - "output_instruction": profile.render_update_output_instruction(), - "fact_chapter": profile.format_facts_chapter(state.content_unit.graph), - } - prompt_data.update(prompt_data_update) - prompt = _create_prompt_template() - known_prefixes = build_llm_prefix_map( - access.ontology_for_prefixes(), - supplemental_ontologies or (), - ) - - try: - # Set known prefixes in context before parsing - RDFGraph.set_known_prefixes(known_prefixes if known_prefixes else None) - - render_report: GraphUpdateRenderReport = await call_llm_with_retry( - llm_tool=llm_tool, - prompt=prompt, - parser=parser, - prompt_kwargs={ - "format_instructions": profile.format_instructions( - GraphUpdateRenderReport, - web_search_enabled=web_search_enabled, - ), - **prompt_data, - }, - llm_graph_format=state.llm_graph_format, - ) - persist_search_request( - state, - WorkflowNode.TEXT_TO_FACTS, - render_report.external_evidence_request, - web_search_enabled, - ) - ontology_context_graph = access.effective_ontology_for_prompt().graph - - def repair_inserts( - graph: RDFGraph, - ) -> tuple[RDFGraph, list[RejectedLiteralTriple]]: - graph, repair_records = _normalize_and_repair_graph( - graph, - ontology_context_graph, - tools, - budget_tracker=state.budget_tracker, - ) - state.applied_repairs.extend(repair_records) - if not tools.object_property_literal_check: - return graph, [] - return partition_object_property_literal_triples( - graph, ontology_context_graph - ) - - graph_update, rejected = finalize_update_report( - render_report, insert_hook=repair_inserts - ) - state.quarantined_literal_triples = rejected - log_quarantine("Facts", rejected) - state.facts_updates.append(graph_update) - state.update_facts() - # Findings were consumed by this render; the loop re-collects fresh. - state.deterministic_findings = [] - # Critic suggestions are consumed by exactly the render they were - # raised against. Leaving them set carried them into every later render - # of the unit -- including the finding-driven repair, which then ran - # under two contradictory contracts at once: the improvement template's - # "think independently, proactively fix additional problems" and the - # findings block's "apply every item, rewrite in place, never delete". - # Reachable only at MAX_VISITS >= 2, which is where the observed loss - # of graph connectivity and gain in validation errors came from. - state.suggestions = Suggestions() - - num_operations, num_triples = graph_update.count_total_triples() - logger.info( - f"Facts update has {num_operations} operation(s) " - f"with {num_triples} total triple(s)." - ) - - # Track triples in budget tracker - state.budget_tracker.add_facts_update(num_operations, num_triples) - - state.set_node_status(WorkflowNode.TEXT_TO_FACTS, Status.SUCCESS) - state.clear_failure() - return state - - except Exception as e: - return _handle_rendering_error( - state, e, FailureStage.GENERATE_GRAPH_UPDATE_FOR_FACTS - ) - finally: - # Clear the context after parsing - RDFGraph.set_known_prefixes(None) + # Restore the loop-level catalog map (or whatever was there), rather + # than clearing to None — the critic and completion passes still need + # it after this render returns. + RDFGraph.set_known_prefixes(previous_prefixes) diff --git a/ontocast/agent/render_ontology.py b/ontocast/agent/render_ontology.py index acb86446..e06b667b 100644 --- a/ontocast/agent/render_ontology.py +++ b/ontocast/agent/render_ontology.py @@ -45,6 +45,7 @@ ) from ontocast.prompt.web_grounding import persist_search_request, search_guidelines_for from ontocast.tool.atomic import AtomicToolBox +from ontocast.tool.llm import LLMConfigurationError logger = logging.getLogger(__name__) @@ -175,6 +176,7 @@ async def render_ontology_fresh( supplemental_ontologies or (), ) + previous_prefixes = RDFGraph.get_known_prefixes() try: RDFGraph.set_known_prefixes(known_prefixes if known_prefixes else None) llm_tool = await tools.get_llm_tool(state.budget_tracker) @@ -224,12 +226,18 @@ async def render_ontology_fresh( state.set_node_status(WorkflowNode.TEXT_TO_ONTOLOGY, Status.SUCCESS) return state + except LLMConfigurationError: + # The provider rejects the request itself, not this attempt at + # it: every other unit is about to be rejected identically. + # Failing one unit here turns a configuration fault into an + # empty run that reports success. + raise except Exception as e: return _handle_ontology_render_error( state, e, FailureStage.GENERATE_TTL_FOR_ONTOLOGY ) finally: - RDFGraph.set_known_prefixes(None) + RDFGraph.set_known_prefixes(previous_prefixes) async def render_ontology_update( @@ -274,6 +282,7 @@ async def render_ontology_update( supplemental_ontologies or (), ) + previous_prefixes = RDFGraph.get_known_prefixes() try: llm_tool = await tools.get_llm_tool(state.budget_tracker) RDFGraph.set_known_prefixes(known_prefixes if known_prefixes else None) @@ -349,9 +358,15 @@ async def render_ontology_update( state.set_node_status(WorkflowNode.TEXT_TO_ONTOLOGY, Status.SUCCESS) return state + except LLMConfigurationError: + # The provider rejects the request itself, not this attempt at + # it: every other unit is about to be rejected identically. + # Failing one unit here turns a configuration fault into an + # empty run that reports success. + raise except Exception as e: return _handle_ontology_render_error( state, e, FailureStage.GENERATE_GRAPH_UPDATE_FOR_ONTOLOGY ) finally: - RDFGraph.set_known_prefixes(None) + RDFGraph.set_known_prefixes(previous_prefixes) diff --git a/ontocast/agent/summarize_chunks.py b/ontocast/agent/summarize_chunks.py index e1d60a41..a82190ac 100644 --- a/ontocast/agent/summarize_chunks.py +++ b/ontocast/agent/summarize_chunks.py @@ -6,7 +6,7 @@ from langchain_core.prompts import ChatPromptTemplate from ontocast.onto.content_unit import ContentUnit -from ontocast.tool.llm import use_budget_tracker +from ontocast.tool.llm import LLMConfigurationError, use_budget_tracker from ontocast.toolbox import ToolBox logger = logging.getLogger(__name__) @@ -64,6 +64,10 @@ async def ensure_unit_summary( max_sentences=state.summary_max_sentences, budget_tracker=budget_tracker, ) + except LLMConfigurationError: + # A unit without a summary is survivable; a run whose every call + # is rejected is not, and it would surface here first. + raise except Exception as exc: logger.warning("Summarization failed for unit %s: %s", unit_index, exc) diff --git a/ontocast/agent/update_common.py b/ontocast/agent/update_common.py index 9bd3e8e9..e8985baf 100644 --- a/ontocast/agent/update_common.py +++ b/ontocast/agent/update_common.py @@ -2,7 +2,7 @@ ``agent/common.py`` is about *calling* the LLM; this module is about what happens to the patch once it has been parsed. Facts updates -(:func:`ontocast.agent.render_facts.render_facts_update`) and ontology updates +and ontology updates (:func:`ontocast.agent.render_ontology.render_ontology_update`) consume the same :class:`GraphUpdateRenderReport`, so the hygiene between "the model answered" and "the update is applied" belongs in one place rather than being reimplemented diff --git a/ontocast/api/app.py b/ontocast/api/app.py index cde60a87..5cc24450 100644 --- a/ontocast/api/app.py +++ b/ontocast/api/app.py @@ -51,7 +51,10 @@ StatusErrorBody, ) from ontocast.api.shapes import build_shapes_router -from ontocast.api.tenancy_resolution import apply_request_tenancy +from ontocast.api.tenancy_resolution import ( + apply_request_tenancy, + resolve_tenant_project, +) from ontocast.config import ServerConfig from ontocast.onto.enum import OntologyContextMode, RenderMode, Status from ontocast.onto.retrieval_capabilities import ( @@ -59,7 +62,6 @@ validate_ontology_context_mode, ) from ontocast.onto.state import AgentState -from ontocast.onto.tenancy import DEFAULT_PROJECT, DEFAULT_TENANT from ontocast.stategraph import create_agent_graph from ontocast.stategraph.unit_pipeline import DocumentConversionError, run_unit_pipeline from ontocast.tool.agg.match_derivation import derive_pair_matches @@ -379,8 +381,20 @@ async def flush( ) if tenant is not None or project is not None: - t = (tenant or DEFAULT_TENANT).strip() - p = (project or DEFAULT_PROJECT).strip() + # Through the shared resolver, not an inline copy: this is the + # one destructive unauthenticated route, and the copy it used + # to carry omitted the non-empty guard, so `?tenant=%20` was + # accepted here and refused everywhere else. + try: + t, p = resolve_tenant_project(tenant, project) + except ValueError as err: + return JSONResponse( + status_code=400, + content=StatusErrorBody( + error=str(err), + error_type=type(err).__name__, + ).model_dump(), + ) try: await tools.clean_tenancy_data(t, p, include_shapes=include_shapes) except NotImplementedError as err: diff --git a/ontocast/api/ontologies.py b/ontocast/api/ontologies.py index a154ec27..2be2e2f9 100644 --- a/ontocast/api/ontologies.py +++ b/ontocast/api/ontologies.py @@ -6,9 +6,8 @@ from fastapi import APIRouter, File, HTTPException, Request, UploadFile from ontocast.api.schemas import OntologyDeleteResponse, OntologyMutationResponse -from ontocast.api.tenancy_resolution import apply_request_tenancy +from ontocast.api.tenancy_resolution import make_scoped_toolbox_resolver from ontocast.config import ServerConfig -from ontocast.onto.enum import OntologyContextMode from ontocast.onto.ontology import Ontology from ontocast.onto.rdfgraph import RDFGraph from ontocast.toolbox import ToolBox @@ -23,27 +22,13 @@ def build_ontology_router( ) -> APIRouter: router = APIRouter(prefix="/ontologies", tags=["ontologies"]) - init_vec = ( - server_config.ontology_context_mode - == OntologyContextMode.SELECTED_VECTOR_SEARCH_ONTOLOGY + apply_ontology_tenancy = make_scoped_toolbox_resolver( + tools, + active_tenant=active_tenant, + active_project=active_project, + server_config=server_config, ) - async def apply_ontology_tenancy(request: Request) -> ToolBox: - """Return the ToolBox serving this request's tenant/project partition. - - Handlers must use the returned ToolBox rather than the enclosing - ``tools``: with per-scope ToolBoxes, the two differ whenever the client - passes ``?tenant=`` / ``?project=``. - """ - scoped, _, _ = await apply_request_tenancy( - request, - tools, - active_tenant=active_tenant, - active_project=active_project, - initialize_vector_store=init_vec, - ) - return scoped - @router.post( "", response_model=OntologyMutationResponse, diff --git a/ontocast/api/parse.py b/ontocast/api/parse.py index 4e76b0ef..e7f4ab4f 100644 --- a/ontocast/api/parse.py +++ b/ontocast/api/parse.py @@ -5,7 +5,10 @@ from enum import StrEnum from typing import TypeVar -from ontocast.config.section_labels import normalise_user_section_label +from ontocast.config.section_labels import ( + all_known_label_ids, + normalise_user_section_label, +) from ontocast.onto.enum import LLMGraphFormat, OntologyContextMode, RenderMode logger = logging.getLogger(__name__) @@ -126,15 +129,55 @@ def parse_strip_provenance_param(value: str | None) -> bool: return False -def _normalise_section_tokens(raw_tokens: list[str]) -> list[str]: +def _normalise_section_tokens(raw_tokens: list[str]) -> tuple[list[str], list[str]]: + """Map user-supplied section names onto canonical labels. + + Args: + raw_tokens: Section names as the caller wrote them. + + Returns: + The recognised canonical labels, and the tokens that matched nothing. + """ result: list[str] = [] + dropped: list[str] = [] for token in raw_tokens: normalised = normalise_user_section_label(token) if normalised is None: - logger.warning("Unrecognised section label %r — skipping", token) + logger.warning("Unrecognised section label %r \u2014 skipping", token) + dropped.append(token) else: result.append(normalised) - return result + return result, dropped + + +def _resolve_section_tokens(raw_tokens: list[str], param: str) -> list[str]: + """Normalise section tokens, rejecting a list where nothing was recognised. + + A partially recognised list still expresses a real intent, so unknown + tokens are dropped with a warning. A list where *every* token is unknown is + a caller mistake whose effect is the opposite of the request: the empty + result reads downstream as an explicit "no sections", which *replaces* the + resolved schema's defaults instead of adding to them. Failing here keeps a + typo from silently disabling section handling the caller never touched. + + Args: + raw_tokens: Non-empty section names as the caller wrote them. + param: Parameter name, used in the error message. + + Returns: + Canonical labels for the recognised tokens. + + Raises: + RequestParamError: Tokens were supplied and none were recognised. + """ + resolved, dropped = _normalise_section_tokens(raw_tokens) + if raw_tokens and not resolved: + known = ", ".join(sorted(all_known_label_ids())) + raise RequestParamError( + param, + f"{param} recognised none of {dropped}; known section labels are: {known}", + ) + return resolved def parse_sections_list_param( @@ -147,13 +190,15 @@ def parse_sections_list_param( param: Parameter name, used only in error messages. Raises: - RequestParamError: The value started with ``[`` but was not a JSON array. + RequestParamError: The value started with ``[`` but was not a JSON + array, or tokens were supplied and none named a known section + label. """ if value is None: return [] if isinstance(value, list): raw_tokens = [str(item).strip() for item in value if str(item).strip()] - return _normalise_section_tokens(raw_tokens) + return _resolve_section_tokens(raw_tokens, param) raw = str(value).strip() if not raw: return [] @@ -167,9 +212,9 @@ def parse_sections_list_param( if not isinstance(parsed, list): raise RequestParamError(param, f"{param} JSON must be an array") raw_tokens = [str(item).strip() for item in parsed if str(item).strip()] - return _normalise_section_tokens(raw_tokens) + return _resolve_section_tokens(raw_tokens, param) raw_tokens = [part.strip() for part in raw.split(",") if part.strip()] - return _normalise_section_tokens(raw_tokens) + return _resolve_section_tokens(raw_tokens, param) def parse_document_type_hint_param(value: str | None) -> str | None: diff --git a/ontocast/api/process_helpers.py b/ontocast/api/process_helpers.py index 4f79d589..9751d8d6 100644 --- a/ontocast/api/process_helpers.py +++ b/ontocast/api/process_helpers.py @@ -14,20 +14,24 @@ from ontocast.agent.serialize import serialize as serialize_agent_state from ontocast.config import Config, ServerConfig from ontocast.onto.constants import DEFAULT_IRI -from ontocast.onto.enum import OntologyContextMode +from ontocast.onto.enum import OntologyContextMode, Status from ontocast.onto.ontology import Ontology from ontocast.onto.rdfgraph import RDFGraph from ontocast.onto.run_manifest import ( RunManifest, RunManifestLLM, RunManifestLoops, + RunManifestPrompting, RunManifestSelection, + RunManifestValidationConfig, + summarize_completion, summarize_loop, ) from ontocast.onto.state import AgentState from ontocast.stategraph.facts_gate import run_facts_gate from ontocast.stategraph.unit_pipeline import DocumentConversionError, run_unit_pipeline from ontocast.tool.chunk.prepare import SectionSelectionEmptyError +from ontocast.tool.llm import LLMConfigurationError from ontocast.tool.triple_manager.core import TripleStoreManager from ontocast.toolbox import ToolBox from ontocast.util.graph_metrics import facts_graph_shape_metrics @@ -129,11 +133,29 @@ def dump_facts_ttl( *, line_number: int | None = None, output_dir: pathlib.Path | None = None, + strip_provenance: bool = True, ) -> pathlib.Path | None: - """Write chunk-stripped facts Turtle when facts exist.""" + """Write the facts Turtle when facts exist. + + Args: + state: Document state carrying ``aggregated_facts``. + file_path: Source file the facts were extracted from. + line_number: Record number for JSONL inputs. + output_dir: Destination directory; defaults to the source's directory. + strip_provenance: Drop chunk-level provenance from the dump. Keeping it + is what lets a statement be traced back to its source span and + re-verified against the document; stripping it stays the default so + existing outputs are unchanged. Same meaning as the HTTP + ``strip_provenance`` parameter. + + Returns: + The path written, or None when there are no facts. + """ if state.aggregated_facts is None or len(state.aggregated_facts) == 0: return None - ttl_content = turtle_from_graph(state.aggregated_facts, strip_provenance=True) + ttl_content = turtle_from_graph( + state.aggregated_facts, strip_provenance=strip_provenance + ) if output_dir is not None: output_dir.mkdir(parents=True, exist_ok=True) output_path = facts_ttl_output_path( @@ -141,7 +163,8 @@ def dump_facts_ttl( ) output_path.write_text(ttl_content, encoding="utf-8") logger.info( - "Dumped facts graph with chunk-level provenance stripped to %s", + "Dumped facts graph with chunk-level provenance %s to %s", + "stripped" if strip_provenance else "retained", output_path, ) return output_path @@ -159,8 +182,19 @@ def dump_validation_report( A batch run otherwise leaves no record of *why* a graph is non-conformant: the findings live on the state and are logged, and every downstream reader ends up re-running a validator to rebuild what the gate already computed. + + ``unit_repairs`` and ``unit_failures`` are the per-unit counterparts of + ``gate_repairs``: what the deterministic passes rewrote in each render + before aggregation, and which units produced nothing. A predicate the + machine substituted is otherwise indistinguishable in the TTL from one the + model asserted, and a unit that failed from one that found nothing. """ - if not state.facts_conformance and not state.facts_validation_findings: + if not ( + state.facts_conformance + or state.facts_validation_findings + or state.facts_repairs_applied + or state.unit_failures + ): return None payload = { "source": file_path.name, @@ -172,6 +206,14 @@ def dump_validation_report( finding.model_dump(mode="json") for finding in state.facts_validation_findings ], + # Keyed by unit index, as facts_repairs is in the HTTP response. + "unit_repairs": { + str(unit_index): [record.model_dump(mode="json") for record in records] + for unit_index, records in sorted(state.facts_repairs_applied.items()) + }, + "unit_failures": [ + failure.model_dump(mode="json") for failure in state.unit_failures + ], } if output_dir is not None: output_dir.mkdir(parents=True, exist_ok=True) @@ -186,6 +228,33 @@ def dump_validation_report( return output_path +def _selection_manifest(state: AgentState, config: Config) -> RunManifestSelection: + """Selection settings plus the label census that says whether they acted.""" + histogram: dict[str, int] = {} + for unit in state.content_units or []: + label = unit.section_label or "(unlabeled)" + histogram[label] = histogram.get(label, 0) + 1 + unlabeled = histogram.get("(unlabeled)", 0) + return RunManifestSelection( + target_sections=state.target_sections, + exclude_sections=state.exclude_sections, + summarize_sections=state.summarize_sections, + # The cap only acts when summarization runs; recording its default + # beside an empty summarize_sections reads as a setting that was on. + summary_max_sentences=( + state.summary_max_sentences if state.summarize_sections else None + ), + bibliography_mode=str(config.get_tool_config().chunk_config.bibliography_mode), + non_content_mode=str(config.get_tool_config().chunk_config.non_content_mode), + labeled_units=(sum(histogram.values()) - unlabeled) if histogram else None, + unlabeled_units=unlabeled if histogram else None, + section_label_histogram=histogram or None, + bibliography_units_skipped=state.bibliography_units_skipped, + undersized_units_skipped=state.undersized_units_skipped, + non_content_units_skipped=state.non_content_units_skipped, + ) + + def dump_run_manifest( state: AgentState, file_path: pathlib.Path, @@ -193,6 +262,9 @@ def dump_run_manifest( config: Config, line_number: int | None = None, output_dir: pathlib.Path | None = None, + shapes_triples: int | None = None, + shapes_prompt_selection: bool | None = None, + fanout_settings_apply: bool = True, ) -> pathlib.Path | None: """Write the run's cost and configuration beside the facts TTL. @@ -202,7 +274,9 @@ def dump_run_manifest( them. One small JSON per document closes that. """ llm_config = config.tool_config.llm_config - facts_validation = config.get_tool_config().facts_validation + tool_config = config.get_tool_config() + facts_validation = tool_config.facts_validation + facts_critic_passes = facts_validation.critic_passes # The .facts.ttl dump strips provenance; count what the file will actually # hold, or the manifest is not comparable to its own TTL (1711 vs 557 on # observed runs). @@ -218,20 +292,23 @@ def dump_run_manifest( render_mode=str(state.render_mode), loops=RunManifestLoops( max_visits=state.max_visits, - max_critic_visits=config.server.max_critic_visits_per_node, - llm_repair_visits=facts_validation.llm_repair_visits, + facts_critic_passes=facts_critic_passes, + ontology_critic_passes=tool_config.ontology_validation.critic_passes, ), critic=summarize_loop(state.facts_loop_telemetry), ontology_critic=summarize_loop(state.ontology_loop_telemetry), + completion=summarize_completion(state.facts_loop_telemetry), ontology_reduce_metrics=dict(state.ontology_reduce_metrics), - selection=RunManifestSelection( - target_sections=state.target_sections, - exclude_sections=state.exclude_sections, - summarize_sections=state.summarize_sections, - summary_max_sentences=state.summary_max_sentences, - bibliography_mode=str( - config.get_tool_config().chunk_config.bibliography_mode - ), + selection=_selection_manifest(state, config), + validation_config=RunManifestValidationConfig( + context_from_units=facts_validation.context_from_units, + json_mode=llm_config.json_mode, + shapes_prompt_contract=facts_validation.shapes_prompt_contract, + shapes_prompt_selection=shapes_prompt_selection, + shapes_triples=shapes_triples, + shacl_inference=str(facts_validation.shacl_inference), + numeric_coverage_mandatory=facts_validation.numeric_coverage_mandatory, + facts_user_instruction_chars=len(state.facts_user_instruction or ""), ), graph_metrics=( facts_graph_shape_metrics( @@ -252,6 +329,30 @@ def dump_run_manifest( think=llm_config.think, num_ctx=llm_config.num_ctx, num_predict=llm_config.num_predict, + reasoning_effort=llm_config.reasoning_effort, + thinking_budget=llm_config.thinking_budget, + requests_per_second=llm_config.requests_per_second, + max_retries=llm_config.max_retries, + prompt_cache_key=llm_config.prompt_cache_key, + max_inflight=llm_config.llm_max_inflight, + ), + prompting=RunManifestPrompting( + llm_graph_format=str(config.server.llm_graph_format), + ontology_chapter_format=str(config.server.ontology_chapter_format), + # Null on the single-unit path rather than echoed back: both + # settings are read only by the document fan-out node, so recording + # a scope that never applied would make a unit-path manifest assert + # a prompt-sharing regime it did not run under. + ontology_context_scope=( + str(config.server.ontology_context_scope) + if fanout_settings_apply + else None + ), + fanout_warmup_units=( + config.server.fanout_warmup_units if fanout_settings_apply else None + ), + parallel_workers=config.server.parallel_workers, + embedding_model_name=tool_config.embedding.model_name, ), budget=state.budget_tracker, ontology_triples=sum( @@ -332,27 +433,33 @@ async def flush_triple_configured_scope(tools: ToolBox) -> None: await tools.triple_store_manager.clean() +#: LangGraph's ceiling on super-steps for one document run. +#: +#: The document graph is a DAG with no back edge (``stategraph/create.py``): +#: convert, chunk, one of the two render blocks, merge, validate, serialize. +#: Its longest path is a dozen super-steps and does not grow with the document, +#: because unit fan-out happens *inside* a node via ``asyncio.gather`` rather +#: than as graph edges. So the limit does not need to scale with chunk count or +#: visit budget -- it never did, and the formula that scaled it by both +#: resolved to this same floor in every shipped configuration. Retry budgets are +#: enforced inside ``run_unit_loop``, not by this number. It stays generous so a +#: future topology change fails loudly on its own merits rather than here. +GRAPH_RECURSION_LIMIT = 1000 + + def calculate_recursion_limit( head_chunks: int | None, server_config: ServerConfig, *, max_visits_per_node: int | None = None, ) -> int: - """Calculate the recursion limit based on max visits and head chunks.""" - visits = ( - max_visits_per_node - if max_visits_per_node is not None - else server_config.max_visits_per_node - ) - if head_chunks is not None: - return max( - server_config.base_recursion_limit, - visits * head_chunks * 10, - ) - return max( - server_config.base_recursion_limit, - visits * server_config.estimated_chunks * 10, - ) + """Recursion limit for one document run. + + Arguments are accepted and ignored: the graph's depth is a property of its + topology, not of the document or the visit budget. See + :data:`GRAPH_RECURSION_LIMIT`. + """ + return GRAPH_RECURSION_LIMIT def _resolve_document_metadata( @@ -386,6 +493,7 @@ def expand_input_to_states( section_schema_id: str | None = None, max_visits: int | None = None, document_metadata: dict[str, object] | None = None, + facts_user_instruction: str = "", ) -> list[AgentState]: """Expand a local input file into one ``AgentState`` per logical record.""" file_bytes = file_path.read_bytes() @@ -412,6 +520,7 @@ def expand_input_to_states( "summary_max_sentences": summary_max_sentences, "document_type_hint": document_type_hint, "section_schema_id": section_schema_id, + "facts_user_instruction": facts_user_instruction, } if file_path.suffix.lower() != ".jsonl": @@ -531,6 +640,12 @@ def _merge_workflow_state_into_agent_state( return workflow_state if not isinstance(workflow_state, dict): return state + # The map nodes set FAILED when *every* unit failed (_map_stage_status), + # and merge_facts preserves it. Off this copy list the batch path could not + # see it, which is how a document that produced nothing still exited 0. + status = workflow_state.get("status") + if status is not None: + state.status = status facts = workflow_state.get("aggregated_facts") if facts is not None: state.aggregated_facts = facts @@ -563,6 +678,17 @@ def _merge_workflow_state_into_agent_state( metrics = workflow_state.get("retrieval_metrics") if metrics: state.retrieval_metrics = dict(metrics) + # The chunk node is the only writer of the routing counters; without this + # the manifest's selection block reports every run as having skipped + # nothing. + for counter in ( + "bibliography_units_skipped", + "undersized_units_skipped", + "non_content_units_skipped", + ): + value = workflow_state.get(counter) + if value: + setattr(state, counter, int(value)) # The manifest's critic blocks read these; leaving them off this copy list # is why case10's manifests reported `critic: {calls: 0}` while their own # retrieval_metrics recorded 20 facts-critic and 26 ontology-critic calls. @@ -575,6 +701,30 @@ def _merge_workflow_state_into_agent_state( reduce_metrics = workflow_state.get("ontology_reduce_metrics") if reduce_metrics: state.ontology_reduce_metrics = dict(reduce_metrics) + # The manifest's selection census reads content_units and the validation + # dump reads the per-unit repairs and failures. Off this copy list, the + # census was always empty on the batch path (labeled_units absent, so a + # section filter that never acted was unrecordable) and the repairs were + # logged and dropped -- while the HTTP path returned both. + content_units = workflow_state.get("content_units") + if content_units is not None: + state.content_units = list(content_units) + unit_failures = workflow_state.get("unit_failures") + if unit_failures is not None: + state.unit_failures = list(unit_failures) + unit_repairs = workflow_state.get("facts_repairs_applied") + if unit_repairs: + state.facts_repairs_applied = { + unit_index: list(records) for unit_index, records in unit_repairs.items() + } + clusters = workflow_state.get("aggregation_clusters") + if clusters: + state.aggregation_clusters = { + final_iri: list(members) for final_iri, members in clusters.items() + } + key_clusters = workflow_state.get("aggregation_key_clusters") + if key_clusters is not None: + state.aggregation_key_clusters = list(key_clusters) return state @@ -597,9 +747,11 @@ async def process_files_input( section_schema_id: str | None = None, max_visits: int | None = None, document_metadata: dict[str, object] | None = None, + facts_user_instruction: str = "", output_dir: pathlib.Path | None = None, facts_output_dir: pathlib.Path | None = None, ontology_output_dir: pathlib.Path | None = None, + strip_provenance: bool = True, ) -> list[pathlib.Path]: """Process each input file, isolating per-file failures. @@ -638,6 +790,7 @@ async def process_files_input( section_schema_id=section_schema_id, max_visits=resolved_max_visits, document_metadata=document_metadata, + facts_user_instruction=facts_user_instruction, ) for state_index, state in enumerate(states): if use_unit_pipeline: @@ -675,6 +828,18 @@ async def process_files_input( state = _merge_workflow_state_into_agent_state( state, workflow_state ) + if state.status == Status.FAILED: + # Every unit failed. The dumps below still run -- an empty + # facts graph next to its manifest is the diagnostic -- but + # the file is on the record as failed, which cli/server.py + # turns into a non-zero exit. Without this a run that + # extracted nothing was indistinguishable from a clean one. + logger.error( + "No unit of %s produced output; recording it as failed", + file_path, + ) + if file_path not in failed_files: + failed_files.append(file_path) line_number: int | None = None if file_path.suffix.lower() == ".jsonl" and len(states) > 1: # Recover line from virtual raw_input key "...:N.json" @@ -692,6 +857,7 @@ async def process_files_input( file_path, line_number=line_number, output_dir=facts_dir, + strip_provenance=strip_provenance, ) dump_ontology_ttls( state, @@ -705,13 +871,31 @@ async def process_files_input( line_number=line_number, output_dir=facts_dir, ) + shapes_graph = tools.shapes_catalog.graph() + _, _, selection_pending = tools.shapes_prompt_contract() dump_run_manifest( state, file_path, config=config, line_number=line_number, output_dir=facts_dir, + shapes_triples=( + len(shapes_graph) if shapes_graph is not None else 0 + ), + shapes_prompt_selection=selection_pending, + fanout_settings_apply=not use_unit_pipeline, ) + except LLMConfigurationError: + # Batch semantics stop here: the provider refuses the request as + # configured, so every remaining file would burn its conversion and + # ontology sync to reach the same rejection. Files already finished + # keep their dumps; this one gets none, and that absence is the + # signal. + logger.error( + "Aborting the batch at %s: the provider rejects every request", + file_path, + ) + raise except Exception: logger.exception("Error processing %s", file_path) if file_path not in failed_files: diff --git a/ontocast/api/shapes.py b/ontocast/api/shapes.py index d10171cc..7a2a7483 100644 --- a/ontocast/api/shapes.py +++ b/ontocast/api/shapes.py @@ -14,9 +14,8 @@ ShapesListResponse, ShapesMutationResponse, ) -from ontocast.api.tenancy_resolution import apply_request_tenancy +from ontocast.api.tenancy_resolution import make_scoped_toolbox_resolver from ontocast.config import ServerConfig -from ontocast.onto.enum import OntologyContextMode from ontocast.toolbox import ToolBox @@ -30,27 +29,13 @@ def build_shapes_router( """Build the ``/shapes`` router bound to ``tools``' tenancy registry.""" router = APIRouter(prefix="/shapes", tags=["shapes"]) - init_vec = ( - server_config.ontology_context_mode - == OntologyContextMode.SELECTED_VECTOR_SEARCH_ONTOLOGY + apply_shapes_tenancy = make_scoped_toolbox_resolver( + tools, + active_tenant=active_tenant, + active_project=active_project, + server_config=server_config, ) - async def apply_shapes_tenancy(request: Request) -> ToolBox: - """Return the ToolBox serving this request's tenant/project partition. - - Handlers must use the returned ToolBox rather than the enclosing - ``tools``: with per-scope ToolBoxes, the two differ whenever the client - passes ``?tenant=`` / ``?project=``. - """ - scoped, _, _ = await apply_request_tenancy( - request, - tools, - active_tenant=active_tenant, - active_project=active_project, - initialize_vector_store=init_vec, - ) - return scoped - @router.get( "", response_model=ShapesListResponse, diff --git a/ontocast/api/tenancy_resolution.py b/ontocast/api/tenancy_resolution.py index c67c00d9..660baba7 100644 --- a/ontocast/api/tenancy_resolution.py +++ b/ontocast/api/tenancy_resolution.py @@ -1,7 +1,10 @@ """Shared HTTP request tenancy resolution for API routes.""" +from collections.abc import Awaitable, Callable + from starlette.requests import Request +from ontocast.config import ServerConfig from ontocast.onto.enum import OntologyContextMode from ontocast.onto.tenancy import DEFAULT_PROJECT, DEFAULT_TENANT from ontocast.toolbox import ToolBox @@ -81,3 +84,41 @@ async def apply_request_tenancy( fail_on_vector_store_error=False, ) return scoped, resolved_tenant, resolved_project + + +def make_scoped_toolbox_resolver( + tools: ToolBox, + *, + active_tenant: str, + active_project: str, + server_config: ServerConfig, +) -> Callable[[Request], Awaitable[ToolBox]]: + """Build the per-request ToolBox resolver a sub-router depends on. + + Every router that serves tenant-scoped data needs the same dependency: + resolve this request's tenant/project partition and hand the handler the + ToolBox that serves it, because with per-scope ToolBoxes the enclosing + ``tools`` is the wrong one whenever the client passes ``?tenant=`` or + ``?project=``. The ``/ontologies`` and ``/shapes`` routers each carried a + byte-identical copy of that closure, differing only in its name. + + Vector-store initialization is decided once here, from the configured + ontology context mode, for the same reason: both copies computed it and a + third router would have computed it again. + """ + initialize_vector_store = ( + server_config.ontology_context_mode + == OntologyContextMode.SELECTED_VECTOR_SEARCH_ONTOLOGY + ) + + async def resolve(request: Request) -> ToolBox: + scoped, _, _ = await apply_request_tenancy( + request, + tools, + active_tenant=active_tenant, + active_project=active_project, + initialize_vector_store=initialize_vector_store, + ) + return scoped + + return resolve diff --git a/ontocast/cli/inspect_sections.py b/ontocast/cli/inspect_sections.py index 86b7cc22..082313f4 100644 --- a/ontocast/cli/inspect_sections.py +++ b/ontocast/cli/inspect_sections.py @@ -190,22 +190,27 @@ def main( hint = parse_document_type_hint_param(document_type_hint) schema_id = parse_section_schema_id_param(section_schema_id) - options = PrepareOptions( - section_schema_id=schema_id, - document_type_hint=hint, - # None and [] mean different things downstream (schema defaults versus - # exclusion disabled), so an omitted flag must stay None. - target_sections=( - parse_sections_list_param(target_sections, param="target-sections") - if target_sections is not None - else None - ), - exclude_sections=( - parse_sections_list_param(exclude_sections, param="exclude-sections") - if exclude_sections is not None - else None - ), - ) + # The parsers are shared with the HTTP layer and signal bad input by + # raising; surface that as a click usage error rather than a traceback. + try: + options = PrepareOptions( + section_schema_id=schema_id, + document_type_hint=hint, + # None and [] mean different things downstream (schema defaults + # versus exclusion disabled), so an omitted flag must stay None. + target_sections=( + parse_sections_list_param(target_sections, param="target-sections") + if target_sections is not None + else None + ), + exclude_sections=( + parse_sections_list_param(exclude_sections, param="exclude-sections") + if exclude_sections is not None + else None + ), + ) + except ValueError as exc: + raise click.BadParameter(str(exc)) from exc # Resolved through the same helper the pipeline uses, so the schema reported # here is necessarily the schema the chunks were actually labeled against. decision = resolve_prepare_schema(document_text, chunk_config, options, chunker) diff --git a/ontocast/cli/plot_graph.py b/ontocast/cli/plot_graph.py index 2ed53abd..1dce4166 100644 --- a/ontocast/cli/plot_graph.py +++ b/ontocast/cli/plot_graph.py @@ -124,94 +124,106 @@ def flow_graph_to_mermaid(flow: FlowGraph) -> str: return "\n".join(lines) + "\n" -def _atomic_loop_core_edges( +def _unit_loop_core_edges( *, render_node: str, critic_node: str ) -> tuple[FlowEdge, ...]: - """Render/critic retry loop without optional web-evidence branches.""" + """The unit loop without the optional web-evidence branches. + + Two things this shape says that the previous one did not. The render loop + retries a *failed* render only -- a successful one is never repeated, so the + critique cannot cost a re-extraction. And the critique ends in an applied + patch rather than in a request: accepting and rejecting differ only in + whether the unit may leave, not in whether the fixes are acted on. + """ return ( FlowEdge("render_loop", render_node), - FlowEdge(render_node, "final_render", "success", conditional=True), + FlowEdge(render_node, "pass_loop", "success", conditional=True), FlowEdge(render_node, "render_loop", "fail", conditional=True), - FlowEdge("final_render", critic_node, "no", conditional=True), - FlowEdge(critic_node, "done", "success", conditional=True), - FlowEdge(critic_node, "render_loop", "fail", conditional=True), FlowEdge("render_loop", "exhausted", "exhausted", conditional=True), + FlowEdge("pass_loop", "findings", "pass", conditional=True), + FlowEdge("findings", critic_node), + FlowEdge(critic_node, "patch", "accept or reject", conditional=True), ) -def _atomic_loop_evidence_edges( +def _unit_loop_evidence_edges( *, render_node: str, critic_node: str ) -> tuple[FlowEdge, ...]: - """Full loop including optional plan/fetch web-evidence on render and critic failure.""" + """The unit loop including optional plan/fetch web evidence.""" return ( FlowEdge("render_loop", render_node), - FlowEdge(render_node, "final_render", "success", conditional=True), + FlowEdge(render_node, "pass_loop", "success", conditional=True), FlowEdge(render_node, "render_fail_search", "fail", conditional=True), FlowEdge("render_fail_search", "evid_r", "yes", conditional=True), FlowEdge("evid_r", "rerender"), - FlowEdge("rerender", "final_render", "success", conditional=True), + FlowEdge("rerender", "pass_loop", "success", conditional=True), FlowEdge("rerender", "render_loop", "fail", conditional=True), FlowEdge("render_fail_search", "render_loop", "no", conditional=True), - FlowEdge("final_render", "critic_loop", "no", conditional=True), - FlowEdge("critic_loop", critic_node), - FlowEdge(critic_node, "done", "success", conditional=True), - FlowEdge(critic_node, "critic_fail_search", "fail", conditional=True), + FlowEdge("render_loop", "exhausted", "exhausted", conditional=True), + FlowEdge("pass_loop", "findings", "pass", conditional=True), + FlowEdge("findings", critic_node), + FlowEdge(critic_node, "patch", "accept", conditional=True), + FlowEdge(critic_node, "critic_fail_search", "reject", conditional=True), FlowEdge("critic_fail_search", "evid_c", "yes", conditional=True), FlowEdge("evid_c", "recritic"), - FlowEdge("recritic", "done", "success", conditional=True), - FlowEdge("recritic", "critic_loop", "fail", conditional=True), - FlowEdge("critic_fail_search", "render_loop", "no", conditional=True), - FlowEdge("critic_loop", "render_loop", "exhausted", conditional=True), - FlowEdge("render_loop", "exhausted", "exhausted", conditional=True), + FlowEdge("recritic", "patch", "accept or reject", conditional=True), + FlowEdge("critic_fail_search", "patch", "no", conditional=True), ) -def facts_loop_flow(*, include_evidence: bool = False) -> FlowGraph: - render_node = "render_facts" - critic_node = "criticise_facts" +def unit_loop_flow( + phase: str, *, include_evidence: bool = False, passes_var: str +) -> FlowGraph: + """The per-unit loop, which is now one shape for both phases. + + Args: + phase: ``"facts"`` or ``"ontology"`` -- only the node captions differ. + include_evidence: Draw the optional web-evidence branches. + passes_var: The setting that bounds the critic passes, named on the + diagram so the picture and the configuration agree. + """ + render_node = f"render_{phase}" + critic_node = f"criticise_{phase}" + noun = "facts" if phase == "facts" else "ontology" + common = ( + FlowNode("start", "Unit start", "terminal"), + FlowNode("ctx", "Resolve / apply
ontology context"), + FlowNode("render_loop", "failed render
1 … MAX_VISITS", "decision"), + FlowNode(render_node, f"Render {noun}"), + FlowNode("pass_loop", f"critic pass
1 … {passes_var}", "decision"), + FlowNode("findings", "Deterministic checks
(no LLM call)"), + FlowNode(critic_node, f"Criticise {noun}
(cites statement ids)"), + FlowNode("patch", "Compile, screen, apply
patch (no LLM call)"), + FlowNode("converged", "changed nothing, or
rolled back?", "decision"), + FlowNode("done", "Return unit state", "terminal"), + FlowNode("exhausted", "Return (retries exhausted)", "terminal"), + ) if include_evidence: nodes = ( - FlowNode("start", "Unit start", "terminal"), - FlowNode("ctx", "Resolve / apply
ontology context"), - FlowNode("render_loop", "render attempt
1 … max_visits", "decision"), - FlowNode(render_node, "Render facts"), + *common, FlowNode("render_fail_search", "initiate_search?", "decision"), FlowNode("evid_r", "Plan + fetch
web evidence"), - FlowNode("rerender", "Re-render facts"), - FlowNode("final_render", "final render
attempt?", "decision"), - FlowNode("quarantine", "Surface unresolved
quarantine"), - FlowNode("done", "Return unit state", "terminal"), - FlowNode("critic_loop", "critic attempt
1 … max_visits", "decision"), - FlowNode(critic_node, "Criticise facts"), + FlowNode("rerender", f"Re-render {noun}"), FlowNode("critic_fail_search", "initiate_search?", "decision"), FlowNode("evid_c", "Plan + fetch
web evidence"), - FlowNode("recritic", "Re-criticise facts"), - FlowNode("exhausted", "Return (retries exhausted)", "terminal"), + FlowNode("recritic", f"Re-criticise {noun}"), ) - loop_edges = _atomic_loop_evidence_edges( + loop_edges = _unit_loop_evidence_edges( render_node=render_node, critic_node=critic_node ) else: - nodes = ( - FlowNode("start", "Unit start", "terminal"), - FlowNode("ctx", "Resolve / apply
ontology context"), - FlowNode("render_loop", "render attempt
1 … max_visits", "decision"), - FlowNode(render_node, "Render facts"), - FlowNode("final_render", "final render
attempt?", "decision"), - FlowNode("quarantine", "Surface unresolved
quarantine"), - FlowNode("done", "Return unit state", "terminal"), - FlowNode(critic_node, "Criticise facts"), - FlowNode("exhausted", "Return (retries exhausted)", "terminal"), - ) - loop_edges = _atomic_loop_core_edges( + nodes = common + loop_edges = _unit_loop_core_edges( render_node=render_node, critic_node=critic_node ) edges = ( FlowEdge("start", "ctx"), FlowEdge("ctx", "render_loop"), *loop_edges, - FlowEdge("final_render", "quarantine", "yes", conditional=True), - FlowEdge("quarantine", "done"), + FlowEdge("patch", "converged"), + FlowEdge("converged", "done", "yes", conditional=True), + FlowEdge("converged", "pass_loop", "no", conditional=True), + FlowEdge("pass_loop", "done", "budget spent", conditional=True), ) return FlowGraph( nodes=nodes, @@ -221,55 +233,19 @@ def facts_loop_flow(*, include_evidence: bool = False) -> FlowGraph: ) -def ontology_loop_flow(*, include_evidence: bool = False) -> FlowGraph: - render_node = "render_ontology" - critic_node = "criticise_ontology" - if include_evidence: - nodes = ( - FlowNode("start", "Unit start", "terminal"), - FlowNode("ctx", "Resolve unit
ontology context"), - FlowNode("render_loop", "render attempt
1 … max_visits", "decision"), - FlowNode(render_node, "Render ontology"), - FlowNode("render_fail_search", "initiate_search?", "decision"), - FlowNode("evid_r", "Plan + fetch
web evidence"), - FlowNode("rerender", "Re-render ontology"), - FlowNode("final_render", "final render
attempt?", "decision"), - FlowNode("done", "Return unit state", "terminal"), - FlowNode("critic_loop", "critic attempt
1 … max_visits", "decision"), - FlowNode(critic_node, "Criticise ontology"), - FlowNode("critic_fail_search", "initiate_search?", "decision"), - FlowNode("evid_c", "Plan + fetch
web evidence"), - FlowNode("recritic", "Re-criticise ontology"), - FlowNode("exhausted", "Return (retries exhausted)", "terminal"), - ) - loop_edges = _atomic_loop_evidence_edges( - render_node=render_node, critic_node=critic_node - ) - else: - nodes = ( - FlowNode("start", "Unit start", "terminal"), - FlowNode("ctx", "Resolve unit
ontology context"), - FlowNode("render_loop", "render attempt
1 … max_visits", "decision"), - FlowNode(render_node, "Render ontology"), - FlowNode("final_render", "final render
attempt?", "decision"), - FlowNode("done", "Return unit state", "terminal"), - FlowNode(critic_node, "Criticise ontology"), - FlowNode("exhausted", "Return (retries exhausted)", "terminal"), - ) - loop_edges = _atomic_loop_core_edges( - render_node=render_node, critic_node=critic_node - ) - edges = ( - FlowEdge("start", "ctx"), - FlowEdge("ctx", "render_loop"), - *loop_edges, - FlowEdge("final_render", "done", "yes", conditional=True), +def facts_loop_flow(*, include_evidence: bool = False) -> FlowGraph: + return unit_loop_flow( + "facts", + include_evidence=include_evidence, + passes_var="FACTS_CRITIC_PASSES", ) - return FlowGraph( - nodes=nodes, - edges=edges, - start_node="start", - end_node="done", + + +def ontology_loop_flow(*, include_evidence: bool = False) -> FlowGraph: + return unit_loop_flow( + "ontology", + include_evidence=include_evidence, + passes_var="ONTOLOGY_CRITIC_PASSES", ) diff --git a/ontocast/cli/server.py b/ontocast/cli/server.py index 1fab6e4b..c37c12f3 100644 --- a/ontocast/cli/server.py +++ b/ontocast/cli/server.py @@ -42,13 +42,26 @@ from ontocast.cli.cache import cache as cache_cli from ontocast.cli.inspect_sections import main as inspect_sections_cli from ontocast.config import Config -from ontocast.onto.enum import OntologyContextMode +from ontocast.onto.enum import OntologyContextMode, RenderMode from ontocast.onto.retrieval_capabilities import validate_ontology_context_mode from ontocast.onto.tenancy import DEFAULT_PROJECT, DEFAULT_TENANT from ontocast.stategraph import create_agent_graph +from ontocast.tool.llm import LLMConfigurationError from ontocast.toolbox import ToolBox from ontocast.util.files import crawl_directories + +class LLMConfigAbort(click.ClickException): + """A provider rejected the request as configured; the run stopped. + + Exits ``EX_CONFIG`` (78) rather than the generic 1, so a driver looping + over benchmark arms can tell a broken configuration from documents that + merely failed to extract. + """ + + exit_code = 78 + + logger = logging.getLogger(__name__) F = TypeVar("F", bound=Callable[..., object]) @@ -86,12 +99,112 @@ def _configure_logging(config: Config) -> None: logger.error("could set logging level correctly %s", e) -def _prepare_path_config(config: Config) -> None: - """Expand configured directories.""" - if config.tool_config.path_config.ontology_directory is not None: - config.tool_config.path_config.ontology_directory = pathlib.Path( - config.tool_config.path_config.ontology_directory - ).expanduser() +def _resolve_seed_directory( + value: str | pathlib.Path | None, + *, + source: str, + kind: str, + missing_is_fatal: bool, +) -> pathlib.Path | None: + """Expand a configured seed directory and report what it resolved to. + + A directory the operator named is an assertion, and the three ways it can + fail are not the same fault: + + * **Not named at all** -- no assertion, nothing to check. A run with no seed + directory is legitimate: ``serve`` is filled through ``POST /ontologies``, + and an ontology-rendering run creates its own vocabulary. + * **Named and empty** -- plausibly deliberate, so a warning naming the + resolved path, which is enough to spot a directory that is not the one + that was meant. + * **Named and absent** -- the assertion is false. Nothing downstream can + tell this from "deliberately none", so the catalog silently comes out + empty and every symptom appears several minutes later in retrieval. For a + batch run, which has no later chance to be given ontologies, this is + fatal here rather than mysterious there. + + Args: + value: Configured directory, from the CLI flag or the environment. + source: What named it, for the error message (flag or environment var). + kind: Human name of what is seeded, e.g. ``"ontology"``. + missing_is_fatal: Raise rather than warn when the path does not + resolve. ``process`` sets this; ``serve`` does not, because a + long-lived server may have the directory appear under it later. + + Returns: + The expanded directory, or ``None`` when nothing usable was named. + + Raises: + click.UsageError: The path was named, does not resolve, and this entry + point cannot continue without it. + """ + if value is None: + logger.info("No %s seed directory configured (%s)", kind, source) + return None + directory = pathlib.Path(value).expanduser() + if not directory.is_dir(): + message = ( + f"{source} points at {directory.absolute()}, which is not a " + f"directory. No {kind} files can be seeded from it." + ) + if missing_is_fatal: + raise click.UsageError(message) + logger.warning("%s", message) + return directory + logger.info("Using %s seed directory %s (%s)", kind, directory.absolute(), source) + return directory + + +def _prepare_path_config( + config: Config, + *, + ontology_dir: str | None = None, + shapes_dir: str | None = None, + missing_is_fatal: bool = False, +) -> None: + """Apply seed-directory overrides and expand the configured directories. + + Called before ``ToolBox(config)``: the toolbox reads both fields lazily in + ``initialize``, so an override applied any later would be ignored. + + ``ontology_dir`` / ``shapes_dir`` carry three distinguishable states. + ``None`` means the flag was omitted and the environment stands. A path + overrides it. The empty string clears it -- which is how a run declares "no + seed ontologies, infer them" against an environment that sets one, and is + why these are plain strings rather than ``click.Path``: ``pathlib.Path("")`` + is ``.``, so an empty flag would silently mean the working directory. + """ + paths = config.tool_config.path_config + facts_validation = config.tool_config.facts_validation + + if ontology_dir is not None: + paths.ontology_directory = pathlib.Path(ontology_dir) if ontology_dir else None + ontology_source = "--ontology-dir" + else: + ontology_source = "ONTOCAST_ONTOLOGY_DIRECTORY" + + if shapes_dir is not None: + facts_validation.shapes_dir = shapes_dir or None + shapes_source = "--shapes-dir" + else: + shapes_source = "FACTS_SHAPES_DIR" + + paths.ontology_directory = _resolve_seed_directory( + paths.ontology_directory, + source=ontology_source, + kind="ontology", + missing_is_fatal=missing_is_fatal, + ) + resolved_shapes = _resolve_seed_directory( + facts_validation.shapes_dir, + source=shapes_source, + kind="SHACL shapes", + missing_is_fatal=missing_is_fatal, + ) + # ``shapes_dir`` is typed ``str | None``, unlike ``ontology_directory``. + facts_validation.shapes_dir = ( + str(resolved_shapes) if resolved_shapes is not None else None + ) @dataclass @@ -110,13 +223,32 @@ def _bootstrap_tools( tenant: str | None, project: str | None, wipe_vector_store: bool | None, + ontology_dir: str | None = None, + shapes_dir: str | None = None, flush_on_clean: bool = False, + batch: bool = False, ) -> BootstrappedRuntime: - """Load config, construct ToolBox, apply tenancy, and initialize tools.""" + """Load config, construct ToolBox, apply tenancy, and initialize tools. + + Args: + ontology_dir: ``--ontology-dir``. ``None`` leaves + ``ONTOCAST_ONTOLOGY_DIRECTORY`` in force; ``""`` clears it. + shapes_dir: ``--shapes-dir``, same three states over ``FACTS_SHAPES_DIR``. + batch: This is ``process``, not ``serve``. A batch run has no later + chance to be given ontologies, so a named-but-absent seed directory + is fatal, and an empty catalog is fatal for the one render mode that + cannot create vocabulary of its own. A server is filled through + ``POST /ontologies``, so neither is. + """ config = Config() config.validate_llm_config() _configure_logging(config) - _prepare_path_config(config) + _prepare_path_config( + config, + ontology_dir=ontology_dir, + shapes_dir=shapes_dir, + missing_is_fatal=batch, + ) if ( config.server.ontology_context_mode == OntologyContextMode.FIXED_SINGLE_ONTOLOGY @@ -153,6 +285,9 @@ def _bootstrap_tools( ontology_context_mode=ontology_context_mode_value, fail_on_vector_store_error=vector_mode_enabled, wipe_vector_store=wipe_vector_store, + require_populated_catalog=( + batch and config.server.render_mode == RenderMode.FACTS + ), ) ) validate_ontology_context_mode(ontology_context_mode_value, tools) @@ -196,6 +331,27 @@ def _shared_runtime_options(fn: F) -> F: f"(default {DEFAULT_PROJECT!r} when omitted; not read from .env)." ), ), + click.option( + "--ontology-dir", + type=str, + default=None, + help=( + "Input catalog: directory of seed ontology *.ttl files, " + "overriding ONTOCAST_ONTOLOGY_DIRECTORY. Pass an empty string " + "to declare no seed ontologies, which an ontology-rendering " + "run answers by creating them from the corpus." + ), + ), + click.option( + "--shapes-dir", + type=str, + default=None, + help=( + "Seed directory of SHACL shape files, overriding " + "FACTS_SHAPES_DIR. Pass an empty string to declare no seed " + "shapes." + ), + ), click.option( "--wipe-vector-store/--no-wipe-vector-store", default=None, @@ -228,6 +384,8 @@ def serve( max_visits: int | None, tenant: str | None, project: str | None, + ontology_dir: str | None, + shapes_dir: str | None, wipe_vector_store: bool | None, ) -> None: """Start the OntoCast API server.""" @@ -235,6 +393,8 @@ def serve( tenant=tenant, project=project, wipe_vector_store=wipe_vector_store, + ontology_dir=ontology_dir, + shapes_dir=shapes_dir, flush_on_clean=False, ) parsed_max_visits = parse_max_visits_param( @@ -324,6 +484,17 @@ def serve( "pass an empty string to disable exclusion." ), ) +@click.option( + "--facts-user-instruction", + type=str, + default="", + help=( + "Deployment-specific guidance appended to the facts render and " + "critic prompts (the same per-request slot the HTTP API exposes). " + "The library prompt stays domain-neutral; domain refinements belong " + "here or in the shapes." + ), +) @click.option( "--summarize-sections", type=str, @@ -358,6 +529,17 @@ def serve( "fiction, general). Overrides --document-type-hint when set." ), ) +@click.option( + "--keep-provenance/--strip-provenance", + "keep_provenance", + default=False, + show_default=True, + help=( + "Keep chunk-level provenance in the dumped facts Turtle. Provenance is " + "what lets a statement be traced back to its source span and " + "re-verified against the document." + ), +) @click.option( "--document-metadata", type=str, @@ -374,6 +556,8 @@ def process( max_visits: int | None, tenant: str | None, project: str | None, + ontology_dir: str | None, + shapes_dir: str | None, wipe_vector_store: bool | None, input_path: pathlib.Path, output_dir: pathlib.Path | None, @@ -382,10 +566,12 @@ def process( use_unit_pipeline: bool, target_sections: str | None, exclude_sections: str | None, + facts_user_instruction: str, summarize_sections: str | None, summary_max_sentences: int, document_type_hint: str | None, section_schema_id: str | None, + keep_provenance: bool, document_metadata: str | None, ) -> None: """Process local files through the extraction pipeline (no HTTP server).""" @@ -393,7 +579,10 @@ def process( tenant=tenant, project=project, wipe_vector_store=wipe_vector_store, + ontology_dir=ontology_dir, + shapes_dir=shapes_dir, flush_on_clean=True, + batch=True, ) # The parsers are shared with the HTTP layer and signal bad input by # raising; surface that as a click usage error rather than a traceback. @@ -432,7 +621,7 @@ def process( input_path = input_path.expanduser() out_dir = output_dir.expanduser() if output_dir is not None else None facts_dir = facts_output_dir.expanduser() if facts_output_dir is not None else None - ontology_dir = ( + ontology_out_dir = ( ontology_output_dir.expanduser() if ontology_output_dir is not None else None ) supported_suffixes = get_supported_input_extensions(runtime.tools) @@ -446,30 +635,41 @@ def process( f"No supported input files under {input_path} " f"(looking for {', '.join(supported_suffixes)})." ) - failed_files = asyncio.run( - process_files_input( - files, - config=runtime.config, - head_chunks=head_chunks, - use_unit_pipeline=use_unit_pipeline, - tools=runtime.tools, - workflow=workflow, - ontology_context_mode_value=runtime.ontology_context_mode, - tenant=runtime.tenant, - project=runtime.project, - target_sections=parsed_target_sections, - exclude_sections=parsed_exclude_sections, - summarize_sections=parsed_summarize_sections, - summary_max_sentences=parsed_summary_max_sentences, - document_type_hint=parsed_document_type_hint, - section_schema_id=parsed_section_schema_id, - max_visits=parsed_max_visits, - document_metadata=parsed_document_metadata, - output_dir=out_dir, - facts_output_dir=facts_dir, - ontology_output_dir=ontology_dir, + try: + failed_files = asyncio.run( + process_files_input( + files, + config=runtime.config, + head_chunks=head_chunks, + use_unit_pipeline=use_unit_pipeline, + tools=runtime.tools, + workflow=workflow, + ontology_context_mode_value=runtime.ontology_context_mode, + tenant=runtime.tenant, + project=runtime.project, + target_sections=parsed_target_sections, + exclude_sections=parsed_exclude_sections, + summarize_sections=parsed_summarize_sections, + summary_max_sentences=parsed_summary_max_sentences, + document_type_hint=parsed_document_type_hint, + section_schema_id=parsed_section_schema_id, + max_visits=parsed_max_visits, + document_metadata=parsed_document_metadata, + facts_user_instruction=facts_user_instruction, + output_dir=out_dir, + facts_output_dir=facts_dir, + ontology_output_dir=ontology_out_dir, + strip_provenance=not keep_provenance, + ) ) - ) + except LLMConfigurationError as exc: + # Not one failed file among many: the request as configured is one the + # provider will never accept, so the batch stopped where it stood. A + # dedicated exit code lets a benchmark driver tell "fix your LLM_* + # settings" from "some documents did not extract". + raise LLMConfigAbort( + f"{exc} -- fix the LLM_* configuration and re-run." + ) from exc if failed_files: # Exit non-zero so a scripted pipeline can tell a partial or total # failure from a clean run. diff --git a/ontocast/config/settings.py b/ontocast/config/settings.py index b1e4722a..1bf991d1 100644 --- a/ontocast/config/settings.py +++ b/ontocast/config/settings.py @@ -21,11 +21,14 @@ ) from ontocast.onto.enum import ( LLMGraphFormat, + OntologyChapterFormat, OntologyContextMode, + OntologyContextScope, RenderMode, VectorDistance, VectorStoreBackend, ) +from ontocast.onto.ontology_condense import TextCaps from ontocast.onto.tenancy import ( DEFAULT_PROJECT, DEFAULT_TENANT, @@ -42,6 +45,7 @@ # BaseSettings, SettingsConfigDict, AliasChoices, Path, Literal, StrEnum) # became part of the package's public API. __all__ = [ + "OntologyValidationConfig", "AggregationConfig", "ChunkConfig", "ClaudeModel", @@ -223,7 +227,6 @@ class EmbeddingProvider(StrEnum): class CrossQueryMergeMode(StrEnum): """How per-query fused hits are merged across proposition windows.""" - HYBRID = "hybrid" MAX_SCORE = "max_score" SUM_SCORE = "sum_score" @@ -264,13 +267,28 @@ class LLMConfig(BaseSettings): default=LLMProvider.OPENAI, description="LLM provider" ) model_name: LLMModelName = Field( - default=OpenAIModel.GPT4_O_MINI, description="LLM model name" + default=OpenAIModel.GPT5_4, description="LLM model name" ) temperature: float = Field(default=0.0, description="LLM temperature setting") base_url: str | None = Field( default=None, description="LLM base URL (for ollama, etc.)" ) api_key: str | None = Field(default=None, description="API key for LLM provider") + prompt_cache_key: str | None = Field( + default=None, + description=( + "OpenAI prompt_cache_key: a routing hint that steers requests " + "sharing a prompt prefix to the same cache shard. The provider " + "caches by prefix either way, but without a key a wide simultaneous " + "fan-out can be spread across machines that each build their own " + "entry, so calls that do share a prefix still miss. Any stable " + "string works; one per deployment or per corpus is the useful " + "granularity, and it must NOT be per-request or it defeats itself. " + "Not a secret and not part of the LLM disk-cache key -- it changes " + "routing, never the response. OpenAI only. Set via " + "LLM_PROMPT_CACHE_KEY." + ), + ) cache_enabled: bool = Field( default=True, description="When true, read and write LLM response disk cache entries.", @@ -302,6 +320,35 @@ class LLMConfig(BaseSettings): "pipeline's effective width. Set to None to wait forever." ), ) + requests_per_second: float | None = Field( + default=None, + gt=0, + description=( + "Sustained provider request rate, paced by a per-process token " + "bucket on request starts (langchain InMemoryRateLimiter). " + "Complements LLM_MAX_INFLIGHT, which caps *concurrency* but not " + "rate: a fan-out of short calls can exceed a provider tier's " + "requests-per-minute while never holding many connections at " + "once. Set from the deployment's provider tier; None (default) " + "means unpaced. A throttle that slips through anyway is counted " + "as llm/rate_limited in the budget. Set via " + "LLM_REQUESTS_PER_SECOND." + ), + ) + max_retries: int | None = Field( + default=None, + ge=0, + description=( + "Retry budget handed to the provider SDK for transport-level " + "failures (rate limits, connection resets), which back off and " + "honour Retry-After. None (default) keeps each SDK's own " + "default. This is the knob to raise when a tier throttles -- the " + "pipeline itself deliberately never retries transport failures " + "(that multiplies request rate exactly when the provider asks " + "for less). Ignored by the Ollama provider, which exposes no " + "retry budget. Set via LLM_MAX_RETRIES." + ), + ) think: bool | None = Field( default=None, description=( @@ -333,6 +380,58 @@ class LLMConfig(BaseSettings): "Directly affects VRAM usage on the inference server." ), ) + json_mode: bool = Field( + default=False, + description=( + "Constrain OpenAI decoding to syntactically valid JSON " + "(response_format json_object). Every response is parsed as a JSON " + "envelope regardless of LLM_GRAPH_FORMAT -- Turtle only makes the " + "graph fields flat strings inside that envelope -- so this makes a " + "whole class of syntax error unreachable instead of repaired after " + "the fact. Strict json_schema mode is deliberately not offered: it " + "requires closed schemas and the graph fields are open. Default off " + "because OpenAI rejects the request unless the word JSON appears in " + "the prompt, which is a property of the prompt set in use. OpenAI " + "only; other providers ignore it." + ), + ) + reasoning_effort: ( + Literal["none", "minimal", "low", "medium", "high", "xhigh"] | None + ) = Field( + default=None, + description=( + "How much reasoning the model spends before it answers " + "(none | minimal | low | medium | high | xhigh). Reasoning tokens " + "are billed inside the output total, so this bounds the part of " + "the bill that reasoning_share_of_output in the budget attributes " + "to thinking; lower effort trades that for latency and cost. Read " + "by OpenAI reasoning models as reasoning_effort and by Gemini 3+ " + "as thinking_level. The vocabulary is the union across providers " + "and across model generations of one provider -- the floor is " + "spelled minimal by some models and none by others, and xhigh is " + "newer than both -- so which levels a given model accepts stays " + "the provider's business, reported as a rejected request rather " + "than guessed at here. None keeps the provider default. Joins the " + "LLM cache key, so changing it re-issues every prompt. Ollama and " + "Anthropic warn and ignore it. Set via LLM_REASONING_EFFORT." + ), + ) + thinking_budget: int | None = Field( + default=None, + ge=-1, + description=( + "Thinking-token budget for Gemini 2.5 thinking models: 0 " + "disables thinking on models that allow it, -1 lets the model " + "choose, a positive value caps it. The integer spelling of " + "LLM_REASONING_EFFORT, read the same way " + "(reasoning_share_of_output). Superseded from Gemini 3 on, where " + "the discrete thinking_level replaces it and the two are mutually " + "exclusive -- set LLM_REASONING_EFFORT for those models instead. " + "None keeps the provider default. Joins the LLM cache key, so " + "changing it re-issues every prompt. Non-Google providers warn " + "and ignore it. Set via LLM_THINKING_BUDGET." + ), + ) model_config = SettingsConfigDict( env_prefix="LLM_", @@ -377,6 +476,30 @@ def validate_model_name(cls, v: LLMModelName, info) -> LLMModelName: ) return v + @model_validator(mode="after") + def validate_reasoning_knobs(self) -> "LLMConfig": + """Reject the two Gemini reasoning spellings being set together. + + The Gemini API treats ``thinking_level`` and ``thinking_budget`` as + mutually exclusive and the client resolves the clash by dropping the + budget with a warning. Absorbing that silently is worse here than + failing: the run would bill one reasoning setting while the manifest + recorded the other, and every arm read off that manifest afterwards + would be attributing cost to a budget that never applied. + """ + if ( + self.provider == LLMProvider.GOOGLE + and self.reasoning_effort is not None + and self.thinking_budget is not None + ): + raise ValueError( + "LLM_REASONING_EFFORT and LLM_THINKING_BUDGET are mutually " + "exclusive on Google: Gemini 3+ reads the first as " + "thinking_level, Gemini 2.5 reads the second. Set whichever " + "matches the model generation, not both." + ) + return self + class ChunkConfig(BaseSettings): """Chunking configuration settings.""" @@ -511,6 +634,48 @@ class ChunkConfig(BaseSettings): "handling." ), ) + min_unit_chars: int = Field( + default=0, + ge=0, + description=( + "Drop content units shorter than this many characters before the " + "extraction fan-out; 0 (default) disables the floor. Distinct from " + "min_size, which is a target the chunker aims at while merging " + "segments -- a heading stub or a caption fragment can still leave " + "chunking well under it. Every unit below the floor otherwise costs " + "a patch retrieval, a full render and a critic call to extract from " + "a fragment. Watch the per-unit node durations and the budget " + "summary to size it for a corpus." + ), + ) + non_content_mode: Literal["extract", "skip"] = Field( + default="extract", + description=( + "Routing for units detected as front/back matter with no domain " + "facts: a unit whose leading heading names author information, " + "notes, ORCID, data availability, competing interests, licence, " + "supporting information and the like *and* that states no " + "unit-adjacent number; or a unit whose tokens are mostly emails, " + "URLs, ORCIDs and initials. 'extract' (default) keeps the unit and " + "marks it is_non_content; 'skip' drops it before the fan-out. " + "Every decision is logged, and skipped units are counted in the " + "run manifest. A measurement anywhere in the unit keeps it, " + "whatever its heading." + ), + ) + max_measurements_per_unit: int = Field( + default=0, + ge=0, + description=( + "Split a sized unit at the sentence boundary nearest its midpoint, " + "recursively, while it states more unit-adjacent numbers than " + "this; 0 (default) disables. Extraction loss tracks how densely a " + "unit packs measurements rather than how long it is, so this " + "targets the dense units without shrinking every unit's share of " + "the prompt. Pieces never go below min_size: a dense unit shorter " + "than twice min_size is left whole." + ), + ) citation_vocabulary: dict[str, str] = Field( default_factory=lambda: { "work_class": "schema:ScholarlyArticle", @@ -622,6 +787,21 @@ class ConverterConfig(BaseSettings): "be dropped in a breaking release." ), ) + repair_numeric_artifacts: bool = Field( + default=False, + description=( + "Repair pattern-local conversion artifacts in every text item at " + "conversion time, so chunk boundaries are computed on repaired " + "text: escaped HTML entities (< > &), carriage-return " + "column wraps, flattened exponents ('2 x 10 6' -> '2 × 10^6'; the " + "bare '10 6' only behind an approximation cue) and single-sided " + "ligature gaps that cannot read as two words ('a ffected', " + "'signifi cant'). Off by default; when on it joins the converter " + "cache key, so enabling it re-converts. Superscript duplication " + "and citation markers fused into values are left alone: they are " + "not recoverable from the text." + ), + ) model_config = SettingsConfigDict( env_prefix="CONVERTER_", @@ -651,41 +831,19 @@ class ServerConfig(BaseSettings): ), ) port: int = Field(default=8999, ge=1, le=65535, description="Server port") - base_recursion_limit: int = Field( - default=1000, ge=1, description="Recursion limit for workflow" - ) - estimated_chunks: int = Field( - default=30, ge=1, description="Estimated number of chunks" - ) max_visits_per_node: int = Field( default=1, ge=1, description=( - "Maximum render attempts per unit loop. At the default of 1 the " - "critic never runs: the single render is also the final one, and a " - "critique that cannot drive a retry is skipped. Raise to 2 or more " - "to enable the LLM critic pass." + "Retries of a *failed* fresh extraction, and nothing else. A render " + "that succeeds is never repeated: improving it is what the critic " + "passes are for, and re-extracting a unit from scratch to fix a " + "local defect is the expensive answer to a cheap question. Raise " + "this only if renders are failing outright (unparseable responses, " + "provider errors)." ), validation_alias=AliasChoices("max_visits_per_node", "max_visits"), ) - max_critic_visits_per_node: int | None = Field( - default=None, - ge=1, - description=( - "Maximum critic attempts per render attempt. The inner critic loop " - "is otherwise bounded by MAX_VISITS_PER_NODE -- the same constant " - "as the outer render loop -- so its worst case is that value " - "*squared* in billed critic calls. That worst case is only " - "reachable when the critic keeps requesting external evidence " - "(WEB_SEARCH_ENABLED=true): a critic that fails without a search " - "request breaks out of the loop, so with grounding off the critic " - "runs at most once per render regardless. None keeps that coupling; " - "set it to 1 to cap the evidence-driven retry path explicitly. " - "Left unset by default because lowering it changes call counts for " - "grounded runs, and that default belongs to a measurement rather " - "than to an argument." - ), - ) render_mode: RenderMode = Field( default=RenderMode.ONTOLOGY_AND_FACTS, description="Rendering mode: ontology, facts, or ontology_and_facts.", @@ -698,6 +856,81 @@ class ServerConfig(BaseSettings): "(legacy, Turtle strings)." ), ) + ontology_chapter_format: OntologyChapterFormat = Field( + default=OntologyChapterFormat.AUTO, + description=( + "Syntax of the ontology chapter in the facts render and critic " + "prompts. 'auto' (default) resolves to 'term_sheet' when " + "render_mode is facts and 'inherit' otherwise -- the cheapest " + "chapter each mode can legally read. 'inherit' follows " + "llm_graph_format; 'turtle' serializes the chapter as Turtle " + "regardless, which spends fewer characters per triple than " + "pretty-printed JSON-LD; 'term_sheet' replaces the serialized " + "graph with a line-per-term listing -- name, surface forms, type, " + "hierarchy, domain/range and usage contract -- dropping the " + "per-statement RDF scaffolding and the prose written for human " + "readers, and is the cheapest of the three by a wide margin. " + "Context only: the graph payloads the model emits stay in " + "llm_graph_format. 'term_sheet' requires a facts-only render " + "mode, because the ontology loop emits a patch against the " + "statements in its chapter and so needs a graph; asking for it " + "explicitly on another mode is rejected, where 'auto' simply " + "yields a graph chapter. 'auto' is resolved here, so the run " + "manifest and the LLM cache key carry the chapter actually built. " + "Changing the resolved value invalidates the LLM cache for facts " + "calls." + ), + ) + ontology_text_max_chars_naming: int | None = Field( + default=None, + ge=1, + description=( + "Character cap on rdfs:label / skos:prefLabel / skos:altLabel in " + "the ontology chapter. Nothing else bounds a single literal, so " + "without a cap the chapter costs what a catalog's authors chose to " + "write rather than what it declares. A tersely authored catalog is " + "unaffected -- these are bounds, not reductions. Clipping is on a " + "word boundary and leaves a visible marker, so the model can tell " + "a clipped name from a complete one. Joins the LLM cache key. Set " + "via ONTOLOGY_TEXT_MAX_CHARS_NAMING. None disables the cap." + ), + ) + ontology_text_max_chars_contract: int | None = Field( + default=None, + ge=1, + description=( + "Character cap on skos:scopeNote / skos:definition, the statements " + "saying when a term applies. Worth clipping rather than dropping: " + "a scope note's first sentence usually carries the contract and " + "the rest elaborates. Joins the LLM cache key. Set via " + "ONTOLOGY_TEXT_MAX_CHARS_CONTRACT. None disables the cap." + ), + ) + ontology_text_max_chars_prose: int | None = Field( + default=None, + ge=1, + description=( + "Character cap on rdfs:comment and the remaining SKOS notes -- " + "description aimed at someone browsing the ontology rather than at " + "an extractor. Joins the LLM cache key. Set via " + "ONTOLOGY_TEXT_MAX_CHARS_PROSE. None disables the cap." + ), + ) + ontology_text_total_budget: int | None = Field( + default=None, + ge=1, + description=( + "Ceiling on the summed length of all text literals in one ontology " + "chapter -- the backstop for a catalog that stays inside the " + "per-role caps by holding very many short terms. Over budget, " + "prose is tightened then dropped, then contracts, and only then " + "are names clipped to a floor; names are never dropped, so a " + "chapter that still does not fit is passed through with a warning " + "rather than made unusable. Read back via the retrieval metrics " + "(text_chars_before/after, literals_clipped, literals_dropped). " + "Joins the LLM cache key. Set via ONTOLOGY_TEXT_TOTAL_BUDGET." + ), + ) ontology_context_mode: OntologyContextMode = Field( default=OntologyContextMode.SELECTED_SINGLE_ONTOLOGY, description=( @@ -722,6 +955,70 @@ class ServerConfig(BaseSettings): "warning, all-or-nothing. Not a prompt bound -- use " "ontology_context_max_triples for context size. None (default) disables it.", ) + ontology_context_required: bool = Field( + default=False, + description="Fail the run when a FACTS unit's ontology context " + "resolves to zero triples, instead of extracting with no catalog " + "vocabulary. For facts an empty context is not a degraded extraction, " + "it is a different one: the renderer falls back on whatever standard " + "vocabulary the prompt names, and the SHACL gate then has no node to " + "constrain, so the run reports a vacuous pass. Turn it on for a " + "deployment that extracts against a curated catalog, where an empty " + "context can only mean the catalog did not load. It is off by default " + "because the default render mode builds ontologies as well as facts, " + "and a corpus with no catalog is that mode's starting point rather " + "than a fault. It never applies to an ONTOLOGY unit, whose empty " + "context is the signal to create a new ontology.", + ) + + @property + def ontology_text_caps(self) -> TextCaps: + """The four text-cap knobs as one value, for the chapter builders. + + Returns a :class:`TextCaps` whose ``active`` is False when nothing is + set, which the chapter path treats as "leave every literal as authored" + -- byte-for-byte, so a deployment that sets none of these cannot see its + prompts or its cache keys move. + """ + return TextCaps( + naming=self.ontology_text_max_chars_naming, + contract=self.ontology_text_max_chars_contract, + prose=self.ontology_text_max_chars_prose, + total_budget=self.ontology_text_total_budget, + ) + + ontology_context_scope: OntologyContextScope = Field( + default=OntologyContextScope.UNIT, + description=( + "Whether the ontology chapter is resolved per content unit ('unit', " + "the default) or once per document and shared ('document'). Per-unit " + "retrieval gives each unit a smaller chapter, but a different one, so " + "no two calls share a prompt prefix and a provider's prefix cache can " + "serve none of them -- a document then pays the chapter at full price " + "once per unit. 'document' unions the per-unit contexts and shows the " + "union to every unit: larger per call, identical across the fan-out, " + "and so charged at the cached rate on every call after the first. It " + "is recall-safe by construction -- the union contains every atom each " + "unit's own retrieval selected -- and costs precision, since a unit " + "also sees its siblings' terms. Pair with FANOUT_WARMUP_UNITS, " + "without which the fan-out issues every call before any of them has " + "populated the cache. Set via ONTOLOGY_CONTEXT_SCOPE." + ), + ) + fanout_warmup_units: int = Field( + default=0, + ge=0, + description=( + "How many content units to run to completion before fanning the rest " + "out concurrently. 0 (default) fans out everything at once. A " + "provider's prefix cache is populated by a *completed* request, so a " + "fan-out that issues N calls sharing a prefix simultaneously has all " + "N miss it; running one unit first turns the other N-1 misses into " + "hits. Only worth setting where the calls actually share a prefix -- " + "that is, with ONTOLOGY_CONTEXT_SCOPE=document -- and it costs the " + "wall-clock of one serialized call. Set via FANOUT_WARMUP_UNITS." + ), + ) ontology_context_max_triples: int | None = Field( default=4000, ge=1, @@ -776,6 +1073,49 @@ class ServerConfig(BaseSettings): case_sensitive=False, ) + @model_validator(mode="after") + def validate_ontology_chapter_format(self) -> "ServerConfig": + """Resolve 'auto' against the render mode, and reject an illegal ask. + + The facts renderer reads its chapter and writes an unrelated graph, so + the chapter is free to be any representation that names the terms. The + ontology renderer and its critic write a *patch against the statements + in the chapter*, which a line-per-term listing cannot express -- there + is nothing to insert into or delete from. + + So the cheapest legal chapter differs by mode, and ``auto`` picks it + rather than asking an operator to know which. It is resolved **here**, + not at the point of use: every consumer reads this field, and a value + that still meant "decide later" would reach the prompt profile, the + LLM cache key and the run manifest as a name for no chapter in + particular. + + An *explicit* ``term_sheet`` on a mode that cannot read one still + fails. Falling back would be silent, and the run would spend an + ontology pass producing patches nobody could apply while the manifest + recorded a setting that never took effect -- which is exactly what + ``auto`` exists to make unnecessary. + """ + if self.ontology_chapter_format == OntologyChapterFormat.AUTO: + self.ontology_chapter_format = ( + OntologyChapterFormat.TERM_SHEET + if self.render_mode == RenderMode.FACTS + else OntologyChapterFormat.INHERIT + ) + return self + if ( + self.ontology_chapter_format == OntologyChapterFormat.TERM_SHEET + and self.render_mode != RenderMode.FACTS + ): + raise ValueError( + "ONTOLOGY_CHAPTER_FORMAT=term_sheet requires RENDER_MODE=facts: " + f"got render_mode={self.render_mode.value}. The ontology loop " + "emits a patch against the statements in its chapter, so that " + "chapter has to be a graph. Use 'turtle' for a cheaper chapter " + "that stays one, or 'auto' to take whichever the mode allows." + ) + return self + class FusekiConfig(BaseSettings): """Fuseki triple store configuration.""" @@ -1152,6 +1492,18 @@ class AggregationConfig(BaseSettings): "prohibition to objects sharing the same predicate." ), ) + unit_scoped_fact_iris: bool = Field( + default=True, + description=( + "Suffix every minted fact IRI with the index of the unit that " + "minted it (__u) before aggregation. Units mint " + "instance IRIs independently, so without this the same local " + "name from two units is one node before any merge guard runs, " + "and the validation gate cannot split a singleton. With it, the " + "pair is a merge candidate like any alias pair; final IRIs never " + "carry the suffix. Off reproduces name-keyed fusion." + ), + ) model_config = SettingsConfigDict( env_prefix="AGG_", @@ -1216,68 +1568,24 @@ class EmbeddingConfig(BaseSettings): class PatchRetrievalConfig(BaseSettings): """Scoring, filtering, and capping of ontology atoms after vector search (backend-agnostic). - Default path is intentionally simple: per-window channel fusion → max-score IRI - dedupe → per-ontology round-robin → window-scaled hard cap. Relative floors, - hybrid tier merge, merged-score ratio, and MMR remain available as advanced - opt-in (non-default) controls. + The path is intentionally simple: per-window channel fusion → max-score IRI + dedupe → per-ontology round-robin → window-scaled hard cap. Merged-score + ratio and MMR remain available as advanced opt-in (non-default) controls. """ - per_query_core_score_ratio: float = Field( - default=0.0, - ge=0.0, - le=1.0, - description=( - "Advanced: within each query, keep core hits whose score is at least this " - "fraction of that query's best core score. 0 disables (default)." - ), - ) - per_query_neighborhood_score_ratio: float = Field( - default=0.0, - ge=0.0, - le=1.0, - description=( - "Advanced: within each query, keep neighborhood hits whose score is at least " - "this fraction of that query's best neighborhood score. 0 disables (default)." - ), - ) - min_core_query_best_score: float = Field( - default=0.0, - ge=0.0, - description=( - "If > 0, queries whose top core score is below this contribute no core hits." - ), - ) - min_neighborhood_query_best_score: float = Field( - default=0.0, - ge=0.0, - description=( - "If > 0, queries whose top neighborhood score is below this contribute no " - "neighborhood hits." - ), - ) - per_query_bm25_score_ratio: float = Field( - default=0.0, - ge=0.0, - le=1.0, - description=( - "Advanced: within each query, keep BM25 hits whose score is at least this " - "fraction of that query's best BM25 score. 0 disables (default)." - ), - ) - min_bm25_query_best_score: float = Field( - default=0.0, - ge=0.0, - description=( - "If > 0, queries whose top BM25 score is below this contribute no BM25 hits." - ), - ) min_merged_max_score: float = Field( default=0.18, ge=0.0, description=( - "After merging hits across queries, if the highest retained score is below this, " - "return an empty patch (no relevant ontology). Set to 0 to disable. Scores are " - "per-window fused rank scores (RRF-style), not raw cosine." + "Relevance floor below which a unit is treated as having no " + "relevant ontology and gets an empty patch. Expressed as a " + "fraction of the best score a window could achieve -- an atom " + "ranked first in every lane -- rather than as an absolute number " + "on the fused scale, because the lane weights and " + "VECTOR_STORE_FUSION_RANK_CONSTANT both rescale that scale, and a " + "threshold calibrated at one setting then rejects everything at " + "another. Scores are per-window fused rank scores (RRF-style), not " + "raw cosine. Set to 0 to disable." ), ) merged_score_ratio: float = Field( @@ -1292,19 +1600,11 @@ class PatchRetrievalConfig(BaseSettings): cross_query_merge_mode: CrossQueryMergeMode = Field( default=CrossQueryMergeMode.MAX_SCORE, description=( - "Cross-window merge: max_score (default; entity best score across windows), " - "sum_score (sum of per-window scores, so a term several windows agree on " - "outranks one window's top hit), or hybrid (tier-1 global seeds + " - "per-ontology tier-2 coverage). All three are followed by the same " - "round-robin / cap stage. Single-window retrieval makes max and sum identical." - ), - ) - max_atoms_tier1: int = Field( - default=12, - ge=0, - description=( - "Hybrid merge only: global cap on strong tier-1 seeds (max score per entity " - "IRI). 0 means no tier-1 cap. Unused in the default max_score path." + "Cross-window merge: max_score (default; entity best score across " + "windows) or sum_score (sum of per-window scores, so a term several " + "windows agree on outranks one window's top hit). Both are followed " + "by the same round-robin / cap stage, and single-window retrieval " + "makes them identical." ), ) per_ontology_seed_quota: int = Field( @@ -1312,20 +1612,12 @@ class PatchRetrievalConfig(BaseSettings): ge=0, description=( "Max seeds retained per ontology IRI when filling the final seed list " - "(round-robin under max_score; tier-2 under hybrid). 0 means no per-ontology " + "(round-robin). 0 means no per-ontology " "cap (global score order only), which is the default: a quota spreads the " "seed budget across ontologies that merely scored something, and measured " "worse on both recall and precision than plain global score order." ), ) - min_entity_score: float = Field( - default=0.3, - ge=0.0, - description=( - "Hybrid merge tier-2 only: minimum per-entity max fused score to qualify. " - "Unused in the default max_score path." - ), - ) per_ontology_atom_floor: int = Field( default=2, ge=0, @@ -1352,6 +1644,30 @@ class PatchRetrievalConfig(BaseSettings): "entire; past that it adds triples for little gain. 0 disables." ), ) + small_module_closure_max_total_triples: int | None = Field( + default=None, + ge=0, + description=( + "Ceiling on the triples all whole-module closures may contribute to " + "one snapshot. None (default) is unlimited: every module that fits " + "SMALL_MODULE_CLOSURE_MAX_TRIPLES and won a seed is included, which " + "is the historical behaviour. When set, candidate modules are " + "admitted in order of retrieval relevance -- the best score any of " + "their atoms achieved -- until the budget is spent, and a module too " + "large for the remaining budget is skipped rather than ending the " + "pass, so a smaller one behind it can still get in. " + "\n\n" + "Relevance, not seed count, is the right ordering here: a module can " + "only win as many seeds as it has terms, so counting them ranks " + "modules by size and excludes precisely the small, sharply relevant " + "vocabulary the closure exists to admit whole. A budget filled " + "best-first also keeps the usual case working, which is a " + "*combination* of modules rather than one winner. Read back from " + "module_closure_iris, module_closure_declined_iris and " + "module_closure_triples in the run manifest. Set via " + "ONTOLOGY_PATCH_SMALL_MODULE_CLOSURE_MAX_TOTAL_TRIPLES." + ), + ) per_role_atom_floor: int = Field( default=12, ge=0, @@ -1461,15 +1777,40 @@ class VectorStoreConfig(BaseSettings): ), ) top_k: int = Field( - default=20, + default=40, ge=1, description=( - "Default number of fused vector hits per query for ontology-patch retrieval. " - "Call sites may pass an explicit ``top_k`` to override this for a single " - "retrieval; when omitted, patch search uses this value. Raising it past 20 " - "measured no further seed-recall gain: the retained-atom cap " - "(ONTOLOGY_PATCH_MAX_ATOMS_BASE) binds first, so the extra candidates are " - "fetched and then discarded." + "Default number of fused vector hits per query for ontology-patch " + "retrieval. Call sites may pass an explicit ``top_k`` to override " + "this for a single retrieval; when omitted, patch search uses this " + "value. " + "\n\n" + "This is how many candidates each window *offers*, which is not the " + "same knob as how many atoms survive: ONTOLOGY_PATCH_MAX_ATOMS_BASE " + "caps the retained set, so a deeper list does not enlarge the " + "snapshot -- it fills the same budget from a wider field, and the " + "terms it recovers are the ones a shallower list ranked just out of " + "reach. Conflating the two caps is what made this read as inert. " + "Deeper costs vector-search time and nothing in the prompt." + ), + ) + bm25_top_k: int | None = Field( + default=None, + ge=1, + description=( + "Depth of the sparse (BM25) lane, when it should differ from TOP_K. " + "None (default) uses TOP_K for every lane, which is the historical " + "behaviour. " + "\n\n" + "Depth is a weight in disguise here, because lanes are fused by " + "reciprocal rank: a sparse list of length N contributes ranks 1..N at " + "full lane weight however weak its tail is. Dense and lexical retrieval " + "also fail differently -- dense degrades into topical near-misses, " + "lexical into unrelated text sharing a token -- so the depth at which " + "each stops paying is not the same number, and one knob for both means " + "tuning either mis-tunes the other. Lower it to keep the sparse lane as " + "evidence for the terms it is uniquely good at (symbols, notations, " + "formulae) without letting its tail vote. Set via VECTOR_STORE_BM25_TOP_K." ), ) induced_subgraph_depth: int = Field( @@ -1559,13 +1900,131 @@ class VectorStoreConfig(BaseSettings): proposition_window_sentences: int = Field( default=2, ge=1, - le=4, - description="Sentence window size used for proposition-level retrieval slicing.", + description=( + "Sentence window size used for proposition-level retrieval slicing. " + "Bounded in practice by the embedding model's sequence limit, not by this " + "setting: a window longer than the encoder accepts is truncated by the " + "encoder, silently, so widening past that point discards query text rather " + "than matching more of it. Watch chapter/query truncation in the retrieval " + "metrics when raising it." + ), + ) + proposition_window_stride: int | None = Field( + default=None, + ge=1, + description=( + "Sentences advanced between consecutive windows. None (default) strides by " + "the full window size, so windows are contiguous and disjoint -- the " + "historical behaviour. " + "\n\n" + "A smaller stride overlaps them, which matters because a statement whose " + "subject and value straddle a window boundary is otherwise in no window at " + "all: neither half is a query that can retrieve the term the whole " + "sentence pair names. Overlap costs queries -- and therefore embedding " + "time and, once PROPOSITION_MAX_WINDOWS binds, coverage elsewhere in the " + "unit. Set via VECTOR_STORE_PROPOSITION_WINDOW_STRIDE." + ), + ) + proposition_window_max_chars: int | None = Field( + default=None, + ge=1, + description=( + "Characters per retrieval query window. None (default) bounds windows " + "by PROPOSITION_WINDOW_SENTENCES instead, which is the historical " + "behaviour and is reproduced exactly. When set, this replaces the " + "sentence bound rather than joining it: the two are alternative ways " + "of saying how much text one query carries, and honouring both means " + "the tighter one silently wins." + "\n\n" + "A sentence count bounds the wrong quantity. What decides whether a " + "query works is its length: how many distinct terms one embedding " + "vector must carry, and whether the encoder truncates it -- which it " + "does on tokens, silently, returning a correctly shaped vector for a " + "prefix of the text. Two sentences of technical prose span an order of " + "magnitude in length, so a single sentence setting is simultaneously " + "too loose for some windows and too tight for others. The splitter " + "also has no abbreviation handling and breaks on every period, so a " + "citation like 'J. Phys. Chem. Lett.' reads as four sentences and a " + "two-sentence window over it carries nothing retrievable." + "\n\n" + "A budget fixes both ends: it caps windows that would truncate, and it " + "coalesces short fragments, since it keeps taking sentences until the " + "budget is met. Set it below the encoder's sequence limit to make " + "truncation impossible; read EMBEDDING_MODEL_NAME's limit and the " + "observed characters-per-token off the retrieval metrics rather than " + "assuming a ratio. Query-side only: changing it needs no reindex." + ), + ) + proposition_window_max_tokens: int | None = Field( + default=None, + ge=1, + description=( + "Encoder word pieces per query window. None (default) leaves the other " + "bounds in charge; when set it replaces both PROPOSITION_WINDOW_SENTENCES " + "and PROPOSITION_WINDOW_MAX_CHARS, and characters stop being consulted." + "\n\n" + "This is the only bound stated in the unit the encoder itself counts in. " + "Characters per token drift with notation, so a character budget that " + "fits one passage truncates the next; set below the encoder's sequence " + "limit, a token budget rules truncation out by construction. It is also " + "the only bound that cuts a sentence longer than the budget -- at a " + "whitespace boundary -- because such a sentence is truncated by the " + "encoder anyway and its tail then reaches no lane at all." + "\n\n" + "Needs a provider that exposes a tokenizer; without one the budget " + "degrades to a character approximation and logs that it did. " + "Query-side only: changing it needs no reindex." + ), + ) + proposition_window_overlap: float = Field( + default=0.0, + ge=0.0, + lt=1.0, + description=( + "Fraction of a window repeated at the start of the next one, under a " + "character or token budget. 0.0 (default) leaves windows disjoint. " + "PROPOSITION_WINDOW_STRIDE is the sentence-granular spelling of the same " + "idea and does not apply under a budget, where a sentence says nothing " + "about how much text is shared. Overlap multiplies queries, so it costs " + "embedding time and, once PROPOSITION_MAX_WINDOWS binds, coverage " + "elsewhere in the unit." + ), + ) + proposition_abbreviation_aware: bool = Field( + default=False, + description=( + "Rejoin window fragments the period-splitter created inside an " + "abbreviation, an initial or a citation run. Off by default: it changes " + "the windows every existing measurement was taken on." + "\n\n" + "The splitter breaks on every period, so a citation reads as several " + "'sentences' and a sentence-bounded window over one of them can carry a " + "dozen characters with nothing retrievable in them. General English and " + "bibliographic shapes only -- initials, a small abbreviation list, and a " + "guard for a fragment that does not open a sentence -- never a domain " + "vocabulary, which would make retrieval corpus-specific." + ), + ) + proposition_measurement_aware: bool = Field( + default=False, + description=( + "Forbid a window break between a number and the unit it is written with, " + "or inside a range, using the number/unit shapes in the shared " + "measurement lexicon (shapes, not a unit vocabulary). Only binds where a " + "cut inside a sentence is possible, which means PROPOSITION_WINDOW_MAX_" + "TOKENS with a sentence over budget: a window ending on 'a red shift of " + "~10' retrieves nothing that the number and its unit together would." + ), ) proposition_max_windows: int = Field( default=16, ge=1, - description="Upper bound on proposition windows generated per document excerpt.", + description=( + "Upper bound on proposition windows generated per document excerpt. Over " + "the bound windows are subsampled evenly rather than truncated, so the " + "excerpt stays covered end to end -- but the text in the dropped windows " + "reaches no dense or sparse lane at all." + ), ) proposition_retrieval_enabled: bool = Field( default=True, @@ -1654,6 +2113,24 @@ class VectorStoreConfig(BaseSettings): "tie-breaker." ), ) + fusion_rank_constant: float = Field( + default=0.0, + ge=0.0, + description=( + "Smoothing term added to each rank before the reciprocal in lane fusion: " + "a lane contributes weight / (constant + rank). 0.0 (default) is the " + "historical behaviour. " + "\n\n" + "At 0 the decay is brutal -- a rank-2 hit is worth half a rank-1 hit and " + "rank 3 a third -- so the fused order is decided almost entirely by which " + "lane put what first, and a lane's deep tail still votes at full weight. " + "Raising it flattens the curve so that agreement *across* lanes outweighs " + "position *within* one, which is the property reciprocal-rank fusion is " + "normally chosen for. The useful range is bounded by the lane depth: a " + "constant far above TOP_K makes every rank nearly equal, which discards " + "the ranking. Set via VECTOR_STORE_FUSION_RANK_CONSTANT." + ), + ) minimal_label_limit: int = Field( default=5, ge=0, @@ -1842,14 +2319,19 @@ class VectorStoreConfig(BaseSettings): ), ) query_unit_signals_enabled: bool = Field( - default=False, + default=True, description=( "Match number-adjacent tokens in the unit text ('4-15 days', " "'200 kV', '0.5 %') case-insensitively and plural-tolerantly " "against catalog surface forms (labels, symbols, UCUM codes) and " "inject the matched entities as additional snapshot seeds at " - "lexical_trigger_score, outside the semantic atom budget. Off by " - "default until the recall-corpus sweep validates it." + "lexical_trigger_score, outside the semantic atom budget. The " + "mechanism fits quantitative extraction exactly -- a stated " + "measurement is a number followed by a unit, and the terms this " + "recovers are the units and qualifiers a value node needs. Query " + "time only, so enabling or disabling it needs no reindex. Turn it " + "off for a catalog whose surface forms are not Latin-script, or " + "one where the facts being extracted are not quantities." ), ) symbol_case_mismatch_policy: SymbolCaseMismatchPolicy = Field( @@ -2014,28 +2496,62 @@ class FactsValidationConfig(BaseSettings): "to an IRI from the ontology context." ), ) - llm_repair_visits: int = Field( + critic_passes: int = Field( default=1, ge=0, description=( - "Finding-driven repair budget per unit, in **LLM calls**: extra " - "render_facts_update requests fed with machine-found MANDATORY " - "fixes (quarantined literals, unknown terms, alias leftovers) and " - "numeric-coverage candidates. Only the trigger is deterministic — " - "each visit is billed. They fire even at MAX_VISITS=1, where the " - "LLM critic never runs, so the default costs up to two provider " - "calls per unit. Set 0 to keep extraction at exactly one call per " - "unit and leave findings to the LLM-free repairs and the gate." + "Review-and-patch passes per facts unit, in **LLM calls**. Each " + "pass re-runs the deterministic checks for free, sends the graph " + "and its findings to the critic, and applies what comes back as a " + "compiled patch. At the default of 1 a unit costs two provider " + "calls: one extraction, one review. Set 0 for extraction only, " + "leaving findings to the LLM-free repairs and the gate." + ), + ) + critic_max_delete_share: float = Field( + default=0.25, + ge=0.0, + le=1.0, + description=( + "Largest share of a unit graph one critic pass may remove. Past " + "this every fix that removes something goes back as residual and " + "only pure additions are applied: a critique wanting to remove " + "more than this has stopped correcting and started rewriting, and " + "a REPLACE stripped of its delete half would be an ADD of the new " + "value beside the old one." + ), + ) + critic_min_deletes: int = Field( + default=5, + ge=0, + description=( + "Deletions always permitted regardless of share. Without a floor " + "the share cap is strictest on short units, where a single " + "legitimate correction is already a large fraction of the graph." + ), + ) + critic_allow_subject_rename: bool = Field( + default=False, + description=( + "Whether a REPLACE may delete statements about one subject while " + "writing about another. That is a rename, and carried out literally " + "it orphans the old node and leaves the new one bare." ), ) property_alias_min_ratio: float = Field( - default=0.85, + default=0.95, ge=0.0, le=1.0, description=( - "SequenceMatcher cutoff for deterministic near-miss property " - "rewrites in catalog namespaces (token containment always " - "qualifies)." + "SequenceMatcher floor for breaking a tie in the deterministic " + "near-miss property rewrite. A predicate absent from both the " + "unit's ontology context and the full catalog is rewritten only " + "to a catalog term whose name tokens contain, are contained by, " + "or spell the same as its own; when several such terms qualify, " + "the best-scoring one wins if it clears this ratio outright. " + "Similarity alone never licenses a rewrite: a high ratio also " + "joins names that differ by a single token, and rewriting across " + "that gap substitutes one property for another." ), ) merge_repair_passes: int = Field( @@ -2062,6 +2578,35 @@ class FactsValidationConfig(BaseSettings): "findings gate alone." ), ) + numeric_identifier_guard: bool = Field( + default=True, + description=( + "Exclude digit groups that are parts of an identifier from the " + "numeric-coverage inventory. Without it a file number or a " + "citation ('600/92', '2024-01-15') is tokenized into digit groups " + "and offered to the critic as numbers missing from the graph, " + "which it answers by structuring them into numeric properties -- " + "and the post-merge multi-value check then flags them. Only groups " + "joined to an identifier inside one token are dropped; a unit " + "attached to a value ('5mg') still counts. Set false to list every " + "digit group." + ), + ) + context_from_units: bool = Field( + default=True, + description=( + "In facts-only runs, seed the merge/validate ontology context from " + "the ontology snapshots the units actually resolved. With no " + "ontology stage there are no reduced artifacts to merge, so the " + "document-level context is otherwise empty and BOTH consumers see " + "it: the aggregator loses the type and functionality declarations " + "its merge guards read, and the validation gate skips every check " + "that needs a vocabulary (reported as " + "validated_without_ontology_context in the retrieval metrics). Set " + "false to reproduce the empty-context behaviour; watch " + "validated_without_ontology_context and ontology_snapshot_triples." + ), + ) suspect_multi_value_severity: Literal["error", "warning"] = Field( default="error", description=( @@ -2071,6 +2616,51 @@ class FactsValidationConfig(BaseSettings): "the un-merge repair." ), ) + suspect_multi_value_require_cross_unit: bool = Field( + default=False, + description=( + "Require evidence that a multi-valued IRI predicate was created by " + "merging before reporting it as an error. The IRI branch of " + "SUSPECT_MULTI_VALUE flags any subject with two objects on a " + "predicate that is single-valued elsewhere in the graph, and error " + "findings drive the un-merge repair -- so a statement one unit " + "asserted with two genuine objects is repaired away. When set, " + "such a pair is reported as a warning instead. Numeric and string " + "branches are unaffected: two distinct quantities on one node are " + "a defect whatever their provenance." + ), + ) + domain_adherence_min_share: float = Field( + default=0.15, + ge=0.0, + le=1.0, + description=( + "Floor on the fraction of a render's distinct schema terms " + "(predicates and rdf:type objects, excluding minted instances and " + "RDF/RDFS/OWL/XSD/SKOS/DC/PROV plumbing) that must come from the " + "unit's ontology context, below which a mandatory " + "DOMAIN_ADHERENCE finding asks for a rewrite. Catches the failure " + "no per-triple check can see: a render that expresses everything " + "in a generic vocabulary is well-formed term by term, exempt from " + "UNKNOWN_TERM, and invisible to every shape, so it reads as " + "extracted while answering nothing. Set 0 to disable, and leave it " + "disabled for deployments that extract without a catalog. Raise it " + "toward the share your own healthy runs report -- watch " + "domain_adherence in the facts findings to calibrate." + ), + ) + domain_adherence_min_terms: int = Field( + default=4, + ge=0, + description=( + "Fewest distinct schema terms a render must use before its catalog " + "share is judged at all. A share over one or two terms is noise: a " + "front-matter unit that types an identifier and an author with " + "generic vocabulary has not abandoned the catalog, and the " + "mandatory finding it raised drove the critic into retyping the " + "identifier as a quantity value. 0 judges every non-empty render." + ), + ) additional_standard_namespaces: list[str] = Field( default_factory=lambda: ["https://schema.org/", "http://schema.org/"], description=( @@ -2168,6 +2758,113 @@ class FactsValidationConfig(BaseSettings): "unaffected." ), ) + shapes_prompt_contract: Literal["off", "auto", "full", "context"] = Field( + default="auto", + description=( + "Render the loaded SHACL shapes as a CONFORMANCE REQUIREMENTS " + "chapter in the facts render and critic prompts, so the renderer " + "is shown the same rulebook the gate validates against instead of " + "being graded on rules no prompt states. The chapter is derived " + "from the shapes at run time -- sh:message verbatim where the " + "author wrote one, a synthesized structural line otherwise. " + "Modes: 'off' -- no chapter; 'full' -- the whole catalog, capped " + "at shapes_prompt_max_lines and memoized per tenancy; 'context' " + "-- per-unit selection, keeping only shapes whose target " + "classes/properties intersect the unit's resolved ontology " + "snapshot (shape relevance is derivative of ontology-term " + "relevance, so the snapshot is the join key and no separate " + "retrieval is needed); 'auto' (default) -- 'full' while the " + "catalog fits the line cap, 'context' once it outgrows it, which " + "keeps small catalogs run-constant and stops large ones being " + "blind-truncated in document order. A deployment without shapes " + "sees an unchanged prompt in every mode. Terms the shapes " + "require are exempt from UNKNOWN_TERM (full catalog, whatever " + "the mode -- they are catalog IRIs, and the validator must never " + "order removal of what the contract asks for)." + ), + ) + shapes_prompt_max_lines: int = Field( + default=60, + ge=1, + description=( + "Cap on total rule lines in the shapes-derived conformance " + "chapter. A prompt-size guard, not a relevance ranking: the " + "chapter notes in-text when rules were truncated, so the model " + "does not read absence as nonexistence." + ), + ) + numeric_coverage_limit: int = Field( + default=30, + ge=0, + description=( + "Cap on missing-numeric mentions listed in a NUMERIC_COVERAGE " + "finding. Bounds prompt size; ordering is shortest-first " + "presentation order, not relevance. 0 disables the finding " + "entirely." + ), + ) + numeric_coverage_mandatory: Literal["off", "measurements", "all"] = Field( + default="off", + description=( + "Which NUMERIC_COVERAGE findings are mandatory, so unextracted " + "source numerics block unit acceptance like any other " + "deterministic finding. The inventory is split in two: " + "'measurements' are numbers written next to a unit, 'unclassified' " + "are bare numbers. 'off' (default) keeps both findings advisory; " + "'measurements' blocks on the unit-adjacent list only; 'all' " + "blocks on both. The booleans true/false are accepted as all/off. " + "Advisory by default because the critic judges per mention " + "whether it is a quantity or an artifact, and blocking forces that " + "judgement into the loop; 'measurements' is the setting for runs " + "that treat a missed unit-adjacent value as a defect." + ), + ) + + @field_validator("numeric_coverage_mandatory", mode="before") + @classmethod + def _coverage_mode_from_bool(cls, value: object) -> object: + """Accept the boolean this knob used to be. + + ``true`` meant every coverage finding blocks, which is now ``all``; + ``false`` is ``off``. Env values arrive as strings, so the textual + booleans are folded too. + """ + if isinstance(value, bool): + return "all" if value else "off" + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in ("true", "1", "yes", "on"): + return "all" + if lowered in ("false", "0", "no", ""): + return "off" + return lowered + return value + + critic_min_triples: int = Field( + default=1, + ge=0, + description=( + "Skip the facts critic for a unit whose render holds fewer triples " + "than this. A critic shown an empty graph scores it perfect and " + "bills a call for nothing; the default skips exactly the empty " + "renders, which are then recorded as skipped rather than reviewed. " + "0 reviews every unit." + ), + ) + completion_passes: int = Field( + default=0, + ge=0, + description=( + "Completion passes per facts unit, in **LLM calls**, run after the " + "critic loop and only when the numeric inventory still lists " + "measurements (a number with its unit) absent from the graph. The " + "pass is insert-only: it is shown a term sheet instead of the " + "ontology chapter, the unit's existing catalog-typed subjects, the " + "text, and the missed measurements with their context, and each " + "new subject it writes is kept or rolled back by the same " + "regression check a critic fix goes through. 0 disables it." + ), + ) shacl_max_triples: int = Field( default=200_000, ge=0, @@ -2245,6 +2942,48 @@ class OntologyValidationConfig(BaseSettings): "'off' disables the scan." ), ) + critic_passes: int = Field( + default=0, + ge=0, + description=( + "Review-and-patch passes per ontology unit, in **LLM calls**. " + "Defaults to 0, which is what the loop has always done in practice: " + "the ontology critic was gated behind a spare render slot and so " + "has never run on a default deployment. Enabling it is a real " + "increase in cost per unit, so it is opt-in until measured." + ), + ) + critic_max_delete_share: float = Field( + default=0.10, + ge=0.0, + le=1.0, + description=( + "Largest share of the delta one critic pass may remove. Stricter " + "than the facts equivalent because an ontology delete propagates " + "onto shared, versioned catalog terminals: its blast radius is " + "every document using the term, not this unit." + ), + ) + critic_min_deletes: int = Field( + default=3, + ge=0, + description="Deletions always permitted regardless of share.", + ) + accept_blocking_finding_kinds: list[str] = Field( + default_factory=lambda: [ + "foreign_delete", + "foreign_namespace", + "subclass_cycle", + "role_confusion", + ], + description=( + "Deterministic ontology findings that block acceptance. The default " + "is the destructive-or-lossy subset only. Blocking on every " + "mandatory finding would put `missing_label` in the set, which " + "fires whenever a render mints a term without a label -- routine, " + "and a permanent per-unit tax rather than a defect signal." + ), + ) model_config = SettingsConfigDict( env_prefix="ONTOLOGY_", @@ -2289,6 +3028,72 @@ def _reject_dual_vector_backends(self) -> ToolConfig: ) return self + @model_validator(mode="after") + def _reject_mmr_with_atom_floors(self) -> ToolConfig: + """MMR and the atom floors are two selection policies for one budget. + + MMR replaces the selection outright, so a non-zero floor is not + enforced under it -- and the failure is silent, because a guarantee + that stops holding produces no error and no metric. Reserving the floor + slots first is not a fix either: the reserve is taken in score order and + would leave MMR nothing to choose whenever one ontology supplies the + candidates, which is the common case. + + So the combination is rejected rather than resolved. Both remedies are + one setting away, and which one is wanted is the operator's decision, + not a default this can guess. + """ + pc = self.patch_retrieval + if pc.mmr_lambda >= 1.0: + return self + floors = { + "ONTOLOGY_PATCH_PER_ONTOLOGY_ATOM_FLOOR": pc.per_ontology_atom_floor, + "ONTOLOGY_PATCH_PER_ROLE_ATOM_FLOOR": pc.per_role_atom_floor, + } + engaged = {name: value for name, value in floors.items() if value > 0} + if not engaged: + return self + named = ", ".join(f"{name}={value}" for name, value in engaged.items()) + raise ValueError( + f"ONTOLOGY_PATCH_MMR_LAMBDA={pc.mmr_lambda} enables MMR reranking, " + f"which selects the whole atom budget itself and therefore cannot " + f"honour the atom floors ({named}). Set the floors to 0 to choose " + f"MMR, or MMR_LAMBDA=1.0 to keep the floors." + ) + + @model_validator(mode="after") + def _warn_on_unreachable_retrieval_settings(self) -> ToolConfig: + """Warn where a retrieval setting cannot bind, before a run pays for it. + + Not an error -- the configuration runs and produces a result. It is + reported because the result is not the one the setting asks for, and + nothing downstream says so: a sweep spends points on an axis that moves + nothing, and an operator reads a number back as evidence about a knob + that never applied. + """ + pc, sc = self.patch_retrieval, self.vector_store + + # The window count is capped, so the scaled budget has a ceiling; above + # it the hard cap is unreachable and every value is the same run. + max_scaled = max( + pc.max_atoms_base, pc.seeds_per_window * sc.proposition_max_windows + ) + if pc.max_atoms > max_scaled: + logger.warning( + "ONTOLOGY_PATCH_MAX_ATOMS=%d cannot bind: the effective cap is " + "min(max_atoms, max(MAX_ATOMS_BASE=%d, SEEDS_PER_WINDOW=%d * " + "windows)), and windows is capped by " + "VECTOR_STORE_PROPOSITION_MAX_WINDOWS=%d, so the cap never " + "exceeds %d. Raise MAX_ATOMS_BASE to raise the budget.", + pc.max_atoms, + pc.max_atoms_base, + pc.seeds_per_window, + sc.proposition_max_windows, + max_scaled, + ) + + return self + class Config(BaseSettings): """Main OntoCast configuration. @@ -2318,6 +3123,50 @@ class Config(BaseSettings): extra="ignore", ) + @model_validator(mode="after") + def warn_when_a_per_unit_chapter_defeats_the_shared_prefix(self) -> "Config": + """Warn when a per-unit conformance chapter cancels a document-scoped one. + + ``ONTOLOGY_CONTEXT_SCOPE=document`` exists to give every unit in the + fan-out one byte-identical prompt prefix, so a provider's prefix cache + can serve every call after the first. The prefix runs from the preamble + through the end of the ontology chapter -- and the *conformance* chapter + sits inside it. A shapes contract of ``context`` selects requirements per + unit, which makes that chapter differ per call and defeats the scope + setting entirely. + + This is a warning rather than an error because ``auto`` reaches + ``context`` on its own once a catalog outgrows the line budget, so a + deployment can arrive here by adding shapes rather than by setting + anything -- and refusing to start would be the wrong answer to that. + The two settings live on different config objects, so this is the only + place that can see both. + """ + if self.server.ontology_context_scope != OntologyContextScope.DOCUMENT: + return self + contract = self.tool_config.facts_validation.shapes_prompt_contract + if contract == "context": + logger.warning( + "FACTS_SHAPES_PROMPT_CONTRACT=context makes the conformance " + "chapter per-unit, and that chapter sits inside the prompt " + "prefix ONTOLOGY_CONTEXT_SCOPE=document is trying to share -- " + "so no two calls in a document will share a prefix and " + "prefix_cache_hit_rate will not improve. Use " + "FACTS_SHAPES_PROMPT_CONTRACT=full to keep the prefix stable, " + "or drop the document scope." + ) + elif contract == "auto": + logger.info( + "ONTOLOGY_CONTEXT_SCOPE=document with " + "FACTS_SHAPES_PROMPT_CONTRACT=auto: if the shapes catalog " + "outgrows FACTS_SHAPES_PROMPT_MAX_LINES the contract resolves " + "to 'context', which makes the conformance chapter per-unit and " + "defeats the shared prompt prefix. Read " + "validation_config.shapes_prompt_selection in the run manifest " + "to see which way it resolved." + ) + return self + @classmethod def in_memory(cls, **overrides: Any) -> "Config": """Build a configuration that needs no external services. diff --git a/ontocast/onto/constants.py b/ontocast/onto/constants.py index 96a286ad..7e80f5d7 100644 --- a/ontocast/onto/constants.py +++ b/ontocast/onto/constants.py @@ -63,6 +63,8 @@ def prefix_lookup_for_ingest() -> dict[str, str]: PROV = Namespace("http://www.w3.org/ns/prov#") SCHEMA = Namespace("https://schema.org/") +FOAF = Namespace("http://xmlns.com/foaf/0.1/") +DCTERMS = Namespace("http://purl.org/dc/terms/") #: Terms OntoCast mints for pipeline telemetry that no standard vocabulary #: covers. Deliberately outside ``DEFAULT_IRI``: fact namespaces drive the @@ -90,6 +92,23 @@ def prefix_lookup_for_ingest() -> dict[str, str]: } ) +#: Classes and predicates the pipeline mints on the document node itself +#: (``apply_document_metadata_provenance``). Same exemption class as +#: :data:`PROVENANCE_METADATA_TERMS`: scaffolding the pipeline writes, not +#: vocabulary the renderer was offered and then invented. Read from here +#: rather than an allow-list of IRIs so the finding cannot go stale when the +#: document node's shape changes. +DOCUMENT_METADATA_TERMS: frozenset[URIRef] = frozenset( + { + FOAF.Document, + DCTERMS.title, + DCTERMS.issued, + DCTERMS.source, + DCTERMS.identifier, + PROV.wasAttributedTo, + } +) + # RDF 1.2 term for linking a reification node to its quoted triple. # Not yet in rdflib's RDF namespace, so we define it manually. RDF_REIFIES = URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#reifies") diff --git a/ontocast/onto/content_unit.py b/ontocast/onto/content_unit.py index da5e8895..7a71ec01 100644 --- a/ontocast/onto/content_unit.py +++ b/ontocast/onto/content_unit.py @@ -77,6 +77,15 @@ class SourceUnit(BaseModel): "rendering extracts citation metadata only, never domain facts." ), ) + is_non_content: bool = Field( + default=False, + description=( + "Unit detected as front/back matter with no domain facts (author " + "block, notes, ORCID, data availability, licence) and kept under " + "CHUNK_NON_CONTENT_MODE=extract; checks that presume domain prose " + "should stand down on it." + ), + ) _hid: str = PrivateAttr(default="") @field_validator("doc_iri", mode="before") @@ -96,7 +105,7 @@ def hid(self) -> str: return self._hid @property - def iri(self): + def iri(self) -> str: """Get the base IRI for this unit. Returns: @@ -105,7 +114,7 @@ def iri(self): return DEFAULT_IRI @property - def iri_absolute(self): + def iri_absolute(self) -> str: """Get the absolute IRI for this unit. Returns: @@ -114,7 +123,7 @@ def iri_absolute(self): return f"{self.doc_iri}/{self.hid}" @property - def namespace(self): + def namespace(self) -> str: """Get the namespace for this unit. Returns: @@ -157,7 +166,7 @@ def graph_absolute(self): return self._graph_absolute @property - def generated_at_iso(self): + def generated_at_iso(self) -> str: """Get generated timestamp in ISO format. Returns: diff --git a/ontocast/onto/docling_helpers.py b/ontocast/onto/docling_helpers.py index 7f34cec2..fe32075d 100644 --- a/ontocast/onto/docling_helpers.py +++ b/ontocast/onto/docling_helpers.py @@ -16,6 +16,44 @@ _LIGATURE_GAP_RE = re.compile(r"(?<=[A-Za-z]) (ffi|ffl|fi|fl|ff) (?=[A-Za-z])") +# Single-sided ligature gaps, accepted only where the joined form cannot be +# two real words. No English word starts with "ff", so "a ffected" and +# "di fferent" have one reading; and a word ending in "fi"/"fl" with a letter +# before it ("signifi cant", "confi ned", "refl ected") has no two-word +# reading either -- "fl oz" is excluded by that letter-before requirement. The +# symmetric gap *before* "fi"/"fl" ("the field", "a flat", "of it") is a real +# two-word phrase far too often to touch. +_LIGATURE_GAP_BEFORE_FF_RE = re.compile(r"(?<=[A-Za-z]) (?=ff)") +_LIGATURE_GAP_AFTER_FI_FL_RE = re.compile(r"(?<=[A-Za-z]f[il]) (?=[A-Za-z])") + +# The entities conversion leaves escaped inside prose (a "<" in "T < 300 K" +# is a comparison, not markup). Deliberately not html.unescape, which also +# expands semicolon-less entities and would turn a "¶" in running text +# into a pilcrow. +_HTML_ENTITY_RE = re.compile(r"&(lt|gt|amp|quot|apos);") +_HTML_ENTITIES = {"lt": "<", "gt": ">", "amp": "&", "quot": '"', "apos": "'"} + +# A carriage return, inline whitespace, newline is a column-wrap artifact. +# Only inline whitespace is consumed: "\r \n\n" is a paragraph break and +# must stay one. +_CARRIAGE_RETURN_WRAP_RE = re.compile(r"\r[ \t\f\v]*\n") + +# "2 × 10 6" is "2 × 10^6" with the superscript flattened: one or two exponent +# digits and never the start of a hyphenated word ("10 6-membered"). The bare +# "10 6" is rejoined only behind an approximation cue ("~", "≈", "order of"); +# on its own it is two numbers more often than one. +# +# A sign is read only in the product form, where the mantissa "×" says the +# tail is one number. Behind a bare cue the same characters are far more often +# a range -- "∼10−15 meV" is ten to fifteen, and publishers typeset the range +# dash as U+2212, the very character a negative exponent would use. +_EXPONENT_TAIL_SIGNED = r"10(?:\s+|\s*(?P[-−–])\s*)(?P\d{1,2})\b(?![-−–]\w)" +_EXPONENT_TAIL_BARE = r"10\s+(?P\d{1,2})\b(?![-−–]\w)" +_EXPONENT_PRODUCT_RE = re.compile(r"(?P\d)\s*[×x]\s*" + _EXPONENT_TAIL_SIGNED) +_EXPONENT_BARE_RE = re.compile( + r"(?P(?:order\s+of|[~≈∼≃])\s*)" + _EXPONENT_TAIL_BARE +) + def plain_text_to_docling_doc(text: str, doc_name: str) -> DoclingDocument: """Wrap plain text as a single-paragraph DoclingDocument.""" @@ -60,19 +98,103 @@ def repair_ligature_gaps(text: str) -> str: return _LIGATURE_GAP_RE.sub(r"\1", text) +def unescape_html_entities(text: str) -> str: + """Expand ``<``, ``>``, ``&``, ``"`` and ``'`` only.""" + return _HTML_ENTITY_RE.sub(lambda match: _HTML_ENTITIES[match.group(1)], text) + + +def normalize_carriage_return_wraps(text: str) -> str: + """Collapse ``\\r\\n`` column wraps to a newline.""" + return _CARRIAGE_RETURN_WRAP_RE.sub("\n", text) + + +def _rejoin_exponent(prefix: str, match: re.Match[str]) -> str: + sign = "-" if match.groupdict().get("sign") else "" + return f"{prefix}10^{sign}{match.group('exp')}" + + +def rejoin_flattened_exponents(text: str) -> str: + """Rejoin ``2 × 10 6`` to ``2 × 10^6`` and ``~10 6`` to ``~10^6``. + + The product form needs a mantissa digit before ``×``/``x``; the bare form + needs an approximation cue before ``10``. Neither fires when the would-be + exponent starts a hyphenated word. + + Only the product form reads a sign. Behind a bare cue a dash between two + numbers is ambiguous -- a negative exponent and a range are written with + the same characters, and the range is the common reading -- so the rule + declines it. The risk is asymmetric: leaving a real ``∼10−6`` as written + costs nothing downstream, while rewriting a range to an exponent destroys + the value and leaves no trace of having done so. + """ + text = _EXPONENT_PRODUCT_RE.sub( + lambda match: _rejoin_exponent(f"{match.group('mantissa')} × ", match), text + ) + return _EXPONENT_BARE_RE.sub( + lambda match: _rejoin_exponent(match.group("cue"), match), text + ) + + +def repair_single_sided_ligature_gaps(text: str) -> str: + """Close ligature gaps that leave the ligature glued to one side. + + Only the two shapes with a single reading are closed: a gap before ``ff`` + (``a ffected``) and a gap after a letter-preceded ``fi``/``fl`` + (``signifi cant``). See the pattern comments for what is left alone. + """ + text = _LIGATURE_GAP_BEFORE_FF_RE.sub("", text) + return _LIGATURE_GAP_AFTER_FI_FL_RE.sub("", text) + + +def repair_numeric_artifacts(text: str) -> str: + """Repair the conversion artifacts that are pattern-local and safe. + + Composes :func:`unescape_html_entities`, + :func:`normalize_carriage_return_wraps`, + :func:`rejoin_flattened_exponents` and + :func:`repair_single_sided_ligature_gaps`, in that order. Each rule + rewrites only a span whose repaired reading is the sole plausible one; + superscript/subscript duplication and citation markers fused into values + are not recoverable from the text and are deliberately not touched. + + Args: + text: One text item as emitted by conversion. + + Returns: + The repaired text; unchanged when no rule applies. + """ + text = unescape_html_entities(text) + text = normalize_carriage_return_wraps(text) + text = rejoin_flattened_exponents(text) + return repair_single_sided_ligature_gaps(text) + + def apply_text_sanitizers( doc: DoclingDocument, *, repair_ligature_gaps_enabled: bool = False, + repair_numeric_artifacts_enabled: bool = False, ) -> DoclingDocument: - """Apply optional post-conversion text sanitizers to a DoclingDocument.""" - if not repair_ligature_gaps_enabled: + """Apply the enabled post-conversion text sanitizers to every text item. + + Runs once at conversion time, so chunk boundaries and the on-disk chunk + cache are computed on repaired text; both flags sit in the converter cache + key. The two-sided ligature rule runs first so a fully isolated ligature + (``e ff ect``) is closed before the single-sided rules see its remainder. + """ + if not (repair_ligature_gaps_enabled or repair_numeric_artifacts_enabled): return doc - # Works around publisher-PDF ligature splits that Docling passes through. - # Removable once upstream Docling normalises ASCII fi/fl/ff gap patterns -- - # a breaking change, since the flag is in the converter cache key. for item in doc.texts: - item.text = repair_ligature_gaps(item.text) + text = item.text + if repair_ligature_gaps_enabled: + # Works around publisher-PDF ligature splits that Docling passes + # through. Removable once upstream Docling normalises ASCII + # fi/fl/ff gap patterns -- a breaking change, since the flag is in + # the converter cache key. + text = repair_ligature_gaps(text) + if repair_numeric_artifacts_enabled: + text = repair_numeric_artifacts(text) + item.text = text return doc diff --git a/ontocast/onto/enum.py b/ontocast/onto/enum.py index 9770a63e..e7444888 100644 --- a/ontocast/onto/enum.py +++ b/ontocast/onto/enum.py @@ -81,7 +81,31 @@ class RetrievalMetric(StrEnum): FACTS_FINDINGS_RESIDUAL = "facts_findings_residual" FACTS_MANDATORY_RESIDUAL = "facts_mandatory_residual" FACTS_CRITIC_CALLS = "facts_critic_calls" + FACTS_CRITIC_FIXES_APPLIED = "facts_critic_fixes_applied" + FACTS_CRITIC_FIXES_RESIDUAL = "facts_critic_fixes_residual" + FACTS_CRITIC_FIXES_NOOP = "facts_critic_fixes_noop" + FACTS_CRITIC_PATCHES_ROLLED_BACK = "facts_critic_patches_rolled_back" + ONTOLOGY_CRITIC_FIXES_APPLIED = "ontology_critic_fixes_applied" + ONTOLOGY_CRITIC_FIXES_RESIDUAL = "ontology_critic_fixes_residual" + ONTOLOGY_CRITIC_FIXES_NOOP = "ontology_critic_fixes_noop" + ONTOLOGY_CRITIC_PATCHES_ROLLED_BACK = "ontology_critic_patches_rolled_back" FACTS_CRITIC_ACCEPTED = "facts_critic_accepted" + #: Units whose critic call failed (timeout, unparseable response) and + #: left the loop unreviewed, and units the loop did not send to the + #: critic at all (empty render, citation metadata). + FACTS_CRITIC_UNITS_UNREVIEWED = "facts_critic_units_unreviewed" + FACTS_CRITIC_UNITS_SKIPPED = "facts_critic_units_skipped" + #: Per-fix outcomes of the compiled critique: fixes undone for leaving + #: the unit worse, inserts refused for minting a placeholder or an + #: annotation-only node, and payloads naming a prefix nothing declared. + FACTS_CRITIC_FIXES_ROLLED_BACK = "facts_critic_fixes_rolled_back" + FACTS_CRITIC_FIXES_JUNK_REFUSED = "facts_critic_fixes_junk_refused" + FACTS_CRITIC_FIXES_UNRESOLVED_PREFIX = "facts_critic_fixes_unresolved_prefix" + #: The insert-only completion pass: calls billed, triples that stayed + #: in, and missed measurements the inventory no longer lists afterwards. + FACTS_COMPLETION_CALLS = "facts_completion_calls" + FACTS_COMPLETION_TRIPLES_INSERTED = "facts_completion_triples_inserted" + FACTS_COMPLETION_MEASUREMENTS_RECOVERED = "facts_completion_measurements_recovered" # Aggregation and the un-merge repair. FACTS_REJECTED_MERGES = "facts_rejected_merges" @@ -126,6 +150,53 @@ class LLMGraphFormat(StrEnum): JSONLD = "jsonld" +class OntologyChapterFormat(StrEnum): + """Syntax of the ``# ONTOLOGY`` chapter in the facts prompts. + + - ``auto`` (default): ``term_sheet`` where the render mode is facts-only, + ``inherit`` otherwise. The cheapest chapter each mode can legally read, + chosen without asking an operator to know which those are. Resolved once + when the configuration is built, so nothing downstream -- profile lookup, + cache key, run manifest -- ever sees this member. + - ``inherit``: the chapter follows :class:`LLMGraphFormat`, so + the model reads the ontology in the syntax it is asked to write. + - ``turtle``: the chapter is Turtle whatever the wire format is. In a + facts prompt the ontology is read-only context -- nothing the model + emits has to match its syntax -- and Turtle spends fewer characters per + triple than pretty-printed JSON-LD, so this trades the read/write + symmetry for a shorter prompt. The graph payloads the model emits stay + in the wire format. + - ``term_sheet``: the chapter is a line-per-term listing rather than a + serialized graph -- name, surface forms, type, hierarchy, domain/range + and usage contract, without the per-statement RDF scaffolding or the + prose written for a human reader. Legal on the facts path only: the + ontology loop emits a patch against the statements it reads, so its + chapter has to remain a graph. + """ + + AUTO = "auto" + INHERIT = "inherit" + TURTLE = "turtle" + TERM_SHEET = "term_sheet" + + +class OntologyContextScope(StrEnum): + """Whether the ontology chapter is resolved per unit or once per document. + + - ``unit`` (default): each content unit retrieves its own context. The + smallest chapter per unit, and a different chapter for every one of them, + so a provider's prefix cache can serve none of them. + - ``document``: every unit's context is resolved once and unioned, and the + union is shown to all of them. Larger per call and recall-safe by + construction -- the union contains everything each unit's own retrieval + selected -- but identical across the fan-out, which is what makes the + chapter cacheable after the first call. + """ + + UNIT = "unit" + DOCUMENT = "document" + + class OntologyContextMode(StrEnum): """How per-unit ontology context is sourced before ontology/facts rendering.""" @@ -179,17 +250,6 @@ class WorkflowNode(StrEnum): CONSISTENCY_CRITIC = "Consistency Critic" -class SPARQLOperationType(StrEnum): - """Enumeration of SPARQL operation types. - - This enum is used across the system for type-safe SPARQL operations. - """ - - INSERT = "INSERT" - UPDATE = "UPDATE" - DELETE = "DELETE" - - class VectorStoreBackend(StrEnum): """Which vector store implementation backs ontology patch retrieval. diff --git a/ontocast/onto/model.py b/ontocast/onto/model.py index 3c0d0dd0..90de30f7 100644 --- a/ontocast/onto/model.py +++ b/ontocast/onto/model.py @@ -1,3 +1,4 @@ +import re from collections.abc import Sequence from enum import StrEnum from typing import Literal @@ -302,11 +303,53 @@ def normalize_severity(cls, v: str) -> str: ), ) + triple_ids: list[int] = Field( + default_factory=list, + description=( + "Ids of the statements this fix removes, taken from the bracketed " + "numbers in the graph chapter. REQUIRED for REMOVE and REPLACE. " + "Cite the id; never retype the statement." + ), + ) + + @field_validator("triple_ids", mode="before") + @classmethod + def coerce_triple_ids(cls, v: object) -> object: + """Accept the shapes providers actually return for a list of ints. + + A bare ``12``, a string ``"12"``, a stringified list ``"[12, 13]"`` and a + list of strings all mean the same thing. This field is the only way a + REMOVE or REPLACE can address anything, so a formatting quirk here would + discard the fix -- and, because the report validates as a whole, every + other fix alongside it. + """ + if v is None: + return [] + if isinstance(v, int) and not isinstance(v, bool): + return [v] + if isinstance(v, str): + v = [part for part in re.split(r"[^0-9]+", v) if part] + if not isinstance(v, (list, tuple, set)): + return v + ids: list[int] = [] + for item in v: + if isinstance(item, bool): + continue + if isinstance(item, int): + ids.append(item) + continue + if isinstance(item, str): + digits = "".join(ch for ch in item if ch.isdigit()) + if digits: + ids.append(int(digits)) + return ids + incorrect_value: str | None = Field( default=None, description=( - "Current incorrect triple/entity/value (for REMOVE and REPLACE). " - "Encoding is defined by deployment llm_graph_format and GRAPH FORMAT INSTRUCTION." + "Legacy. Prefer `triple_ids`. Only consulted when a REMOVE or " + "REPLACE cites no ids, because a retyped statement matches the " + "stored one a minority of the time." ), ) @@ -358,9 +401,13 @@ def to_markdown(self) -> str: if self.text_fragment: lines.append(f' - **Source text:** "{self.text_fragment}"') - # Add incorrect value for REMOVE and REPLACE actions - if self.action in ["REMOVE", "REPLACE"] and self.incorrect_value: - lines.append(f" - **Current (incorrect):** `{self.incorrect_value}`") + # Add the addressed statements for REMOVE and REPLACE actions + if self.action in ["REMOVE", "REPLACE"]: + if self.triple_ids: + cited = ", ".join(str(triple_id) for triple_id in self.triple_ids) + lines.append(f" - **Statements:** `[{cited}]`") + elif self.incorrect_value: + lines.append(f" - **Current (incorrect):** `{self.incorrect_value}`") # Add correct value for ADD and REPLACE actions if self.action in ["ADD", "REPLACE"] and self.correct_value: @@ -482,6 +529,29 @@ def coerce_systemic_critique_summary(cls, v: object) -> str: ) +class FactsCompletionReport(BaseModel): + """Output of the insert-only facts completion pass. + + Structurally the ADD-only slice of :class:`FactsCritiqueReport`: no + ``success``/``score`` because this pass does not judge the render, and no + ``systemic_critique_summary`` because there is nothing to summarize beyond + the fixes themselves. No web-search request either -- the pass runs after + the critic loop has already used its evidence budget. + """ + + proposed_fixes: list[TripleFix] = Field( + default_factory=list, + description=( + "One fix per new subject closure, action ADD only -- never REMOVE " + "or REPLACE, and `triple_ids` is unused. `correct_value` holds " + "every triple the closure needs (rdf:type, the recovered " + "measurement, rdfs:label), attached to an existing catalog-typed " + "subject when the EXISTING SUBJECTS chapter offers one for the " + "same entity rather than minting a duplicate." + ), + ) + + class ExternalEvidenceHit(BaseModel): """Normalized external evidence hit metadata.""" @@ -574,7 +644,11 @@ class FactsUnitFindingKind(StrEnum): NUMERIC_COVERAGE = "numeric_coverage" LABEL_ONLY_NUMBER = "label_only_number" SCALAR_AS_BOUNDS = "scalar_as_bounds" + UNIT_SYMBOL_CASE_MISMATCH = "unit_symbol_case_mismatch" DOMAIN_VIOLATION = "domain_violation" + #: The render used no term from the ontology it was given. Not a + #: property of any single triple, so no other kind can express it. + DOMAIN_ADHERENCE = "domain_adherence" #: Not machine-found: a fix the LLM critic proposed, carried through the #: same repair pipeline so a rejection costs one rewrite-in-place render #: instead of a full re-extraction. @@ -590,12 +664,26 @@ class UnitFinding(BaseModel): adjudicates item by item. """ + #: Declared here as a plain string so code serving both phases -- the + #: acceptance policy, the findings prompt block -- can read it without + #: knowing which enum it came from. Each subclass narrows it to its own + #: ``StrEnum``, whose members *are* strings, so nothing is widened in + #: practice and no kind from one phase can satisfy the other's annotation. + kind: str mandatory: bool = True message: str subject: str = "" predicate: str = "" value: str = "" suggestions: list[str] = Field(default_factory=list) + facet: str = Field( + default="", + description=( + "Variant within ``kind`` for findings that come in more than one: " + "numeric coverage is split into 'measurements' (a number written " + "with its unit) and 'unclassified' (a bare number)." + ), + ) class FactsUnitFinding(UnitFinding): @@ -730,26 +818,56 @@ class UnitFailure(BaseModel): reason: str | None = None -class LoopAttempt(BaseModel): - """Telemetry record for one attempt inside a per-unit render/critic loop. +class RolledBackFix(BaseModel): + """One critic fix applied and undone on its own for leaving the unit worse. + + Carried on the ``critic_patch`` attempt that rolled it back, so a dump + shows *which* correction was refused and why -- previously the whole + pass was undone and the record said only that it had been. + """ - Shared by the facts and the ontology loop — the fields are phase-neutral - and a record's home (``UnitFactsState.attempt_log`` vs - ``UnitOntologyState.attempt_log``) says which loop produced it. + triple_ids: list[int] = Field(default_factory=list) + correct_value: str = "" + #: ``delete_only`` (removed and wrote nothing), ``no_progress`` (shrank the + #: product without resolving anything), ``new_mandatory`` (raised the + #: mandatory finding count), or ``refused`` (the phase rejected the patch). + reason: str = "" + #: Mandatory findings after minus before the fix; positive means it + #: manufactured defects. + mandatory_delta: int = 0 - ``n_deterministic_findings`` / ``n_mandatory_findings`` count findings - against the graph as of this record: for ``llm_repair`` records that is the - residual *after* the repair render, so summing the last repair record per - unit yields the true document-level residual. - ``kind="llm_repair"`` is a finding-*driven* render — it costs a provider - call. LLM-free machine rewrites are not attempts and are recorded as - ``GraphRepairRecord`` instead. +class LoopAttempt(BaseModel): + """Telemetry record for one attempt inside a per-unit render/critic loop. + + Shared by the facts and the ontology loop — the fields are phase-neutral + and a record's home (``UnitFactsState.attempt_log`` vs + ``UnitOntologyState.attempt_log``) says which loop produced it. + + ``n_deterministic_findings`` / ``n_mandatory_findings`` count findings + against the graph as of this record: for ``llm_repair`` records that is the + residual *after* the repair render, so summing the last repair record per + unit yields the true document-level residual. + + ``kind="critic_patch"`` is the LLM-free application of a critique: it costs + nothing and is recorded separately from the ``critic`` call that produced + the fixes, so "what the critique cost" and "what it changed" stay + distinguishable. ``kind="llm_repair"`` is retained for artifacts written by + earlier releases, when a separate finding-driven repair render existed. + + ``kind="critic_skipped"`` records a pass the loop decided not to spend -- + no call was billed -- with the reason in ``accept_reason`` + (``empty_render``, ``citation_metadata``). A ``critic`` record whose + ``accept_reason`` is ``critic_unavailable`` is a billed call that + produced no critique; the unit leaves the loop unreviewed. + ``kind="completion"`` is the insert-only completion pass. """ render_attempt: int = 0 critic_attempt: int = 0 - kind: Literal["render", "critic", "llm_repair"] = "render" + kind: Literal[ + "render", "critic", "critic_patch", "critic_skipped", "completion", "llm_repair" + ] = "render" score: float | None = None success: bool | None = None n_actionable_fixes: int = 0 @@ -762,6 +880,78 @@ class LoopAttempt(BaseModel): #: from a field description with no rubric is exactly the kind of signal #: that has to be watched rather than assumed. severity_counts: dict[str, int] = Field(default_factory=dict) + action_severity_counts: dict[str, int] = Field( + default_factory=dict, + description=( + "Proposed fixes keyed 'ACTION:severity'. Severity alone cannot be " + "read: a REMOVE never blocks acceptance whatever its severity, so " + "'critical' counts mix fixes that gate a render with fixes that " + "cannot. Splitting by action is what makes the two distinguishable " + "from the artifacts." + ), + ) + #: What a ``critic_patch`` attempt actually did. + n_fixes_applied: int = 0 + n_fixes_noop: int = Field( + default=0, + description=( + "Fixes whose delete set and insert set were the same statements. " + "Counted because a critique made mostly of these is a critic " + "producing motion rather than corrections." + ), + ) + n_triples_deleted: int = 0 + n_triples_inserted: int = 0 + patch_rolled_back: bool = Field( + default=False, + description=( + "At least one fix of the pass was undone for leaving the unit " + "worse. Fixes are applied and judged one at a time, so the rest " + "of the pass stands; ``n_fixes_rolled_back`` says how many went." + ), + ) + n_fixes_rolled_back: int = 0 + rolled_back_fixes: list[RolledBackFix] = Field( + default_factory=list, + description="The undone fixes, each with the reason it was refused.", + ) + n_fixes_junk_refused: int = Field( + default=0, + description=( + "Inserts refused at compile time for minting a placeholder: a " + "subject named for an ignored token or artifact, or a new node " + "carrying only annotations and no type." + ), + ) + n_fixes_unresolved_prefix: int = Field( + default=0, + description=( + "Fixes whose payload named a prefix nothing declared, so the " + "statement it meant could not be identified." + ), + ) + n_measurements_recovered: int = Field( + default=0, + description=( + "Completion pass only: missed measurements the numeric inventory " + "no longer lists after the inserts that stayed." + ), + ) + patch_delete_capped: bool = Field( + default=False, + description=( + "The delete-share cap fired: the pass kept its inserts and dropped " + "its deletes." + ), + ) + incumbent_accepted: bool | None = Field( + default=None, + description=( + "What the retired score gate would have decided for this critic " + "attempt, recorded so the switch to a findings-based gate can be " + "judged against a distribution rather than an argument." + ), + ) n_deterministic_findings: int = 0 n_mandatory_findings: int = 0 repair_failed: bool = False diff --git a/ontocast/onto/ontology.py b/ontocast/onto/ontology.py index 05c1df04..a30c8ea8 100644 --- a/ontocast/onto/ontology.py +++ b/ontocast/onto/ontology.py @@ -3,7 +3,7 @@ import re from collections import defaultdict from datetime import datetime -from typing import Annotated, Union +from typing import TYPE_CHECKING, Annotated, Any, Union from pydantic import BaseModel, ConfigDict, Field, PrivateAttr from rdflib import DCTERMS, OWL, RDF, RDFS, SH, XSD, Literal, URIRef @@ -15,6 +15,9 @@ from ontocast.onto.sparql_models import GraphUpdate, TripleOp from ontocast.onto.util import derive_ontology_id +if TYPE_CHECKING: + import networkx as nx + logger = logging.getLogger(__name__) # Semantic version pattern: MAJOR.MINOR.PATCH (e.g., 1.2.3) @@ -114,7 +117,7 @@ class OntologyProperties(BaseModel): ) @property - def namespace(self): + def namespace(self) -> str: """Get the namespace for this ontology. Returns: @@ -239,7 +242,7 @@ def _apply_iri_fragment_hash_or_version(self) -> None: self.iri = base_iri logger.debug("Extracted version from IRI fragment: %s", version_str) - def __init__(self, **kwargs): + def __init__(self, **kwargs: Any): # Pop construction-only flags before pydantic init current_domain = kwargs.pop("current_domain", DEFAULT_DOMAIN) identity_locked = bool(kwargs.pop("identity_locked", False)) @@ -374,7 +377,7 @@ def is_null(self) -> bool: # Check characteristics return self.iri == ONTOLOGY_NULL_IRI and self.ontology_id is None - def set_properties(self, **kwargs): + def set_properties(self, **kwargs: Any): """Set ontology properties from keyword arguments and sync to graph. Only update properties if they are missing (None or empty). Also enforces ontology_id/iri consistency as in __init__, but only @@ -1158,7 +1161,9 @@ def __iadd__(self, other: Union["Ontology", RDFGraph]) -> "Ontology": return self @classmethod - def from_file(cls, file_path: pathlib.Path, format: str = "turtle", **kwargs): + def from_file( + cls, file_path: pathlib.Path, format: str = "turtle", **kwargs: Any + ) -> "Ontology": """Create an Ontology instance by loading a graph from a file. Args: @@ -1186,7 +1191,7 @@ def strip_ontology_header_triples(graph: RDFGraph) -> RDFGraph: return graph @classmethod - def from_working_context(cls, *args, **kwargs): # pragma: no cover + def from_working_context(cls, *args, **kwargs: Any): # pragma: no cover """Removed: use :class:`~ontocast.onto.ontology_snapshot.OntologySnapshot`. Raises: @@ -1238,7 +1243,7 @@ def to_lineage_node(self) -> dict: } @staticmethod - def build_lineage_graph(ontologies: list["Ontology"]): + def build_lineage_graph(ontologies: list["Ontology"]) -> "nx.DiGraph": """Build a NetworkX directed graph representing the lineage of all given ontologies. Constructs a directed graph where nodes represent ontologies (by their hash) diff --git a/ontocast/onto/ontology_apply.py b/ontocast/onto/ontology_apply.py index cbb9b8da..166dd06e 100644 --- a/ontocast/onto/ontology_apply.py +++ b/ontocast/onto/ontology_apply.py @@ -14,7 +14,7 @@ from pydantic import Field from rdflib import Literal, URIRef -from ontocast.onto.constants import COMMON_PREFIXES, DEFAULT_IRI +from ontocast.onto.constants import COMMON_PREFIXES from ontocast.onto.content_unit import ContentUnit, OutputType from ontocast.onto.iri_policy import normalize_namespace_iri, split_namespace_local from ontocast.onto.model import BasePydanticModel @@ -23,8 +23,8 @@ from ontocast.onto.rdfgraph import RDFGraph from ontocast.onto.sparql_models import GraphUpdate from ontocast.onto.util import ( - RDFLIB_DEFAULT_NAMESPACE_URIS, is_rdflib_default_namespace, + non_domain_namespaces, ) from ontocast.tool.ontology_manager import OntologyManager @@ -47,16 +47,13 @@ def is_empty(self) -> bool: return len(self.inserts) == 0 and len(self.deletes) == 0 +# The same membership the prompt's "domain ontologies" clause uses, in stem +# form: this comparison is against normalized IRIs, so trailing / and # are +# stripped. Membership is defined once in ``non_domain_namespaces``. _STANDARD_NAMESPACE_STEMS: frozenset[str] = frozenset( { normalize_namespace_iri(uri.strip("<>"), context="auto").rstrip("/#") - for uri in ( - *RDFLIB_DEFAULT_NAMESPACE_URIS, - *(v.strip("<>") for v in COMMON_PREFIXES.values()), - "https://schema.org/", - "http://schema.org/", - DEFAULT_IRI, - ) + for uri in non_domain_namespaces() } ) diff --git a/ontocast/onto/ontology_condense.py b/ontocast/onto/ontology_condense.py index 6b84397e..b6151802 100644 --- a/ontocast/onto/ontology_condense.py +++ b/ontocast/onto/ontology_condense.py @@ -18,9 +18,10 @@ """ import logging +import re from pydantic import BaseModel, Field -from rdflib import URIRef +from rdflib import RDFS, SKOS, Literal, URIRef from ontocast.onto.graph_prune import ( BFS_PREDICATE_PRIORITY, @@ -44,6 +45,312 @@ ) +#: Marker left where a literal was cut, so the model can tell a clipped +#: definition from a complete one. +CLIP_MARKER = "\u2026" + +#: Text roles, by predicate. Naming carries the surface forms the model matches +#: document text against; contract carries the usage rules a term is only safe +#: to apply under; prose is description a reader wants and an extractor does not. +NAMING_TEXT_PREDICATES: frozenset[URIRef] = frozenset( + {RDFS.label, SKOS.prefLabel, SKOS.altLabel} +) +CONTRACT_TEXT_PREDICATES: frozenset[URIRef] = frozenset( + {SKOS.scopeNote, SKOS.definition} +) +PROSE_TEXT_PREDICATES: frozenset[URIRef] = frozenset( + {RDFS.comment, SKOS.example, SKOS.note, SKOS.editorialNote, SKOS.historyNote} +) + + +class TextCaps(BaseModel): + """Per-role character caps on the text literals reaching a prompt. + + Nothing else in the pipeline bounds a single literal, so chapter size is + otherwise proportional to how chatty a catalog's authors were rather than to + how many terms it offers. These caps make it proportional to the term count, + which the retrieval budget already controls. On a tersely authored catalog + they are a no-op; that is the intended shape -- a bound, not a reduction. + + Clipping rather than dropping is what keeps a usage contract available at a + predictable price: the first sentence of a scope note is the part that says + when a term applies. Where a cap fires, the cut is by content rather than + by position -- the opening sentence and any clause stating when the term + applies, then stop -- so a verbose catalog pays for its terms and not for + its prose style. A cap left unset leaves every literal exactly as authored. + """ + + naming: int | None = Field( + default=None, + ge=1, + description="Cap on rdfs:label / skos:prefLabel / skos:altLabel.", + ) + contract: int | None = Field( + default=None, + ge=1, + description="Cap on skos:scopeNote / skos:definition.", + ) + prose: int | None = Field( + default=None, ge=1, description="Cap on rdfs:comment and other notes." + ) + total_budget: int | None = Field( + default=None, + ge=1, + description=( + "Ceiling on the summed length of all text literals in the chapter. " + "Backstop for a catalog that defeats the per-role caps by holding " + "very many short terms." + ), + ) + + @property + def active(self) -> bool: + """Whether any cap is set at all.""" + return any( + cap is not None + for cap in (self.naming, self.contract, self.prose, self.total_budget) + ) + + def cap_for(self, predicate: URIRef) -> int | None: + """The cap governing ``predicate``, or None when it governs no role.""" + if predicate in NAMING_TEXT_PREDICATES: + return self.naming + if predicate in CONTRACT_TEXT_PREDICATES: + return self.contract + if predicate in PROSE_TEXT_PREDICATES: + return self.prose + return None + + +#: Roles the total budget tightens, in increasing order of harm, each with the +#: shortest cap worth applying to it. +#: +#: The budget only ever *truncates*; it never removes a statement. Dropping a +#: role wholesale is both coarser and less informative: coarser because it +#: overshoots -- shedding a whole role to meet a budget a tighter clip would +#: have met leaves the chapter well under it, having thrown away more than was +#: asked -- and less informative because a clipped definition still says the +#: term has one and still carries its opening words, which is the part that +#: says when the term applies. A removed one says nothing at all. +#: +#: Shedding statements is the triple budget's job (see +#: :data:`GLOSS_PREDICATES`), on the axis where statements are what is over +#: budget. Here the overage is characters, so characters are what is cut. +#: Naming's floor is the highest of the three on purpose: below roughly this +#: length a name stops being one, and a term the model cannot name is not +#: cheaper context but an invitation to invent a term. On a catalog whose names +#: are already shorter than the floor -- the usual case -- this role is reached +#: and does nothing, which is the correct outcome: the chapter is then reported +#: over budget, because what remains is the vocabulary and the fix is to send +#: fewer terms, not shorter names. +_BUDGET_ROLES: tuple[tuple[frozenset[URIRef], int], ...] = ( + (PROSE_TEXT_PREDICATES, 24), + (CONTRACT_TEXT_PREDICATES, 40), + (NAMING_TEXT_PREDICATES, 48), +) + +#: Predicates whose literals the caps govern at all. +_CAPPED_PREDICATES: frozenset[URIRef] = ( + NAMING_TEXT_PREDICATES | CONTRACT_TEXT_PREDICATES | PROSE_TEXT_PREDICATES +) + + +#: A sentence boundary: terminal punctuation, optional closing quote or +#: bracket, whitespace, then something that can open a sentence. +_SENTENCE_BOUNDARY_RE = re.compile(r"(?<=[.!?])[\"')\]]?\s+(?=[A-Z0-9(\"'])") + +#: Trailing forms that end in a period without ending a sentence. General +#: English and citation shapes only -- a single capital initial, the common +#: Latin abbreviations, the reference words a description cites structure with. +_ABBREVIATION_TAIL_RE = re.compile( + r"(?:\b(?:e\.g|i\.e|cf|etc|vs|al|approx|ca|Fig|Eq|Sec|Ref|No|Vol|pp)\." + r"|\b[A-Z]\.)\s*$" +) + +#: Cues that a sentence states *when* a term applies rather than describing it +#: further. Deliberately plain English modality and conditionality: the point +#: is to keep the clause an extractor has to obey, not to parse the prose. +_APPLICABILITY_CUE_RE = re.compile( + r"\b(?:use|uses|used|apply|applies|applicable|only|when|whenever|unless|" + r"must|should|shall|never|always|restricted|intended|reserved|prefer|" + r"preferred|do not|does not|not to be)\b", + re.IGNORECASE, +) + + +def split_sentences(text: str) -> list[str]: + """Split ``text`` into sentences, keeping each one's terminator. + + Abbreviation-aware only to the extent that a period inside ``e.g.`` or a + single capital initial must not end a sentence; anything more would be + guessing at a catalog's writing style. + """ + sentences: list[str] = [] + start = 0 + for match in _SENTENCE_BOUNDARY_RE.finditer(text): + head = text[start : match.start()] + if _ABBREVIATION_TAIL_RE.search(head): + continue + stripped = head.strip() + if stripped: + sentences.append(stripped) + start = match.end() + tail = text[start:].strip() + if tail: + sentences.append(tail) + return sentences + + +def _contract_head(text: str, cap: int) -> str: + """The part of ``text`` worth keeping when it will not fit whole. + + A term's description opens with what it *is* and, where it has one, a + clause saying when it applies; the rest elaborates for a human browsing + the ontology and is the part an extractor never reads. So the trim is by + content, not by position: the first sentence, plus at most one following + sentence that states an applicability condition, and then it stops even + when the cap would have allowed more. + + Cutting on a sentence boundary also removes the failure a word-boundary + clip has: a description truncated mid-clause can read as a *narrower* + contract than the term has, and the model has no way to tell that from a + contract that really ended there. + + Returns: + The retained sentences, or ``""`` when the text offers no better cut + than the caller's word-boundary fallback. + """ + sentences = split_sentences(text) + if len(sentences) < 2: + return "" + first = sentences[0] + if len(first) > cap: + return "" + second = sentences[1] + candidate = f"{first} {second}" + if len(candidate) <= cap and _APPLICABILITY_CUE_RE.search(second): + return candidate + return first + + +def clip_text(text: str, cap: int | None) -> str: + """Clip ``text`` to ``cap`` characters, marking the cut. + + The retained text is at most ``cap`` characters; :data:`CLIP_MARKER` is + appended on top of it, so a clipped literal reads as clipped. A ``cap`` of + None, or text already within it, is returned unchanged -- byte-identical, so + a disabled cap cannot perturb a prompt or its cache key. + + Where the text has more than one sentence the cut is content-aware (see + :func:`_contract_head`) and may retain well under ``cap``; a single + sentence longer than the cap falls back to a word boundary, which is the + only cut available inside it. + + Args: + text: Literal text to bound. + cap: Maximum retained characters, or None to leave ``text`` alone. + + Returns: + Either ``text`` itself or a clipped copy ending in the marker. + """ + if cap is None or len(text) <= cap: + return text + head = _contract_head(text, cap) + if head: + return head + CLIP_MARKER + head = text[:cap].rsplit(" ", 1)[0].rstrip() + if not head: + head = text[:cap].rstrip() + return head + CLIP_MARKER + + +def _text_triples(graph: RDFGraph) -> list[tuple]: + """Every triple whose object is a text literal a cap governs.""" + return [ + triple + for triple in graph + if triple[1] in _CAPPED_PREDICATES and isinstance(triple[2], Literal) + ] + + +def _text_chars(graph: RDFGraph) -> int: + """Summed length of the capped text literals in ``graph``.""" + return sum(len(str(triple[2])) for triple in _text_triples(graph)) + + +def _apply_cap(graph: RDFGraph, predicates: frozenset[URIRef], cap: int) -> int: + """Clip ``predicates``' literals in ``graph`` to ``cap``, in place. + + Every statement survives; only its text is shortened. Language tag and + datatype are carried over, so a clipped literal stays the same kind of + literal it was. + + Returns: + How many literals were actually shortened. + """ + clipped = 0 + for subject, predicate, obj in _text_triples(graph): + if predicate not in predicates: + continue + text = str(obj) + shortened = clip_text(text, cap) + if shortened == text: + continue + graph.remove((subject, predicate, obj)) + graph.add( + ( + subject, + predicate, + Literal(shortened, lang=obj.language) + if obj.language + else Literal(shortened, datatype=obj.datatype), + ) + ) + clipped += 1 + return clipped + + +def _fit_role_to_budget( + graph: RDFGraph, predicates: frozenset[URIRef], floor: int, budget: int +) -> int: + """Clip one role to the *largest* cap that brings ``graph`` inside ``budget``. + + Total text length is monotone non-increasing in the cap, so the largest + admissible cap is found by bisection. Searching for it rather than stepping + through fixed tiers is what keeps the chapter near the budget instead of far + under it: a ladder of preset caps overshoots by however much the next rung + happens to cut, and every character it overshoots by is context the budget + never asked to lose. + + ``floor`` is the shortest cap worth applying to this role. If even that does + not fit, it is applied anyway and the caller moves on to the next role -- + the budget is a target, and the roles after this one are the ones it is + less costly to cut. + + Returns: + How many literals were shortened. + """ + lengths = [ + len(str(triple[2])) + for triple in _text_triples(graph) + if triple[1] in predicates + ] + if not lengths: + return 0 + low, high = floor, max(lengths) + best: int | None = None + while low <= high: + mid = (low + high) // 2 + probe = graph.copy() + _apply_cap(probe, predicates, mid) + if _text_chars(probe) <= budget: + best = mid + low = mid + 1 + else: + high = mid - 1 + return _apply_cap(graph, predicates, best if best is not None else floor) + + class CondenseReport(BaseModel): """What condensing did, for telemetry and for explaining a warning.""" @@ -63,11 +370,24 @@ class CondenseReport(BaseModel): default=False, description="Still above budget after condensing; passed through oversized", ) + text_chars_before: int = Field( + default=0, description="Summed length of capped text literals on entry" + ) + text_chars_after: int = Field( + default=0, description="Summed length of capped text literals after capping" + ) + literals_clipped: int = Field( + default=0, description="Text literals shortened to a per-role cap" + ) + text_over_budget: bool = Field( + default=False, + description="Still above the total text budget after every tightening stage", + ) @property def changed(self) -> bool: - """Whether anything was removed at all.""" - return self.triples_after != self.triples_before + """Whether anything was removed or shortened at all.""" + return bool(self.triples_after != self.triples_before or self.literals_clipped) def as_metrics(self) -> dict[str, int | bool | None]: """Flat mapping for the retrieval-metrics payload.""" @@ -79,6 +399,10 @@ def as_metrics(self) -> dict[str, int | bool | None]: "dropped_structural": self.dropped_structural, "dropped_glosses": self.dropped_glosses, "over_budget": self.over_budget, + "text_chars_before": self.text_chars_before, + "text_chars_after": self.text_chars_after, + "literals_clipped": self.literals_clipped, + "text_over_budget": self.text_over_budget, } @@ -91,9 +415,61 @@ def _drop_predicates(graph: RDFGraph, predicates: frozenset[URIRef]) -> int: return removed +def _cap_text_literals(graph: RDFGraph, caps: TextCaps, report: CondenseReport) -> None: + """Apply ``caps`` to ``graph`` in place, recording what happened on ``report``. + + Per-role caps first, then the total budget as a backstop: the budget + tightens prose before contracts and contracts before names, fitting each + role to the largest cap that still meets the budget, and stops as soon as + the chapter fits. Nothing is ever removed -- a clipped definition still says + the term has one and still carries the words that say when it applies. A + chapter that cannot be made to fit is passed through with a warning, the + same way the triple budget refuses to cut into load-bearing structure. + """ + report.text_chars_before = _text_chars(graph) + for predicates, cap in ( + (NAMING_TEXT_PREDICATES, caps.naming), + (CONTRACT_TEXT_PREDICATES, caps.contract), + (PROSE_TEXT_PREDICATES, caps.prose), + ): + if cap is not None: + report.literals_clipped += _apply_cap(graph, predicates, cap) + + budget = caps.total_budget + if budget is not None: + for predicates, floor in _BUDGET_ROLES: + if _text_chars(graph) <= budget: + break + report.literals_clipped += _fit_role_to_budget( + graph, predicates, floor, budget + ) + report.text_over_budget = _text_chars(graph) > budget + + report.text_chars_after = _text_chars(graph) + if report.text_over_budget: + logger.warning( + "Ontology text still exceeds the literal budget with every role at " + "its shortest cap (%d > %d chars). Passing it through: the budget " + "shortens text, it does not delete statements, and what is left at " + "this point is the vocabulary itself. Reduce the number of terms " + "reaching the prompt (ONTOLOGY_CONTEXT_MAX_TRIPLES, or a narrower " + "retrieval) rather than the length of their names.", + report.text_chars_after, + budget, + ) + elif report.changed: + logger.info( + "Capped ontology text %d -> %d chars (%d literals clipped, none removed).", + report.text_chars_before, + report.text_chars_after, + report.literals_clipped, + ) + + def condense_graph_for_prompt( graph: RDFGraph, max_triples: int | None, + text_caps: TextCaps | None = None, ) -> tuple[RDFGraph, CondenseReport]: """Trim ``graph`` toward ``max_triples``, dropping the least useful triples first. @@ -101,9 +477,16 @@ def condense_graph_for_prompt( fits: header/list noise, then structural scaffolding, then glosses. Structure that lets the model name and place a term is never dropped. + Text literals are bounded first and unconditionally: the triple budget is a + count and says nothing about how long a single ``rdfs:comment`` may be, so a + graph well under it can still carry an unbounded chapter. ``text_caps`` makes + chapter size a function of term count rather than of prose volume. + Args: graph: Ontology graph destined for a prompt. Not mutated. max_triples: Triple budget, or ``None`` to disable condensing entirely. + text_caps: Per-role character caps on text literals, or ``None``/all-unset + to leave every literal as authored. Returns: The condensed graph (or ``graph`` itself when nothing was done) and a @@ -113,10 +496,18 @@ def condense_graph_for_prompt( report = CondenseReport( triples_before=before, triples_after=before, max_triples=max_triples ) - if max_triples is None or before <= max_triples: + capping = text_caps is not None and text_caps.active + fits = max_triples is None or before <= max_triples + if fits and not capping: return graph, report working = graph.copy() + if capping: + assert text_caps is not None + _cap_text_literals(working, text_caps, report) + if fits: + report.triples_after = len(working) + return working, report report.dropped_noise = _drop_predicates(working, NOISY_EXPANSION_PREDICATES) if len(working) > max_triples: diff --git a/ontocast/onto/ontology_snapshot.py b/ontocast/onto/ontology_snapshot.py index 6e21347d..aff9c6e0 100644 --- a/ontocast/onto/ontology_snapshot.py +++ b/ontocast/onto/ontology_snapshot.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING from pydantic import Field, PrivateAttr @@ -16,6 +16,12 @@ from ontocast.onto.ontology import Ontology from ontocast.onto.rdfgraph import RDFGraph +if TYPE_CHECKING: + from collections.abc import Callable + + from ontocast.onto.ontology_condense import CondenseReport, TextCaps + from ontocast.prompt.graph_format import GraphFormatProfile + class OntologySnapshot(BasePydanticModel): """Ephemeral multi-source ontology context for LLM prompts. @@ -52,8 +58,8 @@ class OntologySnapshot(BasePydanticModel): #: Derived prompt text, keyed by graph identity so a reassigned graph misses. #: Populated only through :meth:`prompt_chapter`. - _prompt_cache: dict[tuple[int, int, str, int | None], str] = PrivateAttr( - default_factory=dict + _prompt_cache: dict[tuple[int, int, str, int | None, str | None], str] = ( + PrivateAttr(default_factory=dict) ) def is_empty(self) -> bool: @@ -72,7 +78,14 @@ def invalidate_prompt_cache(self) -> None: """ self._prompt_cache.clear() - def prompt_chapter(self, profile: Any, *, max_triples: int | None = None) -> str: + def prompt_chapter( + self, + profile: GraphFormatProfile, + *, + max_triples: int | None = None, + text_caps: "TextCaps | None" = None, + on_report: "Callable[[CondenseReport], None] | None" = None, + ) -> str: """Serialised ontology chapter for prompts, memoised per graph. Serialising the ontology is the single most expensive step in building a @@ -80,32 +93,55 @@ def prompt_chapter(self, profile: Any, *, max_triples: int | None = None) -> str every render attempt within a unit -- would otherwise redo it on an identical graph, synchronously, on the event loop. - The memo is keyed on the graph's identity, length and the profile's wire - format. It is therefore correct for a snapshot whose graph is replaced, - and *assumes* the graph is not mutated in place, which is the contract - this class already documents ("ephemeral" context, read-only in the unit - loops). Any code that does mutate it must call - :meth:`invalidate_prompt_cache`. + The memo is keyed on the graph's identity, length and the syntax the + chapter is serialised in. It is therefore correct for a snapshot whose + graph is replaced, and *assumes* the graph is not mutated in place, + which is the contract this class already documents ("ephemeral" + context, read-only in the unit loops). Any code that does mutate it + must call :meth:`invalidate_prompt_cache`. Args: profile: Graph format profile supplying the serialisation. + max_triples: Triple budget for the chapter. + text_caps: Per-role character caps on the chapter's text literals. + on_report: Handed what condensing and capping did, on a memo *miss* + only -- the chapter is built once for the whole fan-out, so + reporting it once is what the numbers describe. Returns: - str: The ``# ONTOLOGY`` chapter, including the index appendix. + str: The ``# ONTOLOGY`` chapter, including the index appendix. A + term-sheet chapter carries no appendix: the index names terms by + label and domain/range, which the sheet already states on the + term's own line. """ from ontocast.prompt.ontology_context import build_ontology_index # max_triples is part of the key: the snapshot is shared by reference # across the whole fan-out, so without it the first budget seen would be # served to every later caller. - key = (id(self.graph), len(self.graph), str(profile.format), max_triples) + # The syntax key is the chapter's own discriminator, not the profile's + # wire format: ONTOLOGY_CHAPTER_FORMAT can decouple the two, two + # profiles that render identical bytes share the entry, and a term + # sheet -- which is not a serialization of the graph at all -- gets its + # own name rather than colliding with the Turtle chapter. + key = ( + id(self.graph), + len(self.graph), + profile.ontology_chapter_discriminator, + max_triples, + text_caps.model_dump_json() if text_caps is not None else None, + ) cached = self._prompt_cache.get(key) if cached is not None: return cached chapter = profile.format_ontology_chapter( self.graph, - suffix=build_ontology_index(self.graph), + suffix=( + "" if profile.renders_term_sheet else build_ontology_index(self.graph) + ), max_triples=max_triples, + text_caps=text_caps, + on_report=on_report, ) # Bound the memo: a snapshot only ever holds one live graph, so stale # entries are strictly dead weight after a reassignment. diff --git a/ontocast/onto/rdfgraph.py b/ontocast/onto/rdfgraph.py index 05bec265..9af6cc37 100644 --- a/ontocast/onto/rdfgraph.py +++ b/ontocast/onto/rdfgraph.py @@ -1,3 +1,4 @@ +import hashlib import json import logging import re @@ -106,7 +107,10 @@ def copy_triples(source: Iterable, target: Graph, *, origin: str) -> int: continue target.add(triple) if dropped: - logger.warning( + # Expected on every graph the aggregator annotated: its rdf:reifies + # provenance is triple-term syntax, which the rdflib copy for SHACL + # cannot hold. Not a loss -- the facts dump keeps them. + logger.debug( "%s: dropped %d RDF 1.2 triple-term triple(s); rdflib graphs cannot " "hold them", origin, @@ -439,6 +443,16 @@ def strip_sparql_update_wrapper(turtle_str: str) -> str: return "\n\n".join(result_parts) + "\n" +def _is_namespace_declaration(value: object) -> bool: + """True when a JSON-LD ``@context`` entry declares a namespace IRI. + + Distinguishes ``"qqval": "https://…/qqval#"`` -- a prefix binding, which + an authoritative map may override -- from ``"label": "rdfs:label"`` or a + term-definition object, which it must not touch. + """ + return isinstance(value, str) and "://" in value + + def _prefix_lookup_for_turtle_repair() -> dict[str, str]: """Merged ingest + context prefix map for Turtle repair (context overrides ingest).""" lookup = prefix_lookup_for_ingest() @@ -657,6 +671,25 @@ def _referenced_jsonld_context( } +def _term_sort_key(term: Node) -> tuple[str, str, str]: + """Total order over RDF terms, for deterministic serialization. + + The lexical form alone is not a key: two literals can share it and differ + only in language tag or datatype, and sorting on the shared value leaves + their relative order to whatever the iteration happened to produce -- which + is the nondeterminism this exists to remove, surviving in exactly the places + hardest to notice. + """ + if isinstance(term, Literal): + return (str(term), term.language or "", str(term.datatype or "")) + return (str(term), "", "") + + +def _triple_sort_key(triple: tuple) -> tuple[tuple[str, str, str], ...]: + """Total order over triples, subject then predicate then object.""" + return tuple(_term_sort_key(term) for term in triple) + + class RDFGraph(Graph): """Subclass of rdflib.Graph with Pydantic schema support. @@ -665,7 +698,9 @@ class RDFGraph(Graph): """ @classmethod - def __get_pydantic_core_schema__(cls, _source_type, handler: GetCoreSchemaHandler): + def __get_pydantic_core_schema__( + cls, _source_type: Any, handler: GetCoreSchemaHandler + ) -> core_schema.CoreSchema: """Get the Pydantic core schema for this class. Args: @@ -1101,7 +1136,25 @@ def _repair_unknown_prefix(turtle_str: str, parse_error_message: str) -> str: @staticmethod def _merge_known_prefixes_into_jsonld(data: dict | list) -> dict | list: - """Shallow-merge known prefix URIs into JSON-LD ``@context`` before normalization.""" + """Reconcile a payload's ``@context`` with the known prefix map. + + Two rules, both applied before normalization: + + * a prefix the payload uses but does not declare gets the known + binding, so a compact term is not read as an absolute IRI whose + scheme is the prefix; + * a prefix the payload *does* declare, to a namespace that differs + from the known one, is **overridden**. A model writing a patch is + re-transcribing a namespace it was shown in the prompt, so a + declaration that disagrees with the map is a transcription error, + not a request for a different vocabulary -- and the cost of + honouring it is total: every term under that prefix expands to an + IRI no catalog declares, the whole payload fails the term check, + and the fix is rolled back with whatever it carried. + + Only absolute-IRI declarations are overridden; a context entry that + maps a term to a CURIE or to a term definition object is left alone. + """ known = _known_prefixes_context.get() if not known: return data @@ -1111,8 +1164,21 @@ def merge_context(context: object) -> object: return context merged = dict(context) for prefix, uri in known.items(): - if prefix.startswith("@") or prefix in merged: + if prefix.startswith("@"): + continue + declared = merged.get(prefix) + if declared is None: + merged[prefix] = uri continue + if not _is_namespace_declaration(declared) or declared == uri: + continue + logger.debug( + "JSON-LD payload declares prefix %r as <%s>; the run binds " + "it to <%s> -- overriding the payload", + prefix, + declared, + uri, + ) merged[prefix] = uri return merged @@ -1536,6 +1602,77 @@ def _collect_used_iris(term: Any) -> None: raise RuntimeError("pyoxigraph dump returned no data") return raw.decode() + def canonicalize_bnodes(self) -> "RDFGraph": + """Return a copy whose blank nodes carry content-derived labels. + + rdflib mints blank-node identifiers at random, and the Turtle writer + orders blank-node blocks by those identifiers, so parsing the same files + twice in two processes yields two different serializations of the same + graph. That is invisible inside one process -- the prompt is memoised on + one graph object -- and expensive across them: the LLM disk cache is + keyed on the prompt string, and a provider's prefix cache on the prompt + prefix, so an ontology chapter that differs only in random labels is a + chapter neither cache can ever serve. + + Labels are assigned by iterative refinement: each blank node's signature + starts from the ground terms it is attached to, and each round folds in + its neighbours' signatures, so structure a round away becomes + distinguishing. Nodes are then labelled in signature order. + + Blank nodes whose signatures never separate are automorphic -- they sit + in structurally identical positions -- so which of them takes which + label does not change the resulting triples, and the serialization is + byte-identical either way. That is what makes an arbitrary tie-break + safe here rather than a source of the very instability being removed. + + Returns: + A new graph with the same triples, the same namespace bindings, and + blank nodes named ``_:b0``, ``_:b1``, ... in canonical order. + """ + bnodes = {term for triple in self for term in triple if isinstance(term, BNode)} + if not bnodes: + return self + + signatures: dict[BNode, str] = dict.fromkeys(bnodes, "") + + def describe(term: Node) -> str: + return signatures[term] if isinstance(term, BNode) else f"<{term}>" + + # Two rounds past the first spread structure far enough to separate the + # shapes an ontology actually holds (restrictions, list cells); further + # rounds only cost time on nodes that are genuinely automorphic. + for _ in range(3): + refined: dict[BNode, str] = {} + for node in bnodes: + out = sorted( + f">{predicate}|{describe(obj)}" + for _, predicate, obj in self.triples((node, None, None)) + ) + incoming = sorted( + f"<{predicate}|{describe(subject)}" + for subject, predicate, _ in self.triples((None, None, node)) + ) + refined[node] = hashlib.sha1( + "\u0000".join((*out, *incoming)).encode("utf-8") + ).hexdigest() + signatures = refined + + order = sorted(bnodes, key=lambda node: (signatures[node], str(node))) + renamed = {node: BNode(f"b{index}") for index, node in enumerate(order)} + + canonical = RDFGraph() + for prefix, namespace in self.namespaces(): + canonical.bind(prefix, namespace) + for subject, predicate, obj in self: + canonical.add( + ( + renamed.get(subject, subject), + renamed.get(predicate, predicate), + renamed.get(obj, obj), + ) + ) + return canonical + def serialize_canonical_turtle(self) -> str: """Serialize to Turtle after canonical namespace/prefix sanitization. @@ -1591,12 +1728,23 @@ def serialize_compact_jsonld_for_prompt(self) -> str: """Serialize graph as compact JSON-LD text for LLM context prompts.""" self.sanitize_prefixes_namespaces() context: dict[str, str] = {} - for prefix, namespace in self.namespaces(): + for prefix, namespace in sorted( + self.namespaces(), key=lambda binding: str(binding[0]) + ): if prefix: context[prefix] = str(namespace) nodes: dict[str, dict[str, Any]] = defaultdict(dict) - for subject, predicate, obj in self: + # Sorted, not graph order. rdflib iterates its internal dicts, whose + # order depends on the hash of each term -- randomised per process for + # strings -- so the same graph serialised in two processes produced two + # different documents that differed only in node and value ordering. + # This text is a cache key: the LLM disk cache is keyed on the prompt + # and a provider's prefix cache on its prefix, so an unstable ordering + # meant neither could ever serve a chapter built in an earlier run. + # JSON-LD node order and multi-value order carry no meaning, so fixing + # the order costs nothing but a sort. + for subject, predicate, obj in sorted(self, key=_triple_sort_key): subj_key = str(subject) if "@id" not in nodes[subj_key]: subj_compact = self._compact_iri_for_jsonld(subject) @@ -1642,7 +1790,7 @@ def serialize_compact_jsonld_for_prompt(self) -> str: return fallback.decode("utf-8") return str(fallback) - def __new__(cls, *args, **kwargs): + def __new__(cls, *args, **kwargs: Any): """Create a new RDFGraph instance.""" instance = super().__new__(cls) return instance @@ -1718,7 +1866,7 @@ def update( def sanitize_prefixes_namespaces( self, preferred_namespace_prefixes: dict[str, str] | None = None, - ): + ) -> "RDFGraph": """ Rematches prefixes in an RDFLib graph to correct namespaces when a namespace with the same URI exists. Handles cases where prefixes might not be bound diff --git a/ontocast/onto/retrieval_capabilities.py b/ontocast/onto/retrieval_capabilities.py index e62ad502..8adeefa0 100644 --- a/ontocast/onto/retrieval_capabilities.py +++ b/ontocast/onto/retrieval_capabilities.py @@ -48,3 +48,17 @@ def validate_ontology_context_mode( """Raise if the requested ontology context mode cannot be satisfied.""" if ontology_context_mode == OntologyContextMode.SELECTED_VECTOR_SEARCH_ONTOLOGY: require_vector_retrieval(tools) + + +class EmptyOntologyContextError(OntologyContextConfigError): + """Raised when a unit's ontology context resolves to zero triples. + + An empty context is not a milder version of a good one. The renderer is + instructed to extract "based on provided domain ontology"; handed nothing, + it falls back on whatever standard vocabulary the prompt names, and the + SHACL gate then finds no node its shapes target -- so the run reports a + vacuous pass over an empty focus set. Continuing produces a finished, + plausible, ungrounded graph, which is worse than stopping. + """ + + error_code: str = "EMPTY_ONTOLOGY_CONTEXT" diff --git a/ontocast/onto/run_manifest.py b/ontocast/onto/run_manifest.py index b83bb640..02fdf2b0 100644 --- a/ontocast/onto/run_manifest.py +++ b/ontocast/onto/run_manifest.py @@ -33,6 +33,49 @@ class RunManifestLLM(BaseModel): think: bool | None = None num_ctx: int | None = None num_predict: int | None = None + reasoning_effort: str | None = Field( + default=None, + description=( + "Reasoning effort the run asked for; None = provider default. " + "OpenAI reads it as reasoning_effort, Gemini 3+ as " + "thinking_level. Two dumps that differ here differ in " + "reasoning_tokens before they differ in anything else." + ), + ) + thinking_budget: int | None = Field( + default=None, + description=( + "Gemini 2.5 thinking-token budget the run asked for; None = " + "provider default. The integer spelling of reasoning_effort, " + "superseded by thinking_level from Gemini 3 on." + ), + ) + requests_per_second: float | None = Field( + default=None, + description=( + "Provider request pacing the run used; None = unpaced. A " + "throttled run (llm/rate_limited or llm/timeouts in " + "budget.counters) has directional cost figures only." + ), + ) + max_retries: int | None = None + prompt_cache_key: str | None = Field( + default=None, + description=( + "OpenAI prompt_cache_key routing hint; None = unset. Two runs that " + "differ here can differ in prefix_cache_hit_rate for no reason " + "visible in either dump." + ), + ) + max_inflight: int | None = Field( + default=None, + description=( + "Provider requests allowed in flight at once. A prefix cache entry " + "is only readable once a request has completed, so a wider fan-out " + "lowers the hit rate at identical configuration -- which makes this " + "a cost setting, not just a pacing one." + ), + ) class RunManifestLoops(BaseModel): @@ -45,14 +88,13 @@ class RunManifestLoops(BaseModel): """ max_visits: int = Field( - description="Render/critic budget per unit; at 1 the LLM critic never runs." + description="Retries of a *failed* fresh extraction; not a critic switch." ) - max_critic_visits: int | None = Field( - default=None, - description="Critic attempts per render attempt; None couples to max_visits.", + facts_critic_passes: int = Field( + default=0, description="Review-and-patch passes allowed per facts unit." ) - llm_repair_visits: int = Field( - description="Finding-driven repair renders allowed per unit." + ontology_critic_passes: int = Field( + default=0, description="Review-and-patch passes allowed per ontology unit." ) @@ -70,6 +112,84 @@ class RunManifestSelection(BaseModel): summarize_sections: list[str] | None = None summary_max_sentences: int | None = None bibliography_mode: str | None = None + labeled_units: int | None = Field( + default=None, + description=( + "Content units that carried a section_label after classification. " + "Together with unlabeled_units this records whether a section " + "filter could act at all: an exclusion list against mostly " + "unlabeled units is a no-op the arm name would never reveal." + ), + ) + unlabeled_units: int | None = None + section_label_histogram: dict[str, int] | None = Field( + default=None, + description="section_label -> unit count, '(unlabeled)' included.", + ) + non_content_mode: str | None = None + bibliography_units_skipped: int | None = Field( + default=None, + description="Prepared chunks dropped by CHUNK_BIBLIOGRAPHY_MODE=skip.", + ) + undersized_units_skipped: int | None = Field( + default=None, + description="Prepared chunks dropped by the CHUNK_MIN_UNIT_CHARS floor.", + ) + non_content_units_skipped: int | None = Field( + default=None, + description=( + "Prepared chunks dropped by CHUNK_NON_CONTENT_MODE=skip. The " + "label histogram only counts units that reached extraction, so " + "without these three a unit count difference between two runs " + "cannot be attributed to a routing knob." + ), + ) + + +class RunManifestValidationConfig(BaseModel): + """The validation-facing configuration the run actually used. + + Arms are launched by env vars nothing records; every row here is a knob + whose setting changed a measured outcome in some past run and had to be + reconstructed from logs. The manifest is the record. + """ + + context_from_units: bool | None = None + json_mode: bool | None = None + shapes_prompt_contract: str | None = None + shapes_prompt_selection: bool | None = Field( + default=None, + description=( + "Whether the conformance chapter was selected per unit by the " + "ontology-context join. 'auto' resolves by catalog size, so two " + "arms with identical settings can differ here; this records the " + "behavior that actually ran." + ), + ) + shapes_triples: int | None = Field( + default=None, + description=( + "Size of the merged shapes partition at dump time; 0 or absent " + "means the gate and the prompt contract had no shapes." + ), + ) + shacl_inference: str | None = None + numeric_coverage_mandatory: str | bool | None = Field( + default=None, + description=( + "'off' | 'measurements' | 'all' -- which coverage findings " + "blocked acceptance. Older manifests carry the boolean this used " + "to be." + ), + ) + facts_user_instruction_chars: int | None = Field( + default=None, + description=( + "Length of the per-request facts_user_instruction; 0 = none. The " + "text itself stays out of the manifest -- deployment guidance can " + "carry secrets and the dump is shareable." + ), + ) class RunManifestCritic(BaseModel): @@ -82,10 +202,13 @@ class RunManifestCritic(BaseModel): such a gate is calibrated is a question about the score distribution, and until this existed no artifact recorded a single score -- the answer had to be mined out of the LLM disk cache, which only worked because caching - happened to be on. The ontology loop still runs - that gate (backed there by a scoring rubric whose top band it demands), - and this record is how its accept rate gets measured before the gate is - recalibrated. + happened to be on. Both loops now gate on the deterministic findings and + record what that score gate *would* have said, so the change can be judged + from a distribution rather than an argument. + + ``fixes_*`` describe what the critique actually did, which is a different + question from what it proposed: a critique can name a dozen corrections and + change nothing, and for a long time nothing here could tell the difference. A run must carry its own evidence for the decisions it made. """ @@ -109,6 +232,151 @@ class RunManifestCritic(BaseModel): "it is a run whose severity labels carry no signal." ), ) + fix_action_severity_histogram: dict[str, int] = Field( + default_factory=dict, + description=( + "The same fixes keyed 'ACTION:severity'. Read this before " + "concluding anything from the severity histogram: a REMOVE cannot " + "block acceptance at any severity, so 'critical' alone conflates " + "fixes that gate a render with fixes that never could." + ), + ) + accept_reason_histogram: dict[str, int] = Field( + default_factory=dict, + description=( + "Why each verdict landed: 'clean', 'mandatory_findings', " + "'critic_critical'. Separates a critic that found nothing from one " + "overruled by the deterministic lane -- indistinguishable in the " + "accepted count alone." + ), + ) + incumbent_accepted: int = Field( + default=0, + description=( + "Calls the retired score gate would have accepted. Recorded so " + "replacing that gate can be judged against a distribution." + ), + ) + patch_passes: int = Field( + default=0, description="Critique applications attempted, LLM-free." + ) + fixes_applied: int = Field( + default=0, description="Proposed fixes that reached the graph." + ) + fixes_noop: int = Field( + default=0, + description=( + "Fixes that removed exactly what they re-added. High against " + "`fixes_applied` means the critic is producing motion, not " + "corrections." + ), + ) + patches_rolled_back: int = Field( + default=0, + description=( + "Passes in which at least one fix was undone for leaving the unit " + "worse. Fixes are judged one at a time, so the rest of such a pass " + "stands; `fixes_rolled_back` counts the fixes themselves." + ), + ) + fixes_rolled_back: int = Field( + default=0, + description=( + "Fixes applied and undone on their own: deleted without writing, " + "shrank the product without resolving anything, or raised the " + "mandatory finding count. Non-zero means the critique is " + "provoking data-destroying edits." + ), + ) + fixes_junk_refused: int = Field( + default=0, + description=( + "Inserts refused at compile time for minting a placeholder: a " + "subject named for an ignored token or artifact, or a new node " + "carrying only annotations and no type. The critic's answer to a " + "numeric-coverage finding it could not place." + ), + ) + fixes_unresolved_prefix: int = Field( + default=0, + description=( + "Fixes whose payload named a prefix neither it nor the unit graph " + "declares, sent back as residual rather than applied with the " + "CURIE as the IRI." + ), + ) + units_unreviewed: int = Field( + default=0, + description=( + "Units whose critic call failed (timeout, unparseable response). " + "The render stands unreviewed and the unit leaves the loop FAILED " + "at the critique stage; it used to leave as SUCCESS." + ), + ) + units_skipped: int = Field( + default=0, + description=( + "Units the loop did not send to the critic: render below " + "FACTS_CRITIC_MIN_TRIPLES, or citation metadata. No call billed." + ), + ) + triples_deleted: int = Field(default=0) + triples_inserted: int = Field(default=0) + + +class RunManifestCompletion(BaseModel): + """What the insert-only completion pass bought. + + Runs after the critic loop, only on units whose numeric inventory still + lists a measurement -- a number with its unit -- absent from the graph. + Each new subject it writes is judged like a critic fix and rolled back + on its own when it leaves the unit worse. + """ + + calls: int = Field(default=0, description="Completion calls billed.") + units: int = Field(default=0, description="Units that ran at least one pass.") + subjects_inserted: int = Field( + default=0, description="New subject closures that stayed in the graph." + ) + subjects_rolled_back: int = Field( + default=0, description="New subject closures undone for regressing." + ) + triples_inserted: int = Field(default=0) + measurements_recovered: int = Field( + default=0, + description=( + "Missed measurements the inventory stopped listing after the " + "inserts that stayed. Read against the coverage findings: the " + "pass targets exactly this list." + ), + ) + + +def summarize_completion( + telemetry: dict[int, list[LoopAttempt]], +) -> RunManifestCompletion: + """Reduce per-unit attempt logs to the document's completion record. + + Args: + telemetry: ``AgentState.facts_loop_telemetry``. + + Returns: + The document-level completion summary; all-zero when the pass never + ran, which is what a zero pass budget buys. + """ + per_unit = { + index: [attempt for attempt in attempts if attempt.kind == "completion"] + for index, attempts in telemetry.items() + } + attempts = [attempt for unit in per_unit.values() for attempt in unit] + return RunManifestCompletion( + calls=len(attempts), + units=sum(1 for unit in per_unit.values() if unit), + subjects_inserted=sum(a.n_fixes_applied for a in attempts), + subjects_rolled_back=sum(a.n_fixes_rolled_back for a in attempts), + triples_inserted=sum(a.n_triples_inserted for a in attempts), + measurements_recovered=sum(a.n_measurements_recovered for a in attempts), + ) def summarize_loop( @@ -123,7 +391,7 @@ def summarize_loop( Returns: The document-level critic summary; all-zero when no critic call ran, - which is the default at ``MAX_VISITS=1``. + which is what a zero pass budget buys. """ attempts = [ attempt @@ -131,6 +399,18 @@ def summarize_loop( for attempt in unit_attempts if attempt.kind == "critic" ] + patches = [ + attempt + for unit_attempts in telemetry.values() + for attempt in unit_attempts + if attempt.kind == "critic_patch" + ] + skipped = sum( + 1 + for unit_attempts in telemetry.values() + for attempt in unit_attempts + if attempt.kind == "critic_skipped" + ) scores = sorted(a.score for a in attempts if a.score is not None) histogram: dict[str, int] = {} for score in scores: @@ -138,9 +418,15 @@ def summarize_loop( key = f"{bucket}-{bucket + 9}" histogram[key] = histogram.get(key, 0) + 1 severities: dict[str, int] = {} + action_severities: dict[str, int] = {} + reasons: dict[str, int] = {} for attempt in attempts: for severity, count in attempt.severity_counts.items(): severities[severity] = severities.get(severity, 0) + count + for key, count in attempt.action_severity_counts.items(): + action_severities[key] = action_severities.get(key, 0) + count + if attempt.accept_reason: + reasons[attempt.accept_reason] = reasons.get(attempt.accept_reason, 0) + 1 return RunManifestCritic( calls=len(attempts), accepted=sum(1 for a in attempts if a.success), @@ -149,6 +435,76 @@ def summarize_loop( score_max=scores[-1] if scores else None, score_histogram=histogram, fix_severity_histogram=severities, + fix_action_severity_histogram=dict(sorted(action_severities.items())), + accept_reason_histogram=dict(sorted(reasons.items())), + incumbent_accepted=sum(1 for a in attempts if a.incumbent_accepted), + patch_passes=len(patches), + fixes_applied=sum(a.n_fixes_applied for a in patches), + fixes_noop=sum(a.n_fixes_noop for a in patches), + patches_rolled_back=sum(1 for a in patches if a.patch_rolled_back), + fixes_rolled_back=sum(a.n_fixes_rolled_back for a in patches), + fixes_junk_refused=sum(a.n_fixes_junk_refused for a in patches), + fixes_unresolved_prefix=sum(a.n_fixes_unresolved_prefix for a in patches), + units_unreviewed=sum( + 1 for a in attempts if a.accept_reason == "critic_unavailable" + ), + units_skipped=skipped, + triples_deleted=sum(a.n_triples_deleted for a in patches), + triples_inserted=sum(a.n_triples_inserted for a in patches), + ) + + +class RunManifestPrompting(BaseModel): + """Settings that decide the shape and reuse of the prompts a run sent. + + Every field here moves cost without moving any of the generation settings + beside it, so two runs could previously differ several-fold in tokens with + nothing in either manifest to say why. They are recorded together because + they are read together: the wire format and the chapter format set what a + triple costs, the context scope and the warm-up decide whether the prefix + can be cached at all, and the worker count decides how much of the fan-out + arrives before the first response has populated it. + """ + + llm_graph_format: str | None = Field( + default=None, + description="Wire format the model emitted graphs in.", + ) + ontology_chapter_format: str | None = Field( + default=None, + description=( + "Encoding of the ontology chapter. 'inherit' follows " + "llm_graph_format, so the two fields are not independent -- a run " + "that changed the wire changed the chapter too unless it pinned " + "this." + ), + ) + ontology_context_scope: str | None = Field( + default=None, + description=( + "'unit' retrieves a chapter per content unit, so no two calls share " + "a prompt prefix; 'document' shows every unit the union." + ), + ) + fanout_warmup_units: int | None = Field( + default=None, + description=( + "Units run to completion before the fan-out. Zero means every call " + "of the fan-out issues before any of them has populated the prefix " + "cache they share." + ), + ) + parallel_workers: int | None = Field( + default=None, + description="Units processed concurrently within one document.", + ) + embedding_model_name: str | None = Field( + default=None, + description=( + "Retrieval embedding checkpoint. It decides which terms reach the " + "chapter, and a change to it invalidates the vector index rather " + "than degrading quietly." + ), ) @@ -164,6 +520,14 @@ class RunManifest(BaseModel): loops: RunManifestLoops | None = None critic: RunManifestCritic | None = None ontology_critic: RunManifestCritic | None = None + completion: RunManifestCompletion | None = Field( + default=None, + description=( + "The facts completion pass, from the same attempt log the critic " + "block is read from; None when the pass is disabled." + ), + ) + validation_config: RunManifestValidationConfig | None = None ontology_reduce_metrics: dict[str, Any] = Field( default_factory=dict, description=( @@ -188,6 +552,10 @@ class RunManifest(BaseModel): tenant: str | None = None project: str | None = None llm: RunManifestLLM + prompting: RunManifestPrompting = Field( + default_factory=lambda: RunManifestPrompting(), + description="What the run put in front of the model, and how widely.", + ) budget: BudgetTracker ontology_triples: int = 0 facts_triples: int = 0 diff --git a/ontocast/onto/sparql_models.py b/ontocast/onto/sparql_models.py index 0a27626f..e300c68e 100644 --- a/ontocast/onto/sparql_models.py +++ b/ontocast/onto/sparql_models.py @@ -1,20 +1,17 @@ -"""Pydantic models for graph mutations and tool SPARQL operations. +"""Pydantic models for graph mutations. ``GraphUpdate`` / ``TripleOp`` are the canonical LLM pipeline mutation abstraction -(ordered insert/delete triple patches). ``SPARQLOperationModel`` is used by tooling -(``tool/sparql.py``) — a separate path. +(ordered insert/delete triple patches) used across the extraction pipeline. """ import logging from collections.abc import Set -from typing import Any from typing import Literal as TypingLiteral from pydantic import BaseModel, Field, field_validator from rdflib import BNode, Literal, Node, URIRef from ontocast.onto.constants import COMMON_PREFIXES -from ontocast.onto.enum import SPARQLOperationType from ontocast.onto.llm_graph_payload import LLMGraphWire from ontocast.onto.rdfgraph import RDFGraph, copy_triples, is_rdflib_triple @@ -40,31 +37,6 @@ ) -class SPARQLOperationModel(BaseModel): - """Pydantic model for a single SPARQL operation. - - Attributes: - operation_type: Type of SPARQL operation (INSERT, UPDATE, DELETE) - query: The SPARQL query string - description: Optional description of the operation - metadata: Optional metadata dictionary - """ - - operation_type: SPARQLOperationType = Field( - description="Type of SPARQL operation: INSERT, UPDATE, or DELETE" - ) - query: str = Field( - description="The complete SPARQL query string with proper syntax" - ) - description: str = Field( - default="", description="Optional description of the operation" - ) - metadata: dict[str, Any] = Field( - default_factory=dict, - description="Optional metadata dictionary for the operation", - ) - - class TripleOp(BaseModel): """Operation to modify triples in the RDF graph. diff --git a/ontocast/onto/state.py b/ontocast/onto/state.py index add954e3..79be6f24 100644 --- a/ontocast/onto/state.py +++ b/ontocast/onto/state.py @@ -489,6 +489,24 @@ class AgentState(BasePydanticModel): default_factory=list, description="Pending content units to process.", ) + bibliography_units_skipped: int = Field( + default=0, + description=( + "Prepared chunks dropped by reference-list routing " + "(CHUNK_BIBLIOGRAPHY_MODE=skip)." + ), + ) + undersized_units_skipped: int = Field( + default=0, + description="Prepared chunks dropped by the CHUNK_MIN_UNIT_CHARS floor.", + ) + non_content_units_skipped: int = Field( + default=0, + description=( + "Prepared chunks dropped by front/back-matter routing " + "(CHUNK_NON_CONTENT_MODE=skip)." + ), + ) ontology_artifacts: list[Ontology] = Field( default_factory=list, description="Final per-anchor ontology artifacts produced for this document.", @@ -646,6 +664,17 @@ class AgentState(BasePydanticModel): ), ) + aggregation_cross_unit_pairs: list[tuple[str, str]] = Field( + default_factory=list, + description=( + "Canonical (subject, predicate) pairs whose IRI objects were " + "contributed by more than one unit. A multi-valued predicate " + "asserted by a single unit cannot be the product of an identity " + "merge, so the gate needs this to tell a merge signature from " + "legitimate multi-value modelling." + ), + ) + facts_validation_findings: list[FactsValidationFinding] = Field( default_factory=list, description=( @@ -975,7 +1004,7 @@ def doc_iri(self) -> URIRef: return URIRef(f"{self.current_domain}/doc/{self.doc_hid}") @property - def doc_namespace(self): + def doc_namespace(self) -> str: """Get the document namespace. Returns: diff --git a/ontocast/onto/triple_index.py b/ontocast/onto/triple_index.py new file mode 100644 index 00000000..90b6fb8f --- /dev/null +++ b/ontocast/onto/triple_index.py @@ -0,0 +1,215 @@ +"""Stable per-triple ids for the graph a critic is asked to review. + +A critique is only actionable if the loop can find the triples it names. The +critic used to name them by *requoting* their text into ``incorrect_value``, +which asks the model to reproduce graph content from memory. Measured across a +large corpus of cached critiques that reproduction succeeds a minority of the +time for ``REMOVE`` and barely half the time for ``REPLACE``: the payload comes +back as prose, or as a plausible-but-invented IRI, or -- most often -- as a +node-shaped quote spanning several triples with one predicate slightly wrong, +which fails an all-triples-present guard as a whole. Authoring *new* content in +``correct_value`` has no such problem, because nothing has to match. + +So the fix is not to give the critic a better quoting syntax; it is to stop +asking it to quote. The graph chapter carries an id per triple, the critic cites +ids, and the loop resolves them by lookup. What the model cannot reliably +reproduce, it no longer has to. + +The index is built once per critic call, held on the unit state, and checked +against the graph by fingerprint before any id is resolved -- the loop mutates +the graph between passes, so a fix carried forward as residual must not silently +resolve against a later numbering. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field +from typing import Any + +from pydantic import GetCoreSchemaHandler +from pydantic_core import core_schema +from rdflib.namespace import NamespaceManager +from rdflib.term import BNode, Literal, Node, URIRef + +from ontocast.onto.rdfgraph import RDFGraph + +Triple = tuple[Node, Node, Node] + +#: Sort rank per term kind, so a ``Literal`` and a ``URIRef`` with the same +#: lexical form cannot tie and swap places between two builds of the same graph. +_TERM_RANK: dict[type, int] = {URIRef: 0, BNode: 1, Literal: 2} + +RDF_TYPE = URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type") + + +def _term_key(term: Node, nsmgr: NamespaceManager) -> tuple[int, str]: + return (_TERM_RANK.get(type(term), 3), format_term(term, nsmgr)) + + +def format_term(term: Node, nsmgr: NamespaceManager) -> str: + """Render one term for the indexed listing. + + Literals go through ``Literal.n3``, never through the Turtle serializer's + abbreviating path: rdflib's writer renders ``xsd:double``/``float``/ + ``decimal`` via ``%e`` and loses precision, which is why + :data:`~ontocast.onto.rdfgraph.LOSSLESS_TURTLE_FORMAT` exists at all. The + critic must see the value the graph actually holds, or it will report a + rounding artifact as a defect. + """ + if isinstance(term, BNode): + return f"_:{term}" + try: + return term.n3(nsmgr) + except Exception: + # A term with an unbindable namespace still needs an id; falling back to + # the raw form keeps it addressable rather than dropping it from the + # listing, which would leave a triple the critic can see but not cite. + return str(term) + + +@dataclass(frozen=True) +class IndexedTriple: + """One line of the listing: a statement, and its id when it has one. + + A statement without an id is shown but not addressable. That is how the + ontology chapter draws its read-only boundary: the retrieved catalog is + context the critic must read and must not delete, so it simply has no number + to cite. + """ + + triple: Triple + triple_id: int | None + + +@dataclass(frozen=True) +class TripleIndex: + """Ids ``1..N`` over exactly the triples a prompt chapter shows. + + ``fingerprint`` identifies the graph state the ids were assigned against. + It is a digest of the same rendered lines the listing is built from, not + :meth:`RDFGraph.hash`: that one runs URDNA2015 to get a canonical form + stable across triple-store round trips, which is the right identity for the + catalog and far more work than is needed to answer "is this still the graph + I numbered?". + """ + + by_id: dict[int, Triple] + ids: dict[Triple, int] + fingerprint: str + scope_size: int + #: Subjects in listing order with their statements -- the render order is + #: part of the contract, so it is recorded rather than recomputed. + order: list[tuple[Node, list[IndexedTriple]]] = field(default_factory=list) + + @classmethod + def __get_pydantic_core_schema__( + cls, _source_type: Any, _handler: GetCoreSchemaHandler + ) -> core_schema.CoreSchema: + """Accept instances only; this never crosses a serialization boundary. + + The index holds rdflib terms and is valid only for one graph state, so it + is carried on the state as a within-call reference table and excluded + from every dump. There is nothing to parse it *from*. + """ + return core_schema.is_instance_schema(cls) + + def __len__(self) -> int: + return len(self.by_id) + + @property + def is_empty(self) -> bool: + return not self.by_id + + def resolve(self, triple_id: int) -> Triple | None: + """Return the triple for ``triple_id``, or ``None`` if it is unknown.""" + return self.by_id.get(triple_id) + + def matches(self, graph: RDFGraph) -> bool: + """Whether ``graph`` is still in the state these ids were assigned to.""" + return self.fingerprint == fingerprint_graph(graph) + + +def _sorted_triples(graph: RDFGraph, nsmgr: NamespaceManager) -> list[Triple]: + # rdflib's iteration order is store-dependent -- RDFGraph branches on + # OxigraphStore in several places -- so the order is imposed here rather + # than inherited. Predicates sort with rdf:type first so a subject's block + # opens with what it is. + def key(triple: Triple) -> tuple: + subject, predicate, obj = triple + return ( + _term_key(subject, nsmgr), + (0, "") if predicate == RDF_TYPE else (1, format_term(predicate, nsmgr)), + _term_key(obj, nsmgr), + ) + + return sorted(graph, key=key) + + +def fingerprint_graph(graph: RDFGraph) -> str: + """Digest of a graph's rendered triples, used to detect drift between passes.""" + nsmgr = graph.namespace_manager + digest = hashlib.sha256() + for subject, predicate, obj in _sorted_triples(graph, nsmgr): + digest.update( + f"{format_term(subject, nsmgr)}\t" + f"{format_term(predicate, nsmgr)}\t" + f"{format_term(obj, nsmgr)}\n".encode() + ) + return digest.hexdigest() + + +def build_triple_index( + graph: RDFGraph, *, scope: RDFGraph | None = None +) -> TripleIndex: + """Assign ids to ``graph``'s triples, grouped and ordered by subject. + + Args: + graph: The graph exactly as the prompt will show it. For a chapter that + condenses before serializing, pass the *condensed* graph: an id on a + triple the critic never sees is a delete ordered blind. + scope: When given, only triples also present here receive an id. The + rest are still listed -- the critic needs them to judge -- but cannot + be cited, and so cannot be removed. This is what makes a catalog + delete structurally inexpressible on the ontology side rather than + merely reported after the fact. + + Returns: + TripleIndex: Ids, the reverse lookup, the listing order, and the + fingerprint of the state they were assigned against. + """ + nsmgr = graph.namespace_manager + by_id: dict[int, Triple] = {} + ids: dict[Triple, int] = {} + order: list[tuple[Node, list[IndexedTriple]]] = [] + digest = hashlib.sha256() + + next_id = 0 + current_subject: Node | None = None + current_block: list[IndexedTriple] = [] + for triple in _sorted_triples(graph, nsmgr): + subject, predicate, obj = triple + digest.update( + f"{format_term(subject, nsmgr)}\t" + f"{format_term(predicate, nsmgr)}\t" + f"{format_term(obj, nsmgr)}\n".encode() + ) + triple_id: int | None = None + if scope is None or triple in scope: + next_id += 1 + triple_id = next_id + by_id[triple_id] = triple + ids[triple] = triple_id + if subject != current_subject: + current_subject = subject + current_block = [] + order.append((subject, current_block)) + current_block.append(IndexedTriple(triple=triple, triple_id=triple_id)) + + return TripleIndex( + by_id=by_id, + ids=ids, + fingerprint=digest.hexdigest(), + scope_size=len(by_id), + order=order, + ) diff --git a/ontocast/onto/unit_states.py b/ontocast/onto/unit_states.py index 298f65b2..678876ac 100644 --- a/ontocast/onto/unit_states.py +++ b/ontocast/onto/unit_states.py @@ -2,6 +2,7 @@ import logging from collections import defaultdict +from typing import Literal from pydantic import Field @@ -11,6 +12,7 @@ FailureStage, LLMGraphFormat, OntologyAssemblyMode, + OntologyChapterFormat, Status, WorkflowNode, ) @@ -28,10 +30,12 @@ ) from ontocast.onto.ontology import Ontology from ontocast.onto.ontology_apply import OntologyDelta +from ontocast.onto.ontology_condense import TextCaps from ontocast.onto.ontology_snapshot import OntologySnapshot from ontocast.onto.rdfgraph import RDFGraph, RejectedLiteralTriple from ontocast.onto.sparql_models import GraphUpdate from ontocast.onto.state import AgentState, BudgetTracker +from ontocast.onto.triple_index import TripleIndex logger = logging.getLogger(__name__) @@ -83,10 +87,10 @@ class UnitState(BasePydanticModel): ), ) budget_tracker: BudgetTracker = Field(default_factory=BudgetTracker) + #: Retries of a *failed* render. A successful render is never repeated; + #: improving it is what the critic passes are for, and those are a toolbox + #: budget rather than per-unit state. max_visits_per_node: int = Field(default=1, ge=1) - #: Critic attempts allowed per render attempt. None couples it to - #: ``max_visits_per_node``, which makes the worst case quadratic. - max_critic_visits_per_node: int | None = Field(default=None, ge=1) llm_graph_format: LLMGraphFormat = Field( default=LLMGraphFormat.JSONLD, description=( @@ -103,6 +107,80 @@ class UnitState(BasePydanticModel): "all hold a ToolBox." ), ) + ontology_chapter_format: OntologyChapterFormat = Field( + default=OntologyChapterFormat.INHERIT, + description=( + "Syntax of the ontology chapter in this unit's prompts: 'inherit' " + "follows llm_graph_format, 'turtle' pins the chapter to Turtle. " + "Threaded from ServerConfig like ontology_context_max_triples. " + "Read by the facts loop only; the ontology loop keeps its chapter " + "in the wire format because its output patches what it reads." + ), + ) + ontology_text_caps: TextCaps = Field( + default_factory=TextCaps, + description=( + "Per-role character caps on the text literals of this unit's " + "ontology chapter. Threaded from ServerConfig like " + "ontology_context_max_triples. All-unset is the default and leaves " + "every literal exactly as the catalog authored it. Facts loop only, " + "for the reason the chapter format is: a clipped literal is not the " + "statement an ontology patch would cite." + ), + ) + + critic_fixes_applied: int = Field( + default=0, + description="Critic fixes compiled straight to a patch with no LLM call.", + ) + critic_fixes_residual: int = Field( + default=0, + description=( + "Critic fixes that could not be compiled and were handed back as " + "outstanding work. Never silently dropped." + ), + ) + critic_fixes_noop: int = Field( + default=0, + description=( + "Critic fixes whose delete set and insert set are the same " + "statements, so they asked for no change at all." + ), + ) + critic_fixes_rolled_back: int = Field( + default=0, + description=( + "Critic fixes applied and then undone, one at a time, for leaving " + "the unit worse: deleting without writing, or raising the " + "mandatory finding count. The other fixes of the same pass stay." + ), + ) + critic_fixes_junk_refused: int = Field( + default=0, + description=( + "Critic inserts refused at compile time for minting a placeholder " + "-- a subject named for an ignored token or artifact, or a new " + "node carrying only annotations and no type." + ), + ) + critic_fixes_unresolved_prefix: int = Field( + default=0, + description=( + "Critic fixes whose payload named a prefix neither it nor the " + "unit graph declares, so its statements could not be identified." + ), + ) + + prompt_triple_index: TripleIndex | None = Field( + default=None, + exclude=True, + description=( + "Ids handed to the critic for the graph it was shown, kept so the " + "fixes it cites can be resolved. Excluded from serialization: it is " + "a within-call reference table, not run output, and it is only valid " + "for the graph state its fingerprint names." + ), + ) attempt_log: list[LoopAttempt] = Field( default_factory=list, @@ -176,12 +254,69 @@ def load_external_evidence_for_node(self, node: WorkflowNode) -> None: self.external_evidence_hits = entry.hits self.external_evidence_text = entry.text + # --- patch seam ------------------------------------------------------- + # + # A critic pass mutates whichever graph the phase owns and is rolled back + # against whichever number measures that phase's product. Those are not the + # same thing on both sides, so the loop asks the state rather than deciding + # for itself. + + def patch_target_graph(self) -> RDFGraph: + """The graph a compiled critic patch resolves its ids against.""" + raise NotImplementedError + + def apply_patch(self, update: GraphUpdate) -> bool: + """Apply a compiled patch through this phase's own update channel. + + Returns False when the phase refused it and its graph is unchanged. + """ + raise NotImplementedError + + def product_triple_count(self) -> int: + """Size of what this unit actually contributes downstream.""" + raise NotImplementedError + + def snapshot_for_rollback(self) -> object: + """Opaque token restoring the pre-pass state via :meth:`restore`.""" + raise NotImplementedError + + def restore(self, token: object) -> None: + """Undo a pass, including anything it queued for the reduce step.""" + raise NotImplementedError + class UnitFactsState(UnitState): """Independent per-unit state for facts extraction and critique.""" content_unit: ContentUnit = Field(description="Unit under processing (mutable)") facts_user_instruction: str = Field(default="") + conformance_chapter: str = Field( + default="", + description=( + "Shapes-derived CONFORMANCE REQUIREMENTS chapter, rendered once " + "per tenancy and shared into every unit's render and critic " + "prompts. Empty when the deployment has no shapes or the " + "contract is off." + ), + ) + shapes_contract_terms: tuple[str, ...] = Field( + default=(), + description=( + "IRIs the shapes contract instructs the renderer to emit; " + "exempt from UNKNOWN_TERM so the validator never orders removal " + "of what the contract required. Always the full catalog's terms, " + "whatever chapter selection does." + ), + ) + conformance_selection_pending: bool = Field( + default=False, + description=( + "The conformance chapter is to be selected per unit by joining " + "the shapes on this unit's resolved ontology snapshot -- set by " + "the fan-out when the shapes catalog outgrows the prompt cap, " + "consumed by the unit loop right after context resolution." + ), + ) facts_updates: list[GraphUpdate] = Field(default_factory=list) deterministic_findings: list[FactsUnitFinding] = Field( default_factory=list, @@ -198,6 +333,19 @@ class UnitFactsState(UnitState): "trail distinguishing machine-altered triples from LLM output." ), ) + critic_outcome: Literal["reviewed", "unavailable", "skipped"] | None = Field( + default=None, + description=( + "How the critic pass ended for this unit: 'reviewed' -- a " + "critique came back and was compiled; 'unavailable' -- the call " + "failed (timeout, unparseable response), so the render stands " + "unreviewed and no patch is applied; 'skipped' -- the loop did not " + "call the critic (render below FACTS_CRITIC_MIN_TRIPLES, or a " + "citation-metadata unit). None when no critic pass was configured. " + "A unit whose critic timed out used to leave the loop as SUCCESS, " + "indistinguishable from one the critic accepted." + ), + ) def update_facts(self) -> None: """Apply facts_updates to content_unit.graph and clear the list.""" @@ -209,6 +357,30 @@ def update_facts(self) -> None: self.content_unit.graph = updated_graph self.facts_updates = [] + def patch_target_graph(self) -> RDFGraph: + return self.content_unit.graph + + def apply_patch(self, update: GraphUpdate) -> bool: + """Apply through ``facts_updates``, the same channel a render uses. + + For facts the graph *is* the unit's product, so nothing downstream has + to be told about the change separately. + """ + self.facts_updates.append(update) + self.update_facts() + return True + + def product_triple_count(self) -> int: + return len(self.content_unit.graph) + + def snapshot_for_rollback(self) -> object: + return self.content_unit.graph.copy() + + def restore(self, token: object) -> None: + assert isinstance(token, RDFGraph) + self.content_unit.graph = token + self.facts_updates = [] + class UnitOntologyState(UnitState): """Independent per-unit state for ontology improvement loop.""" @@ -317,6 +489,63 @@ def update_ontology(self) -> bool: self.ontology_updates = [] return True + def patch_target_graph(self) -> RDFGraph: + return self.working_graph + + def apply_patch(self, update: GraphUpdate) -> bool: + """Apply through ``ontology_updates``, never straight to the graph. + + This is the one place the two phases genuinely differ. A unit's ontology + product is not ``working_graph``: :meth:`build_delta` replays + ``all_updates`` onto a *fresh copy of the snapshot* and diffs, so a patch + written directly into the scratchpad would be reported by no delta and + dropped at reduce time -- applied, visible in the loop, and absent from + the output. + + Routing through the update channel also inherits the + ``ontology_max_triples`` backstop: a refusal leaves the graph untouched + and says so, rather than half-applying. + """ + self.ontology_updates.append(update) + if self.update_ontology(): + return True + # Rejected by the size backstop. Drop the pending update so it cannot be + # replayed by `all_updates` into a delta the working graph never saw. + self.ontology_updates = [ + pending for pending in self.ontology_updates if pending is not update + ] + return False + + def product_triple_count(self) -> int: + """Insert count, not working-graph size. + + The working graph is the snapshot plus this unit's delta, so it barely + moves -- a rollback rule keyed on its size would never fire. What the + unit contributes is the delta. + """ + return len(self.build_delta().inserts) + + def snapshot_for_rollback(self) -> object: + return ( + self.working_graph.copy(), + list(self.ontology_updates_applied), + list(self.ontology_updates), + ) + + def restore(self, token: object) -> None: + """Undo a pass, including what it queued for the reduce step. + + Restoring the graph alone is not enough: ``build_delta`` replays the + recorded updates, so a rolled-back patch left in + ``ontology_updates_applied`` would be put back into the product even + though the scratchpad no longer holds it. + """ + assert isinstance(token, tuple) + graph, applied, pending = token + self.working_graph = graph + self.ontology_updates_applied = list(applied) + self.ontology_updates = list(pending) + def working_graph_changed(self) -> bool: """True when the scratchpad graph differs from the seed snapshot. diff --git a/ontocast/onto/util.py b/ontocast/onto/util.py index 9a1f2277..46971a2b 100644 --- a/ontocast/onto/util.py +++ b/ontocast/onto/util.py @@ -96,3 +96,30 @@ def get_rdflib_namespace_mappings() -> dict[str, str]: def is_rdflib_default_namespace(namespace: str) -> bool: """Return True when *namespace* is one of rdflib's built-in bindings.""" return namespace in RDFLIB_DEFAULT_NAMESPACE_URIS + + +def non_domain_namespaces() -> frozenset[str]: + """Namespaces that are never a document's *domain* vocabulary. + + Every rdflib built-in binding (``brick``, ``csvw``, ``xml``, …), every + namespace in ``COMMON_PREFIXES``, both schema.org spellings, and the facts + namespace itself. Two callers ask the same question of this set and used to + each build their own copy: the delta partitioner, which must not treat a + standard namespace as writable, and the prompt's "domain ontologies" clause, + which must not list one. They diverge only in shape -- the partitioner + compares normalized stems -- so the shape conversion lives with each caller + and the *membership* is defined once, here. + + This is deliberately **not** the facts-vocabulary standard set + (``tool/facts_validation/terms.py``), which answers a different question -- + which terms a render may use without being flagged unknown -- and is + operator-extensible through ``FACTS_ADDITIONAL_STANDARD_NAMESPACES``. + Nor is it the aggregator's type-comparison set, which is narrower again. + """ + from ontocast.onto.constants import COMMON_PREFIXES, DEFAULT_IRI + + return ( + RDFLIB_DEFAULT_NAMESPACE_URIS + | frozenset(uri.strip("<>") for uri in COMMON_PREFIXES.values()) + | {"https://schema.org/", "http://schema.org/", DEFAULT_IRI} + ) diff --git a/ontocast/prompt/__init__.py b/ontocast/prompt/__init__.py index b087a144..059e0d91 100644 --- a/ontocast/prompt/__init__.py +++ b/ontocast/prompt/__init__.py @@ -10,6 +10,7 @@ - render_facts: Extract facts from text using ontologies - criticise_ontology: Evaluate and critique ontology quality - criticise_facts: Validate and critique extracted facts +- complete_facts: Insert-only facts completion pass (post-critic-loop) - common: Shared prompt templates and components - graph_format: GraphFormatProfile (prompt + format instructions for llm_graph_format) - llm_json_schema: Format-bound JSON Schema for canonical report models diff --git a/ontocast/prompt/complete_facts.py b/ontocast/prompt/complete_facts.py new file mode 100644 index 00000000..90818efb --- /dev/null +++ b/ontocast/prompt/complete_facts.py @@ -0,0 +1,305 @@ +"""Prompt for the insert-only facts completion pass. + +Runs after the facts render/critic loop, only when the numeric-coverage +inventory (:mod:`ontocast.util.numeric_inventory`) still lists measurements +absent from the unit graph. The render and critic prompts read the whole +ontology chapter; this pass instead gets a compact TERM SHEET -- the +quantity/observation/condition-shaped classes and the unit individuals of the +unit's ontology snapshot -- plus the unit's existing catalog-typed subjects, +so a recovered measurement can attach to what is already there instead of +minting a duplicate. Chapter order matches the render/critic templates: +preamble -> conformance -> term sheet (in place of the ontology chapter) -> +task -> guidelines -> user instruction -> text -> missing measurements. +""" + +from __future__ import annotations + +import re +from collections.abc import Collection + +from rdflib import OWL, RDF, RDFS, URIRef + +from ontocast.onto.constants import DEFAULT_IRI +from ontocast.onto.enum import LLMGraphFormat +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.util.numeric_inventory import NumericInventory, unit_surface_index + +from .common import system_preamble_semantic + +# Shares the render/critic preamble so a provider's prefix cache has a chance +# to serve this pass too, though the chapters after it are unrelated. +preamble = system_preamble_semantic + +template_prompt = """ +{preamble} + +{conformance_chapter} + +{term_sheet} + +{catalog_subjects_chapter} + +# TASK + +The facts graph already extracted from this unit is missing measurements the +source text states. Propose insert-only fixes that add them. + +{completion_instruction} + +{user_instruction} + +{text_chapter} + +{missing_measurements_chapter} + +{output_instruction} + +{format_instructions} +""" + +completion_instruction = f"""\n\n +# COMPLETION GUIDELINES + +This is an insert-only pass, not a re-extraction and not a critique. Every +fix's `action` MUST be `ADD`; never REMOVE or REPLACE, and never fill +`triple_ids` -- nothing here is addressed by id. Leave every existing triple +exactly as it is; propose nothing beyond what recovers a listed measurement. + +1. Facts use the fixed namespace `{DEFAULT_IRI}` with the prefix `cd:`. Type + every new subject via `rdf:type` with a class from the TERM SHEET below + when one fits; reach for a generic vocabulary only when it does not. + Never mint a new subject under a domain ontology prefix. + +2. Prefer the EXISTING SUBJECTS chapter: when a recovered measurement is a + property of a subject already in the graph, attach the new triple(s) to + that subject's IRI instead of minting a new one for the same entity. + +3. Every new `cd:` subject carries `rdfs:label` naming it from the source + text, in the same language. + +4. A property whose range is a class (a unit-valued property among them) + takes an IRI, never a string: resolve the unit token against the TERM + SHEET's unit individuals by label, notation, or symbol, matching + case-sensitively. Keep the value and unit verbatim -- never convert, + round, or re-derive. + +5. Do NOT mint an entity for a bare number, a citation marker, or a + typography artifact. If a listed measurement cannot be placed as a fact, + leave it out rather than guessing a subject for it. + +6. Declare every namespace prefix your fixes use that is not already bound + in the chapters above. +""" + +_OUTPUT_INSTRUCTION_TURTLE = """\n\n +# OUTPUT INSTRUCTION + +Provide `proposed_fixes` as a JSON array of fix objects, every `action` +equal to `ADD`. `correct_value` is a **string** containing valid Turtle: any +`@prefix` declarations not already bound above, then one or more triples +about a single new subject (or a statement attaching a value to an existing +subject named in EXISTING SUBJECTS). Leave `triple_ids` empty. +Example: "cd:quantity_1 a onto:Quantity ; qudt:numericValue \\"1\\"^^xsd:decimal ; qudt:unit unit:EXAMPLE ." +""" + +_OUTPUT_INSTRUCTION_JSONLD = """\n\n +# OUTPUT INSTRUCTION + +Provide `proposed_fixes` as a JSON array of fix objects, every `action` +equal to `ADD`. `correct_value` is a **string** containing valid JSON for one +subject node: an inline `@context` for any prefix not already bound above, +then `@id`/`@type` plus the recovered measurement's properties (or a +statement attaching a value to an existing subject named in EXISTING +SUBJECTS). Leave `triple_ids` empty. +Example: "{\\"@context\\": {\\"qudt\\": \\"http://qudt.org/schema/qudt/\\"}, \\"@id\\": \\"cd:quantity_1\\", \\"@type\\": \\"onto:Quantity\\", \\"qudt:numericValue\\": {\\"@value\\": \\"1\\", \\"@type\\": \\"xsd:decimal\\"}, \\"qudt:unit\\": {\\"@id\\": \\"unit:EXAMPLE\\"}}" +""" + + +def output_instruction_for(fmt: LLMGraphFormat) -> str: + """The GRAPH FORMAT INSTRUCTION for ``correct_value``, by wire format.""" + if fmt == LLMGraphFormat.JSONLD: + return _OUTPUT_INSTRUCTION_JSONLD + return _OUTPUT_INSTRUCTION_TURTLE + + +# Generic, structure-only role hints -- the same kind of local-name pattern +# match numeric_inventory.py uses for "*Unit" classes. Never domain +# vocabulary: applied to whatever ontology graph is supplied, not embedded +# as content. +_TERM_SHEET_CLASS_HINT = re.compile( + r"(quantity|measurement|observation|condition|value|characteristic|parameter)", + re.IGNORECASE, +) + + +def _qname(graph: RDFGraph, term: URIRef) -> str: + """Prefixed name for ``term``, falling back to the full IRI.""" + try: + return graph.namespace_manager.qname(term) + except Exception: + return str(term) + + +def _quantity_like_classes(graph: RDFGraph, *, limit: int) -> list[tuple[str, str]]: + """(qname, label) pairs for classes whose name reads as quantity-shaped.""" + classes: set[URIRef] = set() + for cls in graph.subjects(RDF.type, OWL.Class): + if isinstance(cls, URIRef): + classes.add(cls) + for cls in graph.subjects(RDF.type, RDFS.Class): + if isinstance(cls, URIRef): + classes.add(cls) + + entries: list[tuple[str, str]] = [] + for cls in classes: + label = next((str(o) for o in graph.objects(cls, RDFS.label)), "") + if not _TERM_SHEET_CLASS_HINT.search(f"{label} {cls}"): + continue + entries.append((_qname(graph, cls), label)) + entries.sort(key=lambda pair: pair[0]) + return entries[:limit] + + +def _unit_individuals( + graph: RDFGraph, unit_properties: Collection[str], *, limit: int +) -> list[tuple[str, str]]: + """(qname, surfaces) pairs for unit individuals findable via ``unit_properties``.""" + by_iri: dict[str, list[str]] = {} + for surface, iris in unit_surface_index(graph, unit_properties).items(): + for iri in iris: + by_iri.setdefault(iri, []).append(surface) + + entries: list[tuple[str, str]] = [] + for iri in sorted(by_iri): + surfaces = sorted(by_iri[iri], key=len)[:3] + entries.append((_qname(graph, URIRef(iri)), ", ".join(surfaces))) + return entries[:limit] + + +def build_term_sheet( + ontology_graph: RDFGraph, + unit_properties: Collection[str] = (), + *, + max_classes: int = 40, + max_units: int = 60, +) -> str: + """The TERM SHEET chapter, in place of the full ontology chapter. + + A compact vocabulary this pass can act on without reading the whole + catalog: quantity/observation/condition-shaped classes, and the unit + individuals reachable through ``unit_properties`` (typically the + deployment's configured unit-role property, expanded to an IRI). + + Args: + ontology_graph: The unit's ontology snapshot graph. + unit_properties: IRIs of unit-role properties (e.g. ``qudt:unit``) + whose range/``*Unit``-named classes hold the unit individuals. + max_classes: Cap on listed classes. + max_units: Cap on listed unit individuals. + + Returns: + The chapter text, or "" when the snapshot offers neither. + """ + classes = _quantity_like_classes(ontology_graph, limit=max_classes) + units = _unit_individuals(ontology_graph, unit_properties, limit=max_units) + if not classes and not units: + return "" + + lines = [ + "\n\n# TERM SHEET", + "Compact vocabulary for this pass -- use these; never invent a class " + "or a unit individual.", + ] + if classes: + lines.append("\nQuantity/observation/condition classes:") + for qname, label in classes: + lines.append(f' {qname} "{label}"' if label else f" {qname}") + if units: + lines.append( + "\nUnit individuals (object of a unit-valued property). Symbols are " + "case-sensitive: use a unit only when a listed symbol matches the " + "text exactly." + ) + for qname, surfaces in units: + lines.append(f" {qname} ({surfaces})" if surfaces else f" {qname}") + return "\n".join(lines) + "\n" + + +def build_catalog_subjects_chapter( + fact_graph: RDFGraph, + catalog_terms: Collection[str], + *, + limit: int = 40, +) -> str: + """The EXISTING SUBJECTS chapter: unit subjects already typed by a catalog term. + + Lets the pass attach a recovered measurement to a subject that already + exists rather than minting a duplicate for the same real-world entity. + + Args: + fact_graph: The unit's current facts graph. + catalog_terms: IRIs the unit's ontology snapshot declares or + references (see ``collect_catalog_terms``). + limit: Cap on listed subjects. + + Returns: + The chapter text, or "" when no subject in ``fact_graph`` is typed + by a catalog term. + """ + terms = set(catalog_terms) + if not terms: + return "" + + entries: list[tuple[str, str, str]] = [] + seen: set[URIRef] = set() + for subject, _, obj in fact_graph.triples((None, RDF.type, None)): + if not isinstance(subject, URIRef) or not isinstance(obj, URIRef): + continue + if subject in seen or str(obj) not in terms: + continue + seen.add(subject) + label = next((str(o) for o in fact_graph.objects(subject, RDFS.label)), "") + entries.append((_qname(fact_graph, subject), _qname(fact_graph, obj), label)) + if not entries: + return "" + entries.sort(key=lambda row: row[0]) + entries = entries[:limit] + + lines = [ + "\n\n# EXISTING SUBJECTS", + "Already in the graph -- attach a recovered measurement to one of " + "these when it is about the same entity, instead of minting a new " + "subject:", + ] + for subject, type_qname, label in entries: + row = f" {subject} a {type_qname}" + if label: + row += f' rdfs:label "{label}"' + lines.append(row) + return "\n".join(lines) + "\n" + + +def build_missing_measurements_chapter(inventory: NumericInventory) -> str: + """The MISSING MEASUREMENTS chapter: each value, unit, and source context. + + Args: + inventory: The unit's numeric-coverage inventory (see + ``ontocast.tool.facts_validation.unit_findings.unit_numeric_inventory``), + already restricted to values absent from the graph. + + Returns: + The chapter text, or "" when nothing is missing. + """ + if not inventory.measurements: + return "" + lines = [ + "\n\n# MISSING MEASUREMENTS", + "Numbers stated in the text with a unit, absent from the graph. Add " + "a fact for each you can place; leave out one you cannot.", + "", + ] + for mention in inventory.measurements: + lines.append( + f' "{mention.value} {mention.unit}" in: "...{mention.context}..."' + ) + return "\n".join(lines) + "\n" diff --git a/ontocast/prompt/criticise_facts.py b/ontocast/prompt/criticise_facts.py index 212b8e92..d736d713 100644 --- a/ontocast/prompt/criticise_facts.py +++ b/ontocast/prompt/criticise_facts.py @@ -8,29 +8,38 @@ from .common import system_preamble_semantic +# Opens exactly like the render template (preamble, conformance contract, +# ontology chapter) so the two prompts on a unit share their prefix through +# the end of the ontology chapter -- the part a provider's prefix cache can +# reuse. See prompt/render_facts.py; a test pins the two heads to each other. template_prompt = """ {preamble} +{conformance_chapter} + +{ontology_chapter} + +# TASK + +You are given an ontology, a text and a semantic graph of facts, generated from the text (guided by ontology). +Following evaluation guidelines provide concrete suggestions for improvement of the extracted facts graph with respect to provided text and ontology. + {evaluation_instruction} {user_instruction} -{ontology_chapter} +{text_chapter} {facts_chapter} -{text_chapter} - {graph_format_instruction} {format_instructions} """ -preamble = f""" -{system_preamble_semantic} -You are given an ontology, a text and a semantic graph of facts, generated from the text (guided by ontology). -Following evaluation guidelines provide concrete suggestions for improvement of the extracted facts graph with respect to provided text and ontology. -""" +# Shared verbatim with the renderer; the task statement sits in the template +# after the ontology chapter for the same reason. +preamble = system_preamble_semantic evaluation_instruction = f"""\n\n @@ -39,6 +48,20 @@ 1. Appropriateness: Are the facts appropriate for the document? 2. Completeness: Are all possible facts extracted from the text given the ontology? + - Quantitative completeness is checked mechanically: the NUMERIC COVERAGE + findings list numbers stated in the text but absent from the graph — + measurements (a number with its unit) first, bare numbers after. Do NOT + propose an ADD fix per listed number. A number only becomes a fact + together with the quantity it measures, and a value whose quantity you + can name is an omission the extractor must see in full context: list the + measurements you judge missed — verbatim value, unit, and the phrase + they occur in — in `systemic_critique_summary`; a separate completion pass + extracts them. + - NEVER mint an entity or placeholder node for a bare number, an ignored + token, a citation marker, a page/figure/equation token, or a conversion + artifact. Such tokens are typography, not facts: name them in + `explanation` if at all, never as a subject in `correct_value`. A node + whose only content is a label or comment is not a fact either. 3. Concreteness: Only concrete facts should be extracted. diff --git a/ontocast/prompt/facts_guidelines.py b/ontocast/prompt/facts_guidelines.py index 912aa856..595f1f7f 100644 --- a/ontocast/prompt/facts_guidelines.py +++ b/ontocast/prompt/facts_guidelines.py @@ -17,7 +17,7 @@ 1. Facts MUST use the fixed namespace `{facts_namespace}` with the prefix `cd:`. Local names for facts should not be capitalized (use lowercase_snake_case). 1a. TWO-NAMESPACE CONTRACT (most important rule): - - {domain_ontologies_clause}: schema elements only — classes (as `rdf:type` objects), predicates, and named individuals that exist verbatim in the ontology + - {domain_ontologies_clause} (see the ONTOLOGY section below): schema elements only — classes (as `rdf:type` objects), predicates, and named individuals that exist verbatim in the ontology - `cd:`: ALL new instances extracted from the text, even if typed by an ontology class CORRECT: `cd:trial_1 a onto:Trial ; onto:hasJudgment cd:judgment_1 .` @@ -42,12 +42,18 @@ `rdfs:subPropertyOf` of `onto:hasPart` and its domain/range fit the subject and object. - CORRECT: `cd:assembly_1 onto:hasComponent cd:widget_1 .` -2. Use the provided domain ontology namespace(s) above and standard ontologies (RDFS, OWL, schema.org, etc.) to identify/infer entities, classes, types, and relationships -3. Thoroughly Extract and Link: extract all possible text mentions that correspond to entities, classes, types, or relationships defined in the domain ontology namespace(s) above -4. Enforce typing: all `cd:` entities (facts) are data instances and must be linked via `rdf:type` to a valid operational Class from either the domain ontology namespace(s) above or standard core vocabularies (e.g., `schema:Person`, `schema:Organization`, `onto:Trial`). +2. VOCABULARY PRECEDENCE (read before choosing any term): + - The ONTOLOGY section of this prompt is the vocabulary you are being asked to use. Search it FIRST for every class and property you need, and prefer its terms even when a generic vocabulary has a term that would also fit. + - Generic vocabularies (RDFS, OWL, schema.org, …) are a LAST RESORT, for concepts the provided ontology genuinely does not cover. They are not an equal alternative. + - The reason is mechanical, not stylistic: the deployment validates this graph against shapes written for the provided ontology. A term from that ontology is checked and queryable; a generic substitute is invisible to every shape and every downstream query, so it reads as extracted but answers nothing. + - If the ONTOLOGY section is empty or has no term for a concept, say so by leaving the concept untyped rather than reaching for a generic near-equivalent that changes what was asserted. +3. Thoroughly Extract and Link: extract all possible text mentions that correspond to entities, classes, types, or relationships defined in the provided ontology +3a. QUANTITATIVE COMPLETENESS: extract EVERY quantitative statement in the text — every measured value, duration, condition, concentration, ratio, threshold, and uncertainty — even when the provided ontology has no term for it (the quantity fallback of rule 8 applies in that case). A number attached to a stated quantity is a fact and must reach the graph with its verbatim value and unit; a bare citation, page, figure, or equation token is typography and must not (rule 4). Completeness of the measured content is judged, so silently skipping a stated measurement is a defect, not a simplification. +4. Enforce typing: all `cd:` entities (facts) are data instances and must be linked via `rdf:type` to a valid operational Class — from the provided ontology wherever one fits (e.g. `onto:Trial`), and only otherwise from a generic core vocabulary (e.g. `schema:Person`). - CRITICAL: NEVER type a `cd:` instance as `rdfs:Class` or `rdf:Property`. You are extracting data occurrences, not rewriting or defining the schema. + - Do NOT mint an entity for a bare number, a citation marker, or a reference token (`[12]`, "19", equation numbers). These are typography, not facts. 5. Declare every namespace prefix you use (rdf, rdfs, owl, schema, domain ontologies, cd, etc.). -5a. PREFIX HYGIENE: Use **only** prefix aliases declared in the ontology context above. Do not invent alternative aliases. +5a. PREFIX HYGIENE: Use **only** prefix aliases declared in the ONTOLOGY section below. Do not invent alternative aliases. 6. CRITICAL - Entity Matching & Namespace Isolation Protocol: - Understand the Ontology Contents: The provided ontology contains the schema (Classes and Properties). It may also contain a small set of static Reference Individuals (e.g., fixed status constants, countries, or controlled vocabularies). It does NOT contain the dynamic data instances described in your source text. - The Target Lookup Rule: BEFORE creating a `cd:` entity, check if the text mention refers to one of those static Reference Individuals existing verbatim in the provided ontology context (declared as `owl:NamedIndividual` or an explicit individual token). diff --git a/ontocast/prompt/graph_format.py b/ontocast/prompt/graph_format.py index 06ca95cc..57827651 100644 --- a/ontocast/prompt/graph_format.py +++ b/ontocast/prompt/graph_format.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from contextlib import AbstractContextManager from contextvars import Token from dataclasses import dataclass @@ -10,15 +11,36 @@ from langchain_core.output_parsers import PydanticOutputParser from pydantic import BaseModel -from ontocast.onto.enum import LLMGraphFormat +from ontocast.onto.enum import LLMGraphFormat, OntologyChapterFormat from ontocast.onto.llm_graph_payload import llm_graph_format_ctx -from ontocast.onto.ontology_condense import condense_graph_for_prompt +from ontocast.onto.ontology_condense import ( + CondenseReport, + TextCaps, + condense_graph_for_prompt, +) from ontocast.onto.rdfgraph import RDFGraph +from ontocast.onto.triple_index import TripleIndex, build_triple_index from ontocast.prompt.facts_guidelines import format_facts_operational_guidelines +from ontocast.prompt.graph_index import render_index_table, render_indexed_turtle from ontocast.prompt.llm_json_schema import format_instructions_for_model +from ontocast.prompt.term_sheet import build_ontology_term_sheet T = TypeVar("T", bound=BaseModel) + +@dataclass(frozen=True) +class IndexedChapter: + """A prompt chapter and the triple ids it hands the model. + + The two travel together because they must not drift: the index is only + meaningful for the exact graph state the text was rendered from, and the + resolver checks that before it will delete anything. + """ + + text: str + index: TripleIndex + + # --- Output instructions (single-format per profile; graph-update = base + suffix) --- # Fresh render: one block per deployment format (Turtle ontology/facts, or JSON-LD). @@ -128,26 +150,56 @@ ``` """ -_OUTPUT_INSTRUCTION_CRITIQUE_TURTLE = """\n\n +_CRITIQUE_ADDRESSING_INSTRUCTION = """\n\n +# HOW TO ADDRESS A STATEMENT + +Every statement in the graph chapters above carries an id -- the bracketed number +before it, or its row in the TRIPLE INDEX table. + +1. To REMOVE a statement, put its id in `triple_ids`. Leave `correct_value` empty. +2. To REPLACE a statement, put the id(s) you are replacing in `triple_ids` and put + the replacement in `correct_value`. +3. To ADD, leave `triple_ids` empty and put the new statement in `correct_value`. + +Cite the id. Do NOT retype an existing statement into `incorrect_value`: a retyped +statement has to match the stored one exactly, and a single differing predicate, +prefix, or literal form silently discards the whole fix. The id cannot be wrong. + +Cite several ids in one fix when a change affects several statements about the same +subject. Cite only ids you can see; never guess a number. +""" + +_OUTPUT_INSTRUCTION_CRITIQUE_TURTLE = ( + _CRITIQUE_ADDRESSING_INSTRUCTION + + """\n\n # GRAPH FORMAT INSTRUCTION (LLM_GRAPH_FORMAT=turtle) The deployment emits RDF graph fixes in Turtle syntax. -For each `incorrect_value` and `correct_value` in actionable fixes, provide a **string** -containing valid Turtle: `@prefix` declarations when needed, then one or more triples. +Provide `correct_value` as a **string** containing valid Turtle: `@prefix` declarations +when needed, then one or more triples. Example: "@prefix schema: . cd:alice schema:worksFor cd:acme ." """ +) -_OUTPUT_INSTRUCTION_CRITIQUE_JSONLD = """\n\n +_OUTPUT_INSTRUCTION_CRITIQUE_JSONLD = ( + _CRITIQUE_ADDRESSING_INSTRUCTION + + """\n\n # GRAPH FORMAT INSTRUCTION (LLM_GRAPH_FORMAT=jsonld) -Render output uses embedded JSON-LD objects for graph fields, but critique fixes use **strings** -containing JSON for one subject node each. -For each `incorrect_value` and `correct_value`, provide a **string** with valid JSON for one -subject node (inline `@context` or compact IRIs only): +Render output uses embedded JSON-LD objects for graph fields, but critique fixes use a **string** +containing JSON for one subject node. +Provide `correct_value` as a **string** with valid JSON for one subject node +(inline `@context` or compact IRIs only): Example: "{\\"@context\\": {\\"schema\\": \\"https://schema.org/\\"}, \\"@id\\": \\"cd:alice\\", \\"schema:worksFor\\": {\\"@id\\": \\"cd:acme\\"}}" Use `{"@value": "...", "@type": "xsd:date"}` for typed literals and `{"@value": "...", "@language": "en"}` for language-tagged literals. Never use Turtle ^^ syntax inside these JSON strings. """ +) + + +def _fence_lang(fmt: LLMGraphFormat) -> str: + """Code-fence language tag for a graph serialized in ``fmt``.""" + return "ttl" if fmt == LLMGraphFormat.TURTLE else "json" @dataclass(frozen=True) @@ -155,14 +207,68 @@ class GraphFormatProfile: """Prompt, context serialization, and parser configuration for one LLM graph format.""" format: LLMGraphFormat + #: Syntax of the ``# ONTOLOGY`` chapter built by :meth:`format_ontology_chapter`. + #: ``INHERIT`` follows :attr:`format`; ``TURTLE`` decouples the read-only + #: context from the wire the model writes on. Nothing else on the profile + #: -- output instructions, the facts chapter, parsing -- reads it. + ontology_chapter_format: OntologyChapterFormat = OntologyChapterFormat.INHERIT + + @property + def renders_term_sheet(self) -> bool: + """Whether the ontology chapter is a term sheet rather than a graph.""" + return self.ontology_chapter_format == OntologyChapterFormat.TERM_SHEET + + @property + def ontology_chapter_discriminator(self) -> str: + """What actually determines the chapter text, for a memo key. + + Two profiles that render the same chapter must share a memo entry -- a + JSON-LD profile pinned to Turtle and a plain Turtle profile produce the + same bytes -- so the wire, not the setting, is the discriminator for a + graph chapter. A term sheet is not a serialization of the graph at all, + and reports a Turtle wire only because that is the cheaper of the two it + is not, so it needs its own name here or it would be served the Turtle + chapter. + """ + if self.renders_term_sheet: + return str(OntologyChapterFormat.TERM_SHEET) + return str(self.ontology_chapter_wire) + + @property + def ontology_chapter_wire(self) -> LLMGraphFormat: + """The syntax :meth:`format_ontology_chapter` serializes in. + + Meaningless when :attr:`renders_term_sheet` -- a term sheet is not a + serialization of the graph -- and reported as Turtle there so a caller + that only wants the cheaper-of-the-two answer is not misled. + """ + if self.ontology_chapter_format in ( + OntologyChapterFormat.TURTLE, + OntologyChapterFormat.TERM_SHEET, + ): + return LLMGraphFormat.TURTLE + return self.format def context_fence_lang(self) -> str: - return "ttl" if self.format == LLMGraphFormat.TURTLE else "json" + return _fence_lang(self.format) - def serialize_graph_for_prompt(self, graph: RDFGraph) -> str: - if self.format == LLMGraphFormat.TURTLE: - return graph.serialize_canonical_turtle() - return graph.serialize_compact_jsonld_for_prompt() + def serialize_graph_for_prompt( + self, graph: RDFGraph, *, wire: LLMGraphFormat | None = None + ) -> str: + """Serialize ``graph`` for a prompt chapter. + + Args: + graph: The graph to serialize. + wire: Syntax to use; defaults to the profile's own format. + """ + # Canonical blank-node labels here and not in the serializers + # themselves: this is the path whose output is a cache key, and the + # store and API paths have no reason to pay for the refinement. + canonical = graph.canonicalize_bnodes() + fmt = self.format if wire is None else wire + if fmt == LLMGraphFormat.TURTLE: + return canonical.serialize_canonical_turtle() + return canonical.serialize_compact_jsonld_for_prompt() def format_ontology_chapter( self, @@ -170,6 +276,8 @@ def format_ontology_chapter( *, suffix: str = "", max_triples: int | None = None, + text_caps: TextCaps | None = None, + on_report: Callable[[CondenseReport], None] | None = None, ) -> str: """Serialize the ontology chapter, condensing it toward ``max_triples``. @@ -177,23 +285,148 @@ def format_ontology_chapter( loops, render and critique, the shared snapshot and the ontology loop's mutable working graph -- so it is where the prompt budget is enforced. + The chapter is written in :attr:`ontology_chapter_wire`, which the + deployment may pin to Turtle independently of the wire format: here the + ontology is context the model reads, not a graph it patches, so its + syntax is free to be the denser one. The indexed variant the ontology + critic uses stays on the wire format -- its output is a patch against + the very statements it reads. + ``suffix`` is built by the caller from the uncondensed graph, which stays correct: the index only names terms by ``rdfs:label`` and their domain/range, none of which condensing drops. + + ``text_caps`` bounds the individual literals before either rendering. + The triple budget is a count and says nothing about how long one + ``rdfs:comment`` may be, so a chapter well inside it can still be + unbounded; capping here covers every chapter the pipeline builds. + + ``on_report`` is handed what condensing and capping actually did. A cap + whose effect cannot be read back is a cap nobody can size: whether a + catalog reaches one at all is a property of that catalog, not something + a default can be chosen for in advance. """ - condensed, _ = condense_graph_for_prompt(graph, max_triples) - body = self.serialize_graph_for_prompt(condensed) - chapter = f"\n\n# ONTOLOGY\n\n```{self.context_fence_lang()}\n{body}\n```\n" + condensed, report = condense_graph_for_prompt(graph, max_triples, text_caps) + if on_report is not None: + on_report(report) + if self.renders_term_sheet: + body = build_ontology_term_sheet(condensed) + return f"\n\n# ONTOLOGY\n\n{body}\n" + suffix + wire = self.ontology_chapter_wire + body = self.serialize_graph_for_prompt(condensed, wire=wire) + chapter = f"\n\n# ONTOLOGY\n\n```{_fence_lang(wire)}\n{body}\n```\n" return chapter + suffix - def format_facts_chapter(self, graph: RDFGraph) -> str: - body = self.serialize_graph_for_prompt(graph) - return ( - "\n\n# SEMANTIC GRAPH OF FACTS\n" - "The following facts were extracted\n\n" - f"```{self.context_fence_lang()}\n{body}\n```\n" + def _indexed_body(self, graph: RDFGraph, index: TripleIndex) -> str: + """Body plus ids, in whichever way this format can carry them. + + Turtle takes the ids inline, where the critic is already reading. JSON-LD + cannot: rule 7 of its output instruction demands strictly valid JSON, and + an ``[12]`` marker inside a node object would contradict it -- so the ids + ride in a table after the fenced block instead. One resolver serves both. + """ + if self.format == LLMGraphFormat.TURTLE: + return render_indexed_turtle(graph, index) + return self.serialize_graph_for_prompt(graph) + + def _index_appendix(self, graph: RDFGraph, index: TripleIndex) -> str: + if self.format == LLMGraphFormat.TURTLE or index.is_empty: + return "" + return "\n" + render_index_table(graph, index) + "\n" + + def format_facts_chapter_indexed(self, graph: RDFGraph) -> IndexedChapter: + """The facts chapter with a citable id on every triple. + + Scope is the whole unit graph: for facts the graph *is* the unit's + product, so every triple in it is the critic's to change. + """ + graph.sanitize_prefixes_namespaces() + index = build_triple_index(graph) + body = self._indexed_body(graph, index) + return IndexedChapter( + text=( + "\n\n# SEMANTIC GRAPH OF FACTS\n" + "The following facts were extracted. " + f"{self._addressing_note(index, len(graph))}\n\n" + f"```{self.context_fence_lang()}\n{body}\n```\n" + f"{self._index_appendix(graph, index)}" + ), + index=index, ) + def format_ontology_chapter_indexed( + self, + graph: RDFGraph, + *, + scope: RDFGraph | None = None, + suffix: str = "", + max_triples: int | None = None, + ) -> IndexedChapter: + """The ontology chapter with ids, assigned **after** condensing. + + Condensing drops triples to fit the prompt budget. Numbering before it + would hand out ids for statements the critic never sees, and a delete + cited against one of those is ordered blind. + + ``scope`` narrows which statements are addressable. The ontology critic + is shown the retrieved snapshot *plus* this unit's delta but owns only + the delta, so passing the delta here leaves catalog statements visible + and unciteable -- a delete that would propagate to every document + sharing the terminal simply cannot be expressed. + + Text caps deliberately do not reach here. The critic cites statements by + index and may order a delete against one; a clipped literal would not + match the statement it names, so the delete would silently miss. The + indexed chapter shows the statements exactly as they are stored. + """ + condensed, _ = condense_graph_for_prompt(graph, max_triples) + condensed.sanitize_prefixes_namespaces() + index = build_triple_index(condensed, scope=scope) + body = self._indexed_body(condensed, index) + return IndexedChapter( + text=( + "\n\n# ONTOLOGY\n" + f"{self._addressing_note(index, len(condensed))}\n\n" + f"```{self.context_fence_lang()}\n{body}\n```\n" + f"{self._index_appendix(condensed, index)}" + f"{suffix}" + ), + index=index, + ) + + def _addressing_note(self, index: TripleIndex, shown: int) -> str: + """Explain the ids, and the absence of an id, in one sentence each.""" + if shown == 0: + # Nothing to address, so nothing to explain. A note naming the index + # for an empty chapter invites a citation there is no statement for. + return "" + if len(index) == 0: + return ( + "Every statement below is existing context: read it, but none " + "of it can be cited or removed." + ) + if self.format == LLMGraphFormat.TURTLE: + note = ( + "The bracketed number before each statement is its id; cite ids " + "in `triple_ids` to change or remove a statement." + ) + if len(index) < shown: + note += ( + " Statements marked `[-]` are existing context: read them, " + "but they cannot be cited or removed." + ) + return note + note = ( + "The TRIPLE INDEX below lists the id of each addressable statement; " + "cite ids in `triple_ids` to change or remove one." + ) + if len(index) < shown: + note += ( + " Statements absent from the table are existing context: read " + "them, but they cannot be cited or removed." + ) + return note + def render_fresh_output_instruction(self, *, target: str = "facts") -> str: if self.format == LLMGraphFormat.JSONLD: return _OUTPUT_INSTRUCTION_JSONLD @@ -266,11 +499,41 @@ def __exit__(self, *exc: object) -> None: llm_graph_format_ctx.reset(self._token) -_PROFILES: dict[LLMGraphFormat, GraphFormatProfile] = { - LLMGraphFormat.TURTLE: GraphFormatProfile(format=LLMGraphFormat.TURTLE), - LLMGraphFormat.JSONLD: GraphFormatProfile(format=LLMGraphFormat.JSONLD), +#: ``AUTO`` is deliberately absent: it names a *choice between* the members +#: below rather than a chapter, and the configuration resolves it against the +#: render mode before anything reaches here. A profile for it would silently +#: serve whichever chapter its fallback happened to be, on a run whose manifest +#: recorded the unresolved name. +_PROFILES: dict[tuple[LLMGraphFormat, OntologyChapterFormat], GraphFormatProfile] = { + (fmt, chapter): GraphFormatProfile(format=fmt, ontology_chapter_format=chapter) + for fmt in LLMGraphFormat + for chapter in OntologyChapterFormat + if chapter is not OntologyChapterFormat.AUTO } -def get_graph_format_profile(fmt: LLMGraphFormat) -> GraphFormatProfile: - return _PROFILES[fmt] +def get_graph_format_profile( + fmt: LLMGraphFormat, + *, + ontology_chapter_format: OntologyChapterFormat = OntologyChapterFormat.INHERIT, +) -> GraphFormatProfile: + """The profile for a wire format. + + Args: + fmt: Syntax the model emits graph payloads in. + ontology_chapter_format: Syntax of the ``# ONTOLOGY`` chapter in the + prompts built from this profile. The facts loop passes the + deployment's ``ONTOLOGY_CHAPTER_FORMAT``; callers that leave it at + ``INHERIT`` get a chapter in ``fmt``. ``AUTO`` is rejected -- it is + resolved against the render mode when the configuration is built. + + Raises: + ValueError: If ``ontology_chapter_format`` is ``AUTO``. + """ + if ontology_chapter_format is OntologyChapterFormat.AUTO: + raise ValueError( + "ONTOLOGY_CHAPTER_FORMAT=auto is resolved against the render mode " + "when the configuration is built and must not reach a prompt " + "profile; pass the resolved value." + ) + return _PROFILES[(fmt, ontology_chapter_format)] diff --git a/ontocast/prompt/graph_index.py b/ontocast/prompt/graph_index.py new file mode 100644 index 00000000..73434b64 --- /dev/null +++ b/ontocast/prompt/graph_index.py @@ -0,0 +1,55 @@ +"""Render a graph so every statement carries its :class:`TripleIndex` id. + +The ids themselves live in :mod:`ontocast.onto.triple_index`; only the prompt +surface is here, because how the ids are *shown* depends on the deployment's +graph format while what they mean does not. +""" + +from __future__ import annotations + +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.onto.triple_index import RDF_TYPE, TripleIndex, format_term + + +def render_indexed_turtle(graph: RDFGraph, index: TripleIndex) -> str: + """Render the graph as Turtle-shaped blocks with an inline ``[n]`` per statement. + + Turtle can carry the id inline for free, which puts it where the critic is + already reading. JSON-LD cannot -- its output contract demands strictly + valid JSON -- so that format gets :func:`render_index_table` instead. + + A statement outside the index's scope is listed with a ``[-]`` marker: it is + context to read, not something the critic may cite or remove. + """ + nsmgr = graph.namespace_manager + lines: list[str] = [] + for subject, block in index.order: + lines.append(format_term(subject, nsmgr)) + for position, entry in enumerate(block): + _, predicate, obj = entry.triple + predicate_text = ( + "a" if predicate == RDF_TYPE else format_term(predicate, nsmgr) + ) + terminator = " ." if position == len(block) - 1 else " ;" + marker = "-" if entry.triple_id is None else str(entry.triple_id) + lines.append( + f" [{marker}] {predicate_text} {format_term(obj, nsmgr)}{terminator}" + ) + return "\n".join(lines) + + +def render_index_table(graph: RDFGraph, index: TripleIndex) -> str: + """Render the id table appended to a chapter whose body cannot carry ids. + + Only addressable statements appear: an entry the critic cannot cite would be + an invitation to cite it. + """ + nsmgr = graph.namespace_manager + lines = ["TRIPLE INDEX (id | subject | predicate | object)"] + for triple_id in sorted(index.by_id): + subject, predicate, obj = index.by_id[triple_id] + lines.append( + f" {triple_id} | {format_term(subject, nsmgr)} | " + f"{format_term(predicate, nsmgr)} | {format_term(obj, nsmgr)}" + ) + return "\n".join(lines) diff --git a/ontocast/prompt/ontology_context.py b/ontocast/prompt/ontology_context.py index 068ec395..82b448c2 100644 --- a/ontocast/prompt/ontology_context.py +++ b/ontocast/prompt/ontology_context.py @@ -2,18 +2,14 @@ from rdflib import OWL, RDF, RDFS, Graph, URIRef -from ontocast.onto.constants import COMMON_PREFIXES, DEFAULT_IRI from ontocast.onto.ontology import Ontology -from ontocast.onto.util import RDFLIB_DEFAULT_NAMESPACE_URIS +from ontocast.onto.util import non_domain_namespaces # Exclude every rdflib built-in binding (brick, csvw, xml, …) plus our COMMON # prefixes / DEFAULT_IRI so the prompt "domain ontologies" clause only lists -# author-declared domain namespaces. -_STANDARD_NAMESPACES: frozenset[str] = ( - RDFLIB_DEFAULT_NAMESPACE_URIS - | frozenset(uri.strip("<>") for uri in COMMON_PREFIXES.values()) - | {"https://schema.org/", "http://schema.org/", DEFAULT_IRI} -) +# author-declared domain namespaces. Shared with the delta partitioner, which +# asks the same question in stem form -- see ``non_domain_namespaces``. +_STANDARD_NAMESPACES: frozenset[str] = non_domain_namespaces() def extract_domain_prefix_pairs_from_graph( diff --git a/ontocast/prompt/render_facts.py b/ontocast/prompt/render_facts.py index a29ba73e..f8da8170 100644 --- a/ontocast/prompt/render_facts.py +++ b/ontocast/prompt/render_facts.py @@ -1,29 +1,39 @@ from .common import system_preamble_semantic +# Chapter order is a cost lever, not a stylistic choice. The ontology chapter +# is most of a facts prompt and identical between the render and the critic +# call on a unit, so everything up to its end -- preamble, conformance +# contract, ontology -- is kept byte-identical across the two templates and +# everything phase-specific (task, guidelines, user instruction, text) follows +# it. A provider's prefix cache can then serve the critic the chapter the +# render already paid for. The critic template opens the same way; a test +# pins the two heads to each other. template_prompt = """ {preamble} -{facts_instruction} - -{user_instruction} +{conformance_chapter} {ontology_chapter} -{text_chapter} +# TASK -{fact_chapter} +Generate semantic triples representing facts (not abstract entities) based on provided domain ontology. -{improvement_instruction} +{facts_instruction} + +{user_instruction} + +{text_chapter} {output_instruction} {format_instructions} """ -preamble = f""" -{system_preamble_semantic} -Generate semantic triples representing facts (not abstract entities) based on provided domain ontology. -""" +# Shared verbatim with the critic: the cacheable prefix starts at byte zero, +# so the task statement lives in the template after the ontology chapter +# rather than here. +preamble = system_preamble_semantic _CITATION_METADATA_HEADER = """ # CITATION-METADATA UNIT @@ -76,33 +86,3 @@ def build_citation_metadata_instruction(vocabulary: dict[str, str]) -> str: "cites": vocabulary.get("cites", "the citation property"), } return _CITATION_METADATA_HEADER + _CITATION_VOCABULARY_TEMPLATE.format(**filled) - - -improvement_instruction_template = """\n\n -# IMPROVEMENT INSTRUCTION - -The current graph of factual triples has been reviewed. The items below are the corrections to apply. - -This is a CORRECTION PASS, not a re-extraction. Apply the items and nothing else. - -1. Fix each item by rewriting the offending term or value IN PLACE. - - Do not delete the statement and do not drop extracted data. A response - that only removes triples has resolved nothing: the item is gone because - the data is gone, which is the failure this pass exists to avoid. - - Every corrected statement must survive with its subject and its value intact. - -2. Do NOT add statements that no item asks for. Leaving correct triples exactly - as they are is the expected outcome for every part of the graph no item - mentions. - -3. If an item is contradicted by the source text, do not apply it, and say why - in `explanation`. Never delete or alter other statements as a consequence - - a wrong item licenses skipping that item, nothing more. - -4. Before finalizing, check that: - - Each triple still accurately represents information from the source text - - Existing ontology entities are used instead of new cd: entities - - No ontology-prefixed entity was invented or renamed - - The graph holds at least as much correct data as it did before -{suggestions_instruction} -""" diff --git a/ontocast/prompt/shapes_contract.py b/ontocast/prompt/shapes_contract.py new file mode 100644 index 00000000..378b4391 --- /dev/null +++ b/ontocast/prompt/shapes_contract.py @@ -0,0 +1,255 @@ +"""Render the deployment's SHACL shapes as a prompt chapter. + +The shapes partition already judges every extracted graph; until now the +renderer was graded against a rulebook it was never shown, and the dominant +violation classes were exactly the rules no prompt stated. This module makes +the contract symmetric with the ontology chapter: whatever shapes the +deployment loaded are summarized into prose the renderer (and critic) see. + +The module is domain-agnostic by construction -- it contains no vocabulary +of its own. Every line of the rendered chapter comes from the deployment's +shapes graph, preferring the shape author's ``sh:message`` (which states the +rule in prose, SPARQL constraints included) and synthesizing a line from the +constraint structure only when no message exists. A SPARQL constraint +without a message cannot be summarized mechanically and is omitted with a +warning. +""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from dataclasses import dataclass + +from rdflib import RDF, Graph, URIRef +from rdflib.namespace import SH + +logger = logging.getLogger(__name__) + +CHAPTER_HEADING = "# CONFORMANCE REQUIREMENTS" + +_CHAPTER_PREAMBLE = ( + "The output graph is validated against these structural rules. When you " + "instantiate one of the classes below, satisfy its rules in the same " + "render -- a missing required property is a defect, not an omission to " + "fill in later." +) + +_CHAPTER_CLOSING = ( + "Only instantiate these structures for content actually stated in the " + "text. Never mint a placeholder node or invent a property value to " + "satisfy a rule." +) + + +@dataclass(frozen=True) +class ShapeRequirement: + """One node shape, reduced to a target label and its rule lines. + + ``terms`` are the shape's own contract IRIs (targets, paths, classes, + datatypes, ``sh:in`` members). They serve two consumers: the union over + all requirements is the ``UNKNOWN_TERM`` exemption set, and the per-shape + set is what context-join selection intersects with a unit's resolved + ontology snapshot. + """ + + anchor: str + lines: tuple[str, ...] + terms: tuple[str, ...] = () + closed: bool = False + + +def _curie(graph: Graph, term) -> str: + if not isinstance(term, URIRef): + return str(term) + try: + prefix, _, local = graph.compute_qname(term, generate=False) + return f"{prefix}:{local}" + except Exception: # noqa: BLE001 - unbindable IRIs render absolute + return str(term) + + +def _synthesized_line(graph: Graph, prop) -> str | None: + """A rule line composed from constraint structure, for message-less shapes.""" + path = graph.value(prop, SH.path) + if path is None or not isinstance(path, URIRef): + return None + parts = [] + min_count = graph.value(prop, SH.minCount) + max_count = graph.value(prop, SH.maxCount) + klass = graph.value(prop, SH["class"]) + datatype = graph.value(prop, SH.datatype) + if min_count is not None: + parts.append(f"at least {min_count}") + if max_count is not None: + parts.append(f"at most {max_count}") + if klass is not None: + parts.append(f"of type {_curie(graph, klass)}") + if datatype is not None: + parts.append(f"as {_curie(graph, datatype)}") + if not parts: + return None + return f"{_curie(graph, path)}: " + ", ".join(parts) + + +def derive_shape_requirements(shapes_graph: Graph) -> list[ShapeRequirement]: + """Reduce every node shape to an anchor and prose rule lines. + + Preference order per constraint: the author's ``sh:message`` verbatim; a + synthesized structural line; nothing (SPARQL constraints without a + message, warned once per derivation). + """ + requirements: list[ShapeRequirement] = [] + unrenderable = 0 + for shape in sorted(shapes_graph.subjects(RDF.type, SH.NodeShape), key=str): + anchors = [ + _curie(shapes_graph, t) for t in shapes_graph.objects(shape, SH.targetClass) + ] + anchors.extend( + f"subjects of {_curie(shapes_graph, p)}" + for p in shapes_graph.objects(shape, SH.targetSubjectsOf) + ) + anchors.extend( + f"objects of {_curie(shapes_graph, p)}" + for p in shapes_graph.objects(shape, SH.targetObjectsOf) + ) + if not anchors: + continue + terms: set[str] = set() + for predicate in (SH.targetClass, SH.targetSubjectsOf, SH.targetObjectsOf): + terms.update( + str(t) + for t in shapes_graph.objects(shape, predicate) + if isinstance(t, URIRef) + ) + lines: list[str] = [] + for prop in shapes_graph.objects(shape, SH.property): + for predicate in (SH.path, SH["class"], SH.datatype): + value = shapes_graph.value(prop, predicate) + if isinstance(value, URIRef): + terms.add(str(value)) + in_list = shapes_graph.value(prop, SH["in"]) + if in_list is not None: + terms.update( + str(member) + for member in shapes_graph.items(in_list) + if isinstance(member, URIRef) + ) + message = shapes_graph.value(prop, SH.message) + if message is not None: + lines.append(str(message)) + continue + synthesized = _synthesized_line(shapes_graph, prop) + if synthesized is not None: + lines.append(synthesized) + for sparql in shapes_graph.objects(shape, SH.sparql): + message = shapes_graph.value(sparql, SH.message) or shapes_graph.value( + shape, SH.message + ) + if message is not None: + lines.append(str(message)) + else: + unrenderable += 1 + closed = bool(shapes_graph.value(shape, SH.closed)) + if closed: + lines.append("Do not attach properties beyond the ones above.") + if lines: + requirements.append( + ShapeRequirement( + anchor=" / ".join(anchors), + lines=tuple(dict.fromkeys(lines)), + terms=tuple(sorted(terms)), + closed=closed, + ) + ) + if unrenderable: + logger.warning( + "Shapes prompt contract: %d SPARQL constraint(s) carry no " + "sh:message and cannot be summarized; they are validated but " + "not shown to the renderer", + unrenderable, + ) + return requirements + + +def format_conformance_chapter( + requirements: Sequence[ShapeRequirement], + *, + max_lines: int = 40, +) -> str: + """The prompt chapter, or "" when there is nothing to state. + + ``max_lines`` caps the total rule lines (the chapter is run-constant, so + the cap is a prompt-size guard, not a relevance ranking); truncation is + noted in the chapter itself so the model does not read the tail rules' + absence as their nonexistence. + """ + if not requirements: + return "" + body: list[str] = [] + used = 0 + truncated = False + for requirement in requirements: + remaining = max_lines - used + if remaining <= 0: + truncated = True + break + lines = requirement.lines[:remaining] + if len(lines) < len(requirement.lines): + truncated = True + used += len(lines) + body.append(f"- {requirement.anchor}:") + body.extend(f" - {line}" for line in lines) + chapter = "\n".join( + [CHAPTER_HEADING, _CHAPTER_PREAMBLE, "", *body, "", _CHAPTER_CLOSING] + ) + if truncated: + chapter += "\n(Further rules exist; validation checks all of them.)" + return chapter + + +def select_requirements( + requirements: Sequence[ShapeRequirement], + context_terms: set[str], +) -> list[ShapeRequirement]: + """The requirements whose terms intersect a unit's ontology context. + + Shape relevance is derivative of ontology-term relevance: a shape + constrains classes and properties, and a unit can only instantiate the + ones its resolved snapshot carries — so the snapshot's IRIs are the join + key, and no separate retrieval decision is needed. ``context_terms`` + should be every IRI of the snapshot graph (subjects, predicates and + objects: the schema closure carries superclass IRIs as objects, which is + how a shape targeting a superclass joins a unit typed with the + subclass). Order is preserved. + """ + return [r for r in requirements if context_terms.intersection(r.terms)] + + +def contract_terms(shapes_graph: Graph) -> tuple[str, ...]: + """Every IRI the shapes require the output to use. + + These join the deterministic validator's exempt set: a term the + conformance chapter instructs the renderer to emit must not be flagged + UNKNOWN_TERM when the unit's retrieved ontology context happens not to + contain it -- otherwise the repair lane orders removal of exactly what + the chapter required. + """ + terms: set[str] = set() + for predicate in (SH.targetClass, SH.targetSubjectsOf, SH.targetObjectsOf): + terms.update( + str(t) + for t in shapes_graph.objects(None, predicate) + if isinstance(t, URIRef) + ) + for prop in shapes_graph.objects(None, SH.property): + for predicate in (SH.path, SH["class"], SH.datatype): + value = shapes_graph.value(prop, predicate) + if isinstance(value, URIRef): + terms.add(str(value)) + in_list = shapes_graph.value(prop, SH["in"]) + if in_list is not None: + for member in shapes_graph.items(in_list): + if isinstance(member, URIRef): + terms.add(str(member)) + return tuple(sorted(terms)) diff --git a/ontocast/prompt/term_sheet.py b/ontocast/prompt/term_sheet.py new file mode 100644 index 00000000..a37fc809 --- /dev/null +++ b/ontocast/prompt/term_sheet.py @@ -0,0 +1,271 @@ +"""The ``# ONTOLOGY`` chapter as a term sheet rather than a serialized graph. + +In a facts prompt the ontology is read-only context: the model reads it to pick +terms, and emits its graph in the wire format, never a patch against the chapter +it read. That asymmetry is what makes a non-RDF chapter admissible here and +inadmissible in the ontology loop, whose output *is* a patch against these very +statements. + +Freed from being a graph, the chapter can drop everything RDF spends bytes on +that no extractor reads: a JSON node wrapper or a Turtle subject block per term, +a repeated predicate IRI per statement, and prose written for a human browsing +the ontology. What is left is what lets a model use a term -- its name, the +surface forms a document might spell it with, what it is, where it sits in the +hierarchy, what it connects, and the prose saying when it applies. + +The prose is where the line between re-encoding and cutting sits. Per-statement +RDF scaffolding carries no information a reader of the sheet loses, so removing +it is free. A term's only description is not: dropping it leaves a name and a +parent. So the sheet keeps one prose field per term -- the usage contract where +there is one, the comment otherwise -- and bounds its length rather than its +presence. + +The completion pass established the format (:mod:`ontocast.prompt.complete_facts`) +on a narrower vocabulary; this renders the whole snapshot in it. +""" + +from __future__ import annotations + +from rdflib import OWL, RDF, RDFS, SKOS, Literal, URIRef + +from ontocast.onto.rdfgraph import RDFGraph + +#: Rendered in this order, each under its own heading. Individuals come last: +#: they are the longest section on a unit-bearing catalog and the least +#: structural, so a truncated read still reaches classes and properties. +_CLASS_TYPES: frozenset[URIRef] = frozenset({OWL.Class, RDFS.Class}) +_PROPERTY_TYPES: frozenset[URIRef] = frozenset( + { + RDF.Property, + OWL.ObjectProperty, + OWL.DatatypeProperty, + OWL.AnnotationProperty, + OWL.FunctionalProperty, + OWL.InverseFunctionalProperty, + OWL.SymmetricProperty, + OWL.TransitiveProperty, + } +) + +#: Types that say only "this is a term" and are noise on an individual's line. +_UNINFORMATIVE_TYPES: frozenset[URIRef] = frozenset( + {OWL.NamedIndividual, RDFS.Resource, OWL.Thing} +) + + +def qname_for(graph: RDFGraph, term: URIRef) -> str: + """Prefixed name for ``term``, falling back to the full IRI. + + Args: + graph: Graph whose namespace manager supplies the prefixes. + term: The IRI to shorten. + + Returns: + A prefixed name such as ``ex:PowderSample``, or the IRI itself + when no bound prefix covers it. + """ + try: + return graph.namespace_manager.qname(term) + except Exception: + return str(term) + + +def _texts(graph: RDFGraph, subject: URIRef, predicate: URIRef) -> list[str]: + """Literal objects of ``predicate`` on ``subject``, whitespace-normalised. + + Sorted shortest-first, then alphabetically. rdflib yields objects in its + internal hash order, which Python randomises per process, so an unsorted + read here would pick a different label for the same term on a different run + -- drifting the chapter with no visible cause, and defeating both the + prompt-keyed disk cache and a provider's prefix cache. + """ + return sorted( + { + " ".join(str(obj).split()) + for obj in graph.objects(subject, predicate) + if isinstance(obj, Literal) + }, + key=lambda text: (len(text), text), + ) + + +def _naming(graph: RDFGraph, subject: URIRef) -> tuple[str, list[str]]: + """The term's display name and every other surface form it is known by. + + A term may carry several names -- QUDT spells its units in both British and + American English, for instance -- and picking one and discarding the rest + loses exactly the spelling a source document might have used. So the + shortest becomes the display name and the remainder join the alternative + labels, where they cost a couple of dozen characters each and can be matched + against. + + Returns: + Tuple of (display name, other surface forms), both deterministic. + """ + names = _texts(graph, subject, RDFS.label) + _texts(graph, subject, SKOS.prefLabel) + seen: list[str] = [] + for name in names: + if name not in seen: + seen.append(name) + label = seen[0] if seen else "" + others = set(seen[1:]) | set(_texts(graph, subject, SKOS.altLabel)) + others.discard(label) + return label, sorted(others, key=lambda text: (len(text), text)) + + +def _first_text(graph: RDFGraph, subject: URIRef, *predicates: URIRef) -> str: + """First literal across ``predicates``, in the order given, deterministically.""" + for predicate in predicates: + for text in _texts(graph, subject, predicate): + return text + return "" + + +def _iris(graph: RDFGraph, subject: URIRef, predicate: URIRef) -> list[str]: + """Sorted qnames of the IRI objects of ``predicate`` on ``subject``. + + Blank-node objects are skipped: an ``owl:Restriction`` has no name to print, + and the term sheet has no syntax for an anonymous class expression. The + class it restricts is already named by the term's own line. + """ + return sorted( + qname_for(graph, obj) + for obj in graph.objects(subject, predicate) + if isinstance(obj, URIRef) + ) + + +def _partition(graph: RDFGraph) -> tuple[list[URIRef], list[URIRef], list[URIRef]]: + """Split named subjects into classes, properties and individuals. + + Classification is by declared ``rdf:type``, with two structural fallbacks so + a catalog that under-declares still lands its terms in the right section: a + subject carrying ``rdfs:domain``/``rdfs:range``/``rdfs:subPropertyOf`` is a + property, and one carrying ``rdfs:subClassOf`` is a class. + """ + classes: list[URIRef] = [] + properties: list[URIRef] = [] + individuals: list[URIRef] = [] + for subject in sorted( + {s for s in graph.subjects() if isinstance(s, URIRef)}, key=str + ): + types = {t for t in graph.objects(subject, RDF.type) if isinstance(t, URIRef)} + if types & _PROPERTY_TYPES or any( + (subject, p, None) in graph + for p in (RDFS.domain, RDFS.range, RDFS.subPropertyOf) + ): + properties.append(subject) + elif types & _CLASS_TYPES or (subject, RDFS.subClassOf, None) in graph: + classes.append(subject) + else: + individuals.append(subject) + return classes, properties, individuals + + +def _term_line(graph: RDFGraph, subject: URIRef, *, kind: str) -> list[str]: + """The one-or-two lines describing ``subject``. + + Args: + graph: The snapshot being rendered. + subject: Term to describe. + kind: ``"class"``, ``"property"`` or ``"individual"`` -- selects which + relations are worth printing for this term. + + Returns: + The term's line, plus an indented ``note:`` line when it carries a + usage contract. + """ + label, alt = _naming(graph, subject) + parts = [f" {qname_for(graph, subject)}"] + if label: + parts.append(f'"{label}"') + + if kind == "class": + parents = _iris(graph, subject, RDFS.subClassOf) + _iris( + graph, subject, OWL.equivalentClass + ) + if parents: + parts.append(f"< {', '.join(sorted(set(parents)))}") + elif kind == "property": + domain = _iris(graph, subject, RDFS.domain) + codomain = _iris(graph, subject, RDFS.range) + parts.append(f"{'/'.join(domain) or '?'} -> {'/'.join(codomain) or '?'}") + super_properties = _iris(graph, subject, RDFS.subPropertyOf) + if super_properties: + parts.append(f"< {', '.join(super_properties)}") + else: + types = [ + qname_for(graph, obj) + for obj in graph.objects(subject, RDF.type) + if isinstance(obj, URIRef) and obj not in _UNINFORMATIVE_TYPES + ] + if types: + parts.append(f": {', '.join(sorted(types))}") + + # Every surface form, shortest first. There is deliberately no count cap + # here: alternative labels are the cheapest content in the sheet (a couple + # of dozen characters each) and the most direct thing a document match has + # to work with -- an alphabetical top-N on a qualifier term cuts exactly the + # "~", "\u223c" and "\u2248" a paper actually prints. TextCaps.total_budget is + # the one mechanism that bounds them, and only on a catalog that needs it. + if alt: + parts.append(f"~ {'; '.join(alt)}") + + lines = [" ".join(parts)] + # rdfs:comment is the last resort, not a peer: where a term states a scope + # note it is the usage contract and the comment restates it for a human + # browsing the ontology. But most terms carry no scope note, and for those + # the comment is the ONLY prose they have -- dropping it would leave the + # term as a name and a parent, which is a loss of content rather than a + # re-encoding of it, and no cheaper representation can recover it. + # + # Its length is bounded upstream, not here: TextCaps clips the literals + # before the snapshot reaches any chapter format, and clips by content -- + # opening sentence plus any clause saying when the term applies. Bounding + # it here instead would bound only this one format. + note = _first_text(graph, subject, SKOS.scopeNote, SKOS.definition, RDFS.comment) + if note: + lines.append(f" note: {note}") + return lines + + +_HEADER = """Every term available to you, one per line: + `qname "label"` names it; `< ...` is its parent; `A -> B` is a property's + domain and range; `: T` is an individual's type; `~ ...` are alternative + surface forms the source text may use; `note:` is a usage contract or a + description of when the term applies. +Use these terms and only these. A term absent from this sheet does not exist.""" + + +def build_ontology_term_sheet(graph: RDFGraph) -> str: + """Render ``graph`` as the body of a term-sheet ontology chapter. + + Terms are emitted in sorted IRI order within each section, so the same + snapshot renders byte-identically on every call and across processes -- which + is what lets a provider's prefix cache serve the chapter more than once. + Unlike the RDF chapters, this never depends on blank-node identifiers, whose + labels rdflib mints at random. + + Args: + graph: The unit's ontology snapshot. + + Returns: + The chapter body, or ``""`` when the snapshot names no term. + """ + classes, properties, individuals = _partition(graph) + sections = [ + ("Classes", classes, "class"), + ("Properties (domain -> range)", properties, "property"), + ("Individuals (units, vocabulary values)", individuals, "individual"), + ] + if not any(terms for _, terms, _ in sections): + return "" + + lines = [_HEADER] + for heading, terms, kind in sections: + if not terms: + continue + lines.append(f"\n## {heading}") + for term in terms: + lines.extend(_term_line(graph, term, kind=kind)) + return "\n".join(lines) diff --git a/ontocast/registry.py b/ontocast/registry.py index c5082073..197a7408 100644 --- a/ontocast/registry.py +++ b/ontocast/registry.py @@ -106,6 +106,9 @@ async def get( existing = self._entries.get(key) if existing is not None: self._entries.move_to_end(key) + await self._ensure_vector_store( + existing, ontology_context_mode, fail_on_vector_store_error + ) return existing async with self._lock_for(key): @@ -113,6 +116,9 @@ async def get( existing = self._entries.get(key) if existing is not None: self._entries.move_to_end(key) + await self._ensure_vector_store( + existing, ontology_context_mode, fail_on_vector_store_error + ) return existing from ontocast.toolbox import ToolBox @@ -133,6 +139,33 @@ async def get( await self._evict_if_needed() return tools + @staticmethod + async def _ensure_vector_store( + tools: "ToolBox", + ontology_context_mode: Any, + fail_on_vector_store_error: bool, + ) -> None: + """Prepare a cached scope's vector store when this request needs it. + + A scope is built for the mode of whichever request first reached it, + and it is then cached for the process lifetime. Without this, one + non-vector request for a scope decided the question for every later + vector request in that process: the ontologies it wrote were indexed + nowhere and retrieval returned nothing, with no error anywhere. + """ + if not tools.should_initialize_vector_store(ontology_context_mode): + return + if tools.is_vector_store_ready(): + return + logger.info( + "Preparing the vector store for a cached tenancy scope built without one" + ) + await tools.initialize( + ontology_context_mode=ontology_context_mode, + fail_on_vector_store_error=fail_on_vector_store_error, + wipe_vector_store=False, + ) + async def _evict_if_needed(self) -> None: while len(self._entries) > self.max_scopes: evicted_key, evicted = self._entries.popitem(last=False) diff --git a/ontocast/runtime.py b/ontocast/runtime.py index 0fdf1e91..0df40d92 100644 --- a/ontocast/runtime.py +++ b/ontocast/runtime.py @@ -21,6 +21,7 @@ import logging from ontocast.config import Config, EmbeddingConfig, WebSearchProvider +from ontocast.onto.state import BudgetTracker from ontocast.tool import AtomicToolBox, ChunkerTool, ConverterTool from ontocast.tool.agg.aggregate import EmbeddingBasedAggregator from ontocast.tool.agg.entity_aligner import EntityAligner @@ -141,7 +142,7 @@ async def acreate(cls, config: Config) -> "ToolBoxRuntime": ) return cls(config, llm=llm, cache=cache, prune_cache=False) - async def get_llm_tool(self, budget_tracker): + async def get_llm_tool(self, budget_tracker: BudgetTracker) -> LLMTool: """Return the shared LLM tool, charging usage to ``budget_tracker``. The tracker is bound to the *calling task* rather than to the shared tool diff --git a/ontocast/stategraph/atomic.py b/ontocast/stategraph/atomic.py index bf4bf8d9..a459d984 100644 --- a/ontocast/stategraph/atomic.py +++ b/ontocast/stategraph/atomic.py @@ -13,9 +13,14 @@ import logging import time -from collections.abc import Sequence -from typing import Literal +from collections import Counter +from collections.abc import Awaitable, Callable, Iterable, Sequence +from dataclasses import dataclass, field +from typing import Literal, TypeVar +from rdflib import URIRef + +from ontocast.agent.complete_facts import complete_facts from ontocast.agent.criticise_facts import criticise_facts from ontocast.agent.criticise_ontology import criticise_ontology from ontocast.agent.external_evidence import ( @@ -30,11 +35,18 @@ ExternalEvidenceCacheEntry, ExternalEvidenceRequest, FactsUnitFinding, + FactsUnitFindingKind, + GraphRepairRecord, LoopAttempt, OntologyUnitFinding, - TripleFix, + RolledBackFix, + Suggestions, ) from ontocast.onto.ontology import Ontology +from ontocast.onto.ontology_access import build_llm_prefix_map +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.onto.retrieval_capabilities import OntologyContextConfigError +from ontocast.onto.triple_index import Triple from ontocast.onto.unit_states import UnitFactsState, UnitOntologyState from ontocast.stategraph.context_resolver import ( UnitOntologyContext, @@ -42,13 +54,29 @@ ) from ontocast.stategraph.unit_context import UnitLoopContext from ontocast.tool.atomic import AtomicToolBox -from ontocast.tool.facts_validation import collect_unit_findings -from ontocast.tool.facts_validation.critic_findings import critic_fixes_to_findings +from ontocast.tool.facts_validation import ( + FactsAcceptancePolicy, + ValidationPolicy, + collect_unit_findings, + material_defects, + unit_numeric_inventory, +) +from ontocast.tool.facts_validation.critic_patch import ( + CriticPatchPolicy, + FixPatch, + compile_critic_fixes, +) +from ontocast.tool.llm import LLMConfigurationError from ontocast.tool.ontology_validation import collect_ontology_unit_findings from ontocast.toolbox import ToolBox logger = logging.getLogger(__name__) +#: The two unit states the loop is generic over. Constrained rather than bound: +#: the driver hands its state to helpers that are themselves generic, and a +#: bound would widen those to the union at every call. +UnitStateT = TypeVar("UnitStateT", UnitFactsState, UnitOntologyState) + def _document_supplemental_ontologies(context: UnitLoopContext) -> list[Ontology]: """Non-null reduced ontology artifacts for LLM ingest prefix repair.""" @@ -106,24 +134,6 @@ def _resolve_max_visits_limit(state_visits: int, override: int | None) -> int: return max(1, visits) -def _skip_critic_after_final_render(render_attempt: int, max_visits: int) -> bool: - """True when this render attempt is the last allowed; critic cannot drive a retry.""" - return render_attempt == max_visits - - -def _resolve_critic_visits(unit_state: UnitFactsState | UnitOntologyState) -> int: - """Critic attempts allowed per render attempt. - - Unset means the legacy coupling to ``max_visits_per_node``: the inner - critic loop shares the outer render loop's bound, so the worst case is - ``max_visits ** 2`` billed critic calls. Setting it decouples the two. - """ - override = unit_state.max_critic_visits_per_node - if override is None: - return unit_state.max_visits_per_node - return max(1, override) - - def _collect_facts_findings( unit_state: UnitFactsState, atomic: AtomicToolBox | None = None, @@ -131,18 +141,46 @@ def _collect_facts_findings( """Run the deterministic per-unit validator against the current graph. The toolbox supplies the deployment's namespace exemptions and quantity - fallback vocabulary; ``None`` (tests) means no exemptions. + fallback vocabulary; ``None`` (tests) means no exemptions. Terms the + shapes-derived conformance chapter requires join the exempt set per + unit -- they come from the tenancy's shapes catalog, which the + tenancy-independent toolbox policy cannot carry. """ + policy = atomic.validation_policy if atomic is not None else None + if unit_state.shapes_contract_terms: + base = policy if policy is not None else ValidationPolicy() + policy = base.model_copy( + update={ + "contract_exempt_terms": ( + *base.contract_exempt_terms, + *unit_state.shapes_contract_terms, + ) + } + ) + coverage_limit = atomic.numeric_coverage_limit if atomic is not None else 30 + coverage_mandatory: str | bool = ( + atomic.numeric_coverage_mandatory if atomic is not None else "off" + ) + # The whole catalog, not the unit's retrieved snapshot: a term the + # snapshot omitted is still a term, and must not be reported unknown with + # a look-alike from the snapshot offered as its replacement. + full_catalog_terms = atomic.catalog_terms() if atomic is not None else None return collect_unit_findings( graph=unit_state.content_unit.graph, ontology_graph=unit_state.ontology_snapshot.graph, quarantined=unit_state.quarantined_literal_triples, extraction_text=unit_state.content_unit.extraction_text, fact_namespaces=[DEFAULT_IRI, str(unit_state.content_unit.doc_iri)], + full_catalog_terms=full_catalog_terms, # Citation numerics (pages, years, volume numbers) are not extractable # quantities — never push coverage repair on bibliography units. - coverage_limit=0 if unit_state.content_unit.is_citation_metadata else 30, - policy=atomic.validation_policy if atomic is not None else None, + coverage_limit=( + 0 if unit_state.content_unit.is_citation_metadata else coverage_limit + ), + coverage_mandatory=coverage_mandatory, + policy=policy, + is_citation_metadata=unit_state.content_unit.is_citation_metadata, + is_non_content=unit_state.content_unit.is_non_content, ) @@ -178,169 +216,505 @@ def _collect_ontology_findings( return findings -def _record_facts_attempt( - unit_state: UnitFactsState, +def _record_attempt( + unit_state: UnitStateT, *, - kind: Literal["render", "critic", "llm_repair"], + kind: Literal[ + "render", "critic", "critic_patch", "critic_skipped", "completion", "llm_repair" + ], render_attempt: int, critic_attempt: int = 0, n_findings: int = 0, n_mandatory: int = 0, repair_failed: bool = False, - repair_delete_only: bool = False, + n_fixes_applied: int = 0, + n_fixes_noop: int = 0, + n_triples_deleted: int = 0, + n_triples_inserted: int = 0, + patch_rolled_back: bool = False, + patch_delete_capped: bool = False, + accept_reason: str = "", + rolled_back_fixes: list[RolledBackFix] | None = None, + n_fixes_junk_refused: int = 0, + n_fixes_unresolved_prefix: int = 0, + n_measurements_recovered: int = 0, ) -> None: - """Append one telemetry record for the current loop attempt.""" - graph = unit_state.content_unit.graph + """Append one telemetry record for the current loop attempt. + + ``triple_count`` is the phase's own product measure, so an ontology record + reports what the unit contributes rather than the size of its scratchpad -- + the working graph is the snapshot plus a small delta and barely moves. + """ + undone = list(rolled_back_fixes or ()) unit_state.attempt_log.append( LoopAttempt( render_attempt=render_attempt, critic_attempt=critic_attempt, kind=kind, success=unit_state.status == Status.SUCCESS, + accept_reason=accept_reason, n_deterministic_findings=n_findings, n_mandatory_findings=n_mandatory, repair_failed=repair_failed, - repair_delete_only=repair_delete_only, failure_stage=(str(unit_state.failure_stage) if repair_failed else None), failure_reason=unit_state.failure_reason if repair_failed else None, - triple_count=len(graph), + triple_count=unit_state.product_triple_count(), + n_fixes_applied=n_fixes_applied, + n_fixes_noop=n_fixes_noop, + n_triples_deleted=n_triples_deleted, + n_triples_inserted=n_triples_inserted, + patch_rolled_back=patch_rolled_back or bool(undone), + n_fixes_rolled_back=len(undone), + rolled_back_fixes=undone, + patch_delete_capped=patch_delete_capped, + n_fixes_junk_refused=n_fixes_junk_refused, + n_fixes_unresolved_prefix=n_fixes_unresolved_prefix, + n_measurements_recovered=n_measurements_recovered, + ) + ) + + +@dataclass(frozen=True) +class PatchOutcome: + """What one critic pass changed, and whether the loop should run another.""" + + applied: int = 0 + residual: int = 0 + noop: int = 0 + #: Fixes applied and undone on their own; the rest of the pass stands. + rolled_back: int = 0 + mandatory_after: int = 0 + + @property + def converged(self) -> bool: + """True when another pass has nothing left to work with. + + A pass that changed nothing -- every fix rolled back, or nothing to + apply and nothing mandatory left -- counts as converged: the next pass + would be handed the same graph and the same findings, so repeating it + buys a second identical answer at full price. + """ + return self.applied == 0 and (self.rolled_back > 0 or self.mandatory_after == 0) + + +def _regression_reason( + *, + graph_before: RDFGraph, + graph_after: RDFGraph, + product_before: int, + product_after: int, + mandatory_before: int, + mandatory_after: int, + declared_inserts: Iterable[Triple] = (), +) -> str | None: + """Why a fix left the unit worse than it found it, or ``None``. + + Three signals, each learned from a different way a repair went wrong: + + 1. ``delete_only``: it deleted and wrote nothing. The finding is gone + because the data is gone, which is the outcome the repair contract + exists to forbid. + 2. ``no_progress``: it shrank the product without resolving anything. + Counting findings alone cannot see this -- deleting the flagged + statement drops the count, so the dominant failure mode scored as a + success. + 3. ``new_mandatory``: it *created* mandatory findings. A fix that + manufactures new defects is strictly worse than no fix, however much + else it changed. + + "Wrote nothing" is measured against ``declared_inserts``, not against the + graph diff alone. A REPLACE that removes one wrong property re-states the + whole corrected node, so the parts that were already right apply as + no-ops and the diff shows only the delete -- read as a graph diff, the + correction looks like a bare deletion and is undone. Such a fix still has + to earn its place: with its replacement present and nothing resolved it + falls through to ``no_progress``, and a fix that declares no insert at all + is a pure removal and stays ``delete_only``. + + Judged per fix against the running baseline, so one bad correction is + undone alone instead of taking the whole critique with it. + + Args: + graph_before: The patch target as the previous kept fix left it. + graph_after: The patch target with this fix applied. + product_before: The phase's product triple count before the fix. + product_after: The same count after it. + mandatory_before: Mandatory findings against ``graph_before``. + mandatory_after: Mandatory findings against ``graph_after``. + declared_inserts: The statements the fix's patch says it writes, + whether or not the graph already held them. + + Returns: + The rule that fired, or ``None`` when the fix may stay. + """ + wrote_nothing = not (graph_after - graph_before) and not any( + triple in graph_after for triple in declared_inserts + ) + deleted_something = bool(graph_before - graph_after) + if deleted_something and wrote_nothing: + return "delete_only" + if mandatory_after > mandatory_before: + return "new_mandatory" + if product_after < product_before and mandatory_after >= mandatory_before: + return "no_progress" + return None + + +def _added_mandatory_kinds(before: Sequence, after: Sequence) -> str: + """The mandatory finding kinds a fix added, as ``kind`` or ``kind xN``. + + A rollback warning that reports only counts says a fix made things worse + without saying how, which is the one thing a reader needs to tell a + mis-scoped correction from a genuinely destructive one. + """ + before_counts = Counter(finding.kind for finding in before if finding.mandatory) + after_counts = Counter(finding.kind for finding in after if finding.mandatory) + added = after_counts - before_counts + if not added: + return "none" + return ", ".join( + kind if count == 1 else f"{kind} x{count}" + for kind, count in sorted(added.items()) + ) + + +@dataclass +class _PatchRun: + """What applying a list of per-fix patches one at a time did.""" + + applied: list[FixPatch] = field(default_factory=list) + rolled_back: list[RolledBackFix] = field(default_factory=list) + deleted: int = 0 + inserted: int = 0 + #: Findings against the graph as the run left it. + findings: list = field(default_factory=list) + mandatory_after: int = 0 + + +def _timed_findings( + unit_state: UnitStateT, atomic: AtomicToolBox, phase: "LoopPhase" +) -> list: + """The phase's findings, charged to the deterministic repair budget. + + Applying fixes one at a time runs the validator once per fix instead of + once per pass; the walk is LLM-free but not free, so it is timed where + the other deterministic repairs are. + """ + started = time.perf_counter() + findings = phase.collect_findings(unit_state, atomic) + unit_state.budget_tracker.add_duration( + "repair/deterministic", time.perf_counter() - started + ) + return findings + + +def _apply_patches( + unit_state: UnitStateT, + atomic: AtomicToolBox, + phase: "LoopPhase", + patches: list[FixPatch], + *, + mandatory_before: int, + findings_before: Sequence | None = None, +) -> _PatchRun: + """Apply patches one at a time, undoing each that leaves the unit worse. + + The baseline a fix is judged against is the graph as the previous kept + fix left it, so a fix that resolves a finding lowers the bar for the ones + after it and a fix that manufactures one is caught on its own. A whole + pass used to be undone for the one fix that regressed, which is how a + critique lost its good corrections to one bad one. + + Args: + unit_state: The unit being repaired; mutated in place. + atomic: The tools the phase validates with. + phase: The loop phase, which supplies the validator and policies. + patches: One patch per compiled fix, in the order proposed. + mandatory_before: Mandatory findings against the graph as handed in. + findings_before: Those findings themselves, so a rollback can name + the kinds a fix introduced rather than only their count. + """ + run = _PatchRun() + baseline = mandatory_before + baseline_findings: Sequence = findings_before or () + current_findings: list | None = None + for patch in patches: + token = unit_state.snapshot_for_rollback() + graph_before = unit_state.patch_target_graph().copy() + product_before = unit_state.product_triple_count() + if not unit_state.apply_patch(patch.update): + logger.warning("Critic fix refused by the phase; graph unchanged") + run.rolled_back.append( + RolledBackFix( + triple_ids=list(patch.fix.triple_ids), + correct_value=patch.fix.correct_value or "", + reason="refused", + ) + ) + continue + findings = _timed_findings(unit_state, atomic, phase) + mandatory_after = sum(1 for finding in findings if finding.mandatory) + reason = _regression_reason( + graph_before=graph_before, + graph_after=unit_state.patch_target_graph(), + product_before=product_before, + product_after=unit_state.product_triple_count(), + mandatory_before=baseline, + mandatory_after=mandatory_after, + declared_inserts=patch.insert_triples, + ) + if reason is not None: + introduced = ( + f"; introduced {_added_mandatory_kinds(baseline_findings, findings)}" + if reason == "new_mandatory" + else "" + ) + logger.warning( + "Critic fix left the unit worse (%s; -%d/+%d triples, " + "mandatory %d -> %d%s) — rolling it back", + reason, + patch.deletes, + patch.inserts, + baseline, + mandatory_after, + introduced, + ) + unit_state.restore(token) + run.rolled_back.append( + RolledBackFix( + triple_ids=list(patch.fix.triple_ids), + correct_value=patch.fix.correct_value or "", + reason=reason, + mandatory_delta=mandatory_after - baseline, + ) + ) + continue + run.applied.append(patch) + run.deleted += patch.deletes + run.inserted += patch.inserts + baseline = mandatory_after + baseline_findings = findings + current_findings = findings + if current_findings is None: + # Nothing stayed, so the graph is as it was; the findings are still + # collected here because the caller records and re-evaluates on them. + current_findings = _timed_findings(unit_state, atomic, phase) + baseline = sum(1 for finding in current_findings if finding.mandatory) + run.findings = current_findings + run.mandatory_after = baseline + return run + + +def _reevaluate_unit_status( + unit_state: UnitStateT, + phase: "LoopPhase", + atomic: AtomicToolBox, + findings: list, +) -> None: + """Re-run acceptance on the unit's *current* graph and findings. + + The critic decides acceptance before its patch is applied, so a unit + whose patch then resolved every defect still carried ``FAILED`` out of + the loop -- and was counted as "salvaged from a non-converged loop" by + the reduce, overstating non-convergence. Acceptance is re-derived here + from the post-patch findings and the still-outstanding critic fixes, + with the same ``material_defects`` rule the critic itself used. + """ + defects = material_defects( + findings, + unit_state.suggestions.actionable_fixes, + phase.acceptance_policy(atomic), + ) + if not defects: + if unit_state.status != Status.SUCCESS: + logger.info("%s unit clean after critic patch; marking SUCCESS", phase.name) + unit_state.status = Status.SUCCESS + unit_state.clear_failure() + else: + unit_state.set_failure( + phase.critic_stage, + f"{phase.name} unit has {len(defects)} material defect(s) " + "after critic patch", + ) + + +def _apply_critic_patch( + unit_state: UnitStateT, + atomic: AtomicToolBox, + phase: "LoopPhase", + *, + render_attempt: int, + pass_index: int, + mandatory_before: int, + findings_before: Sequence | None = None, +) -> PatchOutcome: + """Compile the critique into per-fix patches and apply them one at a time. + + This is where a critique stops being a description and becomes a change. + The critic cites statement ids, so the delete side resolves by lookup rather + than by matching text the model retyped from memory -- which is what made + the previous contract lose most of its own removals. Screening then withholds + what the deployment does not allow a pass to destroy, and each fix that + survives is applied on its own and undone on its own if it leaves the unit + worse. + """ + graph = unit_state.patch_target_graph() + compiled = compile_critic_fixes( + unit_state.suggestions.actionable_fixes, + graph, + index=unit_state.prompt_triple_index, + policy=phase.patch_policy(atomic), + ) + # An index is paired with the critique it was built for. Clearing it here + # means a later compile reaching for a previous pass's table resolves + # nothing rather than resolving to the wrong statement. + unit_state.prompt_triple_index = None + + unit_state.critic_fixes_residual = len(compiled.residual) + unit_state.critic_fixes_noop += len(compiled.noop) + unit_state.critic_fixes_junk_refused += compiled.junk_refused + unit_state.critic_fixes_unresolved_prefix += compiled.unresolved_prefix + # An applied fix must stop existing as a request: `suggestions` is what the + # next pass sees as outstanding work, and a fix already carried out would + # be asked for again against a graph that no longer matches it. A rolled + # back fix is not re-requested either: the next pass would repeat it. + unit_state.suggestions = Suggestions( + actionable_fixes=list(compiled.residual), + systemic_critique_summary=unit_state.suggestions.systemic_critique_summary, + ) + + run = _apply_patches( + unit_state, + atomic, + phase, + compiled.patches, + mandatory_before=mandatory_before, + findings_before=findings_before, + ) + unit_state.critic_fixes_applied += len(run.applied) + unit_state.critic_fixes_rolled_back += len(run.rolled_back) + unit_state.deterministic_findings = run.findings + _reevaluate_unit_status(unit_state, phase, atomic, run.findings) + if isinstance(unit_state, UnitFactsState): + unit_state.applied_repairs.extend( + GraphRepairRecord( + kind=FactsUnitFindingKind.CRITIC_FIX, + source=f"ids={patch.fix.triple_ids}" if patch.fix.triple_ids else "", + target=patch.fix.correct_value or "", + ) + for patch in run.applied ) + _record_attempt( + unit_state, + kind="critic_patch", + render_attempt=render_attempt, + critic_attempt=pass_index, + n_findings=len(run.findings), + n_mandatory=run.mandatory_after, + n_fixes_applied=len(run.applied), + n_fixes_noop=len(compiled.noop), + n_triples_deleted=run.deleted, + n_triples_inserted=run.inserted, + rolled_back_fixes=run.rolled_back, + patch_delete_capped=compiled.delete_capped, + n_fixes_junk_refused=compiled.junk_refused, + n_fixes_unresolved_prefix=compiled.unresolved_prefix, + ) + return PatchOutcome( + applied=len(run.applied), + residual=len(compiled.residual), + noop=len(compiled.noop), + rolled_back=len(run.rolled_back), + mandatory_after=run.mandatory_after, ) -async def _run_finding_driven_repair( +async def _run_completion_passes( unit_state: UnitFactsState, - atomic, - supplemental: list[Ontology], + atomic: AtomicToolBox, + phase: "LoopPhase", *, render_attempt: int, - critic_fixes: Sequence[TripleFix] = (), -) -> UnitFactsState: - """Repair machine-found violations with bounded render-update visits. - - Only the *trigger* here is deterministic: each visit is a paid - ``render_facts_update`` call fed with the findings. Machine-applied, - LLM-free rewrites are a different thing entirely and run at parse time - (``agent/render_facts.py::_normalize_and_repair_graph``) and at the - post-merge gate (``tool/facts_validation::apply_shacl_repairs``). - - Runs after the final render (where the LLM critic is skipped): mandatory - findings — quarantined literals, unknown/near-miss terms — drive the loop. - Advisory findings (numeric coverage) ride along in the prompt when a repair - does run, but never trigger one on their own: they fire on nearly every - unit of numeric prose, so gating on them cost an extra render per unit. - A failed repair leaves the pre-repair graph intact (the patch path applies - only parsed operations) and is recorded rather than erased. +) -> None: + """Insert-only completion passes, run once the critic loop is done. + + Each pass asks :func:`ontocast.agent.complete_facts.complete_facts` for + subjects recovering measurements the numeric inventory still lists as + missing, then applies the proposal through :func:`_apply_patches` -- the + same per-subject regression check a critic fix goes through, so an + insert that leaves the unit worse is rolled back on its own. Stops early + once the inventory is empty; the caller has already checked + ``atomic.facts_completion_passes > 0`` and that this is the facts phase. """ - repair_visits = atomic.facts_llm_repair_visits - # Critic fixes join the deterministic findings for the first pass only. - # They are consumed by the render that reads them, exactly like - # `state.suggestions`; re-adding them after each pass would make a fix the - # renderer declined an unresolvable finding and burn the whole budget. - findings = [ - *_collect_facts_findings(unit_state, atomic), - *critic_fixes_to_findings(critic_fixes, atomic.acceptance_policy), - ] - for repair_attempt in range(1, repair_visits + 1): - mandatory = [finding for finding in findings if finding.mandatory] - if not mandatory: - break - logger.info( - "Finding-driven facts repair render %s/%s: %d finding(s) (%d mandatory)", - repair_attempt, - repair_visits, - len(findings), - len(mandatory), + for pass_index in range(1, atomic.facts_completion_passes + 1): + inventory = unit_numeric_inventory( + graph=unit_state.content_unit.graph, + ontology_graph=unit_state.ontology_snapshot.graph, + extraction_text=unit_state.content_unit.extraction_text, + policy=atomic.validation_policy, + limit=atomic.numeric_coverage_limit, ) + if not inventory.measurements: + break + + findings = phase.collect_findings(unit_state, atomic) unit_state.deterministic_findings = findings - graph_before = unit_state.content_unit.graph.copy() - triples_before = len(unit_state.content_unit.graph) - mandatory_before = len(mandatory) - unit_state = await render_facts( - unit_state, atomic, supplemental_ontologies=supplemental + mandatory_before = sum(1 for finding in findings if finding.mandatory) + + fixes = await complete_facts(unit_state, atomic, inventory) + compiled = compile_critic_fixes( + fixes, unit_state.patch_target_graph(), policy=atomic.facts_patch_policy ) - repair_failed = unit_state.status != Status.SUCCESS - repair_delete_only = False - if not repair_failed: - findings = _collect_facts_findings(unit_state, atomic) - mandatory_after = sum(1 for finding in findings if finding.mandatory) - triples_after = len(unit_state.content_unit.graph) - # The findings prompt orders every mandatory item to be fixed by - # rewriting the offending term *in place*, so a genuine repair - # always writes something back. A render that only removed triples - # answered a repair order with a deletion, and the fact that the - # finding is gone afterwards is the deletion working, not a fix. - # - # Keying on the finding count alone cannot see this: deleting the - # flagged statement drops `mandatory_after` below - # `mandatory_before`, so the dominant observed failure mode -- - # repair responses deleting valid - # values outright -- scored as a successful repair. Comparing the - # written triples catches it, and stays quiet for a rewrite that - # happens to shrink the graph by collapsing a duplicate. - wrote_nothing = not (unit_state.content_unit.graph - graph_before) - deleted_something = bool(graph_before - unit_state.content_unit.graph) - no_progress = ( - triples_after < triples_before and mandatory_after >= mandatory_before - ) - if (deleted_something and wrote_nothing) or no_progress: - # Roll back rather than keep the shrunken graph: the pre-repair - # graph holds strictly more data, and its mandatory findings are - # the ones the next attempt (or the residual metric) should see. - logger.warning( - "Finding-driven repair deleted %d triple(s) and wrote %d " - "(%d -> %d triples, mandatory %d -> %d) — rolling back the " - "delete-only repair", - len(graph_before - unit_state.content_unit.graph), - len(unit_state.content_unit.graph - graph_before), - triples_before, - triples_after, - mandatory_before, - mandatory_after, - ) - unit_state.content_unit.graph = graph_before - findings = _collect_facts_findings(unit_state, atomic) - repair_delete_only = True - # Recorded counts are the residual AFTER this repair render (on failure - # the graph is unchanged, so the pre-render findings still describe it). - # This is what `facts_findings_residual` sums document-level; recording - # the pre-render counts here measured what the repair was asked to fix, - # not what survived it. - _record_facts_attempt( + run = _apply_patches( unit_state, - kind="llm_repair", + atomic, + phase, + compiled.patches, + mandatory_before=mandatory_before, + findings_before=findings, + ) + unit_state.deterministic_findings = run.findings + _reevaluate_unit_status(unit_state, phase, atomic, run.findings) + + post_inventory = unit_numeric_inventory( + graph=unit_state.content_unit.graph, + ontology_graph=unit_state.ontology_snapshot.graph, + extraction_text=unit_state.content_unit.extraction_text, + policy=atomic.validation_policy, + limit=atomic.numeric_coverage_limit, + ) + recovered = max( + 0, len(inventory.measurements) - len(post_inventory.measurements) + ) + logger.info( + "Unit facts completion pass %s/%s: %d applied, %d rolled back, " + "%d measurement(s) recovered", + pass_index, + atomic.facts_completion_passes, + len(run.applied), + len(run.rolled_back), + recovered, + ) + _record_attempt( + unit_state, + kind="completion", render_attempt=render_attempt, - critic_attempt=repair_attempt, - n_findings=len(findings), - n_mandatory=sum(1 for finding in findings if finding.mandatory), - repair_failed=repair_failed, - repair_delete_only=repair_delete_only, + critic_attempt=pass_index, + n_findings=len(run.findings), + n_mandatory=run.mandatory_after, + n_fixes_applied=len(run.applied), + n_fixes_noop=len(compiled.noop), + n_triples_deleted=run.deleted, + n_triples_inserted=run.inserted, + rolled_back_fixes=run.rolled_back, + patch_delete_capped=compiled.delete_capped, + n_fixes_junk_refused=compiled.junk_refused, + n_fixes_unresolved_prefix=compiled.unresolved_prefix, + n_measurements_recovered=recovered, ) - if repair_failed: - # The pre-repair graph is intact, so the unit is still usable and - # the loop reports SUCCESS. The diagnosis is copied onto the attempt - # record *before* the state is cleared -- clearing it outright left - # `repair_failed=True` with no stage or reason, so a provider - # timeout and an unparseable response looked identical. - logger.warning( - "Finding-driven facts repair render failed (%s: %s); keeping graph", - unit_state.failure_stage, - unit_state.failure_reason, - ) - unit_state.clear_failure() - unit_state.status = Status.SUCCESS + if not post_inventory.measurements: break - unit_state.deterministic_findings = findings - if findings: - mandatory_count = sum(1 for finding in findings if finding.mandatory) - if mandatory_count: - logger.warning( - "%d mandatory deterministic finding(s) remain unresolved", - mandatory_count, - ) - return unit_state - def _reset_node_evidence_context( state: UnitFactsState | UnitOntologyState, node: WorkflowNode @@ -375,7 +749,7 @@ async def _apply_facts_ontology_context( unit_state: UnitFactsState, context: UnitLoopContext, tools: ToolBox, -) -> UnitFactsState: +) -> None: """Set ontology_snapshot for facts from the per-unit context resolver. Only reached when the caller has no merged document context to hand down @@ -389,202 +763,477 @@ async def _apply_facts_ontology_context( ctx.writable_iris, ) _apply_unit_ontology_context(unit_state, ctx) - return unit_state -async def facts_loop( - state: UnitFactsState, +def _select_conformance_chapter(unit_state: UnitFactsState, tools: ToolBox) -> None: + """Join the shapes contract on this unit's resolved ontology context. + + Runs once per unit, right after context resolution: the fan-out could + not select earlier because the snapshot did not exist yet. The join key + is every IRI of the snapshot graph -- subjects, predicates and objects, + because the schema closure carries superclass IRIs as objects, which is + how a shape targeting a superclass reaches a unit typed with the + subclass -- plus the writable IRIs. An empty snapshot selects nothing: + a unit with no ontology context has no classes to hold to their rules. + """ + context_terms: set[str] = set(unit_state.writable_iris or ()) + for triple in unit_state.ontology_snapshot.graph: + for term in triple: + if isinstance(term, URIRef): + context_terms.add(str(term)) + unit_state.conformance_chapter = tools.shapes_chapter_for_context(context_terms) + unit_state.conformance_selection_pending = False + logger.debug( + "Conformance chapter selected for unit %s: %d chars from %d context terms", + unit_state.content_unit.index, + len(unit_state.conformance_chapter), + len(context_terms), + ) + + +# --- phase adapters ---------------------------------------------------------- +# +# The two unit loops were parallel implementations of the same control flow, +# and they had drifted: only one recorded render attempts, only one stopped +# escalating a rejection into a fresh extraction, and only one kept the +# critique it had just paid for. Rather than fix each divergence twice, the +# flow is written once and the genuine differences -- which agent, which graph, +# which validator, which budget -- are declared here. +# +# The callables are module-level wrappers rather than direct references on +# purpose: a frozen adapter capturing ``render_facts`` at import time would +# defeat every test that monkeypatches this module's globals, and those tests +# would go on passing while silently exercising the real agents. + + +def _same_state(returned: object, given: object) -> None: + """The agents mutate the state they are handed and return it. + + The driver relies on that: it keeps its own reference so the loop stays + generic over the two unit types rather than widening to their union at every + call. If an agent ever starts returning a copy, the loop would carry on with + the stale object and quietly lose that step's work -- so it fails here + instead. + """ + assert returned is given, "unit-loop agents must mutate the state in place" + + +async def _render_facts_phase(state, atomic, supplemental) -> None: + _same_state( + await render_facts(state, atomic, supplemental_ontologies=supplemental), state + ) + + +async def _render_ontology_phase(state, atomic, supplemental) -> None: + _same_state( + await render_ontology(state, atomic, supplemental_ontologies=supplemental), + state, + ) + + +async def _criticise_facts_phase(state, atomic) -> None: + _same_state(await criticise_facts(state, atomic), state) + + +async def _criticise_ontology_phase(state, atomic) -> None: + _same_state(await criticise_ontology(state, atomic), state) + + +def _prepare_facts(unit_state: UnitFactsState) -> None: + """Nothing to stage: the facts graph is the unit's product in place.""" + + +def _facts_critic_skip_reason( + unit_state: UnitFactsState, atomic: AtomicToolBox +) -> str | None: + """Why the facts critic should not be spent on this unit, or ``None``. + + A citation-metadata unit is rendered under the bibliographic instruction + and has no domain facts to review. A render below the triple floor is, + at the default, an empty graph: the critic scores one perfect and bills + a call for it. + """ + if unit_state.content_unit.is_citation_metadata: + return "citation_metadata" + if len(unit_state.content_unit.graph) < atomic.facts_critic_min_triples: + return "empty_render" + return None + + +def _ontology_critic_skip_reason( + unit_state: UnitOntologyState, atomic: AtomicToolBox +) -> str | None: + """The ontology critic has no skip rule; its budget alone decides.""" + return None + + +def _critic_unavailable(unit_state: UnitFactsState | UnitOntologyState) -> bool: + return ( + isinstance(unit_state, UnitFactsState) + and unit_state.critic_outcome == "unavailable" + ) + + +def _prepare_ontology(unit_state: UnitOntologyState) -> None: + """Copy the snapshot into the scratchpad the loop renders against.""" + started = time.perf_counter() + unit_state.working_graph = unit_state.ontology_snapshot.graph.copy() + unit_state.budget_tracker.add_duration( + "ctx/working_graph_copy", time.perf_counter() - started + ) + + +@dataclass(frozen=True) +class LoopPhase: + """Everything that differs between the facts and ontology unit loops.""" + + name: Literal["facts", "ontology"] + render_node: WorkflowNode + critic_node: WorkflowNode + render_stage: FailureStage + critic_stage: FailureStage + # The two agent hooks are side-effecting: they mutate the state the driver + # holds. That is what the agents already do, and saying so here is what lets + # the driver stay generic over the two unit types instead of widening to + # their union at every call. `_same_state` keeps the assumption honest. + prepare: Callable[..., None] + render: Callable[..., Awaitable[None]] + criticise: Callable[..., Awaitable[None]] + collect_findings: Callable[..., list] + critic_passes: Callable[[AtomicToolBox], int] + patch_policy: Callable[[AtomicToolBox], CriticPatchPolicy] + acceptance_policy: Callable[[AtomicToolBox], FactsAcceptancePolicy] + #: Why a critic pass should not be spent on the unit, or ``None`` to run + #: it. Decided per pass, on the current graph. + critic_skip_reason: Callable[..., str | None] + + +FACTS_PHASE = LoopPhase( + name="facts", + render_node=WorkflowNode.TEXT_TO_FACTS, + critic_node=WorkflowNode.CRITICISE_FACTS, + render_stage=FailureStage.GENERATE_GRAPH_UPDATE_FOR_FACTS, + critic_stage=FailureStage.FACTS_CRITIQUE, + prepare=_prepare_facts, + render=_render_facts_phase, + criticise=_criticise_facts_phase, + collect_findings=_collect_facts_findings, + critic_passes=lambda atomic: atomic.facts_critic_passes, + patch_policy=lambda atomic: atomic.facts_patch_policy, + acceptance_policy=lambda atomic: atomic.acceptance_policy, + critic_skip_reason=_facts_critic_skip_reason, +) + +ONTOLOGY_PHASE = LoopPhase( + name="ontology", + render_node=WorkflowNode.TEXT_TO_ONTOLOGY, + critic_node=WorkflowNode.CRITICISE_ONTOLOGY, + render_stage=FailureStage.GENERATE_GRAPH_UPDATE_FOR_ONTOLOGY, + critic_stage=FailureStage.ONTOLOGY_CRITIQUE, + prepare=_prepare_ontology, + render=_render_ontology_phase, + criticise=_criticise_ontology_phase, + collect_findings=_collect_ontology_findings, + critic_passes=lambda atomic: atomic.ontology_critic_passes, + patch_policy=lambda atomic: atomic.ontology_patch_policy, + acceptance_policy=lambda atomic: atomic.ontology_acceptance_policy, + critic_skip_reason=_ontology_critic_skip_reason, +) + + +async def _render_with_evidence( + unit_state: UnitStateT, + atomic: AtomicToolBox, + phase: LoopPhase, + supplemental: list[Ontology], + *, + render_attempt: int, +) -> bool: + """One render attempt, with the evidence-backed retry. True on success.""" + unit_state.node_visits[phase.render_node] += 1 + _reset_node_evidence_context(unit_state, phase.render_node) + await phase.render(unit_state, atomic, supplemental) + _record_attempt(unit_state, kind="render", render_attempt=render_attempt) + if unit_state.status == Status.SUCCESS: + return True + + request = unit_state.get_external_evidence_request(phase.render_node) + if not request.initiate_search: + logger.info( + "Unit %s render failed at attempt %s (no search request)", + phase.name, + render_attempt, + ) + return False + + await plan_external_evidence_for_node(unit_state, atomic, phase.render_node) + await fetch_external_evidence_for_node(unit_state, atomic, phase.render_node) + await phase.render(unit_state, atomic, supplemental) + _record_attempt(unit_state, kind="render", render_attempt=render_attempt) + logger.info( + "Unit %s render %s at attempt %s (with search)", + phase.name, + "recovered" if unit_state.status == Status.SUCCESS else "failed", + render_attempt, + ) + return unit_state.status == Status.SUCCESS + + +async def run_unit_loop( + state: UnitStateT, tools: ToolBox, document_context: UnitLoopContext, + phase: LoopPhase, max_visits_per_node: int | None = None, pre_resolved_context: UnitOntologyContext | None = None, -) -> UnitFactsState: - """Run facts render/critic loop for one content unit. +) -> UnitStateT: + """Extract a unit once, then review and patch it a bounded number of times. + + The two budgets mean different things and no longer trade against each + other. ``MAX_VISITS`` retries a render that *failed*; a render that + succeeded is never repeated, because re-extracting a whole unit is the + expensive answer to a local defect and reliably introduces new ones. The + critic passes then improve what came back, each one re-running the + deterministic checks for free before paying for the critique. Args: - state: Unit facts state to run the loop over. + state: Unit state to run the loop over. tools: Tool container. document_context: Document-level inputs, shared read-only. - max_visits_per_node: Override for the render/critic bound. - pre_resolved_context: Ontology context resolved once by the caller. - The merged document ontology depends only on document-level state, - so the fan-out builds it once and hands the *same object* to every - unit; resolving it here instead cost one full rdflib merge and two - graph copies per unit. Falls back to per-unit resolution when None. + phase: Which of the two loops this is. + max_visits_per_node: Override for the render-failure bound. + pre_resolved_context: Ontology context resolved once by the caller. The + merged document ontology depends only on document-level state, so + the fan-out builds it once and hands the *same object* to every + unit; resolving it per unit cost a full rdflib merge and two graph + copies each time. """ atomic = tools.get_atomic_tools() unit_state = state.model_copy(deep=True) # Charge resolver LLM calls (e.g. ontology selection) to this unit's - # tracker — the copy that survives the loop and is merged by the caller. + # tracker -- the copy that survives the loop and is merged by the caller. # Shallow copy: retrieval_metrics stays shared with the caller's context. document_context = document_context.model_copy( update={"budget_tracker": unit_state.budget_tracker} ) # The stage the loop is currently in, so an unhandled exception is - # attributed to where it happened. Hardcoding the critique stage reported - # a render or context-resolution crash as a failed critique. - stage = FailureStage.GENERATE_GRAPH_UPDATE_FOR_FACTS + # attributed to where it happened rather than always naming the critique. + stage = phase.render_stage + # Catalog prefix map installed after context resolution (see below); the + # finally restores whatever was here so a unit cannot leak its map into + # the next task. ContextVar storage is per-task under the fan-out. + previous_prefixes: dict[str, str] | None = None + installed_prefixes = False try: if pre_resolved_context is not None: _apply_unit_ontology_context(unit_state, pre_resolved_context) + elif isinstance(unit_state, UnitFactsState): + await _apply_facts_ontology_context(unit_state, document_context, tools) else: - unit_state = await _apply_facts_ontology_context( - unit_state, document_context, tools + # The ontology loop is the caller that can answer an empty context + # by inventing vocabulary: ``render_ontology`` branches on an empty + # seed into ``render_ontology_fresh``. + resolved = await resolve_unit_ontology_context( + document_context, + tools, + unit_state.content_unit, + can_create_vocabulary=True, ) + _apply_unit_ontology_context(unit_state, resolved) + if ( + isinstance(unit_state, UnitFactsState) + and unit_state.conformance_selection_pending + ): + _select_conformance_chapter(unit_state, tools) + phase.prepare(unit_state) + + # Install the catalog prefix map for the whole unit loop so critic and + # completion payloads reconcile against it, not only the unit graph's + # bindings. Render agents save/restore rather than clear to None. + previous_prefixes = RDFGraph.get_known_prefixes() + catalog_prefixes = build_llm_prefix_map( + unit_state.ontology_snapshot, + _supplemental_ontologies_for_unit(document_context, unit_state, tools), + ) + RDFGraph.set_known_prefixes(catalog_prefixes if catalog_prefixes else None) + installed_prefixes = True + max_visits = _resolve_max_visits_limit( unit_state.max_visits_per_node, max_visits_per_node ) unit_state.max_visits_per_node = max_visits + rendered = False + render_attempt = 0 + supplemental: list[Ontology] = [] for render_attempt in range(1, max_visits + 1): - stage = FailureStage.GENERATE_GRAPH_UPDATE_FOR_FACTS - unit_state.node_visits[WorkflowNode.TEXT_TO_FACTS] += 1 - _reset_node_evidence_context(unit_state, WorkflowNode.TEXT_TO_FACTS) + stage = phase.render_stage supplemental = _supplemental_ontologies_for_unit( document_context, unit_state, tools ) - unit_state = await render_facts( - unit_state, atomic, supplemental_ontologies=supplemental + rendered = await _render_with_evidence( + unit_state, + atomic, + phase, + supplemental, + render_attempt=render_attempt, ) - _record_facts_attempt( - unit_state, kind="render", render_attempt=render_attempt + if rendered: + break + + if not rendered: + logger.info("Unit %s loop exhausted render retries", phase.name) + unit_state.deterministic_findings = phase.collect_findings( + unit_state, atomic ) - if unit_state.status != Status.SUCCESS: - render_request = unit_state.get_external_evidence_request( - WorkflowNode.TEXT_TO_FACTS - ) - if render_request.initiate_search: - unit_state = await plan_external_evidence_for_node( - unit_state, atomic, WorkflowNode.TEXT_TO_FACTS - ) - unit_state = await fetch_external_evidence_for_node( - unit_state, atomic, WorkflowNode.TEXT_TO_FACTS - ) - unit_state = await render_facts( - unit_state, atomic, supplemental_ontologies=supplemental - ) - _record_facts_attempt( - unit_state, kind="render", render_attempt=render_attempt - ) - if unit_state.status == Status.SUCCESS: - logger.info( - "Unit facts render recovered with search at attempt %s/%s", - render_attempt, - max_visits, - ) - else: - logger.info( - "Unit facts render failed at attempt %s/%s (with search)", - render_attempt, - max_visits, - ) - continue - else: - logger.info( - "Unit facts render failed at attempt %s/%s (no search request)", - render_attempt, - max_visits, - ) - continue + return unit_state - if _skip_critic_after_final_render(render_attempt, max_visits): + stage = phase.critic_stage + for pass_index in range(1, phase.critic_passes(atomic) + 1): + findings = phase.collect_findings(unit_state, atomic) + unit_state.deterministic_findings = findings + mandatory_before = sum(1 for finding in findings if finding.mandatory) + + skip_reason = phase.critic_skip_reason(unit_state, atomic) + if skip_reason is not None: + # No call is billed, and the record says why: an empty render + # reviewed by the critic scores perfect for nothing, and a + # citation-metadata unit has no domain facts to review. logger.info( - "Unit facts loop finishing on final render attempt %s/%s " - "(skipping LLM critic; finding-driven repair renders may " - "still run)", - render_attempt, - max_visits, + "Unit %s critic pass %s skipped (%s)", + phase.name, + pass_index, + skip_reason, ) - return await _run_finding_driven_repair( + if isinstance(unit_state, UnitFactsState): + unit_state.critic_outcome = "skipped" + _record_attempt( unit_state, - atomic, - supplemental, + kind="critic_skipped", render_attempt=render_attempt, + critic_attempt=pass_index, + n_findings=len(findings), + n_mandatory=mandatory_before, + accept_reason=skip_reason, ) + break - stage = FailureStage.FACTS_CRITIQUE - for critic_attempt in range(1, _resolve_critic_visits(unit_state) + 1): - unit_state.node_visits[WorkflowNode.CRITICISE_FACTS] += 1 - _reset_node_evidence_context(unit_state, WorkflowNode.CRITICISE_FACTS) - unit_state.deterministic_findings = _collect_facts_findings( - unit_state, atomic - ) - unit_state = await criticise_facts(unit_state, atomic) - if unit_state.status == Status.SUCCESS: - logger.info( - "Unit facts loop converged at render %s/%s critic %s/%s", - render_attempt, - max_visits, - critic_attempt, - max_visits, + unit_state.node_visits[phase.critic_node] += 1 + _reset_node_evidence_context(unit_state, phase.critic_node) + await phase.criticise(unit_state, atomic) + if unit_state.status != Status.SUCCESS and not _critic_unavailable( + unit_state + ): + request = unit_state.get_external_evidence_request(phase.critic_node) + if request.initiate_search: + await plan_external_evidence_for_node( + unit_state, atomic, phase.critic_node ) - return await _run_finding_driven_repair( - unit_state, - atomic, - supplemental, - render_attempt=render_attempt, + await fetch_external_evidence_for_node( + unit_state, atomic, phase.critic_node ) - - critic_request = unit_state.get_external_evidence_request( - WorkflowNode.CRITICISE_FACTS - ) - if not critic_request.initiate_search: - logger.info( - "Unit facts critic rejected at render %s/%s critic %s/%s " - "without search request; repairing in place", - render_attempt, - max_visits, - critic_attempt, - max_visits, - ) - break - - unit_state = await plan_external_evidence_for_node( - unit_state, atomic, WorkflowNode.CRITICISE_FACTS - ) - unit_state = await fetch_external_evidence_for_node( - unit_state, atomic, WorkflowNode.CRITICISE_FACTS + await phase.criticise(unit_state, atomic) + if _critic_unavailable(unit_state): + # The critic produced no critique, so there is nothing to + # compile and nothing to re-evaluate acceptance from. The + # render is kept as it was and the unit leaves FAILED at the + # critique stage: unreviewed, not accepted. Applying the + # previous pass's suggestions here would patch against a + # critique of a graph that has since changed. + logger.warning( + "Unit %s critic unavailable on pass %s; render kept unreviewed", + phase.name, + pass_index, ) - unit_state = await criticise_facts(unit_state, atomic) - if unit_state.status == Status.SUCCESS: - logger.info( - "Unit facts loop converged with critic search at " - "render %s/%s critic %s/%s", - render_attempt, - max_visits, - critic_attempt, - max_visits, - ) - return await _run_finding_driven_repair( - unit_state, - atomic, - supplemental, - render_attempt=render_attempt, - ) + break - # A rejecting critic no longer escalates to another full render. - # It used to fall through to the next `render_attempt`, which - # re-extracted the unit from scratch under a prompt that invited - # unrequested rewriting -- the expensive, open-ended answer to a - # signal that is now a list of specific defects. Its blocking fixes - # go through the same bounded rewrite-in-place pass the - # deterministic findings use. The outer loop therefore retries only - # on *render failure*, and a unit's worst-case call count no longer - # grows with MAX_VISITS. - return await _run_finding_driven_repair( + outcome = _apply_critic_patch( unit_state, atomic, - supplemental, + phase, render_attempt=render_attempt, - critic_fixes=unit_state.suggestions.actionable_fixes, + pass_index=pass_index, + mandatory_before=mandatory_before, + findings_before=findings, ) + logger.info( + "Unit %s critic pass %s/%s: %d applied, %d residual, %d no-op, " + "%d rolled back", + phase.name, + pass_index, + phase.critic_passes(atomic), + outcome.applied, + outcome.residual, + outcome.noop, + outcome.rolled_back, + ) + if outcome.converged: + break + else: + if phase.critic_passes(atomic) == 0: + # No pass ran, so nothing collected the residual the document + # metric sums. Without this the denominator silently counts + # only units that happened to be criticised. + unit_state.deterministic_findings = phase.collect_findings( + unit_state, atomic + ) - logger.info("Unit facts loop exhausted retries") + if ( + phase.name == "facts" + and atomic.facts_completion_passes > 0 + and isinstance(unit_state, UnitFactsState) + ): + await _run_completion_passes( + unit_state, atomic, phase, render_attempt=render_attempt + ) + + mandatory = sum( + 1 for finding in unit_state.deterministic_findings if finding.mandatory + ) + if mandatory: + logger.warning( + "%d mandatory deterministic finding(s) remain unresolved", mandatory + ) return unit_state + except (OntologyContextConfigError, LLMConfigurationError): + # Not a unit failure. Both describe the deployment, not the unit, and + # are identical for every other unit in the document: an unresolvable + # ontology context (the whole point of ONTOLOGY_CONTEXT_REQUIRED is + # that the run stops rather than emitting an ungrounded graph), or a + # request the provider refuses outright. Recording either per unit + # instead let the fan-out finish, write a zero-triple manifest and exit + # successfully -- the vacuous pass these errors exist to prevent, now + # with a traceback per unit to bury the cause. + logger.error( + "Deployment fault while running %s units; stopping the run", + phase.name, + ) + raise except Exception as exc: - logger.exception("Unhandled exception in facts_loop") + logger.exception("Unhandled exception in %s unit loop", phase.name) unit_state.set_failure(stage, str(exc)) return unit_state + finally: + if installed_prefixes: + RDFGraph.set_known_prefixes(previous_prefixes) + + +async def facts_loop( + state: UnitFactsState, + tools: ToolBox, + document_context: UnitLoopContext, + max_visits_per_node: int | None = None, + pre_resolved_context: UnitOntologyContext | None = None, +) -> UnitFactsState: + """Run the render/critic loop for one content unit's facts.""" + return await run_unit_loop( + state, + tools, + document_context, + FACTS_PHASE, + max_visits_per_node=max_visits_per_node, + pre_resolved_context=pre_resolved_context, + ) async def ontology_loop( @@ -593,154 +1242,11 @@ async def ontology_loop( document_context: UnitLoopContext, max_visits_per_node: int | None = None, ) -> UnitOntologyState: - """Run ontology render/critic loop for one content unit. - - Per-unit ontology context is assembled via ``resolve_unit_ontology_context`` - before the first render. - """ - atomic = tools.get_atomic_tools() - unit_state = state.model_copy(deep=True) - # Charge resolver LLM calls to this unit's surviving tracker; shallow copy - # keeps retrieval_metrics shared with the caller's context. - document_context = document_context.model_copy( - update={"budget_tracker": unit_state.budget_tracker} + """Run the render/critic loop for one content unit's ontology delta.""" + return await run_unit_loop( + state, + tools, + document_context, + ONTOLOGY_PHASE, + max_visits_per_node=max_visits_per_node, ) - # See facts_loop: the stage an unhandled exception is attributed to tracks - # where the loop actually is, rather than always naming the critique. - stage = FailureStage.GENERATE_GRAPH_UPDATE_FOR_ONTOLOGY - try: - ctx = await resolve_unit_ontology_context( - document_context, tools, unit_state.content_unit - ) - _apply_unit_ontology_context(unit_state, ctx) - working_copy_start = time.perf_counter() - unit_state.working_graph = unit_state.ontology_snapshot.graph.copy() - unit_state.budget_tracker.add_duration( - "ctx/working_graph_copy", time.perf_counter() - working_copy_start - ) - - max_visits = _resolve_max_visits_limit( - unit_state.max_visits_per_node, max_visits_per_node - ) - unit_state.max_visits_per_node = max_visits - - for render_attempt in range(1, max_visits + 1): - stage = FailureStage.GENERATE_GRAPH_UPDATE_FOR_ONTOLOGY - unit_state.node_visits[WorkflowNode.TEXT_TO_ONTOLOGY] += 1 - _reset_node_evidence_context(unit_state, WorkflowNode.TEXT_TO_ONTOLOGY) - supplemental = _supplemental_ontologies_for_unit( - document_context, unit_state, tools - ) - unit_state = await render_ontology( - unit_state, atomic, supplemental_ontologies=supplemental - ) - if unit_state.status != Status.SUCCESS: - render_request = unit_state.get_external_evidence_request( - WorkflowNode.TEXT_TO_ONTOLOGY - ) - if render_request.initiate_search: - unit_state = await plan_external_evidence_for_node( - unit_state, atomic, WorkflowNode.TEXT_TO_ONTOLOGY - ) - unit_state = await fetch_external_evidence_for_node( - unit_state, atomic, WorkflowNode.TEXT_TO_ONTOLOGY - ) - unit_state = await render_ontology( - unit_state, atomic, supplemental_ontologies=supplemental - ) - if unit_state.status == Status.SUCCESS: - logger.info( - "Unit ontology render recovered with search at attempt %s/%s", - render_attempt, - max_visits, - ) - else: - logger.info( - "Unit ontology render failed at attempt %s/%s (with search)", - render_attempt, - max_visits, - ) - continue - else: - logger.info( - "Unit ontology render failed at attempt %s/%s (no search request)", - render_attempt, - max_visits, - ) - continue - - if _skip_critic_after_final_render(render_attempt, max_visits): - logger.info( - "Unit ontology loop finishing on final render attempt %s/%s " - "(no further extract; skipping critic)", - render_attempt, - max_visits, - ) - # The residual metric needs findings even when no critic runs - # (the MAX_VISITS=1 default). - unit_state.deterministic_findings = _collect_ontology_findings( - unit_state, atomic - ) - return unit_state - - stage = FailureStage.ONTOLOGY_CRITIQUE - for critic_attempt in range(1, _resolve_critic_visits(unit_state) + 1): - unit_state.node_visits[WorkflowNode.CRITICISE_ONTOLOGY] += 1 - _reset_node_evidence_context( - unit_state, WorkflowNode.CRITICISE_ONTOLOGY - ) - unit_state.deterministic_findings = _collect_ontology_findings( - unit_state, atomic - ) - unit_state = await criticise_ontology(unit_state, atomic) - if unit_state.status == Status.SUCCESS: - logger.info( - "Unit ontology loop converged at render %s/%s critic %s/%s", - render_attempt, - max_visits, - critic_attempt, - max_visits, - ) - return unit_state - - critic_request = unit_state.get_external_evidence_request( - WorkflowNode.CRITICISE_ONTOLOGY - ) - if not critic_request.initiate_search: - logger.info( - "Unit ontology critic failed at render %s/%s critic %s/%s " - "without search request", - render_attempt, - max_visits, - critic_attempt, - max_visits, - ) - break - - unit_state = await plan_external_evidence_for_node( - unit_state, atomic, WorkflowNode.CRITICISE_ONTOLOGY - ) - unit_state = await fetch_external_evidence_for_node( - unit_state, atomic, WorkflowNode.CRITICISE_ONTOLOGY - ) - unit_state = await criticise_ontology(unit_state, atomic) - if unit_state.status == Status.SUCCESS: - logger.info( - "Unit ontology loop converged with critic search at " - "render %s/%s critic %s/%s", - render_attempt, - max_visits, - critic_attempt, - max_visits, - ) - return unit_state - - logger.info("Unit ontology loop exhausted retries") - unit_state.deterministic_findings = _collect_ontology_findings( - unit_state, atomic - ) - return unit_state - except Exception as exc: - logger.exception("Unhandled exception in ontology_loop") - unit_state.set_failure(stage, str(exc)) - return unit_state diff --git a/ontocast/stategraph/context_resolver.py b/ontocast/stategraph/context_resolver.py index a579e84d..c48af9f1 100644 --- a/ontocast/stategraph/context_resolver.py +++ b/ontocast/stategraph/context_resolver.py @@ -2,6 +2,8 @@ import logging import time from collections import Counter +from collections.abc import Sequence +from typing import Any from pydantic import BaseModel, Field @@ -15,7 +17,10 @@ from ontocast.onto.null import NULL_ONTOLOGY from ontocast.onto.ontology_snapshot import OntologySnapshot from ontocast.onto.rdfgraph import RDFGraph -from ontocast.onto.retrieval_capabilities import require_vector_retrieval +from ontocast.onto.retrieval_capabilities import ( + EmptyOntologyContextError, + require_vector_retrieval, +) from ontocast.stategraph.unit_context import UnitLoopContext from ontocast.tool.chunk.proposition import split_proposition_windows from ontocast.tool.llm import use_budget_tracker @@ -58,6 +63,19 @@ def _unit_queries(unit: SourceUnit, tools: ToolBox) -> list[str]: text, max_sentences=vcfg.proposition_window_sentences, max_windows=vcfg.proposition_max_windows, + stride=vcfg.proposition_window_stride, + max_chars=vcfg.proposition_window_max_chars, + max_tokens=vcfg.proposition_window_max_tokens, + # Only consulted by a token budget; resolving it lazily keeps the encoder + # out of the default path, where the splitter is regex alone. + token_counter=( + tools.embedding_tool.token_lengths + if vcfg.proposition_window_max_tokens is not None + else None + ), + abbreviation_aware=vcfg.proposition_abbreviation_aware, + measurement_aware=vcfg.proposition_measurement_aware, + overlap=vcfg.proposition_window_overlap, ) @@ -115,6 +133,110 @@ def build_merged_document_ontology_context( ) +async def build_unioned_document_ontology_context( + context: UnitLoopContext, + tools: ToolBox, + units: Sequence[SourceUnit], +) -> UnitOntologyContext | None: + """Resolve every unit's context once and union them into one shared context. + + Per-unit retrieval gives each unit a smaller chapter than the union would + be, and that is a real saving on the *first* call for a unit. It is a loss + on every call after it: the chapter is the bulk of a facts prompt, no two + units get the same one, and a provider's prefix cache can therefore serve + none of them -- so a document pays the chapter once per unit at full price + instead of once at full price and N-1 times at the cached rate. + + Unioning is the trade that makes the second arrangement available. It is + recall-safe by construction: the union contains every atom each unit's own + retrieval selected, so no unit is shown less than it would have been. What + it costs is precision -- a unit also sees its siblings' terms -- and the + per-call token count, which is why it is a setting and not the default. + + Retrieval runs concurrently, bounded the same way the unit fan-out is. In + the vector modes this is embedding and graph work with no LLM call; the + LLM-selection mode does spend one call per unit here, exactly as it would + have spent inside the fan-out. + + Args: + context: Document-level loop inputs. + tools: Toolbox holding the catalog and retrieval. + units: The document's content units. + + Returns: + The unioned context, or None when no unit resolved anything -- which + leaves the caller on the per-unit path rather than handing every unit an + empty snapshot. + """ + if not units: + return None + started = time.perf_counter() + context.budget_tracker.incr("ctx/union_document_ontology.calls") + + limit = max(1, tools.config.server.parallel_workers) + semaphore = asyncio.Semaphore(limit) + + async def resolve(unit: SourceUnit) -> UnitOntologyContext | None: + async with semaphore: + try: + return await resolve_unit_ontology_context(context, tools, unit) + except EmptyOntologyContextError: + # A unit whose own context is empty must not void the document's: + # the guard exists to catch a catalog that did not load, and the + # union is exactly the evidence that it did. + return None + + resolved = await asyncio.gather(*(resolve(unit) for unit in units)) + + merged_graph = RDFGraph() + writable: list[str] = [] + sources: list[str] = [] + for ctx in resolved: + if ctx is None or not len(ctx.snapshot.graph): + continue + merged_graph += ctx.snapshot.graph + writable.extend(ctx.writable_iris) + sources.extend(ctx.snapshot.source_iris) + if not len(merged_graph): + context.budget_tracker.add_duration( + "ctx/union_document_ontology", time.perf_counter() - started + ) + return None + + merged_graph.sanitize_prefixes_namespaces() + snapshot = OntologySnapshot.from_graph( + merged_graph, + # Sorted and de-duplicated so the same document renders the same + # chapter twice: an unstable source order is an unstable prompt, and an + # unstable prompt is the thing this whole path exists to avoid. + source_iris=sorted(set(sources)), + assembly_mode=OntologyAssemblyMode.DOCUMENT_MERGED_REDUCED, + title="Unioned document ontology context", + description=( + "Union of the per-unit retrieved contexts, shared by every unit so " + "the ontology chapter is identical across the fan-out." + ), + strip_headers=True, + ) + context.budget_tracker.add_duration( + "ctx/union_document_ontology", time.perf_counter() - started + ) + context.retrieval_metrics[RetrievalMetric.ONTOLOGY_SNAPSHOT_TRIPLES] = len( + snapshot.graph + ) + logger.info( + "Unioned ontology context over %d unit(s): %d triples, %d source(s).", + len(units), + len(snapshot.graph), + len(snapshot.source_iris), + ) + return UnitOntologyContext( + snapshot=snapshot, + writable_iris=sorted(set(writable)), + confidence=1.0, + ) + + async def _resolve_selected_single_ontology_context( context: UnitLoopContext, tools: ToolBox, @@ -191,6 +313,83 @@ async def _resolve_fixed_single_ontology_context( ) +async def _diagnose_empty_snapshot( + tools: ToolBox, metrics: dict[str, Any] | None +) -> str: + """Name the subsystem that produced an empty ontology snapshot. + + Catalog-first, because retrieval can select exactly the right atoms and + still yield nothing when the triple store lists no graphs to expand them + against -- the index and the store disagreeing is a deployment fault, not a + tuning one, and reporting it as a threshold problem sends an operator to + lower thresholds that were never involved. + + Two things this has to get right that reading the metrics alone cannot: + + - **A missing key is not a zero.** When retrieval short-circuits on zero + atoms it never reaches the catalog, so ``catalog_context_triples`` is + absent rather than ``0``. Testing it with ``== 0`` therefore skipped both + catalog branches on exactly the run where the catalog was the cause, and + reported the empty index instead -- true, but the symptom rather than the + fault. The catalog is asked directly when the metrics cannot answer. + - **``atoms_after_dedupe`` is counted after the score gate**, so a + threshold rejection records zero of them. "Scored below the retrieval + thresholds" was thus unreachable for the case it names; ``candidate_hits`` + and ``threshold_rejected``, taken before the gate, are what separate + "search found nothing" from "a threshold ate everything". + + Args: + tools: Toolbox, for inspecting the catalog and index as a last resort. + metrics: ``last_retrieval_metrics`` from the patch retriever. + + Returns: + str: A one-line cause, stored under ``empty_snapshot_reason``. + """ + metrics = metrics or {} + catalog_triples = metrics.get("catalog_context_triples") + if catalog_triples is None: + # Retrieval never consulted the catalog, so ask it. + if not tools.ontology_manager.has_ontologies: + return "the ontology catalog is empty (no ontologies stored)" + else: + graph_reads = (metrics.get("catalog_graph_cache_hits") or 0) + ( + metrics.get("catalog_graph_cache_misses") or 0 + ) + if catalog_triples == 0 and graph_reads == 0: + return ( + "the ontology catalog resolved to zero graphs -- the vector " + "index and the triple store disagree about which ontologies " + "exist" + ) + if catalog_triples == 0: + return "the ontology catalog is empty (no ontologies stored)" + if metrics.get("atoms_final"): + return ( + "the catalog is populated but the induced subgraph over the " + "selected atoms came back empty" + ) + + indexed_iris: set[str] = set() + if tools.vector_store is not None: + try: + indexed_iris = await asyncio.to_thread( + tools.vector_store.list_indexed_ontology_iris + ) + except Exception as exc: + logger.warning("Could not inspect the vector index: %s", exc) + if not indexed_iris: + return ( + "the vector index is empty or unreadable, though the catalog holds " + "ontologies -- they were never indexed, or the index was wiped " + "without a reindex" + ) + if metrics.get("threshold_rejected"): + return "all candidate atoms scored below the retrieval thresholds" + if metrics.get("candidate_hits"): + return "candidate atoms were filtered out after retrieval" + return "no candidate atoms matched the unit's queries" + + async def _resolve_ensemble_context( context: UnitLoopContext, tools: ToolBox, @@ -241,29 +440,11 @@ async def _resolve_ensemble_context( ) if not len(patch_graph): # An empty snapshot reaching the renderer means it will extract with no - # vocabulary at all. Distinguish the causes: an empty index is a - # deployment problem, everything-below-threshold is a tuning problem, - # and neither should read as "this passage had no relevant terms". - indexed_iris: set[str] = set() - if tools.vector_store is not None: - try: - indexed_iris = await asyncio.to_thread( - tools.vector_store.list_indexed_ontology_iris - ) - except Exception as exc: - logger.warning("Could not inspect the vector index: %s", exc) - if not indexed_iris: - reason = "vector index is empty or unreadable" - elif metrics and metrics.get("atoms_after_dedupe"): - reason = "all candidate atoms scored below the retrieval thresholds" - else: - reason = "no candidate atoms matched the unit's queries" + # vocabulary at all, so name the subsystem at fault before deciding + # whether to continue. + reason = await _diagnose_empty_snapshot(tools, metrics) context.retrieval_metrics[RetrievalMetric.EMPTY_SNAPSHOT_REASON] = reason - logger.warning( - "Ontology context for this unit is empty (%s); extraction will " - "proceed with no catalog vocabulary.", - reason, - ) + logger.warning("Ontology context for this unit is empty (%s)", reason) preferred = tools.ontology_manager.preferred_namespace_prefixes or None patch_graph.sanitize_prefixes_namespaces(preferred_namespace_prefixes=preferred) @@ -288,7 +469,27 @@ async def resolve_unit_ontology_context( context: UnitLoopContext, tools: ToolBox, unit: SourceUnit, + *, + can_create_vocabulary: bool = False, ) -> UnitOntologyContext: + """Assemble the ontology context one content unit is rendered against. + + Args: + context: Document-level loop inputs. + tools: Toolbox holding the catalog and retrieval. + unit: The content unit being rendered. + can_create_vocabulary: Whether the caller can act on an empty context + by inventing vocabulary. True for the ontology loop, which answers + an empty seed with ``render_ontology_fresh``; false for the facts + loop, which can only fall back on generic terms. + + Returns: + The resolved context, possibly empty. + + Raises: + EmptyOntologyContextError: The context is empty, the caller cannot + create vocabulary, and this deployment requires a context. + """ mode = context.ontology_context_mode context.retrieval_metrics[RetrievalMetric.ONTOLOGY_CONTEXT_MODE] = mode.value if mode == OntologyContextMode.SELECTED_SINGLE_ONTOLOGY: @@ -305,6 +506,42 @@ async def resolve_unit_ontology_context( context.retrieval_metrics[RetrievalMetric.ONTOLOGY_SNAPSHOT_TRIPLES] = len( resolved.snapshot.graph ) + # Checked here, not per mode, for the same reason the size is recorded here: + # every mode can return an empty context, and the two that bound nothing + # were also the two that reported nothing. + # + # Two exemptions, both because an empty context is not a fault for them: + # + # * A unit with no retrievable text has nothing to extract either way. + # * A caller that can create vocabulary. The ontology renderer branches on + # exactly this condition -- an empty seed sends it to + # ``render_ontology_fresh``, which mints a new catalog ontology from the + # text -- so raising here made the one path designed for an empty catalog + # unreachable, and turned "this corpus has no ontology yet" into a + # deployment error. It also stopped a populated-catalog run whenever the + # selector honestly reported that no catalog ontology fits. + if ( + not len(resolved.snapshot.graph) + and unit.text.strip() + and not can_create_vocabulary + and tools.config.server.ontology_context_required + ): + reason = context.retrieval_metrics.get( + RetrievalMetric.EMPTY_SNAPSHOT_REASON, "no ontology context was assembled" + ) + raise EmptyOntologyContextError( + f"Ontology context for this content unit is empty: {reason}. " + "Extraction would fall back on generic vocabulary and the " + "conformance gate would then have no node to constrain, reporting " + "a vacuous pass. Fix the catalog, or set " + "ONTOLOGY_CONTEXT_REQUIRED=false to extract without one " + "deliberately." + ) + if not len(resolved.snapshot.graph) and can_create_vocabulary: + logger.info( + "No ontology context for this unit; rendering a fresh ontology from " + "its text" + ) return resolved diff --git a/ontocast/stategraph/facts_gate.py b/ontocast/stategraph/facts_gate.py index b13e3499..0bba3919 100644 --- a/ontocast/stategraph/facts_gate.py +++ b/ontocast/stategraph/facts_gate.py @@ -33,6 +33,7 @@ ValidationPolicy, apply_shacl_repairs, collect_shacl_shapes, + count_shacl_focus_nodes, dedupe_literal_variants, record_facts_gate_metrics, shacl_catalog_contradictions, @@ -130,6 +131,11 @@ def run_facts_gate( facts_validation = tools.config.get_tool_config().facts_validation shapes_graph = collect_shacl_shapes(ontology_graph, tools.shapes_catalog.graph()) + # The check must see what the unit validator sees, or it reports + # contradictions no unit ever raised: the whole catalog rather than the + # union of retrieved snapshots (a term a snapshot omitted is still a + # term), and the shapes-contract exemptions the unit loop applies. + _, contract_terms, _ = tools.shapes_prompt_contract() contradictions = shacl_catalog_contradictions( shapes_graph, ontology_graph, @@ -139,7 +145,9 @@ def run_facts_gate( ), quantity_fallback_vocabulary=facts_validation.quantity_fallback_vocabulary, code_predicates=tuple(facts_validation.code_predicates), + contract_exempt_terms=contract_terms, ), + catalog_terms=tools.ontology_manager.catalog_terms(), ) if contradictions: # Data cannot satisfy both sides: the shapes demand these properties @@ -175,6 +183,11 @@ def run_validation() -> FactsValidationReport: shacl_advanced=facts_validation.shacl_advanced, shacl_max_triples=facts_validation.shacl_max_triples, key_supported_subjects=state.aggregation_key_clusters, + cross_unit_pairs=( + state.aggregation_cross_unit_pairs + if facts_validation.suspect_multi_value_require_cross_unit + else None + ), ) def merge_signature_errors(report: FactsValidationReport) -> int: @@ -229,9 +242,13 @@ def merge_signature_errors(report: FactsValidationReport) -> int: previous_graph = state.aggregated_facts previous_clusters = state.aggregation_clusters previous_key_clusters = state.aggregation_key_clusters + previous_cross_unit_pairs = state.aggregation_cross_unit_pairs state.aggregated_facts = result.graph state.aggregation_clusters = result.merged_clusters state.aggregation_key_clusters = result.key_supported_clusters + # Re-aggregation rewrites subject IRIs, so the pairs the validator + # keys on have to come from the pass it is about to score. + state.aggregation_cross_unit_pairs = result.cross_unit_object_pairs candidate_report = run_validation() candidate_errors = merge_signature_errors(candidate_report) if candidate_errors >= previous_errors: @@ -246,6 +263,7 @@ def merge_signature_errors(report: FactsValidationReport) -> int: state.aggregated_facts = previous_graph state.aggregation_clusters = previous_clusters state.aggregation_key_clusters = previous_key_clusters + state.aggregation_cross_unit_pairs = previous_cross_unit_pairs rejected_repairs += 1 break repair_passes += 1 @@ -292,6 +310,7 @@ def merge_signature_errors(report: FactsValidationReport) -> int: report.findings, shacl_evaluated=report.shacl_evaluated, repairs=state.facts_gate_repairs, + focus_nodes=count_shacl_focus_nodes(state.aggregated_facts, shapes_graph), ) record_facts_gate_metrics( state.retrieval_metrics, diff --git a/ontocast/stategraph/node_factories.py b/ontocast/stategraph/node_factories.py index 2c2e7b3f..7bb4f116 100644 --- a/ontocast/stategraph/node_factories.py +++ b/ontocast/stategraph/node_factories.py @@ -14,6 +14,7 @@ from ontocast.onto.enum import ( OntologyAssemblyMode, OntologyContextMode, + OntologyContextScope, RetrievalMetric, Status, WorkflowNode, @@ -31,12 +32,14 @@ ) from ontocast.onto.ontology_snapshot import OntologySnapshot from ontocast.onto.rdfgraph import RDFGraph +from ontocast.onto.retrieval_capabilities import OntologyContextConfigError from ontocast.onto.state import UNIT_SUM_SUFFIX, AgentState, BudgetTracker from ontocast.onto.unit_states import UnitFactsState, UnitOntologyState from ontocast.stategraph.atomic import facts_loop, ontology_loop from ontocast.stategraph.context_resolver import ( aggregate_writable_metrics, build_merged_document_ontology_context, + build_unioned_document_ontology_context, ) from ontocast.stategraph.facts_gate import run_facts_gate from ontocast.stategraph.helpers import ( @@ -47,6 +50,7 @@ reconcile_fresh_ontologies, ) from ontocast.stategraph.unit_context import UnitLoopContext +from ontocast.tool.llm import LLMConfigurationError from ontocast.tool.ontology_validation import ( apply_minted_duplicate_rewrites, detect_minted_duplicates, @@ -59,6 +63,12 @@ T = TypeVar("T") +#: Failures that describe the deployment rather than the content unit: an +#: ontology context the run cannot resolve, and a request the provider refuses +#: outright. Both are identical for every sibling unit, so _gather_units +#: re-raises them instead of counting N of them as unit errors. +_DEPLOYMENT_FAULTS = (OntologyContextConfigError, LLMConfigurationError) + @asynccontextmanager async def _unit_slot(semaphore: asyncio.Semaphore) -> AsyncIterator[float]: @@ -81,6 +91,8 @@ async def _gather_units( node: WorkflowNode, state: AgentState, tasks: Sequence[Coroutine[Any, Any, T]], + *, + index_offset: int = 0, ) -> tuple[list[T], int]: """Run per-unit tasks concurrently, recording the stage's event-loop stall. @@ -95,10 +107,24 @@ async def _gather_units( the node while its siblings kept running as orphans -- their provider spend billed and then discarded. + Two classes of failure are deliberately not isolated -- ``_DEPLOYMENT_FAULTS``. + :class:`~ontocast.onto.retrieval_capabilities.OntologyContextConfigError` + and :class:`~ontocast.tool.llm.LLMConfigurationError` both describe the + deployment, not the unit, so they are identical for every sibling and + isolating them turns a configuration fault into N unit failures and a + successful, empty run. They are re-raised once the gather has drained, which + orphans nothing -- every sibling has already been awaited. Note that + subclassing ``BaseException`` would not achieve this: ``return_exceptions`` + captures those as values too. + Args: node: Fan-out node the tasks belong to; namespaces the metric keys. state: Document state whose tracker the stage metrics land on. tasks: Per-unit coroutines to run concurrently. + index_offset: Index of ``tasks[0]`` among the document's units. A + warm-up splits the fan-out into two gathers, and without this the + second batch would name its failures by position within the batch -- + reporting unit 0 for a unit that is not unit 0. Returns: tuple: Successful results in submission order, and the number of units @@ -109,6 +135,10 @@ async def _gather_units( state.budget_tracker.add_duration(f"{node}/loop_lag_total", lag.total) state.budget_tracker.add_duration(f"{node}/loop_lag_max", lag.peak) + for item in raw: + if isinstance(item, _DEPLOYMENT_FAULTS): + raise item + results: list[T] = [] failures = 0 for index, item in enumerate(raw): @@ -116,7 +146,11 @@ async def _gather_units( failures += 1 state.budget_tracker.incr(f"{node}/unit_errors") logger.exception( - "Unit %s raised during %s: %s", index, node, item, exc_info=item + "Unit %s raised during %s: %s", + index + index_offset, + node, + item, + exc_info=item, ) continue results.append(item) @@ -179,9 +213,6 @@ async def process_unit( ontology_user_instruction=state.ontology_user_instruction, budget_tracker=unit_budget, max_visits_per_node=state.max_visits, - max_critic_visits_per_node=( - tools.config.server.max_critic_visits_per_node - ), current_domain=state.current_domain, ontology_max_triples=tools.config.server.ontology_max_triples, llm_graph_format=state.llm_graph_format, @@ -333,6 +364,21 @@ async def process_unit( state.retrieval_metrics[RetrievalMetric.ONTOLOGY_CRITIC_ACCEPTED] = sum( 1 for attempt in ontology_critic_attempts if attempt.success ) + ontology_patch_attempts = [ + attempt + for attempts in state.ontology_loop_telemetry.values() + for attempt in attempts + if attempt.kind == "critic_patch" + ] + state.retrieval_metrics[RetrievalMetric.ONTOLOGY_CRITIC_FIXES_APPLIED] = sum( + attempt.n_fixes_applied for attempt in ontology_patch_attempts + ) + state.retrieval_metrics[RetrievalMetric.ONTOLOGY_CRITIC_FIXES_NOOP] = sum( + attempt.n_fixes_noop for attempt in ontology_patch_attempts + ) + state.retrieval_metrics[RetrievalMetric.ONTOLOGY_CRITIC_PATCHES_ROLLED_BACK] = ( + sum(1 for attempt in ontology_patch_attempts if attempt.patch_rolled_back) + ) # Document-level insert/delete consensus + namespace apply onto catalog bases. merged_delta = merge_unit_deltas(unit_deltas) @@ -614,6 +660,18 @@ async def render_facts(state: AgentState) -> AgentState: merged_context = build_merged_document_ontology_context( UnitLoopContext.from_agent_state(state) ) + if ( + merged_context is None + and tools.config.server.ontology_context_scope + == OntologyContextScope.DOCUMENT + ): + # No ontology stage ran, so there is nothing to merge -- but the + # deployment has asked for one chapter per document rather than one + # per unit. Resolve every unit's context up front and union it, so + # the fan-out shares a prompt prefix instead of N distinct ones. + merged_context = await build_unioned_document_ontology_context( + UnitLoopContext.from_agent_state(state), tools, state.content_units + ) if merged_context is not None: # Hand the same graph to merge/validate downstream instead of # letting each rebuild it. @@ -628,18 +686,23 @@ async def process_unit( # does the work when facts run without an ontology stage. await ensure_unit_summary(state, unit_index, tools, unit_budget) unit_context = UnitLoopContext.from_agent_state(state, unit_budget) + conformance_chapter, contract_terms, selection_pending = ( + tools.shapes_prompt_contract() + ) facts_state = UnitFactsState( content_unit=state.content_units[unit_index], ontology_snapshot=_empty_unit_snapshot(), ontology_patch_sources=[], facts_user_instruction=state.facts_user_instruction, + conformance_chapter=conformance_chapter, + shapes_contract_terms=contract_terms, + conformance_selection_pending=selection_pending, budget_tracker=unit_budget, max_visits_per_node=state.max_visits, - max_critic_visits_per_node=( - tools.config.server.max_critic_visits_per_node - ), llm_graph_format=state.llm_graph_format, ontology_context_max_triples=tools.config.server.ontology_context_max_triples, + ontology_chapter_format=tools.config.server.ontology_chapter_format, + ontology_text_caps=tools.config.server.ontology_text_caps, ) loop_start = time.perf_counter() result = await facts_loop( @@ -666,10 +729,32 @@ async def process_unit( result.assembly_mode_used, ) - tasks = [process_unit(i) for i, _ in enumerate(state.content_units)] - raw_results, unit_errors = await _gather_units( - WorkflowNode.RENDER_FACTS, state, tasks + # A provider's prefix cache is populated by a request that has already + # completed, so a fan-out that issues every call at once has all of them + # miss a prefix they all share. Running the first unit alone turns the + # rest into hits -- but only where the chapter is shared, which is why + # this is a separate knob rather than an implication of the scope. + warmup = min( + tools.config.server.fanout_warmup_units, + len(state.content_units), ) + raw_results: list = [] + unit_errors = 0 + if warmup: + warm_results, warm_errors = await _gather_units( + WorkflowNode.RENDER_FACTS, + state, + [process_unit(i) for i in range(warmup)], + ) + raw_results.extend(warm_results) + unit_errors += warm_errors + rest = [process_unit(i) for i in range(warmup, len(state.content_units))] + if rest: + more_results, more_errors = await _gather_units( + WorkflowNode.RENDER_FACTS, state, rest, index_offset=warmup + ) + raw_results.extend(more_results) + unit_errors += more_errors ordered_results = sorted(raw_results, key=lambda item: item[0]) facts_units: list[ContentUnit] = [] @@ -679,6 +764,14 @@ async def process_unit( # so the residual has "units" as its denominator. findings_residual = 0 mandatory_residual = 0 + critic_fixes_applied = 0 + critic_fixes_residual = 0 + critic_fixes_noop = 0 + critic_fixes_rolled_back = 0 + critic_fixes_junk_refused = 0 + critic_fixes_unresolved_prefix = 0 + critic_units_unreviewed = 0 + critic_units_skipped = 0 unit_contexts: dict[int, tuple[str, list[str], OntologyAssemblyMode]] = {} for ( unit_index, @@ -692,6 +785,17 @@ async def process_unit( mandatory_residual += sum( 1 for finding in result.deterministic_findings if finding.mandatory ) + critic_fixes_applied += result.critic_fixes_applied + critic_fixes_residual += result.critic_fixes_residual + critic_fixes_noop += result.critic_fixes_noop + critic_fixes_rolled_back += result.critic_fixes_rolled_back + critic_fixes_junk_refused += result.critic_fixes_junk_refused + critic_fixes_unresolved_prefix += result.critic_fixes_unresolved_prefix + # A unit whose critic call failed used to leave the loop SUCCESS, + # indistinguishable from one the critic accepted; a unit the loop + # never sent to the critic looked the same. Both are counted. + critic_units_unreviewed += result.critic_outcome == "unavailable" + critic_units_skipped += result.critic_outcome == "skipped" if result.attempt_log: state.facts_loop_telemetry[unit_index] = list(result.attempt_log) if result.applied_repairs: @@ -729,6 +833,20 @@ async def process_unit( f"{salvaged_failed_count}/{len(state.content_units)} unit(s)" ) + if merged_context is None and ( + tools.config.get_tool_config().facts_validation.context_from_units + ): + # No ontology stage ran, so build_merged_document_ontology_context + # had nothing to merge. Without this both MERGE_FACTS and + # VALIDATE_FACTS run against an empty ontology graph although every + # unit rendered against a real one. + unit_ontology_context = _union_unit_ontology_context(ordered_results) + if len(unit_ontology_context) > 0: + state.facts_ontology_context = unit_ontology_context + state.retrieval_metrics[RetrievalMetric.ONTOLOGY_SNAPSHOT_TRIPLES] = ( + len(unit_ontology_context) + ) + _, state.unit_patch_sources, _, anchor_counts = aggregate_writable_metrics( unit_contexts ) @@ -741,22 +859,13 @@ async def process_unit( for attempts in state.facts_loop_telemetry.values() for attempt in attempts ] - state.retrieval_metrics[RetrievalMetric.FACTS_LLM_REPAIR_RENDERS_TOTAL] = sum( - 1 for attempt in all_attempts if attempt.kind == "llm_repair" - ) - # A repair render that itself fails leaves the pre-repair graph intact - # and the unit reports SUCCESS, so without this the crash is recorded on - # the attempt log and observed nowhere. - state.retrieval_metrics[RetrievalMetric.FACTS_LLM_REPAIR_RENDERS_FAILED] = sum( - 1 for attempt in all_attempts if attempt.repair_failed - ) - # Repair renders rolled back for answering the findings prompt with - # deletions. Non-zero means the prompt or the validator is provoking - # data-destroying responses -- the failure mode that has cost runs a - # large share of their value nodes while logging nothing a run - # could be judged by. - state.retrieval_metrics[RetrievalMetric.FACTS_REPAIR_DELETE_ONLY] = sum( - 1 for attempt in all_attempts if attempt.repair_delete_only + # Patch passes undone for leaving the unit worse -- deleting without + # writing, shrinking without resolving, or manufacturing new mandatory + # findings. Non-zero means the critique is provoking data-destroying + # responses, the failure mode that has cost runs a large share of their + # value nodes while logging nothing a run could be judged by. + state.retrieval_metrics[RetrievalMetric.FACTS_CRITIC_PATCHES_ROLLED_BACK] = sum( + 1 for attempt in all_attempts if attempt.patch_rolled_back ) # Residual is read off each unit's final findings, not off the attempt # log. Summing `attempts[-1]` where `kind == "llm_repair"` silently @@ -783,6 +892,51 @@ async def process_unit( state.retrieval_metrics[RetrievalMetric.FACTS_CRITIC_ACCEPTED] = sum( 1 for attempt in critic_attempts if attempt.success ) + # What the critique actually bought, split by how it was paid for. The + # loop used to discard every fix on an accepted render, so the honest + # value of these two together was once "far less than the call count + # suggests" -- and nothing recorded it. + state.retrieval_metrics[RetrievalMetric.FACTS_CRITIC_FIXES_APPLIED] = ( + critic_fixes_applied + ) + state.retrieval_metrics[RetrievalMetric.FACTS_CRITIC_FIXES_RESIDUAL] = ( + critic_fixes_residual + ) + # Fixes that removed exactly what they re-added. A critique made mostly + # of these is a critic producing motion rather than corrections, which + # nothing could see before: they used to be counted as fixes that + # landed. + state.retrieval_metrics[RetrievalMetric.FACTS_CRITIC_FIXES_NOOP] = ( + critic_fixes_noop + ) + state.retrieval_metrics[RetrievalMetric.FACTS_CRITIC_FIXES_ROLLED_BACK] = ( + critic_fixes_rolled_back + ) + state.retrieval_metrics[RetrievalMetric.FACTS_CRITIC_FIXES_JUNK_REFUSED] = ( + critic_fixes_junk_refused + ) + state.retrieval_metrics[ + RetrievalMetric.FACTS_CRITIC_FIXES_UNRESOLVED_PREFIX + ] = critic_fixes_unresolved_prefix + state.retrieval_metrics[RetrievalMetric.FACTS_CRITIC_UNITS_UNREVIEWED] = ( + critic_units_unreviewed + ) + state.retrieval_metrics[RetrievalMetric.FACTS_CRITIC_UNITS_SKIPPED] = ( + critic_units_skipped + ) + # The insert-only completion pass, read off the attempt log like the + # critic's own ledger: calls billed, inserts that stayed, and missed + # measurements the inventory stopped listing. + completion_attempts = [a for a in all_attempts if a.kind == "completion"] + state.retrieval_metrics[RetrievalMetric.FACTS_COMPLETION_CALLS] = len( + completion_attempts + ) + state.retrieval_metrics[RetrievalMetric.FACTS_COMPLETION_TRIPLES_INSERTED] = ( + sum(a.n_triples_inserted for a in completion_attempts) + ) + state.retrieval_metrics[ + RetrievalMetric.FACTS_COMPLETION_MEASUREMENTS_RECOVERED + ] = sum(a.n_measurements_recovered for a in completion_attempts) state.facts_units = facts_units state.status = _map_stage_status( failed_without_output_count, len(state.content_units) @@ -792,6 +946,47 @@ async def process_unit( return render_facts +def _union_unit_ontology_context( + results: Sequence[tuple[int, UnitFactsState, str, list[str], OntologyAssemblyMode]], +) -> RDFGraph: + """Union the ontology snapshots the facts units actually resolved. + + Only used when no ontology stage ran: there are then no reduced artifacts + to merge, so the document-level context would be empty even though every + unit rendered against a real one. + + Snapshots are deduplicated by their contributing catalog IRIs. In + single-select mode every unit resolves the same catalog ontology into its + own snapshot object, so unioning them unfiltered would pay a full rdflib + merge per unit for a graph already present. + + Args: + results: The fan-out's per-unit results, in unit order. + + Returns: + The union of the distinct unit snapshots; empty when none carried one. + """ + union = RDFGraph() + seen_sources: set[tuple[str, ...]] = set() + seen_objects: set[int] = set() + for _, result, _, _, _ in results: + snapshot = result.ontology_snapshot + if snapshot is None or len(snapshot.graph) == 0: + continue + key = tuple(sorted(snapshot.source_iris)) + if key: + if key in seen_sources: + continue + seen_sources.add(key) + elif id(snapshot) in seen_objects: + continue + seen_objects.add(id(snapshot)) + union += snapshot.graph + if len(union) > 0: + union.sanitize_prefixes_namespaces() + return union + + def _facts_aggregation_inputs(state: AgentState) -> tuple[RDFGraph, dict]: """Ontology context and document metadata shared by merge and validate. @@ -838,6 +1033,7 @@ def merge_facts(state: AgentState) -> AgentState: state.aggregated_facts = result.graph state.aggregation_clusters = result.merged_clusters state.aggregation_key_clusters = result.key_supported_clusters + state.aggregation_cross_unit_pairs = result.cross_unit_object_pairs state.retrieval_metrics[RetrievalMetric.FACTS_REJECTED_MERGES] = ( result.rejected_merge_count ) diff --git a/ontocast/stategraph/unit_pipeline.py b/ontocast/stategraph/unit_pipeline.py index fff1c775..664a0cb7 100644 --- a/ontocast/stategraph/unit_pipeline.py +++ b/ontocast/stategraph/unit_pipeline.py @@ -142,7 +142,6 @@ async def run_unit_pipeline( ontology_user_instruction=agent_state.ontology_user_instruction, budget_tracker=deepcopy(agent_state.budget_tracker), max_visits_per_node=max_visits, - max_critic_visits_per_node=(tools.config.server.max_critic_visits_per_node), current_domain=agent_state.current_domain, ontology_max_triples=tools.config.server.ontology_max_triples, llm_graph_format=agent_state.llm_graph_format, @@ -169,16 +168,23 @@ async def run_unit_pipeline( ) if agent_state.render_facts: + conformance_chapter, contract_terms, selection_pending = ( + tools.shapes_prompt_contract() + ) facts_state = UnitFactsState( content_unit=unit, ontology_snapshot=_empty_snapshot(), ontology_patch_sources=[], facts_user_instruction=agent_state.facts_user_instruction, + conformance_chapter=conformance_chapter, + shapes_contract_terms=contract_terms, + conformance_selection_pending=selection_pending, budget_tracker=deepcopy(agent_state.budget_tracker), max_visits_per_node=max_visits, - max_critic_visits_per_node=(tools.config.server.max_critic_visits_per_node), llm_graph_format=agent_state.llm_graph_format, ontology_context_max_triples=tools.config.server.ontology_context_max_triples, + ontology_chapter_format=tools.config.server.ontology_chapter_format, + ontology_text_caps=tools.config.server.ontology_text_caps, ) logger.info("run_unit_pipeline: starting facts loop") facts_context = UnitLoopContext.from_agent_state(agent_state) diff --git a/ontocast/tool/agg/aggregate.py b/ontocast/tool/agg/aggregate.py index 487bcb7e..f9df0689 100644 --- a/ontocast/tool/agg/aggregate.py +++ b/ontocast/tool/agg/aggregate.py @@ -15,6 +15,7 @@ import logging import re +from collections.abc import Sequence from difflib import SequenceMatcher from enum import StrEnum from itertools import combinations @@ -28,6 +29,7 @@ from ontocast.config import AggregationConfig from ontocast.onto.constants import ( DEFAULT_IRI, + DOCUMENT_METADATA_TERMS, PROV, SCHEMA, prefix_lookup_for_ingest, @@ -59,6 +61,7 @@ string_values_compatible, tokens_alias_compatible, ) +from .unit_scope import scope_fact_iris, unscoped_iri from .uri_builder import EntityRole, URIBuilder, to_lower_camel_case logger = logging.getLogger(__name__) @@ -76,6 +79,10 @@ "issued": DCTERMS.issued, "source_system": PROV.wasAttributedTo, } +# Predicates above plus ``FOAF.Document`` / ``DCTERMS.source`` / +# ``DCTERMS.identifier`` are exactly :data:`DOCUMENT_METADATA_TERMS` — the +# gate's exemption set is derived from that constant, not from this map. +assert set(_DOC_METADATA_FIRST_CLASS.values()) <= DOCUMENT_METADATA_TERMS _DOC_METADATA_IDENTIFIER_KEYS = frozenset({"doi", "isbn", "pmid", "arxiv_id", "handle"}) _DOC_METADATA_SOURCE_KEYS = frozenset({"source_uri", "source_url"}) _DOC_METADATA_ENTITY_LINKS: dict[str, tuple[URIRef, URIRef]] = { @@ -472,6 +479,55 @@ def build_merged_clusters( return merged_clusters +def build_cross_unit_object_pairs( + units: Sequence[ContentUnit], + final_mapping: dict[URIRef, URIRef], +) -> list[tuple[str, str]]: + """(subject, predicate) pairs whose IRI objects came from more than one unit. + + A statement asserted with several objects *by a single unit* cannot be the + product of an identity merge -- two applicants who jointly lodged one + application are one unit's reading of one sentence. Only a pair whose + objects arrive from different units could have been created by merging, so + this is the evidence the validation gate needs before treating a + multi-valued IRI predicate as a merge signature. + + Keys are canonicalized through ``final_mapping``, because the gate sees the + post-merge graph and a pre-merge subject IRI would never match. + + Args: + units: The content units that contributed to the merged graph. + final_mapping: Source entity -> final URI, as used for the rewrite. + + Returns: + Sorted (subject, predicate) IRI-string pairs, each with at least two + distinct objects spanning at least two units. + """ + contributions: dict[tuple[str, str], dict[str, set[int]]] = {} + for index, unit in enumerate(units): + if unit.graph is None: + continue + for subject, predicate, obj in unit.graph: + if not isinstance(subject, URIRef) or not isinstance(obj, URIRef): + continue + if not isinstance(predicate, URIRef): + continue + key = (str(final_mapping.get(subject, subject)), str(predicate)) + objects = contributions.setdefault(key, {}) + objects.setdefault(str(final_mapping.get(obj, obj)), set()).add(index) + + pairs: list[tuple[str, str]] = [] + for key, objects in contributions.items(): + if len(objects) < 2: + continue + contributing_units: set[int] = set() + for unit_indices in objects.values(): + contributing_units |= unit_indices + if len(contributing_units) > 1: + pairs.append(key) + return sorted(pairs) + + class AggregationResult(BaseModel): """Outcome of one aggregation pass, including merge bookkeeping. @@ -493,6 +549,10 @@ class AggregationResult(BaseModel): least one natural-key pair (a shared identifier value). The validation gate treats label disagreement inside these clusters as name variance rather than a merge signature. + cross_unit_object_pairs: Canonical (subject, predicate) pairs whose IRI + objects were contributed by more than one unit -- the only ones a + merge could have created. See + :func:`build_cross_unit_object_pairs`. """ model_config = ConfigDict(arbitrary_types_allowed=True) @@ -502,6 +562,7 @@ class AggregationResult(BaseModel): merged_clusters: dict[str, list[str]] = Field(default_factory=dict) rejected_merge_count: int = 0 key_supported_clusters: list[str] = Field(default_factory=list) + cross_unit_object_pairs: list[tuple[str, str]] = Field(default_factory=list) class _EntityCollectionState(BaseModel): @@ -593,6 +654,9 @@ def __init__( self.initials_distinct_guard = cfg.initials_distinct_guard self.natural_key_merge = cfg.natural_key_merge self.type_guard_untyped = str(cfg.type_guard_untyped) + self.unit_scoped_fact_iris = cfg.unit_scoped_fact_iris + if candidate_similarity_threshold is None: + self._warn_inert_similarity_threshold(cfg) # Pipeline components (EntityClusterer imports sklearn/ST lazily). # The clusterer runs at the permissive candidate threshold: candidates @@ -612,6 +676,32 @@ def __init__( blocked_sameas_namespaces=(self.base_iri,), ) + @staticmethod + def _warn_inert_similarity_threshold(cfg: AggregationConfig) -> None: + """Warn when the aligner threshold is tuned but the pipeline one is not. + + ``similarity_threshold`` drives only the cross-graph + :class:`~ontocast.tool.agg.entity_aligner.EntityAligner`; this + aggregator clusters and gates at ``candidate_similarity_threshold``. + Setting the former away from its default while the latter stays at its + default is the signature of someone tuning the wrong knob. + """ + fields = AggregationConfig.model_fields + if ( + cfg.similarity_threshold != fields["similarity_threshold"].default + and cfg.candidate_similarity_threshold + == fields["candidate_similarity_threshold"].default + ): + logger.warning( + "AGG_SIMILARITY_THRESHOLD=%s is set, but the in-pipeline " + "aggregator does not read it: it clusters and gates at " + "AGG_CANDIDATE_SIMILARITY_THRESHOLD (still at its default %s). " + "AGG_SIMILARITY_THRESHOLD only drives the cross-graph " + "EntityAligner (/align_entities, match-graphs).", + cfg.similarity_threshold, + cfg.candidate_similarity_threshold, + ) + @staticmethod def _entity_in_namespace(entity: URIRef, namespace: URIRef | str | None) -> bool: """Return True when *entity* is under the provided namespace.""" @@ -679,7 +769,7 @@ def _jaccard(left: set[str], right: set[str]) -> float: @staticmethod def _instance_like_local_name(entity: URIRef) -> str | None: """Return normalized local name when URI ends with numeric suffix.""" - local_name = normalize_uri_local_name(entity).replace(" ", "") + local_name = normalize_uri_local_name(unscoped_iri(entity)).replace(" ", "") if not local_name: return None match = _INSTANCE_LOCAL_NAME_RE.match(local_name) @@ -922,12 +1012,17 @@ def _pair_distinctness_veto( must not end up merged through a chain of intermediate aliases. """ pair = frozenset((left, right)) - if direct_relation_pairs is not None and pair in direct_relation_pairs: + # Direct relations and sibling pairs are recorded by name; the gate's + # merge vetoes name scoped source IRIs. Both spellings are checked. + name_pair = frozenset((unscoped_iri(left), unscoped_iri(right))) + if direct_relation_pairs is not None and ( + pair in direct_relation_pairs or name_pair in direct_relation_pairs + ): return True left_rep = representations.get(left) right_rep = representations.get(right) if guard_context is not None: - if pair in guard_context.sibling_pairs: + if name_pair in guard_context.sibling_pairs: return True if left_rep is not None and right_rep is not None: if self.literal_conflict_guard and self._have_conflicting_literals( @@ -1110,7 +1205,8 @@ def _merge_validation_failures( ) -> list[str]: failures: list[str] = [] if guard_context is not None: - if frozenset((left, right)) in guard_context.sibling_pairs: + name_pair = frozenset((unscoped_iri(left), unscoped_iri(right))) + if name_pair in guard_context.sibling_pairs: failures.append("sibling") left_rep = representations.get(left) right_rep = representations.get(right) @@ -1448,9 +1544,17 @@ def _collect_all_entities( # during rewrite, because unit.graph still contains the original terms. for s, p, o in unit.graph: if isinstance(s, URIRef) and isinstance(o, URIRef): - self._register_direct_relation(state=state, subject=s, obj=o) + # Structural guards key on *names*, as they did before + # unit scoping: a subject mentioned in two units points at + # two scoped objects, and when those carry the same name + # they must not read as siblings, nor make the predicate + # look single-valued on twice the subjects. + name_s, name_o = unscoped_iri(s), unscoped_iri(o) + self._register_direct_relation( + state=state, subject=name_s, obj=name_o + ) if isinstance(p, URIRef) and p != RDF.type: - state.object_groups.setdefault((s, p), set()).add(o) + state.object_groups.setdefault((name_s, p), set()).add(name_o) for term in (s, p, o): if isinstance(term, URIRef): self._register_entity(entity=term, unit=unit, state=state) @@ -1791,6 +1895,9 @@ def aggregate_graphs( merged_clusters=merged_clusters, rejected_merge_count=len(rejected_merges), key_supported_clusters=key_supported_clusters, + cross_unit_object_pairs=build_cross_unit_object_pairs( + active_units, final_mapping + ), ) def postprocess_facts_units( @@ -1814,6 +1921,13 @@ def postprocess_facts_units( facts graph. Business-oriented keys mint typed entities under ``doc_namespace`` (defaults to the document facts namespace). + With ``unit_scoped_fact_iris`` on, every minted fact IRI is suffixed + with its unit index *in place* on the unit graph before aggregation + (see :mod:`ontocast.tool.agg.unit_scope`), so a local name minted by + two units is a merge decision rather than an accidental fusion. The + rewrite is idempotent, which keeps the validation gate's + re-aggregation of the same units on identical input. + Args: units: Facts content units to aggregate. ontology_graph: Merged ontology context for classification/guards. @@ -1829,6 +1943,10 @@ def postprocess_facts_units( """ for unit in units: unit.sanitize() + if self.unit_scoped_fact_iris and unit.type != OutputType.ONTOLOGIES: + scope_fact_iris( + unit.graph, unit.index, (self.base_iri, str(unit.doc_iri)) + ) result = self.aggregate_graphs( units=units, ontology_graph=ontology_graph, merge_vetoes=merge_vetoes ) diff --git a/ontocast/tool/agg/clustering.py b/ontocast/tool/agg/clustering.py index 35ecb80a..6a4aae89 100644 --- a/ontocast/tool/agg/clustering.py +++ b/ontocast/tool/agg/clustering.py @@ -12,6 +12,7 @@ from ontocast.tool.sentence_transformer import SharedEncoder, get_shared_encoder from .normalizer import EntityRepresentation +from .unit_scope import strip_unit_scope logger = logging.getLogger(__name__) @@ -206,7 +207,9 @@ def __init__(self): def compute_simplicity_score(self, entity: URIRef) -> float: """Compute simplicity score for an entity URI. - Lower score = simpler = better + Lower score = simpler = better. A unit-scope suffix is ignored: it + adds underscores and digits in proportion to the unit index, which + would otherwise bias representative choice toward earlier units. Args: entity: Entity URI @@ -214,7 +217,7 @@ def compute_simplicity_score(self, entity: URIRef) -> float: Returns: Simplicity score (lower is better) """ - uri_str = str(entity) + uri_str = strip_unit_scope(str(entity)) # Factors that increase complexity (decrease simplicity) score = 0.0 diff --git a/ontocast/tool/agg/normalizer.py b/ontocast/tool/agg/normalizer.py index cefbc77d..b11206ec 100644 --- a/ontocast/tool/agg/normalizer.py +++ b/ontocast/tool/agg/normalizer.py @@ -24,6 +24,8 @@ stable_sorted_triples, ) +from .unit_scope import unscoped_iri + if TYPE_CHECKING: from ontocast.tool.agg.uri_builder import EntityRole @@ -143,6 +145,10 @@ def normalize_string(self, text: str) -> str: def normalize_uri(self, uri: URIRef) -> str: """Extract and normalize the local part of a URI. + A unit-scope suffix (``__u``) is not part of the name: two + units minting the same local name must produce the same normal form, + or the exact-alias tier could never reunite them. + Args: uri: URI to normalize @@ -152,8 +158,9 @@ def normalize_uri(self, uri: URIRef) -> str: Examples: 'http://example.org/PLRedShift' -> 'pl red shift' 'http://example.org/PL_red_shift_value' -> 'pl red shift value' + 'http://example.org/PL_red_shift__u3' -> 'pl red shift' """ - return normalize_uri_local_name(uri) + return normalize_uri_local_name(unscoped_iri(uri)) def is_ontology_entity(self, entity: URIRef) -> bool: """Check if an entity belongs to an ontology namespace. @@ -212,7 +219,9 @@ def extract_entity_context(self, entity: URIRef, graph: RDFGraph) -> EntityConte if p == RDF.type and isinstance(o, URIRef): types.append(o) elif isinstance(p, URIRef) and isinstance(o, URIRef): - predicate_iri_objects.setdefault(p, set()).add(o) + # Compared across units by the functional-object guard: + # same-named objects of two mentions are one object there. + predicate_iri_objects.setdefault(p, set()).add(unscoped_iri(o)) # Collect labels if p == RDFS.label and isinstance(o, Literal): @@ -272,6 +281,10 @@ def extract_entity_context(self, entity: URIRef, graph: RDFGraph) -> EntityConte ) def _render_term(self, term: Node) -> str: + # Neighborhood text feeds the embedding; a unit-scope suffix would + # make otherwise identical neighborhoods differ by unit. + if isinstance(term, URIRef): + term = unscoped_iri(term) return render_term_for_text(term) def _build_neighborhood_representation( diff --git a/ontocast/tool/agg/rewriter.py b/ontocast/tool/agg/rewriter.py index cee90f02..645168d0 100644 --- a/ontocast/tool/agg/rewriter.py +++ b/ontocast/tool/agg/rewriter.py @@ -34,6 +34,8 @@ from ontocast.onto.iri_policy import is_in_namespace, normalize_namespace_iri from ontocast.onto.rdfgraph import RDFGraph +from .unit_scope import unscoped_iri + logger = logging.getLogger(__name__) # Local alias for readability @@ -88,8 +90,11 @@ def _emit_sameas_links( return for canonical, originals in merged_entities.items(): for original in originals: - if self.should_emit_sameas(original, canonical): - target_graph.add((canonical, OWL.sameAs, original)) + # A unit-scope suffix is aggregation bookkeeping, not an alias + # anyone asserted: the alias record names the source IRI. + source = unscoped_iri(original) + if self.should_emit_sameas(source, canonical): + target_graph.add((canonical, OWL.sameAs, source)) def apply_mapping_to_triple( self, diff --git a/ontocast/tool/agg/unit_scope.py b/ontocast/tool/agg/unit_scope.py new file mode 100644 index 00000000..16934106 --- /dev/null +++ b/ontocast/tool/agg/unit_scope.py @@ -0,0 +1,212 @@ +"""Unit-scoped fact IRIs: cross-unit coreference as a merge decision. + +Units render facts independently, and each mints instance IRIs from the +text in front of it, so two units that both write ``cd:temperature_value`` +produce one IRI for what may be two different measurements. Aggregation +keys its entity collection by IRI, which fuses the two nodes before any +merge guard sees them, and the validation gate's un-merge repair only +dissolves clusters of two or more *source* IRIs -- a name collision is a +singleton there and can never be split. + +Scoping rewrites every minted fact IRI of a unit graph to carry the unit +index as a suffix on its local name:: + + temperature_value -> temperature_value__u3 + +The suffix stays inside the local name (no new path segment), so namespace +splitting keeps working and the entity remains a fact under its namespace. +Two units naming the same thing now arrive as two source entities that earn +their merge like any other alias pair: clustered by the embedding model and +validated by the symbolic guards, where disjoint literal values or +conflicting functional objects keep them apart. The suffix is stripped +wherever a local name is read -- normal forms, structured-id fallbacks, +representative selection, final minting -- so a served graph never carries +it. + +Only *instances* are scoped. An IRI the unit itself uses as a predicate, as +a type, or as a schema-relation target is vocabulary the unit refers to +rather than mints; its cross-unit identity is by name. +""" + +from __future__ import annotations + +import re +from collections.abc import Sequence + +from rdflib import OWL, RDF, RDFS, Node, URIRef + +from ontocast.onto.iri_policy import normalize_namespace_iri, split_namespace_local +from ontocast.onto.rdfgraph import RDFGraph + +#: Separator between a minted local name and the index of the unit that minted it. +UNIT_SCOPE_MARKER = "__u" + +_UNIT_SCOPE_RE = re.compile(rf"{re.escape(UNIT_SCOPE_MARKER)}(-?\d+)$") + +#: Types whose subjects are vocabulary terms and are never scoped. +_SCHEMA_ROLE_TYPES: frozenset[URIRef] = frozenset( + { + RDFS.Class, + OWL.Class, + RDF.Property, + OWL.ObjectProperty, + OWL.DatatypeProperty, + OWL.AnnotationProperty, + } +) + +#: Predicates whose objects are vocabulary terms and are never scoped. +_SCHEMA_TARGET_PREDICATES: frozenset[URIRef] = frozenset( + { + RDF.type, + RDFS.subClassOf, + RDFS.subPropertyOf, + RDFS.domain, + RDFS.range, + OWL.equivalentClass, + OWL.equivalentProperty, + OWL.inverseOf, + } +) + + +def strip_unit_scope(name: str) -> str: + """Return *name* without a trailing unit-scope suffix. + + Accepts a bare local name or a full IRI string: the suffix is terminal + either way, and a name that carries none is returned unchanged. + + Args: + name: Local name or IRI string. + + Returns: + The name with any ``__u`` suffix removed. + """ + return _UNIT_SCOPE_RE.sub("", name) + + +def scope_local_name(local_name: str, unit_index: int) -> str: + """Return *local_name* carrying the scope of *unit_index*. + + An existing scope suffix is replaced rather than stacked, so scoping a + unit graph twice is a no-op -- the validation gate re-aggregates units + that were already scoped by the first aggregation pass. + + Args: + local_name: Local name as minted by the unit. + unit_index: Position of the minting unit in the document. + + Returns: + ``__u``. + """ + return f"{strip_unit_scope(local_name)}{UNIT_SCOPE_MARKER}{unit_index}" + + +def unit_scope_index(name: str) -> int | None: + """Return the unit index carried by *name*, or None when it is unscoped. + + Args: + name: Local name or IRI string. + + Returns: + The index encoded in the trailing scope suffix, if any. + """ + match = _UNIT_SCOPE_RE.search(name) + return int(match.group(1)) if match else None + + +def unscoped_iri(iri: URIRef) -> URIRef: + """Return *iri* with any unit-scope suffix removed from its local name. + + Args: + iri: Possibly scoped IRI. + + Returns: + The same object when unscoped, otherwise a new IRI without the suffix. + """ + text = str(iri) + stripped = strip_unit_scope(text) + return iri if stripped == text else URIRef(stripped) + + +def _vocabulary_terms(graph: RDFGraph) -> set[URIRef]: + """IRIs the graph uses as vocabulary: predicates, types, schema targets.""" + terms: set[URIRef] = set() + for subject, predicate, obj in graph: + if isinstance(predicate, URIRef): + terms.add(predicate) + if predicate in _SCHEMA_TARGET_PREDICATES and isinstance(obj, URIRef): + terms.add(obj) + if ( + predicate == RDF.type + and obj in _SCHEMA_ROLE_TYPES + and isinstance(subject, URIRef) + ): + terms.add(subject) + return terms + + +def scope_fact_iris( + graph: RDFGraph, + unit_index: int, + fact_namespaces: Sequence[str | URIRef], +) -> dict[URIRef, URIRef]: + """Suffix every minted fact IRI in *graph* with the unit index, in place. + + A term is rewritten when it sits in subject or object position, lives + under one of *fact_namespaces*, and is not vocabulary of the graph itself + (see :func:`_vocabulary_terms`). Every occurrence of a rewritten IRI is + replaced, so the graph stays internally consistent; predicates and + literals are untouched. Idempotent for the same unit index. + + Args: + graph: Unit facts graph, modified in place. + unit_index: Position of the unit in the document. + fact_namespaces: Namespaces holding minted instances (the configured + facts base and the unit's document IRI); empty entries are + ignored. + + Returns: + Mapping from each rewritten source IRI to its scoped IRI. + """ + namespaces = tuple( + normalize_namespace_iri(str(namespace), context="facts") + for namespace in fact_namespaces + if namespace + ) + if not namespaces or len(graph) == 0: + return {} + + vocabulary = _vocabulary_terms(graph) + mapping: dict[URIRef, URIRef] = {} + + def scoped(term: Node) -> Node: + if not isinstance(term, URIRef) or term in vocabulary: + return term + cached = mapping.get(term) + if cached is not None: + return cached + text = str(term) + if not any(text.startswith(namespace) for namespace in namespaces): + return term + namespace, local = split_namespace_local(text) + if namespace is None or not local: + return term + target = URIRef(f"{namespace}{scope_local_name(local, unit_index)}") + if target == term: + return term + mapping[term] = target + return target + + updates: list[tuple[tuple[Node, Node, Node], tuple[Node, Node, Node]]] = [] + for subject, predicate, obj in graph: + new_subject = scoped(subject) + new_obj = scoped(obj) + if new_subject != subject or new_obj != obj: + updates.append( + ((subject, predicate, obj), (new_subject, predicate, new_obj)) + ) + for old, new in updates: + graph.remove(old) + graph.add(new) + return mapping diff --git a/ontocast/tool/agg/uri_builder.py b/ontocast/tool/agg/uri_builder.py index 5516f41a..4bc22632 100644 --- a/ontocast/tool/agg/uri_builder.py +++ b/ontocast/tool/agg/uri_builder.py @@ -27,6 +27,7 @@ from ontocast.onto.rdfgraph import RDFGraph from .normalizer import EntityRepresentation +from .unit_scope import strip_unit_scope, unit_scope_index logger = logging.getLogger(__name__) @@ -156,6 +157,17 @@ def to_lower_camel_case(normalized: str) -> str: return words[0] + "".join(w.capitalize() for w in words[1:]) +def _source_local_name(entity: URIRef) -> str: + """Local name of a source entity without its unit-scope suffix. + + The suffix is aggregation bookkeeping (digits and underscores), not part + of what the unit named; reading it here would turn every scoped instance + into a structured identifier and leak the suffix into final IRIs. + """ + local = str(entity).rsplit("/", 1)[-1].rsplit("#", 1)[-1] + return strip_unit_scope(local) + + def has_structured_id(entity: URIRef) -> bool: """Detect if an entity represents a structured/external identifier. @@ -168,7 +180,7 @@ def has_structured_id(entity: URIRef) -> bool: Returns: True if the entity appears to have a structured ID. """ - local = str(entity).rsplit("/", 1)[-1].rsplit("#", 1)[-1] + local = _source_local_name(entity) return bool(re.search(r"\d", local) and "_" in local) @@ -183,7 +195,7 @@ def format_structured_id(entity: URIRef) -> str: Returns: Cleaned identifier string. """ - local = str(entity).rsplit("/", 1)[-1].rsplit("#", 1)[-1] + local = _source_local_name(entity) cleaned = re.sub(r"[^\w]", "_", local) cleaned = re.sub(r"_+", "_", cleaned).strip("_") if not cleaned: @@ -197,6 +209,10 @@ def normalize_local_name( ) -> str: """Produce a properly-cased local name following RDF conventions. + Both sources of the name -- the normal form and the structured-id + fallback on the source IRI -- are read without any unit-scope suffix, + so a minted local name never carries the unit that minted it. + Args: representation: Entity representation with metadata. role: Entity role (an :class:`EntityRole` value). @@ -337,6 +353,11 @@ def create_entity_uri_mapping( Fact entities are always rendered in their source ``doc_iri`` namespace. Ontology entities are preserved as their canonical URI. + Entities are minted in a fixed order -- by unscoped IRI, then by unit + index -- so when two unmerged entities compete for one local name the + counter suffix lands on the same one every run, rather than on + whichever a set iteration happened to yield first. + Args: identity_mapping: Mapping ``entity -> canonical_entity``. representations: All entity representations. @@ -351,7 +372,12 @@ def create_entity_uri_mapping( mapping: dict[URIRef, URIRef] = {} canonical_cache: dict[tuple[URIRef, str], URIRef] = {} - for entity, canonical in identity_mapping.items(): + def mint_order(item: tuple[URIRef, URIRef]) -> tuple[str, int, str]: + text = str(item[0]) + index = unit_scope_index(text) + return (strip_unit_scope(text), -1 if index is None else index, text) + + for entity, canonical in sorted(identity_mapping.items(), key=mint_order): rep = representations.get(canonical) if rep is None: mapping[entity] = entity diff --git a/ontocast/tool/atomic.py b/ontocast/tool/atomic.py index 976bfe64..6e68fcb6 100644 --- a/ontocast/tool/atomic.py +++ b/ontocast/tool/atomic.py @@ -1,12 +1,21 @@ """Minimal tool contracts for atomic render/critic loops.""" +import copy from typing import Protocol from pydantic import BaseModel -from ontocast.config import FactsValidationConfig, WebSearchConfig +from ontocast.config import ( + FactsValidationConfig, + OntologyValidationConfig, + WebSearchConfig, +) from ontocast.onto.enum import WorkflowNode -from ontocast.tool.facts_validation import FactsAcceptancePolicy, ValidationPolicy +from ontocast.tool.facts_validation import ( + CriticPatchPolicy, + FactsAcceptancePolicy, + ValidationPolicy, +) from ontocast.tool.llm import LLMTool @@ -34,6 +43,19 @@ async def search(self, query: str, max_results: int) -> list[SearchHit]: ... +class AtomicOntologyCatalog(Protocol): + """The slice of an ontology catalog the per-unit repairs read. + + A unit sees a retrieved *snapshot* of the catalog; the repairs that decide + whether a term is real need the *whole* catalog, since a term the snapshot + did not retrieve is still a term. + """ + + def catalog_terms(self) -> set[str]: + """Every IRI any served ontology declares or references.""" + ... + + def _domain_set(values: list[str]) -> set[str]: """Normalize a configured domain list to a lowercase lookup set.""" return {value.strip().lower() for value in values if value.strip()} @@ -57,7 +79,9 @@ def __init__( search_provider: AtomicSearchProvider | None = None, web_search_config: WebSearchConfig | None = None, facts_validation_config: FactsValidationConfig | None = None, + ontology_validation_config: OntologyValidationConfig | None = None, citation_vocabulary: dict[str, str] | None = None, + ontology_catalog: AtomicOntologyCatalog | None = None, ): """Build the atomic tool surface. @@ -68,23 +92,52 @@ def __init__( web_search_config: Web-grounding settings. Defaults to :class:`WebSearchConfig`, which is disabled unless configured. facts_validation_config: Facts-gate settings consumed by the render - and repair paths. Defaults to :class:`FactsValidationConfig`. + and critic paths. Defaults to :class:`FactsValidationConfig`. + ontology_validation_config: Ontology-delta settings, including the + ontology critic's pass budget and patch limits. Defaults to + :class:`OntologyValidationConfig`. citation_vocabulary: Bibliographic terms for citation-metadata units. Configuration rather than retrieval: a reference list is not domain content, so its vocabulary never reaches the catalog. + ontology_catalog: The scope's full ontology catalog, read by the + per-unit repairs for term membership. The shared surface is + built without one; :meth:`scoped_to_catalog` binds it per + scope. """ web_search = web_search_config or WebSearchConfig() facts_validation = facts_validation_config or FactsValidationConfig() + ontology_validation = ontology_validation_config or OntologyValidationConfig() self.llm_provider = llm_provider self.search_provider = search_provider self.web_search_config = web_search + self.ontology_catalog: AtomicOntologyCatalog | None = ontology_catalog self.object_property_literal_check = ( facts_validation.object_property_literal_check ) - # Finding-driven repair renders: each one is a provider call. - self.facts_llm_repair_visits = facts_validation.llm_repair_visits + # Review-and-patch passes: each one is a provider call. + self.facts_critic_passes = facts_validation.critic_passes + self.ontology_critic_passes = ontology_validation.critic_passes + # Below this many rendered triples the facts critic is skipped: a + # review of an empty graph is a billed call that changes nothing. + self.facts_critic_min_triples = facts_validation.critic_min_triples + # Insert-only completion passes after the critic loop, each a provider + # call, taken only while measurements are still missing. + self.facts_completion_passes = facts_validation.completion_passes + self.facts_patch_policy = CriticPatchPolicy( + max_delete_share=facts_validation.critic_max_delete_share, + min_deletes=facts_validation.critic_min_deletes, + allow_subject_rename=facts_validation.critic_allow_subject_rename, + ) + self.ontology_patch_policy = CriticPatchPolicy( + max_delete_share=ontology_validation.critic_max_delete_share, + min_deletes=ontology_validation.critic_min_deletes, + allow_subject_rename=False, + ) + # Numeric-coverage knobs for the deterministic per-unit validator. + self.numeric_coverage_limit = facts_validation.numeric_coverage_limit + self.numeric_coverage_mandatory = facts_validation.numeric_coverage_mandatory # Code predicates for the LLM-free code -> catalog IRI repair. self.code_predicates: tuple[str, ...] = tuple(facts_validation.code_predicates) self.property_alias_min_ratio = facts_validation.property_alias_min_ratio @@ -106,6 +159,9 @@ def __init__( additional_standard_namespaces=self.additional_standard_namespaces, quantity_fallback_vocabulary=self.quantity_fallback_vocabulary, code_predicates=self.code_predicates, + numeric_identifier_guard=facts_validation.numeric_identifier_guard, + domain_adherence_min_share=facts_validation.domain_adherence_min_share, + domain_adherence_min_terms=facts_validation.domain_adherence_min_terms, ) # A sibling of ValidationPolicy, deliberately not a field on it. # ValidationPolicy answers "what must never be flagged"; this answers @@ -115,6 +171,12 @@ def __init__( self.acceptance_policy = FactsAcceptancePolicy( blocking_fix_severity=facts_validation.accept_blocking_severity, ) + self.ontology_acceptance_policy = FactsAcceptancePolicy( + blocking_finding_kinds=frozenset( + ontology_validation.accept_blocking_finding_kinds + ), + blocking_fix_severity=facts_validation.accept_blocking_severity, + ) self.web_search_enabled = web_search.enabled self.web_search_top_k = web_search.top_k @@ -135,6 +197,32 @@ def __init__( self.web_search_blocked_domains = _domain_set(web_search.blocked_domains) self.web_search_min_snippet_chars = web_search.min_snippet_chars + def scoped_to_catalog(self, catalog: AtomicOntologyCatalog) -> "AtomicToolBox": + """A shallow copy of this surface bound to ``catalog``. + + The surface is tenancy-independent and shared by every scope; a + catalog is not. Binding on a copy rather than on ``self`` keeps one + scope's catalog from being read by a unit of another scope that runs on + the same shared instance. + """ + scoped = copy.copy(self) + scoped.ontology_catalog = catalog + return scoped + + def catalog_terms(self) -> set[str]: + """Every IRI the bound catalog declares or references, across all of it. + + Membership set for the per-unit repairs: a predicate present here is a + real catalog term even when the unit's retrieved snapshot omits it, and + must never be rewritten toward a look-alike the snapshot does carry. + Empty when no catalog is bound. The catalog memoises the set on its + served versions, so calling this per unit is cheap; treat the result + as read-only. + """ + if self.ontology_catalog is None: + return set() + return self.ontology_catalog.catalog_terms() + async def get_llm_tool(self, budget_tracker) -> LLMTool: """Return a budget-aware LLM tool instance.""" return await self.llm_provider.get_llm_tool(budget_tracker) diff --git a/ontocast/tool/chunk/chunker.py b/ontocast/tool/chunk/chunker.py index 3164e2a1..bdbb755c 100644 --- a/ontocast/tool/chunk/chunker.py +++ b/ontocast/tool/chunk/chunker.py @@ -18,6 +18,12 @@ logger = logging.getLogger(__name__) +# Joins the chunk cache key. Bump whenever the split algorithm changes its output +# for the same text and config: the key is otherwise unchanged, so the cache +# would keep serving boundaries computed by the old algorithm. +# 1: seeded PCA/UMAP with PCA init (boundaries reproducible across runs). +CHUNKER_CACHE_FORMAT_VERSION = 1 + @lru_cache(maxsize=1) def _embedding_model_available() -> bool: @@ -67,7 +73,7 @@ def __init__( self, chunk_config: ChunkConfig | None = None, cache: Cacher | None = None, - **kwargs, + **kwargs: Any, ): """Initialize the ChunkerTool. @@ -254,6 +260,7 @@ def __call__(self, doc: str) -> list[str]: "chunking_mode": self.chunking_mode, "max_size": self.config.max_size, "min_size": self.config.min_size, + "cache_format_version": CHUNKER_CACHE_FORMAT_VERSION, } # Check cache first diff --git a/ontocast/tool/chunk/non_content.py b/ontocast/tool/chunk/non_content.py new file mode 100644 index 00000000..5a43a86a --- /dev/null +++ b/ontocast/tool/chunk/non_content.py @@ -0,0 +1,225 @@ +"""Non-content unit detection: front and back matter that carries no domain facts. + +Author blocks, ORCID lists, competing-interest notes, data-availability and +licence statements survive chunking as their own units whenever the section +classifier has no label for them -- the sibling sub-blocks of a labelled +``acknowledgements`` heading fall out unlabeled and are kept. Each then costs +a full render and a critic call and yields nothing but mistyped people and +identifiers. Detection is deterministic and errs toward keeping: + +- a unit whose leading heading (its first line, or the most specific docling + breadcrumb heading) names a front/back-matter section, **and** whose text + states no unit-adjacent number (``util.measurement_lexicon``, the same + reading the coverage inventory and the density split use), is non-content; + a measurement anywhere in it keeps it, however it is headed; +- a unit whose tokens are mostly emails, URLs, ORCIDs and initials, and + which states no measurement, is non-content; +- a short unit that is licence boilerplate with no measurement is + non-content. + +Like bibliography detection this is subject-domain-agnostic but English and +Latin-script bound: the heading vocabulary is English and the identifier +shapes are Western. A miss is extracted as ordinary content, which is the +pre-existing behaviour. + +Routing is decided by ``CHUNK_NON_CONTENT_MODE``: + +- ``extract`` (default): the unit is kept and marked ``is_non_content`` so + downstream checks that presume domain prose can stand down; +- ``skip``: the unit is dropped before extraction. +""" + +from __future__ import annotations + +import re + +from ontocast.config.section_labels import normalise_heading_line +from ontocast.util.measurement_lexicon import unit_adjacent_numbers + +#: Section labels (from the section-label schemas) whose units are +#: front/back matter by construction. +NON_CONTENT_SECTION_LABELS = frozenset({"acknowledgements"}) + +#: Share of identifier-shaped tokens above which a unit is an author block. +NON_CONTENT_TOKEN_SHARE = 0.4 + +#: Longest unit the licence-boilerplate rule may claim; a licensing +#: discussion in body prose runs longer than a licence notice. +LICENCE_BOILERPLATE_MAX_CHARS = 1200 + +_MAX_HEADING_LEN = 80 + +# Anchored over the normalised heading line (markdown, glyphs, numbering and +# emphasis stripped), so "## ■ AUTHOR INFORMATION" reaches this as +# "AUTHOR INFORMATION". +_HEADING_ALTERNATIVES = ( + r"authors?", + r"authors?[’']?s?\s+information", + r"author\s+contributions?", + r"author\s+details", + r"affiliations?", + r"corresponding\s+authors?", + r"notes?", + r"orcid(?:\s*i\.?d\.?s?)?", + r"(?:data|code|materials?)(?:\s+and\s+(?:code|materials?|software))?" + r"\s+availability(?:\s+statement)?", + r"availability\s+of\s+(?:data|code|materials?)(?:\s+and\s+(?:code|materials?))?", + r"competing\s+(?:financial\s+)?interests?(?:\s+statement)?", + r"declarations?\s+of\s+(?:competing\s+)?interests?", + r"conflicts?\s+of\s+interests?(?:\s+statement)?", + r"licen[cs]e(?:\s+information)?", + r"open\s+access", + r"rights\s+and\s+permissions", + r"supporting\s+information(?:\s+available)?", + r"associated\s+content", + r"additional\s+information", + r"publisher[’']?s?\s+note", + r"ethics\s+(?:declarations?|statement|approval)", + r"funding(?:\s+(?:information|statement|sources?))?", + r"acknowledg(?:e?ments?|ments?)", + r"copyright", + r"disclaimer", +) +_HEADING_RE = re.compile( + r"^(?:" + "|".join(_HEADING_ALTERNATIVES) + r")\s*$", re.IGNORECASE +) + +_EMAIL_RE = re.compile(r"^[\w.+-]+@[\w-]+(?:\.[\w-]+)+[.,;:)]*$") +_URL_RE = re.compile(r"^(?:https?://|www\.|doi\.org/|orcid\.org/|doi:)\S+$", re.I) +_ORCID_RE = re.compile( + r"^(?:https?://orcid\.org/)?\d{4}-\d{4}-\d{4}-\d{3}[\dX][.,;:)]*$" +) +_INITIALS_RE = re.compile(r"^(?:[A-Z]\.-?)+[,;]?$") +#: Punctuation-only tokens (markdown glyphs, bullets, comment markers) that +#: would only dilute the share. +_NOISE_TOKEN_RE = re.compile(r"^[^\w]+$") + +# Notice shapes only: a paragraph that *discusses* licences ("we compare +# Creative Commons licences") must not match, so the CC mention has to sit in +# a "licensed under" construction. +_LICENCE_RE = re.compile( + r"(?:licen[cs]ed|distributed|published|available)\s+under\s+(?:a|an|the)\s+" + r"creative\s+commons" + r"|all\s+rights\s+reserved|©\s*(?:19|20)\d{2}" + r"|\(c\)\s*(?:19|20)\d{2}|copyright\s*©?\s*(?:19|20)\d{2}", + re.IGNORECASE, +) + + +def first_line(text: str) -> str: + """The first non-empty line of ``text``, stripped; empty when there is none.""" + for line in text.splitlines(): + stripped = line.strip() + if stripped: + return stripped + return "" + + +def has_non_content_heading(text: str, headings: list[str] | None) -> bool: + """Whether the unit's leading heading names a front/back-matter section. + + Both the first line of the text and the most specific breadcrumb heading + are tried: a unit that opens with ``## Notes`` matches by its first line, + and the tail of a long author block matches by its breadcrumb. + + Args: + text: Unit text. + headings: Docling heading breadcrumb, outermost first, when known. + + Returns: + True when either candidate normalises to a listed heading. + """ + candidates = [first_line(text)] + if headings: + candidates.append(headings[-1]) + for candidate in candidates: + if not candidate or len(candidate) > _MAX_HEADING_LEN: + continue + normalised = normalise_heading_line(candidate) + if normalised and _HEADING_RE.match(normalised): + return True + return False + + +def metadata_token_share(text: str) -> float: + """Fraction of whitespace tokens that are emails, URLs, ORCIDs or initials. + + Args: + text: Unit text. + + Returns: + A value in ``[0, 1]``; 0 for empty text. + """ + tokens = [token for token in text.split() if not _NOISE_TOKEN_RE.match(token)] + if not tokens: + return 0.0 + hits = sum( + 1 + for token in tokens + if _EMAIL_RE.match(token) + or _URL_RE.match(token) + or _ORCID_RE.match(token) + or _INITIALS_RE.match(token) + ) + return hits / len(tokens) + + +def states_measurement(text: str) -> bool: + """Whether ``text`` states at least one unit-adjacent number. + + A single-letter unit followed by a period is discounted: in an author + block the affiliation digit and the initial that follows it ("Smith,1 A. + B. Jones") read as "1 A" -- one ampere -- and that is the one shape of + front matter this test exists to catch. A real single-letter unit ends a + sentence rarely enough that the loss is a kept unit, not a dropped one. + + Args: + text: Unit text. + + Returns: + True when a measurement is stated. + """ + for mention in unit_adjacent_numbers(text): + if len(mention.unit) > 1: + return True + if text[mention.end : mention.end + 1] != ".": + return True + return False + + +def is_non_content_unit( + text: str, + headings: list[str] | None, + section_label: str | None, +) -> bool: + """Whether a prepared unit is front/back matter with no domain facts. + + Args: + text: Unit text (markdown or plain). + headings: Docling heading breadcrumb for the unit, when known. + section_label: Section label from the chunk-prepare pipeline, when any. + + Returns: + True when the unit should not be mined for domain facts. + """ + stripped = text.strip() + if not stripped: + return False + labelled = ( + section_label is not None + and section_label.lower().strip() in NON_CONTENT_SECTION_LABELS + ) + if labelled or has_non_content_heading(stripped, headings): + if not states_measurement(stripped): + return True + if metadata_token_share( + stripped + ) >= NON_CONTENT_TOKEN_SHARE and not states_measurement(stripped): + return True + if ( + len(stripped) <= LICENCE_BOILERPLATE_MAX_CHARS + and _LICENCE_RE.search(stripped) + and not states_measurement(stripped) + ): + return True + return False diff --git a/ontocast/tool/chunk/prepare.py b/ontocast/tool/chunk/prepare.py index b460e840..b6ae7901 100644 --- a/ontocast/tool/chunk/prepare.py +++ b/ontocast/tool/chunk/prepare.py @@ -6,7 +6,7 @@ import logging import re from collections.abc import Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from functools import lru_cache from typing import TYPE_CHECKING @@ -37,7 +37,8 @@ merge_doc_item_refs, starts_with_section_heading, ) -from ontocast.tool.chunk.sizing import merge_small_parts +from ontocast.tool.chunk.sizing import merge_small_parts, split_by_measurement_density +from ontocast.util.measurement_lexicon import unit_adjacent_numbers from ontocast.util.optional import require if TYPE_CHECKING: @@ -703,6 +704,39 @@ def _merge_prepared_chunks( return merged +def _split_dense_chunks( + chunks: list[PreparedChunk], + config: ChunkConfig, +) -> list[PreparedChunk]: + """Split sized chunks that pack more measurements than the configured cap. + + Runs after merging, on final units, so it is the last word on unit + boundaries: a piece inherits its parent's headings, refs and label. A + split changes what each render is shown, so each one is logged. + """ + cap = config.max_measurements_per_unit + if cap <= 0: + return chunks + split: list[PreparedChunk] = [] + for chunk in chunks: + pieces = split_by_measurement_density( + chunk.text, max_measurements=cap, min_size=config.min_size + ) + if len(pieces) > 1: + logger.info( + "Split chunk (%d chars, %d measurements, section_label=%r) into " + "%d pieces of %s chars (CHUNK_MAX_MEASUREMENTS_PER_UNIT=%d)", + len(chunk.text), + len(unit_adjacent_numbers(chunk.text)), + chunk.section_label, + len(pieces), + [len(piece) for piece in pieces], + cap, + ) + split.extend(replace(chunk, text=piece) for piece in pieces) + return split + + def _size_segments( segments: list[PrepareSegment], splitter: ChunkerTool, @@ -711,7 +745,8 @@ def _size_segments( expanded: list[PreparedChunk] = [] for segment in segments: expanded.extend(_expand_segment(segment, splitter, config)) - return _merge_prepared_chunks(expanded, config.min_size, config.max_size) + merged = _merge_prepared_chunks(expanded, config.min_size, config.max_size) + return _split_dense_chunks(merged, config) def _build_hybrid_chunker(config: ChunkConfig) -> HybridChunker: @@ -890,7 +925,9 @@ async def prepare_content_units( unlabeled = sum(1 for s in segments if s.section_label is None) if unlabeled: - logger.warning( + # Normal for text with no headings (plain-text and JSON inputs); the + # manifest's section_label_histogram is the record. + logger.info( "%s segment(s) remain without section_label after classification", unlabeled, ) diff --git a/ontocast/tool/chunk/proposition.py b/ontocast/tool/chunk/proposition.py index adc875dc..42b67d66 100644 --- a/ontocast/tool/chunk/proposition.py +++ b/ontocast/tool/chunk/proposition.py @@ -1,20 +1,191 @@ -"""Lightweight text splitting helpers (no ML dependencies).""" +"""Lightweight text splitting helpers (no ML dependencies). + +The default path here is pure regex and stays that way. The opt-in bounds -- +an encoder-token budget and the measurement-aware break guard -- take their +extra knowledge as *arguments* (a token counter, the shared measurement +lexicon), so this module never grows a dependency on an encoder or on a +domain vocabulary. +""" from __future__ import annotations +import logging import re +from collections.abc import Callable + +from ontocast.util.measurement_lexicon import unit_adjacent_numbers + +logger = logging.getLogger(__name__) # Regex pattern for splitting text into sentences # Matches: paragraph breaks (double newlines) OR sentence endings followed by capital letters SENTENCE_SPLIT_REGEX = r"(?:\n\s*\n+)|(?<=[.!?])\s+(?=[A-Z][a-z])" +#: One text's worth of tokens per text, or ``None`` where the provider exposes +#: no tokenizer. :meth:`EmbeddingTool.token_lengths` has exactly this shape; the +#: splitter takes it as a callable so it stays free of the encoder. +TokenCounter = Callable[[list[str]], list[int] | None] + +#: Characters per token assumed when a token budget is requested but the +#: provider exposes no tokenizer. Deliberately on the low side of what dense +#: technical prose measures, so the fallback under-fills rather than overruns +#: the encoder's limit -- the failure the budget exists to prevent. +FALLBACK_CHARS_PER_TOKEN = 3.5 + +#: Words whose trailing period is part of the word. General English and +#: bibliographic shapes only: a domain vocabulary here would make the splitter +#: corpus-specific, which is exactly what the sentence bound is being replaced +#: for. Written without the period, lowercase; matched case-insensitively. +ABBREVIATIONS: frozenset[str] = frozenset( + { + # bibliographic + "al", + "cf", + "ed", + "eds", + "edn", + "eq", + "eqs", + "fig", + "figs", + "no", + "nos", + "pp", + "ref", + "refs", + "sec", + "sect", + "suppl", + "tab", + "tabs", + "vol", + "vols", + # general English + "approx", + "ca", + "e.g", + "i.e", + "vs", + "viz", + # titles and name particles + "dr", + "prof", + "mr", + "mrs", + "ms", + "st", + "jr", + "sr", + "inc", + "ltd", + } +) + +#: A short alphabetic token whose period may be an abbreviation mark rather +#: than a full stop -- an initial ("J."), a truncation ("Phys."), a symbol +#: ("nm."). Shape only; whether it *is* one is decided by its neighbours. +_ABBREVIATION_SHAPE = re.compile(r"^[A-Za-z]{1,6}\.$") + +#: A fragment that does not continue a sentence: it opens with a capital, a +#: quote or a bracket. Anything else -- a lowercase word, a digit, a symbol -- +#: continues what came before, so the period before it was not a full stop. +_SENTENCE_OPENER = re.compile(r"^[\"'“‘(\[]*[A-Z]") + +#: Word tokens a fragment needs before it can be read as a whole sentence. +_MIN_SENTENCE_WORDS = 4 + +_LOWERCASE_WORD = re.compile(r"^[a-z]+[.,;:]?$") + +_SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+|\n\s*\n+") + def split_proposition_windows( text: str, max_sentences: int = 2, max_windows: int = 16, + stride: int | None = None, + max_chars: int | None = None, + max_tokens: int | None = None, + token_counter: TokenCounter | None = None, + abbreviation_aware: bool = False, + measurement_aware: bool = False, + overlap: float = 0.0, ) -> list[str]: - """Split text into short proposition-like windows for retrieval.""" + """Split text into short proposition-like windows for retrieval. + + Args: + text: Passage to split. + max_sentences: Sentences per window. Not applied when ``max_chars`` or + ``max_tokens`` is set: the three are alternative ways of saying how + much text one query carries, and honouring more than one means the + tighter one silently wins. + max_windows: Ceiling on the windows returned; over it, windows are sampled + evenly across the passage rather than truncated, so the tail still + contributes a query. + stride: Sentences advanced between windows. ``None`` (default) strides by + the window's own length, giving contiguous, disjoint windows. A smaller + stride overlaps them, which is what lets a statement spanning a window + boundary appear whole in some window -- with disjoint windows it appears + in none, and neither half retrieves what the pair together names. + Sentence-granular, so it says nothing about how much text is repeated; + ``overlap`` is the fractional form the budget modes take. + max_chars: Characters per window. ``None`` (default) bounds windows by + sentence count instead, which is the historical behaviour and is + reproduced exactly. + + A sentence count is a poor bound on how much text a query carries. Two + sentences of technical prose range over an order of magnitude in + length, and the encoder truncates on *tokens*, so a sentence-bounded + window can silently lose its tail. The splitter also has no + abbreviation handling and breaks on every ``.``, so "J. Phys. Chem. + Lett." is four "sentences" and a two-sentence window over a citation + carries nothing retrievable at all. + + A character budget addresses both ends: it caps the long windows that + truncate, and it *coalesces* short fragments, because it keeps taking + sentences until the budget is reached. A single sentence longer than + the budget is emitted whole rather than cut -- the encoder truncates it + either way, and cutting first only loses more. + max_tokens: Encoder word pieces per window, measured with ``token_counter``. + ``None`` (default) leaves the other bounds in charge; when set it + replaces both of them, and characters stop being consulted at all. + + This is the only bound that equalises what a query carries, because it + is the unit the encoder itself counts in: characters per token drift + with notation, so a character budget that fits one passage truncates + the next. Set below the encoder's sequence limit, it makes truncation + impossible by construction -- which a character budget can only + approximate. Unlike the character budget it *does* cut a sentence that + alone exceeds the budget, at a whitespace boundary, because a sentence + emitted whole would be truncated by the encoder anyway and the tail + would then reach no lane at all. + token_counter: Word pieces per text, typically + ``EmbeddingTool.token_lengths``. Required by ``max_tokens``; when it is + absent or reports ``None``, the budget degrades to a character + approximation (``FALLBACK_CHARS_PER_TOKEN``) and says so once, rather + than silently reverting to the sentence bound. + abbreviation_aware: Merge fragments the period-splitter created inside an + abbreviation, an initial or a citation run. A prerequisite for any + equal-size packing rather than a candidate of its own: without it the + packer's atoms include "Chem. Lett.", and a window bound counted in + those atoms is not counting sentences. General English and + bibliographic shapes only -- see :data:`ABBREVIATIONS`. + measurement_aware: Forbid a break between a number and the unit it is + written with, or inside a range, using the number/unit *shapes* in + :mod:`ontocast.util.measurement_lexicon`. Only binds where a cut + inside a sentence is possible, which today means ``max_tokens`` with a + sentence over budget: a window ending on "a red shift of ~10" retrieves + nothing that "~10 meV" would. + overlap: Fraction of a window repeated at the start of the next one, for + the budget modes. ``0.0`` (default) leaves windows disjoint. The + fractional form exists because ``stride`` counts sentences, which under + a budget says nothing about how much text is shared. Overlap multiplies + queries, so it is paid for per window and, once ``max_windows`` binds, + in coverage elsewhere in the passage. + + Returns: + list[str]: Windows in document order. + """ cleaned = text.strip() if not cleaned: return [] @@ -22,20 +193,45 @@ def split_proposition_windows( raise ValueError("max_sentences must be >= 1") if max_windows <= 0: raise ValueError("max_windows must be >= 1") + if max_chars is not None and max_chars <= 0: + raise ValueError("max_chars must be >= 1") + if max_tokens is not None and max_tokens <= 0: + raise ValueError("max_tokens must be >= 1") + if not 0.0 <= overlap < 1.0: + raise ValueError("overlap must be in [0, 1)") + step = max_sentences if stride is None else stride + if step <= 0: + raise ValueError("stride must be >= 1") # Keep this splitter lightweight and deterministic. sentence_parts = [ - part.strip() - for part in re.split(r"(?<=[.!?])\s+|\n\s*\n+", cleaned) - if part.strip() + part.strip() for part in _SENTENCE_SPLIT.split(cleaned) if part.strip() ] + if abbreviation_aware: + sentence_parts = _merge_abbreviations(sentence_parts) if not sentence_parts: return [cleaned[:1000]] if cleaned else [] + pieces = _windows( + sentence_parts, + max_sentences=max_sentences, + step=step, + stride=stride, + max_chars=max_chars, + max_tokens=max_tokens, + token_counter=token_counter, + measurement_aware=measurement_aware, + overlap=overlap, + ) + windows: list[str] = [] - for index in range(0, len(sentence_parts), max_sentences): - window = " ".join(sentence_parts[index : index + max_sentences]).strip() - if window: + seen: set[str] = set() + for window in pieces: + # A stride below the window size makes the final windows suffixes of one + # another once the tail is shorter than a full window; emitting those twice + # would pay for an identical query and skew the fusion ranks it feeds. + if window and window not in seen: + seen.add(window) windows.append(window) if len(windows) > max_windows: @@ -54,3 +250,314 @@ def split_proposition_windows( windows = [windows[index] for index in sorted(picked)] return windows or [cleaned[:1000]] + + +def _windows( + sentence_parts: list[str], + *, + max_sentences: int, + step: int, + stride: int | None, + max_chars: int | None, + max_tokens: int | None, + token_counter: TokenCounter | None, + measurement_aware: bool, + overlap: float, +) -> list[str]: + """Dispatch to the bound in force, tightest declaration first.""" + if max_tokens is not None: + sizer = _token_sizer(token_counter) + if sizer is not None: + atoms = _token_atoms( + sentence_parts, max_tokens, sizer, measurement_aware=measurement_aware + ) + return _pack(atoms, max_tokens, overlap, join_cost=0) + max_chars = max(1, round(max_tokens * FALLBACK_CHARS_PER_TOKEN)) + if max_chars is None: + return _windows_by_sentence_count(sentence_parts, max_sentences, step) + if stride is not None: + return _windows_by_char_budget(sentence_parts, max_chars, stride) + atoms = [(part, len(part)) for part in sentence_parts] + return _pack(atoms, max_chars, overlap) + + +def _windows_by_sentence_count( + sentence_parts: list[str], max_sentences: int, step: int +) -> list[str]: + """Windows of a fixed sentence count, strided by ``step`` sentences.""" + return [ + " ".join(sentence_parts[index : index + max_sentences]).strip() + for index in range(0, len(sentence_parts), step) + ] + + +def _windows_by_char_budget( + sentence_parts: list[str], max_chars: int, stride: int +) -> list[str]: + """Windows packed to a character budget, strided by whole sentences. + + The sentence-granular stride is kept for the character budget because it is + the historical spelling of overlap; ``overlap`` is the fractional form and + the two are mutually exclusive. + """ + windows: list[str] = [] + start = 0 + while start < len(sentence_parts): + length = 0 + stop = start + while stop < len(sentence_parts): + addition = len(sentence_parts[stop]) + (1 if stop > start else 0) + if stop > start and length + addition > max_chars: + break + length += addition + stop += 1 + windows.append(" ".join(sentence_parts[start:stop]).strip()) + start = start + stride + return windows + + +def _pack( + atoms: list[tuple[str, int]], budget: int, overlap: float, join_cost: int = 1 +) -> list[str]: + """Greedily fill windows to ``budget``, repeating ``overlap`` of each. + + Each window starts at an atom and takes as many following atoms as fit, so a + window's atom count varies with how long its atoms are -- which is the point + of a budget. An atom that alone exceeds the budget becomes its own window. + + The next window starts far enough back that roughly ``overlap`` of the + window just emitted is repeated, measured in the same unit as the budget. + Progress is guaranteed: the start always advances by at least one atom, so a + high overlap costs queries rather than looping. + + ``join_cost`` is what the separator itself costs in the budget's unit: one + character, but no word piece, since a tokenizer does not emit one for a + space. + """ + windows: list[str] = [] + start = 0 + while start < len(atoms): + size = 0 + stop = start + while stop < len(atoms): + addition = atoms[stop][1] + (join_cost if stop > start else 0) + if stop > start and size + addition > budget: + break + size += addition + stop += 1 + windows.append(" ".join(text for text, _ in atoms[start:stop]).strip()) + if overlap <= 0.0: + start = stop + continue + target = overlap * size + repeated = 0 + index = stop + while index - 1 > start and repeated + atoms[index - 1][1] <= target: + index -= 1 + repeated += atoms[index][1] + start = max(index, start + 1) + return windows + + +def _token_sizer( + token_counter: TokenCounter | None, +) -> Callable[[list[str]], list[int]] | None: + """Net word pieces per text: the provider's count minus its fixed overhead. + + A tokenizer counts the special tokens it wraps every text in, so summing raw + counts over the atoms of a window over-charges the window by one wrapper per + atom. Subtracting the wrapper -- measured once, from the empty string -- + makes the atom sizes additive, which is what the packer needs. + + Returns: + A sizer, or ``None`` when the provider exposes no tokenizer. + """ + if token_counter is None: + _warn_no_tokenizer() + return None + try: + baseline = token_counter([""]) + except Exception: # noqa: BLE001 - a tokenizer failure must not break retrieval + baseline = None + if baseline is None: + _warn_no_tokenizer() + return None + overhead = baseline[0] if baseline else 0 + + def sizer(texts: list[str]) -> list[int]: + if not texts: + return [] + counts = token_counter(texts) + if counts is None: + # Only reachable if the provider degrades mid-pass; charge the + # fallback rather than treating unknown as free. + return [ + max(1, round(len(text) / FALLBACK_CHARS_PER_TOKEN)) for text in texts + ] + return [max(1, count - overhead) for count in counts] + + return sizer + + +_warned_no_tokenizer = False + + +def _warn_no_tokenizer() -> None: + global _warned_no_tokenizer + if _warned_no_tokenizer: + return + _warned_no_tokenizer = True + logger.warning( + "A token budget for retrieval windows was requested but the embedding " + "provider exposes no tokenizer; falling back to ~%.1f characters per " + "token. The budget is then an approximation and no longer rules out " + "encoder truncation.", + FALLBACK_CHARS_PER_TOKEN, + ) + + +def _token_atoms( + sentence_parts: list[str], + budget: int, + sizer: Callable[[list[str]], list[int]], + *, + measurement_aware: bool, +) -> list[tuple[str, int]]: + """Sentences, with any sentence over budget cut at a whitespace boundary. + + Cutting is what separates the token budget from the character one. A + sentence longer than the encoder accepts is truncated by the encoder + whatever we do, and its tail then reaches no lane at all; cut here, the tail + is a query of its own. + """ + sizes = sizer(sentence_parts) + texts: list[str] = [] + for part, size in zip(sentence_parts, sizes, strict=True): + if size <= budget: + texts.append(part) + continue + texts.extend( + _cut_sentence(part, budget, sizer, measurement_aware=measurement_aware) + ) + # Re-measure: a cut piece's size is not the sum of its words' sizes, because + # word pieces are found across the whole string. + return list(zip(texts, sizer(texts), strict=True)) + + +def _cut_sentence( + text: str, + budget: int, + sizer: Callable[[list[str]], list[int]], + *, + measurement_aware: bool, +) -> list[str]: + """One over-budget sentence as whitespace-bounded pieces. + + Each piece is measured as the substring it will actually be, not as the sum + of its words: word pieces are found across a whole string, so summing per- + word counts overshoots and would let a piece land over the budget the cut + exists to respect. Prefix counts are monotone, so one batched measurement + per piece finds the longest prefix that fits. + """ + words = list(re.finditer(r"\S+", text)) + if len(words) <= 1: + return [text] + spans = _measurement_spans(text) if measurement_aware else [] + + pieces: list[str] = [] + start = 0 + while start < len(words): + begin = words[start].start() + prefixes = [text[begin : word.end()] for word in words[start:]] + stop = start + 1 + for offset, size in enumerate(sizer(prefixes)): + if size > budget: + break + stop = start + offset + 1 + if stop < len(words): + stop = _shift_break(words, start, stop, spans) + pieces.append(text[begin : words[stop - 1].end()]) + start = stop + return pieces + + +def _measurement_spans(text: str) -> list[tuple[int, int]]: + """Character spans running from a number to the unit it is written with. + + A range yields one mention per number, all ending past the shared unit, so + the first mention's span already covers the whole range and the guard needs + no separate notion of one. + """ + return [(mention.start, mention.end) for mention in unit_adjacent_numbers(text)] + + +def _shift_break( + words: list[re.Match[str]], + start: int, + stop: int, + spans: list[tuple[int, int]], +) -> int: + """Move a break out of a number/unit pair, backwards, or leave it alone. + + Backwards rather than forwards: moving the break earlier keeps the window + within its budget, while moving it later would push the window over the + encoder's limit -- and a truncated window loses the pair anyway. A break + that cannot move without emptying the window stays where it is. + """ + while stop - 1 > start: + offset = words[stop - 1].end() + if not any(begin < offset < end for begin, end in spans): + return stop + stop -= 1 + return stop + + +def _merge_abbreviations(parts: list[str]) -> list[str]: + """Rejoin fragments split on a period that was not a full stop. + + The splitter breaks on every ``.``, so an initial, a truncated word or a + citation run becomes several "sentences" -- and a two-sentence window over + one of them carries nothing retrievable. Three shape rules, no vocabulary + beyond the general abbreviation list: + + * the next fragment does not open a sentence (it starts lowercase, with a + digit, or with a symbol), so the period before it was not a full stop; + * the previous fragment ends in a known abbreviation; + * the previous fragment ends in an abbreviation *shape* and does not read as + a whole sentence -- too few words, or no lowercase word in it, which is + what a run of initials and truncations looks like. + """ + merged: list[str] = [] + for part in parts: + if merged and not _is_boundary(merged[-1], part): + merged[-1] = f"{merged[-1]} {part}" + else: + merged.append(part) + return merged + + +def _is_boundary(before: str, after: str) -> bool: + """Whether the period between two fragments ended a sentence.""" + if not _SENTENCE_OPENER.match(after): + return False + tokens = before.split() + if not tokens: + return False + last = tokens[-1] + if last.rstrip(".").lower() in ABBREVIATIONS: + return False + if _ABBREVIATION_SHAPE.match(last) and not _reads_as_sentence(tokens): + return False + return True + + +def _reads_as_sentence(tokens: list[str]) -> bool: + """Whether a fragment has the shape of a whole sentence. + + Enough words to be one, and at least one ordinary lowercase word -- the test + that separates prose from a run of initials and truncated titles, which + carry capitals throughout. + """ + if len(tokens) < _MIN_SENTENCE_WORDS: + return False + return any(_LOWERCASE_WORD.match(token) for token in tokens) diff --git a/ontocast/tool/chunk/section_llm.py b/ontocast/tool/chunk/section_llm.py index 40087365..358852e4 100644 --- a/ontocast/tool/chunk/section_llm.py +++ b/ontocast/tool/chunk/section_llm.py @@ -25,7 +25,7 @@ document_type_context, format_batch_items, ) -from ontocast.tool.llm import record_active_span +from ontocast.tool.llm import LLMConfigurationError, record_active_span if TYPE_CHECKING: from ontocast.toolbox import ToolBox @@ -139,6 +139,10 @@ async def classify_sections_batched( try: response = await tools.llm(prompt) parsed = parser.parse(response.content or "") + except LLMConfigurationError: + # Falling back to the per-segment path is pointless when the + # provider rejects the request itself -- it rejects that one too. + raise except Exception as exc: logger.warning( "Batched section classification failed for %s excerpt(s): %s", @@ -252,6 +256,11 @@ async def classify_index(index: int) -> tuple[int, str | None]: document_type_hint=document_type_hint, ) return index, label + except LLMConfigurationError: + # An unlabeled segment is survivable; a deployment that rejects + # every request is not, and every sibling task is about to hit + # it identically. + raise except Exception as exc: logger.warning( "LLM section classification failed for segment %s: %s", @@ -274,6 +283,11 @@ async def classify_index(index: int) -> tuple[int, str | None]: ) } for item in results: + if isinstance(item, LLMConfigurationError): + # return_exceptions captures the guard above as a value, so the + # re-raise has to happen here. Once the gather has drained, so + # nothing is orphaned. + raise item if isinstance(item, BaseException): logger.warning("Section classification task failed: %s", item) _apply_llm_labels(segments, labels) diff --git a/ontocast/tool/chunk/sizing.py b/ontocast/tool/chunk/sizing.py index a8aa9b09..c1ba4cbe 100644 --- a/ontocast/tool/chunk/sizing.py +++ b/ontocast/tool/chunk/sizing.py @@ -2,12 +2,17 @@ from __future__ import annotations +import re from collections.abc import Callable from ontocast.config import ChunkConfig +from ontocast.tool.chunk.proposition import SENTENCE_SPLIT_REGEX +from ontocast.util.measurement_lexicon import unit_adjacent_numbers DEFAULT_PART_SEPARATOR = "\n\n" +_SENTENCE_BOUNDARY_RE = re.compile(SENTENCE_SPLIT_REGEX) + def hard_cap_parts(parts: list[str], max_size: int) -> list[str]: """Split parts that still exceed ``max_size`` at word or character boundaries.""" @@ -136,3 +141,65 @@ def size_bounded_text( config.max_size, separator=separator, ) + + +def _midpoint_sentence_cut(text: str, min_size: int) -> tuple[int, int] | None: + """The sentence boundary nearest the midpoint leaving both halves ``>= min_size``. + + Returns: + ``(start, end)`` of the boundary whitespace, so the left half is + ``text[:start]`` and the right half ``text[end:]``; ``None`` when no + boundary satisfies the floor. + """ + midpoint = len(text) / 2 + best: tuple[int, int] | None = None + best_distance = float("inf") + for match in _SENTENCE_BOUNDARY_RE.finditer(text): + start, end = match.span() + if start < min_size or len(text) - end < min_size: + continue + distance = abs(start - midpoint) + if distance < best_distance: + best, best_distance = (start, end), distance + return best + + +def split_by_measurement_density( + text: str, + *, + max_measurements: int, + min_size: int, +) -> list[str]: + """Split ``text`` while it states more measurements than ``max_measurements``. + + Extraction loss tracks how many measurements a unit packs, not how long + it is, so the cut is by density rather than by size: the text is cut at + the sentence or paragraph boundary nearest its midpoint and each half is + re-checked, recursively. No piece is ever shorter than ``min_size``; a + dense unit that cannot be cut without producing one is returned whole. + + Args: + text: Unit text. + max_measurements: Cap on unit-adjacent numbers per piece; ``<= 0`` + disables splitting. + min_size: Floor on piece length in characters. + + Returns: + The stripped text as one piece, or its pieces in text order. + """ + stripped = text.strip() + if not stripped: + return [] + if max_measurements <= 0: + return [stripped] + if len(unit_adjacent_numbers(stripped)) <= max_measurements: + return [stripped] + cut = _midpoint_sentence_cut(stripped, min_size) + if cut is None: + return [stripped] + start, end = cut + return split_by_measurement_density( + stripped[:start], max_measurements=max_measurements, min_size=min_size + ) + split_by_measurement_density( + stripped[end:], max_measurements=max_measurements, min_size=min_size + ) diff --git a/ontocast/tool/chunk/util.py b/ontocast/tool/chunk/util.py index c7615797..543344fa 100644 --- a/ontocast/tool/chunk/util.py +++ b/ontocast/tool/chunk/util.py @@ -18,6 +18,15 @@ __all__ = ["SENTENCE_SPLIT_REGEX", "SemanticChunker", "split_proposition_windows"] +# Fixed seed for the PCA/UMAP reduction, so a given text and chunk config always +# yield the same unit boundaries. Not a tuning knob: any value is equally good, +# what matters is that it never changes between runs. +_CLUSTER_RANDOM_STATE = 0 + +# Below this many sentences there is one embedding window for the whole text, +# which is too few points to cluster; the text is packed by size instead. +_SENTENCE_WINDOW_SIZE = 5 + class SemanticChunker(BaseDocumentTransformer): def __init__( @@ -42,7 +51,7 @@ def __init__( def _build_sentence_windows( self, sentences: List[str], - window_size: int = 5, + window_size: int = _SENTENCE_WINDOW_SIZE, ) -> List[str]: if len(sentences) <= window_size: return [" ".join(sentences)] @@ -73,6 +82,9 @@ def _cluster_sentences( ensure chunks respect max_size constraints. Large clusters will be split post-processing. + Deterministic: the reduction is seeded, so the same vectors always get + the same labels, in any process. + Args: vectors: Embedding vectors for sentences. sentences: Original sentence texts for length validation. @@ -83,11 +95,26 @@ def _cluster_sentences( # 1. PCA to reduce noise pca_dims = min(vectors.shape[0] - 1, 50) if pca_dims > 1: - vectors = PCA(n_components=pca_dims).fit_transform(vectors) + # Seeded: svd_solver="auto" picks the randomized solver on large inputs. + vectors = PCA( + n_components=pca_dims, random_state=_CLUSTER_RANDOM_STATE + ).fit_transform(vectors) # 2. UMAP to 5 dimensions - # n_neighbors=2 captures very local structure for chunking - reducer = UMAP(n_components=5, n_neighbors=2, min_dist=0.0, metric="cosine") + # n_neighbors=2 captures very local structure for chunking. That graph + # is always disconnected, and the default spectral init lays its + # components out differently on every run even when seeded, so the + # init is PCA. umap forces n_jobs=1 once seeded; passing it avoids the + # override warning. + reducer = UMAP( + n_components=5, + n_neighbors=2, + min_dist=0.0, + metric="cosine", + init="pca", + random_state=_CLUSTER_RANDOM_STATE, + n_jobs=1, + ) reduced_vectors = reducer.fit_transform(vectors) # 3. HDBSCAN with parameters favoring more clusters @@ -155,9 +182,15 @@ def split_text(self, text: str) -> List[str]: # (we can't split sentences, so we must keep it whole) return sentences - windows = self._build_sentence_windows(sentences, window_size=5) - vectors = self._get_embeddings(windows) - labels = self._cluster_sentences(vectors, sentences) + if len(sentences) <= _SENTENCE_WINDOW_SIZE: + # One window for all sentences: nothing to cluster, pack by size. + labels = np.zeros(len(sentences), dtype=int) + else: + windows = self._build_sentence_windows( + sentences, window_size=_SENTENCE_WINDOW_SIZE + ) + vectors = self._get_embeddings(windows) + labels = self._cluster_sentences(vectors, sentences) # Process sentences in original order, grouping consecutive sentences # from the same cluster into chunks diff --git a/ontocast/tool/converter.py b/ontocast/tool/converter.py index e75de3c2..4ce28510 100644 --- a/ontocast/tool/converter.py +++ b/ontocast/tool/converter.py @@ -31,6 +31,14 @@ # practice of renaming the cache subdirectory. CONVERTER_CACHE_FORMAT_VERSION = 1 +# Bumped whenever a numeric-artifact repair rule changes the text it produces. +# Without it a rule fix is inert wherever conversions are cached: the key is +# the same, so the cache keeps serving text repaired by the old rule and the +# fix looks like it did nothing. It joins the key only when the repair flag is +# set -- with repair off no rule ran, so no rule version can have changed the +# output, and pre-existing entries stay valid. +CONVERTER_REPAIR_RULES_VERSION = 2 + def _build_layout_options(config: ConverterConfig) -> Any: pipeline_options_module = importlib.import_module( @@ -157,7 +165,7 @@ def __init__( self, cache: Cacher | None = None, converter_config: ConverterConfig | None = None, - **kwargs, + **kwargs: Any, ): """Initialize the converter tool. @@ -227,6 +235,13 @@ def __call__(self, file_input: bytes | str | pathlib.Path) -> DoclingDocument: # Check cache first. The format version lives in the key, so bumping it # orphans stale entries in place rather than stranding a whole directory. config_dict = self.converter_config.model_dump(mode="json") + # Off is the pre-existing output, so the flag joins the key only when + # it changes the text: enabling it re-converts, leaving it off keeps + # every conversion cached before the flag existed. + if config_dict.get("repair_numeric_artifacts"): + config_dict["repair_rules_version"] = CONVERTER_REPAIR_RULES_VERSION + else: + config_dict.pop("repair_numeric_artifacts", None) config_dict["cache_format_version"] = CONVERTER_CACHE_FORMAT_VERSION cached_result = self.cache.get(content_for_cache, config=config_dict) if cached_result is not None: @@ -267,6 +282,9 @@ def __call__(self, file_input: bytes | str | pathlib.Path) -> DoclingDocument: converted_result = apply_text_sanitizers( converted_result, repair_ligature_gaps_enabled=self.converter_config.repair_ligature_gaps, + repair_numeric_artifacts_enabled=( + self.converter_config.repair_numeric_artifacts + ), ) # Cache the result as JSON for stable serialization diff --git a/ontocast/tool/facts_validation/__init__.py b/ontocast/tool/facts_validation/__init__.py index 33e6d9ed..44404c69 100644 --- a/ontocast/tool/facts_validation/__init__.py +++ b/ontocast/tool/facts_validation/__init__.py @@ -13,8 +13,15 @@ accept_reason, material_defects, ) +from ontocast.tool.facts_validation.critic_patch import ( + CompiledFixes, + CriticPatchPolicy, + apply_compiled_patch, + compile_critic_fixes, +) from ontocast.tool.facts_validation.gate import ( FactsValidationReport, + count_shacl_focus_nodes, record_facts_gate_metrics, summarize_conformance, validate_aggregated_facts, @@ -47,9 +54,12 @@ from ontocast.tool.facts_validation.unit_findings import ( collect_unit_findings, domain_violation_findings, + unit_numeric_inventory, ) __all__ = [ + "CompiledFixes", + "CriticPatchPolicy", "FactsAcceptancePolicy", "FactsValidationReport", "MaterialDefect", @@ -57,12 +67,14 @@ "ShaclViolation", "ValidationPolicy", "accept_reason", + "apply_compiled_patch", "apply_shacl_repairs", "build_surface_index", "collect_catalog_terms", "collect_declared_namespaces", "collect_shacl_shapes", "collect_unit_findings", + "compile_critic_fixes", "dedupe_literal_variants", "domain_violation_findings", "expand_vocabulary_terms", @@ -77,6 +89,8 @@ "resolve_unique_surface", "run_shacl", "shacl_catalog_contradictions", + "count_shacl_focus_nodes", "summarize_conformance", + "unit_numeric_inventory", "validate_aggregated_facts", ] diff --git a/ontocast/tool/facts_validation/acceptance.py b/ontocast/tool/facts_validation/acceptance.py index 193eba55..8667aa40 100644 --- a/ontocast/tool/facts_validation/acceptance.py +++ b/ontocast/tool/facts_validation/acceptance.py @@ -24,7 +24,7 @@ from pydantic import BaseModel, Field -from ontocast.onto.model import FactsUnitFinding, FactsUnitFindingKind, TripleFix +from ontocast.onto.model import TripleFix, UnitFinding #: Severity cut for critic-proposed fixes. ``never`` means the critic's #: severity label is not trusted to block at all and only deterministic @@ -61,24 +61,30 @@ class FactsAcceptancePolicy(BaseModel): worse than the score gate it replaces. """ - blocking_finding_kinds: frozenset[FactsUnitFindingKind] | None = None + blocking_finding_kinds: frozenset[str] | None = None blocking_fix_severity: BlockingFixSeverity = "critical" - def blocks_finding(self, finding: FactsUnitFinding) -> bool: - """True when this deterministic finding must be repaired before exit.""" + def blocks_finding(self, finding: UnitFinding) -> bool: + """True when this deterministic finding must be repaired before exit. + + Typed on the shared base, and matched on the kind's *value*, so one + policy serves both phases: the facts and ontology finding kinds are + separate enums with no member in common, and the alternative was a + second policy class differing only in an annotation. + """ if self.blocking_finding_kinds is None: return finding.mandatory - return finding.kind in self.blocking_finding_kinds + return str(finding.kind) in self.blocking_finding_kinds def blocks_fix(self, fix: TripleFix) -> bool: """True when this critic-proposed fix must be applied before exit. - A ``REMOVE`` fix never blocks, whatever its severity. The repair prompt - it would be rendered into states that a finding is never resolved by - deleting the statement, so a mandatory REMOVE would contradict the block - it sits in -- the same shape of contradiction that - ``shacl_catalog_contradictions`` exists to catch, and one that has - already caused repair renders to delete valid values wholesale. + A ``REMOVE`` fix never blocks, whatever its severity. Acceptance is + about whether the unit may leave the loop, and a removal that the patch + screening refused -- because it would empty a subject, or exceed the + delete cap -- is precisely a removal that should not hold the unit back. + The screening decides what may be deleted; this decides what is worth + another pass, and a deletion is never the thing worth insisting on. """ if self.blocking_fix_severity == "never": return False @@ -90,7 +96,7 @@ def blocks_fix(self, fix: TripleFix) -> bool: def material_defects( - findings: Sequence[FactsUnitFinding], + findings: Sequence[UnitFinding], fixes: Sequence[TripleFix], policy: FactsAcceptancePolicy | None = None, ) -> list[MaterialDefect]: @@ -99,8 +105,9 @@ def material_defects( Args: findings: Deterministic findings collected against the current graph. fixes: Fixes the LLM critic proposed, if it ran. Empty is normal -- - at ``MAX_VISITS=1`` the critic never runs and acceptance rests - entirely on the findings. + with ``FACTS_CRITIC_PASSES=0`` the critic never runs and + acceptance rests entirely on the findings. (The critic budget is + independent of ``MAX_VISITS``, which bounds failed renders only.) policy: The deployment's cut. ``None`` uses the defaults. Returns: diff --git a/ontocast/tool/facts_validation/critic_findings.py b/ontocast/tool/facts_validation/critic_findings.py deleted file mode 100644 index 393474bd..00000000 --- a/ontocast/tool/facts_validation/critic_findings.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Convert critic-proposed fixes into the finding shape the repair pass reads. - -The loop had two repair channels that never met. Deterministic findings went to -``_run_finding_driven_repair``, a bounded rewrite-in-place pass costing one -patch render. Critic fixes went somewhere else entirely: they were stashed on -``state.suggestions`` and consumed by the *next full render*, which re-extracted -the unit from scratch under a prompt that also invited it to "proactively -identify and fix additional problems not mentioned in the critique". - -So the cheap, contract-bound channel carried the reliable evidence and the -expensive, open-ended one carried the model's opinion. Routing both through the -same findings pipeline is what makes a rejection cost a repair rather than a -re-extraction. -""" - -from collections.abc import Sequence - -from ontocast.onto.model import FactsUnitFinding, FactsUnitFindingKind, TripleFix -from ontocast.tool.facts_validation.acceptance import FactsAcceptancePolicy - - -def critic_fixes_to_findings( - fixes: Sequence[TripleFix], - policy: FactsAcceptancePolicy | None = None, -) -> list[FactsUnitFinding]: - """Render critic fixes as findings, blocking ones marked mandatory. - - ``mandatory`` follows the policy's severity cut, with one exception that is - not configurable: an ``action="REMOVE"`` fix is **never** mandatory. The - findings block it would be rendered into states that a finding is never - resolved by deleting the statement, so a mandatory REMOVE would contradict - the instruction printed directly above it -- the same contradiction shape - ``shacl_catalog_contradictions`` exists to catch, and one that has already - caused repair renders to delete valid values wholesale. - - Args: - fixes: Fixes from the critique report, in the order proposed. - policy: The deployment's severity cut. ``None`` uses the defaults. - - Returns: - One finding per fix, advisory unless the policy blocks on it. - """ - active = policy if policy is not None else FactsAcceptancePolicy() - findings: list[FactsUnitFinding] = [] - for fix in fixes: - message = f"{fix.action}: {fix.explanation}".strip() - if fix.incorrect_value: - message = f"{message} (currently: {fix.incorrect_value})" - findings.append( - FactsUnitFinding( - kind=FactsUnitFindingKind.CRITIC_FIX, - mandatory=active.blocks_fix(fix), - message=message, - value=fix.incorrect_value or "", - suggestions=[fix.correct_value] if fix.correct_value else [], - ) - ) - return findings diff --git a/ontocast/tool/facts_validation/critic_patch.py b/ontocast/tool/facts_validation/critic_patch.py new file mode 100644 index 00000000..592bed94 --- /dev/null +++ b/ontocast/tool/facts_validation/critic_patch.py @@ -0,0 +1,993 @@ +"""Compile critic-proposed fixes into a validated graph patch, with no LLM call. + +The loop's invariant is that **every mutation is a compiled, validated +``GraphUpdate``** -- that is what keeps the two-namespace contract, IRI policy +and literal repair in one place. It had drifted into a stricter and less +defensible rule: that every mutation must come from a *render call*. The +consequence was that a critic fix naming the exact triple to drop still cost a +full re-extraction of the unit, so the loop only ever spent that on fixes it +considered blocking and dropped the rest. + +A fix that names the statements it removes and supplies the ones it adds is +already a patch. Compiling it here costs nothing and leaves the invariant +intact: the result goes through the same ``GraphUpdate`` the renderer's wire +compiles to. + +**How a fix names what it removes matters more than anything else here.** The +original contract asked the critic to retype the offending statement into +``incorrect_value``, and to match it had to reproduce the stored triple exactly +-- same prefix form, same predicate, same literal shape. Across a large corpus +of real critiques that reproduction succeeds a minority of the time for +``REPLACE`` and almost never for a bare ``REMOVE``: the payload arrives as +prose, as a plausible-but-invented IRI, or as a node-shaped quote spanning +several statements with one predicate wrong, which fails the all-present check +as a whole. Authoring *new* content in ``correct_value`` has no such problem, +because nothing has to match. + +So the primary path is by id: the critic is shown a numbered graph and cites +:attr:`TripleFix.triple_ids`, which resolve by lookup. Requoting remains as a +fallback for a fix that cites no id, under the same exact-match rule as before. + +Still deliberately conservative. A cited id the index never issued sends the +whole fix back rather than acting on the part that resolved; a statement already +gone is not deleted again; and a fix that removes exactly what it re-adds is +recorded as asking for nothing rather than counted as a fix that landed. +""" + +from __future__ import annotations + +import json +import logging +import re +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import Any + +from rdflib import RDF, RDFS, SKOS, BNode, Graph, URIRef +from rdflib.namespace import DCTERMS +from rdflib.term import Node + +from ontocast.onto.model import TripleFix +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.onto.sparql_models import GraphUpdate, TripleOp +from ontocast.onto.triple_index import Triple, TripleIndex + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class FixPatch: + """One kept fix as its own delete-then-insert patch. + + The loop applies these one at a time and judges each on its own, so a fix + that leaves the unit worse is undone alone instead of taking the whole + pass with it -- which is how a critique used to lose its good corrections + to one bad one. + """ + + fix: TripleFix + update: GraphUpdate + deletes: int = 0 + inserts: int = 0 + + @property + def insert_triples(self) -> list[Triple]: + """The statements this patch declares it writes. + + Not the same as what applying it adds to the graph. A REPLACE + re-states the whole corrected node, so the parts that were already + right are written as no-ops and the graph diff shows only the delete. + The rollback test has to judge "wrote nothing" against what the fix + meant to write, or it undoes corrections whose replacement the graph + already held. + """ + return [ + triple + for operation in self.update.triple_operations + if operation.type == "insert" + for triple in operation.graph + ] + + +@dataclass(frozen=True) +class CompiledFixes: + """The mechanical half of a critique, split from the half needing a render.""" + + #: Delete-then-insert patch over every fix in ``applied``, or ``None`` + #: when nothing compiled. The whole-pass view; ``patches`` is the same + #: content one fix at a time. + update: GraphUpdate | None = None + #: One patch per fix in ``applied``, in the order proposed. + patches: list[FixPatch] = field(default_factory=list) + #: Fixes folded into ``update``. + applied: list[TripleFix] = field(default_factory=list) + #: Fixes that need a scoped repair render. + residual: list[TripleFix] = field(default_factory=list) + #: Fixes whose delete set and insert set are the same statements. They ask + #: for no change, so they are neither applied nor sent back as work -- but + #: they are counted, because a critique made mostly of these is a critic + #: producing motion rather than corrections, and nothing used to see it. + noop: list[TripleFix] = field(default_factory=list) + #: Ids cited that the index never issued. Counted, never guessed at. + bad_index_refs: int = 0 + #: True when the delete-share cap fired: every fix that removes something + #: went back as residual and only the pure additions were kept. + delete_capped: bool = False + #: Delete halves withheld by screening, in fixes. + deletes_refused: int = 0 + #: Fixes whose payload named a prefix nothing declares -- neither the + #: payload's own ``@context`` nor the unit graph's bindings -- so the + #: statement it meant could not be identified. Sent back as residual and + #: counted here rather than applied with ``prefix:local`` as the IRI. + unresolved_prefix: int = 0 + #: Fixes whose inserts would mint a placeholder: a subject named for an + #: ignored token or artifact, or a new node carrying only annotations + #: and no type. The critic's way of "resolving" a coverage finding for a + #: number it could not place; refused and sent back rather than applied. + junk_refused: int = 0 + #: Fixes whose payload typed a literal its lexical form cannot hold -- + #: the same defect the render path quarantines. Sent back as residual + #: rather than written into the unit graph, where every later + #: serialization and validation walk would re-derive the failed value. + quarantined_literal: int = 0 + + +@dataclass(frozen=True) +class CriticPatchPolicy: + """How much destruction one critic pass is allowed to do. + + A compiled patch is transparent -- both halves are known before anything is + touched -- so the limits are enforced by withholding the delete half rather + than by inspecting the wreckage afterwards. Every rule here drops something + and counts it; none of them raises. + """ + + #: Largest share of the target graph one pass may remove. A critique that + #: wants to delete more than this has stopped correcting and started + #: rewriting, which is the operation the loop deliberately does not offer. + #: Over the cap the fixes that remove anything go back whole -- a REPLACE + #: stripped of its delete half is an ADD of the new value beside the old + #: one, a different edit from the one proposed -- and only pure additions + #: are kept. + max_delete_share: float = 0.25 + #: Deletions always permitted regardless of share. Without a floor the cap + #: is strictest exactly where it should be loosest: on a short unit a single + #: legitimate correction is already a large fraction of the graph. + min_deletes: int = 5 + #: Whether a REPLACE may delete statements about one subject while writing + #: about another. That is a rename, not a correction, and it leaves the new + #: subject untyped and unlabelled while orphaning the old one. + allow_subject_rename: bool = False + + +def _bindings_for_payload(graph: Graph) -> dict[str, str]: + """Prefix bindings for parsing a critic fix: catalog map under the unit graph. + + The unit graph's binding wins for any prefix both declare — a graph that + already uses ``qqval:`` is the authority for what that prefix means under + repair. Catalog-only prefixes (installed for the unit loop via + :meth:`RDFGraph.set_known_prefixes`) fill in so a fix can introduce a term + the unit has not used yet, rather than expanding it against nothing. + """ + catalog = RDFGraph.get_known_prefixes() or {} + unit = {prefix: str(uri) for prefix, uri in graph.namespaces() if prefix} + return {**catalog, **unit} + + +def _prefix_header(graph: Graph) -> str: + """Turtle ``@prefix`` lines for the bindings a critic fix may use. + + Critic fixes are written as fragments in the prompt's vocabulary and + usually omit their prefix declarations, so a bare fragment will not parse + on its own. The unit graph's bindings cover the common case; the catalog + map fills prefixes a fix introduces for the first time. + """ + lines = [ + f"@prefix {prefix}: <{uri}> ." + for prefix, uri in sorted(_bindings_for_payload(graph).items()) + ] + return "\n".join(lines) + "\n" if lines else "" + + +#: A Turtle prefix declaration, in either syntax rdflib accepts. +_TURTLE_PREFIX_DECL = re.compile( + r"^[ \t]*(?:@prefix|(?i:PREFIX))[ \t]+([A-Za-z][\w.-]*)?:[ \t]*<([^>]*)>[ \t]*\.?[ \t]*$", + re.M, +) + + +def _drop_conflicting_prefix_declarations(body: str, graph: Graph) -> str: + """Remove ``@prefix`` lines that rebind a prefix already bound for the unit. + + The Turtle counterpart of the JSON-LD ``@context`` reconciliation in + :func:`_with_unit_bindings`, and it exists for the same reason: the header + from :func:`_prefix_header` is *prepended*, and in Turtle a later + declaration wins, so a payload that re-declares ``qqval:`` to a namespace + it half-remembered silently takes every term under that prefix out of the + catalog. Dropping the line leaves the header's binding in force. + """ + bindings = _bindings_for_payload(graph) + + def replace(match: re.Match) -> str: + prefix, declared = match.group(1) or "", match.group(2) + bound = bindings.get(prefix) + if bound is None or bound == declared: + return match.group(0) + logger.warning( + "Critic Turtle payload re-declares prefix %r as <%s>; the unit " + "graph binds it to <%s> -- dropping the re-declaration", + prefix, + declared, + bound, + ) + return "" + + return _TURTLE_PREFIX_DECL.sub(replace, body) + + +#: A JSON-LD term object that turned up inside an otherwise-Turtle fragment. +_JSONLD_VALUE = re.compile( + r'\{\s*"@value"\s*:\s*("(?:[^"\\]|\\.)*")\s*' + r'(?:,\s*"@(?Ptype|language)"\s*:\s*"(?P[^"]+)"\s*)?\}' +) +_JSONLD_ID = re.compile(r'\{\s*"@id"\s*:\s*"([^"]+)"\s*\}') +#: An absolute IRI written without its angle brackets. The lookbehind keeps it +#: off IRIs that are already bracketed or sitting inside a quoted literal. +_BARE_IRI = re.compile(r"""(?"'\[\]{}]+)""") + + +def _turtleize(body: str) -> str: + """Rewrite the JSON-LD shapes models mix into a Turtle fragment. + + The deployment's ``llm_graph_format`` names one syntax for fix payloads, and + the model does not reliably use it -- under a JSON-LD deployment roughly two + in five authored payloads come back as Turtle, and the ones that fail are + usually a Turtle body with a JSON-LD term object where the object should be + (``rdfs:label {"@value": "x", "@language": "en"}``). Neither parser accepts + that hybrid, so it used to be discarded whole. + + Nothing here guesses at meaning: ``@value``/``@language``/``@type`` map onto + the Turtle literal forms that mean the same thing, ``@id`` unwraps to the + IRI it names, and a bare absolute IRI gets the angle brackets Turtle + requires. A payload that is still not a statement afterwards stays + unparseable. + """ + + def value(match: re.Match) -> str: + literal, kind, tag = match.group(1), match.group("kind"), match.group("tag") + if kind == "language": + return f"{literal}@{tag}" + if kind == "type": + return f"{literal}^^{tag}" + return literal + + body = _JSONLD_VALUE.sub(value, body) + body = _JSONLD_ID.sub(lambda match: match.group(1), body) + return _BARE_IRI.sub(lambda match: "<" + match.group(1).rstrip(".,;") + ">", body) + + +def _terminator_candidates(body: str) -> list[str]: + """A fragment lifted out of a Turtle document, with its ending repaired. + + Such a fragment often keeps the trailing predicate-list separator it was cut + before, or loses its terminator entirely. + """ + candidates = [body] + if body.endswith((";", ",")): + candidates.append(body[:-1] + " .") + if not body.endswith("."): + candidates.append(body + " .") + return candidates + + +#: ``prefix:`` head of a compact IRI. ``(?!//)`` keeps ``http://`` and +#: ``https://`` out, the same way the prompt serializer draws the line. +_COMPACT_HEAD = re.compile(r"^([A-Za-z][A-Za-z0-9_.-]*):(?!//)") +#: Schemes that are legitimately written without ``//``. Anything else in the +#: ``scheme:rest`` shape after parsing is a prefix that was never expanded. +_OPAQUE_SCHEMES = ("urn:", "mailto:", "tag:", "doi:", "did:", "tel:", "data:") +_SCHEME_WITH_AUTHORITY = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://") + + +def _compact_heads(value: object, found: set[str]) -> None: + """Collect prefix heads from every position a compact IRI can occupy. + + Keys that are not keywords, and the ``@id`` / ``@type`` / ``@value`` + strings. ``@value`` is a literal and gains nothing from a binding, but a + model that wrote a CURIE there meant an IRI, and listing the prefix costs + nothing. + """ + if isinstance(value, dict): + for key, item in value.items(): + if key.startswith("@"): + if key in ("@id", "@type", "@value") and isinstance(item, str): + match = _COMPACT_HEAD.match(item) + if match: + found.add(match.group(1)) + elif key == "@type" and isinstance(item, list): + _compact_heads(item, found) + continue + match = _COMPACT_HEAD.match(key) + if match: + found.add(match.group(1)) + _compact_heads(item, found) + elif isinstance(value, list): + for item in value: + _compact_heads(item, found) + elif isinstance(value, str): + match = _COMPACT_HEAD.match(value) + if match: + found.add(match.group(1)) + + +def _declared_in_context(context: object) -> set[str]: + declared: set[str] = set() + if isinstance(context, dict): + declared.update(key for key in context if not key.startswith("@")) + elif isinstance(context, list): + for item in context: + declared |= _declared_in_context(item) + return declared + + +def _with_unit_bindings(body: str, graph: Graph) -> str | None: + """Reconcile a JSON-LD payload's ``@context`` with the unit's bindings. + + The mirror of :func:`_prefix_header` for the other syntax, and it does two + things. A JSON-LD fix written in the prompt's vocabulary usually arrives + with no ``@context``, and the JSON-LD processor then reads ``cd:sample_1`` + as an absolute IRI with scheme ``cd`` -- which parses, and is wrong; so + prefixes the payload uses and does not declare are filled in from the unit + graph, refined by the catalog map the unit loop installed. + + A prefix the payload *declares* to a namespace the unit already binds + elsewhere is **overridden**. The critic is re-transcribing a namespace it + was shown alongside the statements it is correcting, so a declaration that + disagrees with the graph under repair is a transcription error rather than + a second vocabulary -- and honouring it costs the whole fix: every term + under that prefix becomes an IRI no catalog declares, the payload trips the + unknown-term check, and the patch is rolled back carrying whatever it + proposed. A fix that adds a measurement and a fix that adds the + observation pointing at it are separate patches, so the rollback also + leaves a dangling reference behind. + + Returns: + The payload with bindings reconciled, or ``None`` when it is not JSON. + """ + try: + payload = json.loads(body) + except ValueError: + return None + used: set[str] = set() + _compact_heads(payload, used) + document: dict[str, Any] + if isinstance(payload, list): + document = {"@graph": payload} + elif isinstance(payload, dict): + document = payload + else: + return None + context = document.get("@context") + bindings = _bindings_for_payload(graph) + overridden = _override_conflicting_declarations(context, bindings) + missing = { + prefix: bindings[prefix] + for prefix in sorted(used - _declared_in_context(context)) + if prefix in bindings + } + if not missing and not overridden: + return body + if context is None: + document["@context"] = missing + elif isinstance(context, dict): + for prefix, namespace in missing.items(): + context.setdefault(prefix, namespace) + elif isinstance(context, list): + context.append(missing) + else: + document["@context"] = [context, missing] + return json.dumps(document) + + +def _override_conflicting_declarations( + context: object, bindings: dict[str, str] +) -> int: + """Rewrite declared prefixes that disagree with ``bindings``, in place. + + Only absolute-IRI declarations are touched: a context entry mapping a term + to a CURIE, or to a term-definition object, is not a prefix binding. + + Returns: + How many declarations were overridden. + """ + if isinstance(context, list): + return sum( + _override_conflicting_declarations(item, bindings) for item in context + ) + if not isinstance(context, dict): + return 0 + overridden = 0 + for prefix, declared in list(context.items()): + if prefix.startswith("@") or prefix not in bindings: + continue + if not isinstance(declared, str) or "://" not in declared: + continue + if declared == bindings[prefix]: + continue + logger.debug( + "Critic payload declares prefix %r as <%s>; the unit graph binds " + "it to <%s> -- overriding the payload", + prefix, + declared, + bindings[prefix], + ) + context[prefix] = bindings[prefix] + overridden += 1 + return overridden + + +def _is_absolute_iri(term: Node) -> bool: + if not isinstance(term, URIRef): + return True + text = str(term) + return bool(_SCHEME_WITH_AUTHORITY.match(text)) or text.lower().startswith( + _OPAQUE_SCHEMES + ) + + +def _has_unexpanded_terms(parsed: Graph) -> bool: + """Whether any subject, predicate or ``rdf:type`` object is still a CURIE. + + Checked after parsing in both syntaxes: a JSON-LD processor accepts + ``prefix:local`` as an IRI, and Turtle accepts ````. Either + way the statement names nothing in the graph, and a patch built from it + would insert a ghost or delete nothing while reporting success. + """ + for subject, predicate, obj in parsed: + if not _is_absolute_iri(subject) or not _is_absolute_iri(predicate): + return True + if predicate == RDF.type and not _is_absolute_iri(obj): + return True + return False + + +@dataclass(frozen=True) +class _ParsedPayload: + """A payload's triples, or the reason there are none.""" + + graph: Graph | None = None + #: The payload parsed but named a prefix nothing declared. + unresolved_prefix: bool = False + #: The payload parsed but typed a literal its lexical form cannot hold. + quarantined_literal: bool = False + + +def _has_quarantined_literal(parsed: Graph) -> bool: + """Whether any object literal fails XSD validation for its own datatype. + + The same test the render path applies to a fresh graph, applied to a fix + payload. A ``"10^-15"^^xsd:decimal`` parses, so nothing stops it entering + the unit graph -- where every later serialization, SPARQL compile and + validation walk re-derives its value and rdflib logs the conversion + failure again. Refusing the fix keeps the bad literal out of the graph + instead of carrying it for the rest of the unit's life. + """ + checked = RDFGraph() + checked += parsed + _, rejected = RDFGraph.partition_invalid_typed_literals(checked) + return bool(rejected) + + +def _parse_payload(text: str | None, graph: Graph) -> _ParsedPayload: + """Parse one fix payload, tolerating what models actually emit. + + Deliberately format-tolerant rather than format-dispatched: the payload's + syntax is whatever the model chose, not what the deployment asked for. + + Args: + text: ``correct_value`` (or a legacy ``incorrect_value``) as written. + graph: The unit graph, for prefix bindings. + + Returns: + The parsed triples, or why there are none: an empty result for an + unparseable or statement-less payload, ``unresolved_prefix`` for one + whose terms could not be expanded to IRIs, ``quarantined_literal`` + for one typing a literal its lexical form cannot hold. + """ + body = (text or "").strip() + if not body: + return _ParsedPayload() + if body[0] in "{[": + bound = _with_unit_bindings(body, graph) + if bound is None: + return _ParsedPayload() + try: + parsed = Graph() + parsed.parse(data=bound, format="json-ld") + except Exception: + return _ParsedPayload() + # A JSON body that yields no statement -- a bare literal, or a node + # reference with no predicates -- is not a patch, whatever it meant. + if not len(parsed): + return _ParsedPayload() + if _has_unexpanded_terms(parsed): + return _ParsedPayload(unresolved_prefix=True) + if _has_quarantined_literal(parsed): + return _ParsedPayload(quarantined_literal=True) + return _ParsedPayload(graph=parsed) + + header = _prefix_header(graph) + body = _drop_conflicting_prefix_declarations(body, graph) + for candidate in [ + *_terminator_candidates(body), + *_terminator_candidates(_turtleize(body)), + ]: + try: + parsed = Graph() + parsed.parse(data=header + candidate, format="turtle") + except Exception: + continue + if len(parsed): + if _has_unexpanded_terms(parsed): + return _ParsedPayload(unresolved_prefix=True) + if _has_quarantined_literal(parsed): + return _ParsedPayload(quarantined_literal=True) + return _ParsedPayload(graph=parsed) + return _ParsedPayload() + + +def _parse_fragment(text: str | None, graph: Graph) -> Graph | None: + """The triples of one fix payload, or ``None``; see :func:`_parse_payload`.""" + return _parse_payload(text, graph).graph + + +def _deletes_by_id( + fix: TripleFix, graph: RDFGraph, index: TripleIndex +) -> tuple[list[Triple] | None, int]: + """Resolve a fix's cited ids against the index it was shown. + + Returns ``(None, bad)`` when any cited id is unknown: a critique that names + a statement the index never issued has lost track of the graph, and the + honest response is to send the whole fix back rather than act on the part + that happened to resolve. + + Ids are looked up in the index object handed to this call, which maps ids to + the *terms* they stood for -- so a later renumbering cannot silently + redirect them. What can still go stale is the graph, hence the membership + check: a statement already gone is simply not deleted again. + """ + resolved: list[Triple] = [] + bad = 0 + for triple_id in fix.triple_ids: + triple = index.resolve(triple_id) + if triple is None: + bad += 1 + continue + if triple in graph: + resolved.append(triple) + if bad: + return None, bad + return resolved, 0 + + +#: Local names a critic gives the placeholder it mints for a number it could +#: not place: ``ignored_token_3``, ``typography_artifact_2``, ``token_12``. +_JUNK_LOCAL_NAME = re.compile( + r"(ignored|typograph|artifact|placeholder|_token\b|token_)", re.IGNORECASE +) +_ANNOTATION_PREDICATES = frozenset({RDFS.label, RDFS.comment, DCTERMS.description}) +_SKOS_NAMESPACE = str(SKOS) + + +def _local_name(iri: str) -> str: + for separator in ("#", "/"): + head, sep, local = iri.rpartition(separator) + if sep and local: + return local + return iri + + +def _is_annotation(predicate: Node) -> bool: + return predicate in _ANNOTATION_PREDICATES or str(predicate).startswith( + _SKOS_NAMESPACE + ) + + +def _junk_reason(inserts: list[Triple], graph: Graph) -> str | None: + """Why the inserted statements mint a placeholder rather than a fact. + + Two shapes, both learned from what a critic writes when a coverage + finding names a number it cannot place: a subject whose local name says + it is an ignored token or artifact, and a *new* subject (nothing in the + graph says anything about it) carrying only labels, comments or notes and + no ``rdf:type``. Annotations added to a subject the graph already + describes are a legitimate correction and pass. + """ + subjects = {subject for subject, _, _ in inserts if isinstance(subject, URIRef)} + for subject in subjects: + if _JUNK_LOCAL_NAME.search(_local_name(str(subject))): + return "placeholder_name" + for subject in subjects: + if (subject, None, None) in graph: + continue + predicates = {predicate for s, predicate, _ in inserts if s == subject} + if RDF.type in predicates: + continue + if predicates and all(_is_annotation(p) for p in predicates): + return "annotation_only" + return None + + +@dataclass +class _Candidate: + """One fix reduced to the statements it would remove and add.""" + + fix: TripleFix + deletes: list[Triple] = field(default_factory=list) + inserts: list[Triple] = field(default_factory=list) + + +def _bnode_closure(deletes: list[Triple], graph: RDFGraph) -> list[Triple]: + """Every statement about a blank node the fix touches. + + Deleting part of a blank node leaves the stub the ontology validator flags + as a degenerate restriction: an ``owl:Restriction`` that constrains nothing, + still hanging off its ``subClassOf`` edge. A blank node is one object, so it + goes whole or not at all. + """ + closure = list(deletes) + seen = {triple for triple in deletes} + for _, _, obj in list(deletes): + if not isinstance(obj, BNode): + continue + for triple in graph.triples((obj, None, None)): + if triple not in seen: + seen.add(triple) + closure.append(triple) + for subject in {s for s, _, _ in deletes if isinstance(s, BNode)}: + for triple in graph.triples((subject, None, None)): + if triple not in seen: + seen.add(triple) + closure.append(triple) + for triple in graph.triples((None, None, subject)): + if triple not in seen: + seen.add(triple) + closure.append(triple) + return closure + + +def _subject_survives( + subject: Node, + graph: RDFGraph, + deletes: list[Triple], + inserted_subjects: set[Node], +) -> bool: + """Whether anything is still said about ``subject`` after the pass.""" + if isinstance(subject, BNode): + # A blank node has no identity of its own to orphan -- it exists only as + # the object of the statement that references it, and the closure rule + # above removes that edge too. Emptying one *is* deleting it, which is + # the intended outcome, not the accident this check guards against. + return True + if subject in inserted_subjects: + return True + remaining = [ + triple + for triple in graph.triples((subject, None, None)) + if triple not in deletes + ] + return bool(remaining) + + +def _screen( + candidates: list[_Candidate], + graph: RDFGraph, + policy: CriticPatchPolicy, +) -> tuple[list[_Candidate], list[TripleFix], int, bool]: + """Withhold the delete halves the policy does not allow. + + Returns the surviving candidates, the fixes pushed back to residual, how + many delete halves were refused, and whether the share cap fired. + """ + refused = 0 + residual: list[TripleFix] = [] + inserted_subjects = { + subject for candidate in candidates for subject, _, _ in candidate.inserts + } + + kept: list[_Candidate] = [] + for candidate in candidates: + if not candidate.deletes: + kept.append(candidate) + continue + + candidate.deletes = _bnode_closure(candidate.deletes, graph) + delete_subjects = {subject for subject, _, _ in candidate.deletes} + + if candidate.fix.action == "REPLACE" and not policy.allow_subject_rename: + insert_subjects = {subject for subject, _, _ in candidate.inserts} + if not delete_subjects <= insert_subjects: + # Writing about a different subject than it removes: the fix's + # real intent is to mint a new node, which a delete/insert pair + # cannot express without leaving the new one bare. + candidate.deletes = [] + refused += 1 + if not candidate.inserts: + residual.append(candidate.fix) + continue + + if candidate.fix.action == "REMOVE": + orphaned = [ + subject + for subject in delete_subjects + if not _subject_survives( + subject, graph, candidate.deletes, inserted_subjects + ) + ] + if orphaned: + # Removing the last statement about a node deletes the node. + # The repair contract is to correct a statement in place, so a + # fix that empties a subject is asking for something else and + # goes back for judgement rather than being carried out. + candidate.deletes = [] + refused += 1 + residual.append(candidate.fix) + continue + + kept.append(candidate) + + total_deletes = {triple for candidate in kept for triple in candidate.deletes} + capped = False + allowance = max(policy.min_deletes, policy.max_delete_share * len(graph)) + if len(total_deletes) > allowance: + # Over the cap every fix that removes something goes back whole. The + # previous rule kept the insert halves, which turned a REPLACE into an + # ADD: the new value landed beside the old one instead of in its + # place, and a REMOVE simply vanished. Pure additions are still worth + # having -- they add rather than destroy -- so those stay. + capped = True + survivors: list[_Candidate] = [] + for candidate in kept: + if candidate.deletes: + residual.append(candidate.fix) + else: + survivors.append(candidate) + kept = survivors + + return kept, residual, refused, capped + + +def compile_critic_fixes( + fixes: Sequence[TripleFix], + graph: RDFGraph, + *, + index: TripleIndex | None = None, + policy: CriticPatchPolicy | None = None, +) -> CompiledFixes: + """Split a critique into a mechanical patch and the fixes needing a render. + + Args: + fixes: Fixes from the critique report, in the order proposed. + graph: The rendered unit graph the fixes refer to. + index: The ids handed to the critic for this graph. When present, a fix + that cites ids is resolved by lookup; the requoting path is the + fallback for a fix that cites none. + policy: Limits on what the patch may destroy. ``None`` uses the + defaults, which are the facts-side ones. + + Returns: + CompiledFixes: ``update`` is delete-then-insert over the fixes in + ``applied``; ``residual`` holds what needs judgement, ``noop`` what asks + for nothing. + """ + active = policy if policy is not None else CriticPatchPolicy() + candidates: list[_Candidate] = [] + residual: list[TripleFix] = [] + noop: list[TripleFix] = [] + bad_index_refs = 0 + unresolved_prefix = 0 + junk_refused = 0 + quarantined_literal = 0 + + for fix in fixes: + parsed_correct = _parse_payload(fix.correct_value, graph) + correct = parsed_correct.graph + # A payload that named an unexpandable prefix is counted once per fix, + # and only when the fix needed that payload: a REMOVE cited by id is + # carried out whatever its unused correct_value says. + unresolved = parsed_correct.unresolved_prefix and fix.action != "REMOVE" + # A payload holding an invalid typed literal yields no graph, so an + # ADD or REPLACE built on it has nothing to insert and falls through + # to residual below. Counted here, once, where the reason is still + # known -- and, as with an unresolved prefix, only when the fix needed + # the payload at all. + if parsed_correct.quarantined_literal and fix.action != "REMOVE": + logger.info( + "Critic fix refused (quarantined_literal): %s", fix.correct_value + ) + quarantined_literal += 1 + insert_triples = ( + [triple for triple in correct if triple not in graph] if correct else [] + ) + if fix.action in ("ADD", "REPLACE") and insert_triples: + junk = _junk_reason(insert_triples, graph) + if junk is not None: + # Whole fix back, delete half included: carrying out the + # removal alone would be the delete-only edit the rollback + # exists to undo. + logger.info("Critic fix refused (%s): %s", junk, fix.correct_value) + junk_refused += 1 + residual.append(fix) + continue + + matched: list[Triple] = [] + if fix.action in ("REMOVE", "REPLACE"): + if index is not None and fix.triple_ids: + by_id, bad = _deletes_by_id(fix, graph, index) + bad_index_refs += bad + if by_id is None: + unresolved_prefix += int(unresolved) + residual.append(fix) + continue + matched = by_id + else: + # Fallback for a fix that cites no id. The quoted statements must + # all be present: a misquote has misunderstood the graph, and + # acting on it would delete something the critic never looked at. + parsed_incorrect = _parse_payload(fix.incorrect_value, graph) + incorrect = parsed_incorrect.graph + unresolved = unresolved or parsed_incorrect.unresolved_prefix + quoted = ( + [triple for triple in incorrect if triple in graph] + if incorrect + else [] + ) + if not quoted or len(quoted) != len(incorrect or []): + unresolved_prefix += int(unresolved) + residual.append(fix) + continue + matched = quoted + + # A fix that removes exactly what it re-adds changes nothing. Left in + # `residual` it would ask the next pass to redo nothing; applied, it + # would inflate the count of fixes that "landed". + if matched and set(matched) == set(correct or []): + noop.append(fix) + continue + + if fix.action == "REMOVE": + if not matched: + residual.append(fix) + continue + candidates.append(_Candidate(fix=fix, deletes=matched)) + elif fix.action == "ADD": + if not insert_triples: + unresolved_prefix += int(unresolved) + residual.append(fix) + continue + candidates.append(_Candidate(fix=fix, inserts=insert_triples)) + elif fix.action == "REPLACE": + if not matched or not correct: + unresolved_prefix += int(unresolved) + residual.append(fix) + continue + candidates.append( + _Candidate(fix=fix, deletes=matched, inserts=list(correct)) + ) + else: + residual.append(fix) + + kept, screened_out, refused, capped = _screen(candidates, graph, active) + residual.extend(screened_out) + + deletes = RDFGraph() + inserts = RDFGraph() + applied: list[TripleFix] = [] + patches: list[FixPatch] = [] + for candidate in kept: + if not candidate.deletes and not candidate.inserts: + residual.append(candidate.fix) + continue + fix_deletes = RDFGraph() + fix_inserts = RDFGraph() + for triple in candidate.deletes: + fix_deletes.add(triple) + for triple in candidate.inserts: + fix_inserts.add(triple) + # Delete-then-insert leaves a triple on both sides present either way, + # so dropping it from the delete side changes nothing and keeps the + # patch honest about what it removes. + for triple in fix_inserts: + fix_deletes.remove(triple) + operations: list[TripleOp] = [] + if len(fix_deletes): + operations.append(TripleOp(type="delete", graph=fix_deletes)) + if len(fix_inserts): + operations.append(TripleOp(type="insert", graph=fix_inserts)) + if not operations: + noop.append(candidate.fix) + continue + patches.append( + FixPatch( + fix=candidate.fix, + update=GraphUpdate(triple_operations=operations), + deletes=len(fix_deletes), + inserts=len(fix_inserts), + ) + ) + applied.append(candidate.fix) + for triple in fix_deletes: + deletes.add(triple) + for triple in fix_inserts: + inserts.add(triple) + + for triple in inserts: + deletes.remove(triple) + + if not patches: + return CompiledFixes( + residual=residual, + noop=noop, + bad_index_refs=bad_index_refs, + delete_capped=capped, + deletes_refused=refused, + unresolved_prefix=unresolved_prefix, + junk_refused=junk_refused, + quarantined_literal=quarantined_literal, + ) + + combined: list[TripleOp] = [] + if len(deletes): + combined.append(TripleOp(type="delete", graph=deletes)) + if len(inserts): + combined.append(TripleOp(type="insert", graph=inserts)) + logger.info( + "Critic fixes: %d compiled to patches (-%d/+%d triples), " + "%d need a render, %d asked for no change, %d delete half(s) refused, " + "%d refused as placeholders, %d with an unresolved prefix, " + "%d with a quarantined literal%s", + len(applied), + len(deletes), + len(inserts), + len(residual), + len(noop), + refused, + junk_refused, + unresolved_prefix, + quarantined_literal, + " (delete-share cap fired)" if capped else "", + ) + return CompiledFixes( + update=GraphUpdate(triple_operations=combined), + patches=patches, + applied=applied, + residual=residual, + noop=noop, + bad_index_refs=bad_index_refs, + delete_capped=capped, + deletes_refused=refused, + unresolved_prefix=unresolved_prefix, + junk_refused=junk_refused, + quarantined_literal=quarantined_literal, + ) + + +def apply_compiled_patch(graph: RDFGraph, update: GraphUpdate) -> None: + """Apply a compiled patch to ``graph`` in place, deletes before inserts. + + The ordering is the one :meth:`GraphUpdateRenderReport.to_graph_update` + fixes, and the operations are the same ``TripleOp``s a render produces -- + this is the render's apply step over an in-memory graph, not a second way + to mutate one. + + Args: + graph: The unit graph to patch. + update: The compiled patch. + """ + for operation in update.triple_operations: + if operation.type == "delete": + for triple in operation.graph: + graph.remove(triple) + else: + for triple in operation.graph: + graph.add(triple) diff --git a/ontocast/tool/facts_validation/gate.py b/ontocast/tool/facts_validation/gate.py index 8b1ed4b3..1e14c2a3 100644 --- a/ontocast/tool/facts_validation/gate.py +++ b/ontocast/tool/facts_validation/gate.py @@ -12,9 +12,9 @@ from pydantic import BaseModel, Field from rdflib import OWL, RDF, RDFS, Literal, URIRef -from rdflib.namespace import PROV, XSD +from rdflib.namespace import PROV, SH, XSD -from ontocast.onto.constants import PROVENANCE_METADATA_TERMS +from ontocast.onto.constants import DOCUMENT_METADATA_TERMS, PROVENANCE_METADATA_TERMS from ontocast.onto.enum import RetrievalMetric from ontocast.onto.model import ( FactsValidationFinding, @@ -78,11 +78,58 @@ def error_findings(self) -> list[FactsValidationFinding]: return [finding for finding in self.findings if finding.severity == "error"] +def count_shacl_focus_nodes( + data_graph: RDFGraph, shapes_graph: RDFGraph | None +) -> int | None: + """Count data-graph nodes any shape actually targets. + + This is the denominator ``conforms`` is silent about. A validation run over + zero focus nodes reports no violations for the same reason an empty query + returns no rows -- nothing was examined. Reported alongside ``conforms`` so + a clean result cannot be read as a passing one when the shapes and the data + never met. + + Class targeting follows ``rdfs:subClassOf`` because SHACL's + ``sh:targetClass`` is subclass-aware. + + Args: + data_graph: The graph that was validated. + shapes_graph: The shapes it was validated against; ``None`` when SHACL + did not run. + + Returns: + int | None: Number of distinct focus nodes, or ``None`` if no shapes. + """ + if shapes_graph is None or not len(shapes_graph): + return None + focus: set = set() + for target_class in shapes_graph.objects(None, SH.targetClass): + classes = {target_class} + # Walk down the hierarchy: a shape on a superclass targets instances + # of every subclass too. + pending = [target_class] + while pending: + current = pending.pop() + for sub in data_graph.subjects(RDFS.subClassOf, current): + if sub not in classes: + classes.add(sub) + pending.append(sub) + for klass in classes: + focus.update(data_graph.subjects(RDF.type, klass)) + focus.update(shapes_graph.objects(None, SH.targetNode)) + for predicate in shapes_graph.objects(None, SH.targetSubjectsOf): + focus.update(data_graph.subjects(predicate, None)) + for predicate in shapes_graph.objects(None, SH.targetObjectsOf): + focus.update(data_graph.objects(None, predicate)) + return len(focus) + + def summarize_conformance( findings: Sequence[FactsValidationFinding], *, shacl_evaluated: bool | None = None, repairs: Sequence[GraphRepairRecord] = (), + focus_nodes: int | None = None, ) -> dict: """Roll findings up into the shape a report or a client can read. @@ -95,11 +142,16 @@ def summarize_conformance( shacl_evaluated: Whether SHACL actually ran (see :class:`FactsValidationReport`). repairs: LLM-free repairs the gate applied. + focus_nodes: Data-graph nodes the shapes actually target, from + :func:`count_shacl_focus_nodes`. Zero means the shapes examined + nothing, so a violation-free result says nothing about the data. Returns: - ``conforms`` (None when SHACL did not run), counts by severity, by - finding kind, by SHACL constraint component and shape, and the applied - repair counts by kind. + ``conforms`` (None when SHACL did not run **or** when it ran over an + empty focus set), counts by severity, by finding kind, by SHACL + constraint component and shape, and the applied repair counts by kind. + ``shacl_vacuous`` flags the empty-focus-set case, which is a + measurement failure rather than a passing grade. """ shacl_findings = [ finding @@ -121,9 +173,12 @@ def summarize_conformance( for record in repairs: repairs_by_kind[str(record.kind)] = repairs_by_kind.get(str(record.kind), 0) + 1 + vacuous = bool(shacl_evaluated) and focus_nodes == 0 return { "shacl_evaluated": shacl_evaluated, - "conforms": None if not shacl_evaluated else not shacl_findings, + "conforms": (None if not shacl_evaluated or vacuous else not shacl_findings), + "shacl_focus_nodes": focus_nodes, + "shacl_vacuous": vacuous, "findings": len(findings), "errors": sum(1 for finding in findings if finding.severity == "error"), "warnings": sum(1 for finding in findings if finding.severity == "warning"), @@ -301,14 +356,18 @@ def _non_catalog_vocabulary_findings( continue if term.startswith(_SCAFFOLDING_NAMESPACES): continue - # Chunk-metadata terms the pipeline mints itself. The prov: guard above - # only covers predicates, so schema:position, schema:identifier and the - # chunk node's own prov:Entity / schema:Text types were reported as + # Chunk- and document-metadata terms the pipeline mints itself. The + # ``prov:`` guard above only covers predicates, so schema:position, + # schema:identifier, the chunk node's own types, and the document + # node's ``foaf:Document`` / ``dcterms:title`` were reported as # vocabulary the renderer improvised, on every run whose catalog does - # not happen to include prov and schema.org. No catalog will ever - # supply them: they are scaffolding, like rdfs:label. + # not happen to include them. No catalog will ever supply them: they + # are scaffolding, like rdfs:label. Both sets are read from the same + # modules that emit them so the exemption cannot go stale. if URIRef(term) in PROVENANCE_METADATA_TERMS: continue + if URIRef(term) in DOCUMENT_METADATA_TERMS: + continue if any(term.startswith(ns) for ns in namespaces): continue subjects = sorted(used[term]) @@ -390,6 +449,7 @@ def validate_aggregated_facts( shacl_advanced: bool = True, shacl_max_triples: int = 0, key_supported_subjects: Sequence[str] | None = None, + cross_unit_pairs: Sequence[tuple[str, str]] | None = None, ) -> FactsValidationReport: """Check post-merge invariants over the aggregated facts graph. @@ -410,7 +470,9 @@ def validate_aggregated_facts( majority (distinct names collapsed into one node); or >= 2 IRI objects on a predicate that is single-valued for a dominant majority of other subjects. Severity is configurable — legitimate - multi-value modeling exists, bad merges are far more common. + multi-value modeling exists, bad merges are far more common. The + IRI branch additionally accepts ``cross_unit_pairs``, which + separates the two by provenance rather than by frequency. - ``DEGENERATE_COREFERENCE``: one IRI object shared by >= 2 distinct functional-ish predicates of one subject (collapsed range bounds). - ``SHACL``: optional, when ``pyshacl`` is installed and shapes exist. @@ -433,6 +495,13 @@ def validate_aggregated_facts( shacl_inference: pyshacl pre-inference mode (see :func:`run_shacl`). shacl_advanced: Enable SHACL Advanced Features. shacl_max_triples: Skip SHACL above this graph size; 0 disables. + cross_unit_pairs: Canonical (subject, predicate) pairs whose IRI + objects came from more than one unit. When supplied, an + IRI-branch SUSPECT_MULTI_VALUE finding on a pair *not* listed + here is reported as a warning and never vetoes a cluster: a + single unit asserting two objects on one predicate is reading + one sentence, not the residue of a bad identity decision. + None disables the distinction. key_supported_subjects: Final URIs of merge clusters backed by natural-key evidence. Irreconcilable *string* values on these subjects are reported as warnings, not errors: "Application no. @@ -500,6 +569,9 @@ def validate_aggregated_facts( findings: list[FactsValidationFinding] = [] flagged_pairs: set[tuple[URIRef, URIRef]] = set() + cross_unit_object_keys = ( + set(cross_unit_pairs) if cross_unit_pairs is not None else None + ) for (subject, predicate), objects in sorted( object_groups.items(), key=lambda item: (str(item[0][0]), str(item[0][1])) @@ -598,13 +670,23 @@ def validate_aggregated_facts( and predicate not in functional and predicate in dominant_single ): - flagged_pairs.add((subject, predicate)) + # Frequency says this predicate is usually single-valued, which on + # its own is evidence of nothing: a genuinely multi-valued + # statement is rare by construction. Provenance is what separates + # the two cases -- only objects arriving from different units could + # have been brought together by an identity decision. + merge_created = ( + cross_unit_object_keys is None + or (str(subject), str(predicate)) in cross_unit_object_keys + ) + if merge_created: + flagged_pairs.add((subject, predicate)) findings.append( FactsValidationFinding( kind=FactsValidationFindingKind.SUSPECT_MULTI_VALUE, severity=( "error" - if suspect_multi_value_severity == "error" + if suspect_multi_value_severity == "error" and merge_created else "warning" ), message=( diff --git a/ontocast/tool/facts_validation/literal_repair.py b/ontocast/tool/facts_validation/literal_repair.py index 56e0674f..eccb9ac1 100644 --- a/ontocast/tool/facts_validation/literal_repair.py +++ b/ontocast/tool/facts_validation/literal_repair.py @@ -222,25 +222,99 @@ def repair_literal_type_objects( return rewritten, findings, applied +def _folded_name(local: str) -> str: + """A local name with case and separators removed, for spelling equality.""" + return re.sub(r"[_\-]", "", local).lower() + + +def _alias_qualifies(alias_local: str, candidate_local: str) -> bool: + """Whether a candidate is a near-miss of the alias rather than a look-alike. + + Token containment either way (``value`` in ``numericValue``, ``lowerBound`` + in ``hasLowerBound``), or the same spelling once case and separators are + folded (``numericvalue`` for ``numericValue``). A candidate that swaps one + token for another never qualifies, however close the two strings are: + that is a different term, not a misspelt one. + """ + alias_tokens = _name_tokens(alias_local) + candidate_tokens = _name_tokens(candidate_local) + if alias_tokens and ( + alias_tokens <= candidate_tokens or candidate_tokens <= alias_tokens + ): + return True + folded = _folded_name(alias_local) + return bool(folded) and folded == _folded_name(candidate_local) + + +def _select_alias_target( + alias: str, candidates: Sequence[str], min_ratio: float +) -> str | None: + """The one candidate ``alias`` may be rewritten to, or None. + + Exactly one qualifying candidate (see :func:`_alias_qualifies`) wins + outright. Among several, the ``SequenceMatcher`` ratio breaks the tie only + when the best-scoring candidate clears ``min_ratio`` and no other + candidate matches its score; the ratio never admits a candidate that did + not qualify on its own. + """ + alias_local = _local_name(alias) + qualifying = [ + candidate + for candidate in candidates + if _alias_qualifies(alias_local, _local_name(candidate)) + ] + if len(qualifying) == 1: + return qualifying[0] + if not qualifying: + return None + scored = sorted( + ( + ( + SequenceMatcher( + None, alias_local.lower(), _local_name(candidate).lower() + ).ratio(), + candidate, + ) + for candidate in qualifying + ), + key=lambda item: (-item[0], item[1]), + ) + best_ratio, best = scored[0] + if best_ratio < min_ratio or scored[1][0] == best_ratio: + return None + return best + + def repair_property_aliases( graph: RDFGraph, ontology_graph: RDFGraph | None, *, - min_ratio: float = 0.85, + min_ratio: float = 0.95, exempt_terms: set[str] | None = None, + full_catalog_terms: set[str] | None = None, ) -> tuple[int, list[FactsUnitFinding], list[GraphRepairRecord]]: """Rewrite near-miss predicates in catalog namespaces; report ambiguity. A predicate whose namespace belongs to the ontology context but which is not itself a catalog term is a near-miss (``qqval:lowerBound`` for - ``qqval:hasLowerBound``). When exactly one candidate scores above - ``min_ratio`` (token containment counts as 1.0) the rewrite is applied - deterministically; otherwise a mandatory finding carries the top - suggestions. + ``qqval:hasLowerBound``). The rewrite is deterministic and fires only when + exactly one candidate *qualifies*: its name tokens contain the alias's, + the alias's contain its, or the two spell the same once case and + separators are folded. Several qualifying candidates are a tie that the + ``SequenceMatcher`` ratio may break -- the best-scoring one wins when it + clears ``min_ratio`` and nothing ties it. Similarity alone never licenses + a rewrite: a high ratio also joins names that differ by exactly one token, + and rewriting across that gap substitutes one property for another. + Anything else becomes a mandatory finding carrying the top suggestions. Only namespaces the catalog *declares* terms in are eligible (see :func:`collect_declared_namespaces`); ``exempt_terms`` (expanded fallback - vocabulary) are never treated as near-misses. + vocabulary) are never treated as near-misses, and neither is a predicate + in ``full_catalog_terms``: a term the whole catalog declares is real even + when this unit's snapshot did not retrieve it, and the snapshot's + look-alike is then a different property, not the intended one. Such a + predicate is left untouched here; whether it is reported is the term + checks' business. Returns: Tuple of (number of rewritten triples, unresolved findings, @@ -251,6 +325,7 @@ def repair_property_aliases( return 0, [], [] declared_namespaces = collect_declared_namespaces(ontology_graph) exempt = exempt_terms or set() + known_elsewhere = full_catalog_terms or set() findings: list[FactsUnitFinding] = [] applied: list[GraphRepairRecord] = [] @@ -261,31 +336,16 @@ def repair_property_aliases( if isinstance(predicate, URIRef) and str(predicate) not in catalog_terms and str(predicate) not in exempt + and str(predicate) not in known_elsewhere and _namespace_of(str(predicate)) in declared_namespaces } for alias in sorted(predicates, key=str): candidates = _alias_candidates( alias, graph, catalog_terms, ontology_graph=ontology_graph ) - strong = [ - candidate - for candidate in candidates - if _name_tokens(_local_name(str(alias))) - and ( - _name_tokens(_local_name(str(alias))) - <= _name_tokens(_local_name(candidate)) - or _name_tokens(_local_name(candidate)) - <= _name_tokens(_local_name(str(alias))) - or SequenceMatcher( - None, - _local_name(str(alias)).lower(), - _local_name(candidate).lower(), - ).ratio() - >= min_ratio - ) - ] - if len(strong) == 1: - replacement = URIRef(strong[0]) + target = _select_alias_target(str(alias), candidates, min_ratio) + if target is not None: + replacement = URIRef(target) alias_triples = 0 for subject, predicate, obj in list(graph.triples((None, alias, None))): graph.remove((subject, predicate, obj)) diff --git a/ontocast/tool/facts_validation/shacl.py b/ontocast/tool/facts_validation/shacl.py index fea3d223..bd27a27a 100644 --- a/ontocast/tool/facts_validation/shacl.py +++ b/ontocast/tool/facts_validation/shacl.py @@ -8,7 +8,7 @@ from __future__ import annotations import logging -from collections.abc import Sequence +from collections.abc import Collection, Sequence from typing import Literal as TypingLiteral from pydantic import BaseModel, Field @@ -221,6 +221,7 @@ def shacl_catalog_contradictions( ontology_graph: RDFGraph | None, *, policy: ValidationPolicy | None = None, + catalog_terms: Collection[str] | None = None, ) -> list[str]: """Property paths the shapes require but the unit validator would flag. @@ -231,10 +232,24 @@ def shacl_catalog_contradictions( in practice, where shapes required ``qudt:numericValue`` while the validator's mandatory findings drove repair renders to delete it. Callers log the returned IRIs as configuration errors. + + Args: + shapes_graph: The shapes the gate validates against. + ontology_graph: The ontology context the facts were rendered against. + policy: The exemptions the unit validator applies. Pass the same + policy the unit loop uses -- including its shapes-contract + exemptions -- or the check reports contradictions the validator + never raises. + catalog_terms: The whole catalog's term inventory, when + ``ontology_graph`` is only a retrieved subset of it. The unit + validator judges unknown terms against the whole catalog, so a + term the snapshot omitted is not a contradiction. """ if shapes_graph is None or ontology_graph is None: return [] - catalog_terms = collect_catalog_terms(ontology_graph) + catalog_terms = set(collect_catalog_terms(ontology_graph)) | set( + catalog_terms or () + ) if not catalog_terms: return [] policy = policy or ValidationPolicy() @@ -478,6 +493,15 @@ def _shacl_repairs_for( ) ) + if pruned: + _cascade_empty_referrers( + graph, + pruned=pruned, + removals=removals, + records=records, + fact_namespaces=fact_namespaces, + ) + return _ShaclRepairPlan( removals=removals, additions=additions, @@ -487,6 +511,78 @@ def _shacl_repairs_for( ) +def _cascade_empty_referrers( + graph: RDFGraph, + *, + pruned: set[Node], + removals: list[tuple], + records: list[GraphRepairRecord], + fact_namespaces: Sequence[str], +) -> None: + """Extend a prune to referrers the prune itself empties. + + Pruning a placeholder value node removes the one triple that pointed at it, + and the subject left behind can be a placeholder in turn: an observation + with a type and a label, no feature of interest, no result. Stopping there + is the worst of both outcomes -- the evidence that an extraction was + attempted is gone, and the ``sh:minCount`` violation that fired on the + subject is still in the report, because the pass computed its violations + before it pruned anything. Following the prune up its referrers turns that + into a clean removal, and it uses the same test the direct prune does: a + node asserts nothing when what survives carries no predicate beyond + ``rdf:type`` / ``rdfs:label`` / ``skos:prefLabel``. + """ + frontier = set(pruned) + while frontier: + candidates = { + subject + for node in frontier + for subject, _ in graph.subject_predicates(node) + if subject not in pruned + } + frontier = set() + for candidate in candidates: + if not _violation_in_fact_scope(graph, candidate, fact_namespaces): + continue + surviving = [ + (predicate, obj) + for predicate, obj in graph.predicate_objects(candidate) + if obj not in pruned + ] + if any( + predicate not in _EMPTY_NODE_PREDICATES for predicate, _ in surviving + ): + continue + referrers = {subject for subject, _ in graph.subject_predicates(candidate)} + if len(referrers) > 1: + # Shared: removing it changes statements this pass never saw. + continue + pruned.add(candidate) + frontier.add(candidate) + # Only what this step adds: the edges from the candidate onto the + # nodes already being pruned are queued for removal as those + # nodes' incoming triples, and counting them twice would overstate + # what the cascade removed. + extra = [ + *graph.triples((None, None, candidate)), + *((candidate, predicate, obj) for predicate, obj in surviving), + ] + if not extra: + # Nothing of the candidate outlives the prune that reached it, + # so it leaves the graph either way; a record here would name a + # removal of nothing. + continue + removals.extend(extra) + records.append( + GraphRepairRecord( + kind=FactsGateRepairKind.SHACL_PRUNE, + source=str(candidate), + target="", + triple_count=len(extra), + ) + ) + + def _fact_scope_violations( graph: RDFGraph, violations: Sequence[ShaclViolation], diff --git a/ontocast/tool/facts_validation/terms.py b/ontocast/tool/facts_validation/terms.py index f25e72d9..df77d9c4 100644 --- a/ontocast/tool/facts_validation/terms.py +++ b/ontocast/tool/facts_validation/terms.py @@ -146,15 +146,35 @@ class ValidationPolicy(BaseModel): additional_standard_namespaces: tuple[str, ...] = () quantity_fallback_vocabulary: dict[str, str] | None = None code_predicates: tuple[str, ...] = () + #: IRIs the shapes-derived conformance chapter instructs the renderer to + #: emit. Same class of exemption as the fallback vocabulary: the prompt + #: recommends these terms, so flagging them UNKNOWN_TERM would have the + #: repair lane delete exactly what the prompt required. Threaded per unit + #: from the tenancy's shapes catalog. + contract_exempt_terms: tuple[str, ...] = () + #: Keep identifier digit groups out of the numeric-coverage inventory. + #: Same class as the exemptions above -- something configuration says the + #: deterministic checks must not put in front of the renderer. + numeric_identifier_guard: bool = False + #: Floor on the share of schema terms a render must take from the + #: catalog before DOMAIN_ADHERENCE fires. 0 disables the check. + domain_adherence_min_share: float = 0.15 + #: Fewest distinct schema terms a render must use before its catalog + #: share is judged at all. A share over a handful of terms is noise: a + #: front-matter unit typing an identifier and an author with generic + #: vocabulary is not a render that abandoned the catalog. + domain_adherence_min_terms: int = 4 def standard_namespaces(self) -> tuple[str, ...]: """Built-in meta-vocabulary namespaces plus the configured ones.""" return (*_STANDARD_NAMESPACES, *self.additional_standard_namespaces) def exempt_terms(self, *graphs: RDFGraph | None) -> set[str]: - """Exact IRIs configuration blessed: fallback vocabulary + code predicates.""" + """Exact IRIs configuration blessed: fallback vocabulary, code + predicates, and the shapes-contract terms.""" terms = expand_vocabulary_terms(self.quantity_fallback_vocabulary, *graphs) terms.update(self.code_predicates) + terms.update(self.contract_exempt_terms) return terms diff --git a/ontocast/tool/facts_validation/unit_findings.py b/ontocast/tool/facts_validation/unit_findings.py index 347eaea4..ccddcb6e 100644 --- a/ontocast/tool/facts_validation/unit_findings.py +++ b/ontocast/tool/facts_validation/unit_findings.py @@ -10,6 +10,7 @@ import logging import re from collections.abc import Sequence +from typing import Literal as TypingLiteral from rdflib import RDF, RDFS, SKOS, Literal, URIRef @@ -24,11 +25,13 @@ from ontocast.tool.agg.signatures import canonical_literal, harvest_max_one_predicates from ontocast.tool.facts_validation.terms import ( _FORBIDDEN_NAMESPACES, + _STANDARD_NAMESPACES, ValidationPolicy, _alias_candidates, _declared_domains, _described_classes, _local_name, + _name_tokens, _namespace_of, _resolve_type_literal, _superclass_closure, @@ -37,10 +40,78 @@ collect_declared_namespaces, expand_vocabulary_terms, ) -from ontocast.util.numeric_inventory import canonical_number, missing_numeric_mentions +from ontocast.util.measurement_lexicon import unit_adjacent_numbers +from ontocast.util.numeric_inventory import ( + NumericInventory, + canonical_number, + missing_numeric_inventory, + unit_surface_index, + unit_surfaces_in_ontology, + unit_symbol_index, +) logger = logging.getLogger(__name__) +CoverageMode = TypingLiteral["off", "measurements", "all"] + + +def coverage_mode(value: str | bool | None) -> CoverageMode: + """Normalise the coverage-mandatory knob, boolean form included. + + ``True`` is the setting's old spelling of ``all`` and ``False`` of + ``off``; anything unrecognised is ``off``, the advisory default. + """ + if isinstance(value, bool): + return "all" if value else "off" + if isinstance(value, str): + lowered = value.strip().lower() + if lowered == "measurements": + return "measurements" + if lowered in ("all", "true", "1", "yes", "on"): + return "all" + return "off" + + +def unit_numeric_inventory( + *, + graph: RDFGraph, + ontology_graph: RDFGraph | None, + extraction_text: str, + policy: ValidationPolicy | None = None, + limit: int = 30, +) -> NumericInventory: + """Numbers of the unit text absent from its graph, measurements first. + + The unit surfaces come from the unit individuals of the ontology context + (found through the configured unit-role property), so a catalog-specific + unit counts as a measurement once the catalog declares it. Measurements + are judged against structured ``(number, unit)`` pairs in the graph — + a bare numeric literal does not clear a unit-adjacent mention. Shared by + the coverage findings and the completion pass, which must agree on what + is missing. + """ + policy = policy or ValidationPolicy() + unit_properties = expand_vocabulary_terms( + _vocabulary_role_subset(policy.quantity_fallback_vocabulary, "unit"), + graph, + ontology_graph, + ) + numeric_value_properties = expand_vocabulary_terms( + _vocabulary_role_subset(policy.quantity_fallback_vocabulary, "numeric_value"), + graph, + ontology_graph, + ) + return missing_numeric_inventory( + extraction_text, + graph, + unit_surfaces=unit_surfaces_in_ontology(ontology_graph, unit_properties), + ontology_graph=ontology_graph, + numeric_value_properties=numeric_value_properties, + unit_properties=unit_properties, + ignore_identifier_fragments=policy.numeric_identifier_guard, + limit=limit, + ) + def domain_violation_findings( graph: RDFGraph, @@ -237,6 +308,119 @@ def _label_only_number_findings( ) +def _unit_symbol_case_findings( + graph: RDFGraph, + ontology_graph: RDFGraph | None, + extraction_text: str, + fact_namespaces: Sequence[str], + policy: ValidationPolicy, +) -> list[FactsUnitFinding]: + """Flag a unit whose declared symbol matches the text only by letter case. + + Symbols are case-significant: a catalog may hold two units whose symbols + differ by case alone, a prefix apart in magnitude. Retrieval and prompt + assembly are case-folded in places, so both units can be offered side by + side, and a render that picks the wrong one is a silent magnitude error + that no other check sees -- the value is numeric, the unit is a real + catalog individual, and the shapes conform. + + The check is fully generic: symbols come from the catalog's own code, + symbol and notation literals (:func:`unit_symbol_index`), the text side + from the unit-adjacent numbers of the unit's own text. A node is flagged + only when its numeric value is written in the text next to a token that + equals one of the assigned unit's symbols after case folding and none of + them exactly, **and** a different catalog unit declares that token as a + surface exactly -- the competing unit is what makes the case difference + a magnitude error rather than a spelling variant, and it is offered as + the suggestion. Mandatory: the value is otherwise recorded a factor wrong. + """ + if not extraction_text: + return [] + unit_properties = expand_vocabulary_terms( + _vocabulary_role_subset(policy.quantity_fallback_vocabulary, "unit"), + graph, + ontology_graph, + ) + numeric_value_properties = expand_vocabulary_terms( + _vocabulary_role_subset(policy.quantity_fallback_vocabulary, "numeric_value"), + graph, + ontology_graph, + ) + if not unit_properties or not numeric_value_properties: + return [] + symbols = unit_symbol_index(ontology_graph, unit_properties) + if not symbols: + return [] + surfaces = unit_surface_index(ontology_graph, unit_properties) + tokens_by_value: dict[str, set[str]] = {} + for mention in unit_adjacent_numbers(extraction_text, frozenset(surfaces)): + canonical = canonical_number(mention.value) + if canonical is not None: + tokens_by_value.setdefault(canonical, set()).add(mention.unit) + if not tokens_by_value: + return [] + unit_predicates = {URIRef(iri) for iri in unit_properties} + value_predicates = {URIRef(iri) for iri in numeric_value_properties} + findings: list[FactsUnitFinding] = [] + flagged: set[URIRef] = set() + for subject, predicate, unit in graph: + if predicate not in unit_predicates or not isinstance(unit, URIRef): + continue + if not isinstance(subject, URIRef) or subject in flagged: + continue + if not any(str(subject).startswith(ns) for ns in fact_namespaces): + continue + declared = symbols.get(str(unit)) + if not declared: + continue + folded = {symbol.lower() for symbol in declared} + for value_predicate in value_predicates: + for obj in graph.objects(subject, value_predicate): + if not isinstance(obj, Literal): + continue + canonical = canonical_literal(obj) + if canonical is None or canonical[1] != "numeric": + continue + written = tokens_by_value.get(canonical[0], set()) + if any(token in declared for token in written): + continue + competing = [ + ( + token, + [iri for iri in surfaces.get(token, ()) if iri != str(unit)], + ) + for token in sorted(written) + if token.lower() in folded + ] + competing = [(token, iris) for token, iris in competing if iris] + if not competing: + continue + token, suggestions = competing[0] + findings.append( + FactsUnitFinding( + kind=FactsUnitFindingKind.UNIT_SYMBOL_CASE_MISMATCH, + message=( + f"<{subject}> records {canonical[0]} with unit " + f"<{unit}>, whose declared symbol " + f"({', '.join(sorted(declared))}) differs from the " + f"text's '{token}' only by letter case, and " + f"another catalog unit declares '{token}' exactly. " + "Unit symbols are case-sensitive: assign the unit " + "whose declared symbol matches the text exactly." + ), + subject=str(subject), + predicate=str(predicate), + value=token, + suggestions=suggestions, + ) + ) + flagged.add(subject) + break + if subject in flagged: + break + return findings + + def _scalar_as_bounds_findings( graph: RDFGraph, ontology_graph: RDFGraph | None, @@ -298,6 +482,140 @@ def _scalar_as_bounds_findings( return findings +def domain_vocabulary_share( + graph: RDFGraph, + catalog_terms: set[str], + fact_namespaces: Sequence[str], +) -> tuple[int, int]: + """Count distinct schema terms drawn from the catalog, and the total. + + Schema position only -- predicates and ``rdf:type`` objects. Instances in + the fact namespaces are excluded (they are supposed to be minted, not + looked up), and so are the RDF/RDFS/OWL/XSD/SKOS/DC/PROV plumbing + namespaces, which every graph uses regardless of which catalog it was + given and would otherwise float the ratio for free. Generic *content* + vocabularies such as schema.org deliberately stay in the denominator: + reaching for them instead of the catalog is exactly what this measures. + + Args: + graph: The rendered unit graph. + catalog_terms: Terms declared by the unit's ontology context. + fact_namespaces: Namespaces holding minted instances. + + Returns: + tuple[int, int]: ``(from_catalog, total)`` over distinct terms. + """ + used: set[str] = set() + from_catalog: set[str] = set() + for _, predicate, obj in graph: + candidates = [predicate] + if predicate == RDF.type: + candidates.append(obj) + for term in candidates: + if not isinstance(term, URIRef): + continue + text = str(term) + if any(text.startswith(ns) for ns in fact_namespaces): + continue + if text.startswith(_STANDARD_NAMESPACES): + continue + used.add(text) + if text in catalog_terms: + from_catalog.add(text) + return len(from_catalog), len(used) + + +def _domain_adherence_findings( + graph: RDFGraph, + catalog_terms: set[str], + fact_namespaces: Sequence[str], + min_share: float, + *, + min_terms: int = 4, +) -> list[FactsUnitFinding]: + """Flag a render that barely used the vocabulary it was handed. + + Every other finding here judges one triple. This one judges the render as a + whole, because the failure it catches is invisible term by term: + substituting a generic vocabulary for the catalog produces triples that are + individually well-formed, pass ``UNKNOWN_TERM`` (standard namespaces are + exempt by default), and satisfy every shape -- their subjects are in no + shape's target class. The graph looks extracted and answers nothing. + + A share rather than an all-or-nothing test, because a graph picks up a + catalog term or two by accident (a unit class, a quantity wrapper) while + still expressing the substance in generic terms. + + Silent when the unit has no catalog to adhere to, so a deliberately + catalog-free deployment is not spammed; when ``min_share`` is 0; and when + the render uses fewer than ``min_terms`` distinct schema terms. The last + guard exists because a share has no meaning over a denominator of one or + two: a front-matter unit that types an identifier and an author with + generic vocabulary is not a render that abandoned the catalog, and the + mandatory finding it used to raise drove the critic into retyping the + identifier as a quantity value. + + Args: + graph: The rendered unit graph. + catalog_terms: Terms declared by the unit's ontology context. + fact_namespaces: Namespaces holding minted instances. + min_share: Floor on the catalog share, in [0, 1]. 0 disables. + min_terms: Fewest distinct schema terms before the share is judged. + + Returns: + list[FactsUnitFinding]: At most one finding. + """ + if not catalog_terms or not len(graph) or min_share <= 0: + return [] + from_catalog, total = domain_vocabulary_share(graph, catalog_terms, fact_namespaces) + if not total or total < min_terms or from_catalog / total >= min_share: + return [] + return [ + FactsUnitFinding( + kind=FactsUnitFindingKind.DOMAIN_ADHERENCE, + message=( + f"Only {from_catalog} of {total} schema terms in this render " + "come from the ontology you were given; the rest are generic " + "vocabulary. Re-express the same facts with terms from the " + "ONTOLOGY section wherever one fits -- a generic substitute is " + "validated by no shape and reachable by no query written " + "against the catalog. Keep the statements and their values; " + "change the terms." + ), + ) + ] + + +def _folded_local_name(iri: str) -> str: + return re.sub(r"[_\-]", "", _local_name(iri)).lower() + + +def _containment_qualified(term: URIRef, candidates: list[str]) -> list[str]: + """Keep only replacement candidates that are near-misses of the term. + + The same qualification the LLM-free alias repair applies: a suggestion is + offered when the local-name tokens of one side are a subset of the + other's (``value`` in ``numericValue``), or when the two spell the same + name once case and separators are folded -- never on string similarity + alone. A pair that swaps one token for another + (``hasASiteComponent`` / ``hasBSiteComponent``) is a different property, + and offering it as a candidate would reproduce through the critic the + substitution the repair no longer makes. + """ + alias_tokens = _name_tokens(_local_name(str(term))) + folded = _folded_local_name(str(term)) + kept: list[str] = [] + for candidate in candidates: + candidate_tokens = _name_tokens(_local_name(candidate)) + if alias_tokens and ( + alias_tokens <= candidate_tokens or candidate_tokens <= alias_tokens + ): + kept.append(candidate) + elif folded and folded == _folded_local_name(candidate): + kept.append(candidate) + return kept + + def collect_unit_findings( *, graph: RDFGraph, @@ -306,7 +624,11 @@ def collect_unit_findings( extraction_text: str, fact_namespaces: list[str], coverage_limit: int = 30, + coverage_mandatory: str | bool = "off", policy: ValidationPolicy | None = None, + is_citation_metadata: bool = False, + is_non_content: bool = False, + full_catalog_terms: set[str] | None = None, ) -> list[FactsUnitFinding]: """Assemble all deterministic findings for one rendered unit graph. @@ -314,16 +636,31 @@ def collect_unit_findings( suggestions), forbidden-namespace terms (``example.org``), doc-namespace predicates, unresolved catalog near-misses, predicates asserted on a subject whose type contradicts their ``rdfs:domain``, and value nodes - whose only numeric content sits in a label. Advisory-strong: numeric - mentions of the source text absent from the graph — the renderer decides - per item whether each is an extractable quantity or an artifact. + whose only numeric content sits in a label. Advisory by default: numeric + mentions of the source text absent from the graph, as two findings -- + measurements (a number with its unit, in text order with context) and + bare numbers -- whose mandatory flag ``coverage_mandatory`` sets + (``off`` | ``measurements`` | ``all``; the booleans are ``off``/``all``). The policy's exempt terms (the sanctioned fallback vocabulary the facts prompt itself names, plus code predicates) never raise UNKNOWN_TERM: flagging the vocabulary the prompt recommends produced mandatory findings that repair renders obeyed by deleting correct data. + + A citation-metadata unit is rendered with the bibliographic vocabulary + and told to keep away from the domain ontology, so its catalog share is + zero by instruction; ``DOMAIN_ADHERENCE`` is not judged there, nor on a + non-content unit (front matter, author notes, identifiers), which has no + domain facts for the catalog to describe. + + ``full_catalog_terms`` is the whole catalog's inventory, as opposed to + the unit's retrieved snapshot in ``ontology_graph``. A term the snapshot + did not retrieve is still a term, so it is never reported as unknown -- + and, for a term that is genuinely unknown, replacement candidates are + offered only under token containment, never on similarity alone. """ policy = policy or ValidationPolicy() + known_terms = set(full_catalog_terms or ()) findings: list[FactsUnitFinding] = [] standard_namespaces = policy.standard_namespaces() fallback_terms = policy.exempt_terms(graph, ontology_graph) @@ -395,12 +732,15 @@ def collect_unit_findings( "statement with catalog/standard vocabulary." ), predicate=text, - suggestions=_alias_candidates( + suggestions=_containment_qualified( term, - graph, - catalog_terms, - ontology_graph=ontology_graph, - position=position, + _alias_candidates( + term, + graph, + catalog_terms, + ontology_graph=ontology_graph, + position=position, + ), ) if catalog_terms else [], @@ -424,12 +764,15 @@ def collect_unit_findings( kind=FactsUnitFindingKind.UNKNOWN_TERM, message=role_message, predicate=text, - suggestions=_alias_candidates( + suggestions=_containment_qualified( term, - graph, - catalog_terms, - ontology_graph=ontology_graph, - position=position, + _alias_candidates( + term, + graph, + catalog_terms, + ontology_graph=ontology_graph, + position=position, + ), ) if catalog_terms else [], @@ -441,6 +784,7 @@ def collect_unit_findings( catalog_terms and namespace in declared_namespaces and text not in catalog_terms + and text not in known_terms and text not in fallback_terms and not namespace.startswith(standard_namespaces) ): @@ -456,12 +800,15 @@ def collect_unit_findings( "the statement." ), predicate=text, - suggestions=_alias_candidates( + suggestions=_containment_qualified( term, - graph, - catalog_terms, - ontology_graph=ontology_graph, - position=position, + _alias_candidates( + term, + graph, + catalog_terms, + ontology_graph=ontology_graph, + position=position, + ), ), ) ) @@ -469,7 +816,26 @@ def collect_unit_findings( findings.extend( _scalar_as_bounds_findings(graph, ontology_graph, normalized_fact_namespaces) ) + findings.extend( + _unit_symbol_case_findings( + graph, + ontology_graph, + extraction_text, + normalized_fact_namespaces, + policy, + ) + ) findings.extend(domain_violation_findings(graph, ontology_graph)) + if not is_citation_metadata and not is_non_content: + findings.extend( + _domain_adherence_findings( + graph, + catalog_terms, + normalized_fact_namespaces, + policy.domain_adherence_min_share, + min_terms=policy.domain_adherence_min_terms, + ) + ) findings.extend( _label_only_number_findings( graph, @@ -487,21 +853,70 @@ def collect_unit_findings( ) ) - missing = missing_numeric_mentions(extraction_text, graph, limit=coverage_limit) - if missing: + if coverage_limit > 0: + findings.extend( + _numeric_coverage_findings( + unit_numeric_inventory( + graph=graph, + ontology_graph=ontology_graph, + extraction_text=extraction_text, + policy=policy, + limit=coverage_limit, + ), + coverage_mode(coverage_mandatory), + ) + ) + + return findings + + +def _numeric_coverage_findings( + inventory: NumericInventory, mode: CoverageMode +) -> list[FactsUnitFinding]: + """The two coverage findings, measurements first. + + Advisory unless the mode says otherwise: the critic judges per mention + whether it is a quantity or an artifact, and blocking forces that + judgement into the loop. The measurement list carries each value's unit + and the phrase it occurs in, which is what lets the completion pass place + it; the bare-number list is what the text alone cannot classify. + """ + findings: list[FactsUnitFinding] = [] + if inventory.measurements: + listed = "; ".join( + f"{mention.value} {mention.unit} («{mention.context}»)" + for mention in inventory.measurements + ) + findings.append( + FactsUnitFinding( + kind=FactsUnitFindingKind.NUMERIC_COVERAGE, + facet="measurements", + mandatory=mode in ("measurements", "all"), + message=( + "These measurements — a number written with its unit — " + "appear in the source text but not in the graph. Each is a " + "stated quantity unless it is typography: extract it as a " + "typed literal on the quantity it measures, with the " + "verbatim value and the source unit (never convert): " + listed + ), + value=", ".join(inventory.measurement_values()), + ) + ) + if inventory.unclassified: findings.append( FactsUnitFinding( kind=FactsUnitFindingKind.NUMERIC_COVERAGE, - mandatory=False, + facet="unclassified", + mandatory=mode == "all", message=( "These numbers appear in the source text but not in the " "graph. For each: extract it as a typed literal on an " "appropriate node (verbatim value and source unit — never " "convert units) if it is a factual quantity, or ignore it " - "if it is a page/citation/figure artifact: " + ", ".join(missing) + "if it is a page/citation/figure artifact: " + + ", ".join(inventory.unclassified) ), - value=", ".join(missing), + value=", ".join(inventory.unclassified), ) ) - return findings diff --git a/ontocast/tool/llm.py b/ontocast/tool/llm.py index cce64c66..16d81b3c 100644 --- a/ontocast/tool/llm.py +++ b/ontocast/tool/llm.py @@ -42,6 +42,7 @@ import asyncio import logging +import re import time import weakref from contextlib import contextmanager @@ -103,6 +104,21 @@ class LLMRequestTimeoutError(RuntimeError): """ +class LLMConfigurationError(RuntimeError): + """The provider rejected the request itself, not this attempt at it. + + A bad key, a model the account cannot reach, or a parameter value the model + does not accept is a property of the deployment: identical for every content + unit and every retry. Isolating it the way a bad render is isolated turns + one configuration fault into N unit failures and a run that finishes, + reports success, and writes nothing. + + Deliberately not a sibling of :class:`LLMRequestTimeoutError` under a shared + base: an ``except (timeout, configuration)`` clause would re-issue a request + the provider has already said it will never accept. + """ + + @contextmanager def use_budget_tracker(budget_tracker: Any): """Charge LLM usage inside this block to ``budget_tracker``.""" @@ -177,10 +193,39 @@ def llm_cache_config( "num_predict": config.num_predict, "num_ctx": config.num_ctx, } + # The reasoning knobs join the key only when set. The key is a hash of + # this whole mapping, so an unconditional ``None`` entry would evict every + # entry written before the knobs existed -- and those were produced under + # the provider default, which is exactly what ``None`` still means. + if config.reasoning_effort is not None: + config_dict["reasoning_effort"] = config.reasoning_effort + if config.thinking_budget is not None: + config_dict["thinking_budget"] = config.thinking_budget config_dict.update(extra) return config_dict +# The gpt-5 series (gpt-5, gpt-5-mini, gpt-5-nano) is provider-pinned to +# temperature 1.0 and rejects any other value, so the configured temperature has +# to be overridden rather than passed through. Anchored on a following hyphen or +# end-of-string so it does not also swallow later families whose names merely +# start the same way -- gpt-5.4 takes a temperature like any other model, and +# silently forcing it to 1.0 would make every benchmark arm undecodable. +_TEMPERATURE_PINNED_TO_ONE = re.compile(r"^gpt-5(-|$)") + + +def _reads_thinking_level(model_name: str) -> bool: + """Whether ``model_name`` is a Gemini generation that reads thinking_level. + + Google replaced the integer ``thinking_budget`` with the discrete + ``thinking_level`` in Gemini 3, and the two are mutually exclusive with the + level winning. Read from the major version rather than matched against a + fixed list, so a generation newer than this package is classified too. + """ + match = re.match(r"gemini-(\d+)", str(model_name).strip().lower()) + return match is not None and int(match.group(1)) >= 3 + + def _inflight_semaphore(max_inflight: int) -> asyncio.Semaphore: """Return the in-flight limiter for ``max_inflight`` on the running loop.""" loop = asyncio.get_running_loop() @@ -190,6 +235,111 @@ def _inflight_semaphore(max_inflight: int) -> asyncio.Semaphore: return per_loop[max_inflight] +#: Exception class names the providers raise on throttling. Matched by name +#: so no provider SDK is imported here: openai.RateLimitError, anthropic's +#: RateLimitError, and Google's ResourceExhausted all identify themselves. +_RATE_LIMIT_ERROR_NAMES = ("RateLimitError", "ResourceExhausted") + + +def _is_rate_limit_error(exc: BaseException) -> bool: + """Whether an exception (or its cause chain) is a provider throttle.""" + seen: set[int] = set() + current: BaseException | None = exc + while current is not None and id(current) not in seen: + seen.add(id(current)) + if type(current).__name__ in _RATE_LIMIT_ERROR_NAMES: + return True + if "429" in str(current) and "rate" in str(current).lower(): + return True + current = current.__cause__ or current.__context__ + return False + + +#: Exception class names that are always the deployment rather than the +#: request: a missing or wrong key, or a model the account cannot reach. +#: Matched by name for the same reason as the throttle names above -- no +#: provider SDK is imported here. +_CONFIG_ERROR_NAMES = ( + # openai / anthropic + "AuthenticationError", + "PermissionDeniedError", + "NotFoundError", + # google + "Unauthenticated", + "PermissionDenied", + "NotFound", +) + +#: Class names for a rejected request. A 400 is only a deployment fault when it +#: names a *parameter*, so these gate on the markers below rather than on their +#: own. +_BAD_REQUEST_ERROR_NAMES = ( + "BadRequestError", + "UnprocessableEntityError", + "InvalidArgument", +) + +#: Message markers that identify a rejected request as naming the *request* +#: rather than the document -- an unsupported value, an unknown argument, a +#: model that does not exist, a key that is not valid. Every one of these is +#: identical on the next call and on every other unit. +#: +#: Deliberately specific: the ``invalid_request_error`` type appears on every +#: OpenAI 400, so matching it would make this "any 400 that is not on the unit +#: list" -- and the unit list can only ever name the failure modes already seen. +_CONFIG_MESSAGE_MARKERS = ( + "does not support", + "unsupported parameter", + "unsupported value", + "unsupported_value", + "unrecognized request argument", + "unknown_parameter", + "unknown field", + "model_not_found", + "does not exist or you do not have access", + "invalid_api_key", + "incorrect api key", +) + +#: Markers that keep a rejected request a *unit* fault: they name the input, +#: not the configuration. This chunk was too long; its siblings may be fine, and +#: aborting the run over one oversized unit would be a regression. +_UNIT_FAULT_MARKERS = ( + "context_length_exceeded", + "maximum context length", + "string too long", + "too many tokens", +) + + +def _is_llm_configuration_error(exc: BaseException) -> bool: + """Whether an exception (or its cause chain) is a deployment fault. + + A deployment fault is one that every subsequent call will hit identically: + the request as configured is not one the provider will ever accept. Detected + by exception shape rather than type, like :func:`_is_rate_limit_error`. + Callers must check the throttle first -- a 429 is retryable and must never + reclassify as this. + """ + seen: set[int] = set() + current: BaseException | None = exc + while current is not None and id(current) not in seen: + seen.add(id(current)) + name = type(current).__name__ + text = str(current).lower() + if name in _CONFIG_ERROR_NAMES: + return True + looks_rejected = name in _BAD_REQUEST_ERROR_NAMES or "400" in text + if ( + looks_rejected + and any(marker in text for marker in _CONFIG_MESSAGE_MARKERS) + and not any(marker in text for marker in _UNIT_FAULT_MARKERS) + ): + return True + current = current.__cause__ or current.__context__ + return False + + def _opt_int(source: Any, key: str) -> int | None: """Read ``key`` from a mapping as an int, or None when absent/unusable.""" if not isinstance(source, dict): @@ -285,8 +435,15 @@ def _usage_metadata_from(usage: TokenUsage) -> dict[str, Any] | None: def _chars_received_from_result(result: Any) -> int: - if isinstance(result, AIMessage) and result.content: - return len(result.content) + """Response length in characters, whatever shape the content arrived in. + + Providers that answer with a list of typed content blocks would otherwise + be measured in *blocks*: ``len`` of a two-block reply is 2, and + ``chars_received`` for such a run under-reports by orders of magnitude. + Measured on the same normalised text every parser reads. + """ + if isinstance(result, AIMessage): + return len(_content_to_str(result.content)) if result.content else 0 return len(str(result)) @@ -368,7 +525,7 @@ def __init__( self, cache: Cacher | None = None, budget_tracker: Any = None, - **kwargs, + **kwargs: Any, ): """Initialize the LLM tool. @@ -396,8 +553,8 @@ def create( config: LLMConfig, cache: Cacher | None = None, budget_tracker: Any = None, - **kwargs, - ): + **kwargs: Any, + ) -> "LLMTool": """Create a new LLM tool instance synchronously. Args: @@ -426,8 +583,8 @@ async def acreate( config: LLMConfig, cache: Cacher | None = None, budget_tracker: Any = None, - **kwargs, - ): + **kwargs: Any, + ) -> "LLMTool": """Create a new LLM tool instance asynchronously. Args: @@ -450,8 +607,30 @@ async def setup(self): Raises: ValueError: If the provider is not supported. """ + # Cross-provider pacing and retry kwargs. The rate limiter is a + # per-process token bucket on request *starts* (langchain-core + # InMemoryRateLimiter): the inflight semaphore caps concurrency, this + # paces the sustained rate underneath it -- set it from the provider + # tier. `max_retries` tunes the provider SDK's own 429/backoff + # retries; there is deliberately no retry loop at this layer (see + # agent/common.py -- retrying here multiplies request rate exactly + # when the provider asks for less). + pacing_kwargs: dict[str, Any] = {} + if self.config.requests_per_second is not None: + from langchain_core.rate_limiters import InMemoryRateLimiter + + pacing_kwargs["rate_limiter"] = InMemoryRateLimiter( + requests_per_second=self.config.requests_per_second, + check_every_n_seconds=0.1, + max_bucket_size=max(1.0, self.config.requests_per_second), + ) + retry_kwargs: dict[str, Any] = {} + if self.config.max_retries is not None: + retry_kwargs["max_retries"] = self.config.max_retries + self._warn_ignored_reasoning_knobs() + if self.config.provider == LLMProvider.OPENAI: - if self.config.model_name.startswith("gpt-5"): + if _TEMPERATURE_PINNED_TO_ONE.match(str(self.config.model_name)): self.config.temperature = 1.0 logger.warning( f"Setting temperature to {self.config.temperature} for gpt-5 class " @@ -460,6 +639,26 @@ async def setup(self): ChatOpenAI = require( "langchain_openai", feature="The OpenAI LLM provider" ).ChatOpenAI + openai_kwargs: dict[str, Any] = {} + if self.config.json_mode: + # Constrains decoding to valid JSON at the provider, so a + # truncated or bracket-swapped envelope cannot be produced in + # the first place. Requires the word "JSON" in the prompt -- + # test_prompt_json_mode_precondition holds the prompt set to + # that. + openai_kwargs["response_format"] = {"type": "json_object"} + if self.config.prompt_cache_key: + # Routing only. The provider caches on the prefix regardless; + # this keeps requests that share one from being spread over + # shards that each have to build the entry themselves -- which + # is exactly what a unit fan-out issuing N calls with the same + # ontology chapter would otherwise do. + openai_kwargs["prompt_cache_key"] = self.config.prompt_cache_key + reasoning_kwargs: dict[str, Any] = {} + if self.config.reasoning_effort is not None: + # A client field rather than a model_kwargs entry: the client + # routes it to whichever API parameter the model expects. + reasoning_kwargs["reasoning_effort"] = self.config.reasoning_effort self._llm = ChatOpenAI( model=self.config.model_name, temperature=self.config.temperature, @@ -467,6 +666,10 @@ async def setup(self): api_key=( SecretStr(self.config.api_key) if self.config.api_key else None ), + model_kwargs=openai_kwargs, + **reasoning_kwargs, + **pacing_kwargs, + **retry_kwargs, ) elif self.config.provider == LLMProvider.OLLAMA: ollama_kwargs: dict[str, Any] = { @@ -483,7 +686,7 @@ async def setup(self): ChatOllama = require( "langchain_ollama", feature="The Ollama LLM provider" ).ChatOllama - self._llm = ChatOllama(**ollama_kwargs) + self._llm = ChatOllama(**ollama_kwargs, **pacing_kwargs) elif self.config.provider == LLMProvider.ANTHROPIC: anthropic_kwargs: dict[str, Any] = { "model": self.config.model_name, @@ -496,19 +699,89 @@ async def setup(self): ChatAnthropic = require( "langchain_anthropic", feature="The Anthropic LLM provider" ).ChatAnthropic - self._llm = ChatAnthropic(**anthropic_kwargs) + self._llm = ChatAnthropic( + **anthropic_kwargs, **pacing_kwargs, **retry_kwargs + ) elif self.config.provider == LLMProvider.GOOGLE: ChatGoogleGenerativeAI = require( "langchain_google_genai", feature="The Google LLM provider" ).ChatGoogleGenerativeAI + google_kwargs: dict[str, Any] = {} + if self.config.reasoning_effort is not None: + # Gemini 3+ spells the lever as a discrete ``thinking_level`` + # over the same minimal|low|medium|high vocabulary OpenAI uses. + # The client exposes it as ``reasoning_effort`` (aliased to + # ``thinking_level``) and routes it into ThinkingConfig, so it + # is a client field here too rather than a model_kwargs entry. + google_kwargs["reasoning_effort"] = self.config.reasoning_effort + if self.config.thinking_budget is not None and not _reads_thinking_level( + self.config.model_name + ): + # Dropped rather than forwarded on Gemini 3+: the generation + # does not read it, and _warn_ignored_reasoning_knobs has + # already said so. Sending it anyway would make that warning a + # lie and hand the API a parameter of the wrong generation. + google_kwargs["thinking_budget"] = self.config.thinking_budget self._llm = ChatGoogleGenerativeAI( model=self.config.model_name, temperature=self.config.temperature, google_api_key=self.config.api_key, + **google_kwargs, + **pacing_kwargs, + **retry_kwargs, ) else: raise ValueError(f"Unsupported provider: {self.config.provider}") + def _warn_ignored_reasoning_knobs(self) -> None: + """Warn about a provider knob the configured model does not read. + + ``LLM_REASONING_EFFORT`` is the shared vocabulary: OpenAI reasoning + models read it as ``reasoning_effort`` and Gemini 3+ as + ``thinking_level``. ``LLM_THINKING_BUDGET`` is the Gemini 2.5 spelling + and is superseded from Gemini 3 on. A knob the model does not read is a + silent no-op: the run bills full reasoning while the manifest records a + budget that never applied. + """ + provider = self.config.provider + ignored: list[tuple[str, str]] = [] + if self.config.reasoning_effort is not None and provider not in ( + LLMProvider.OPENAI, + LLMProvider.GOOGLE, + ): + ignored.append( + ( + f"LLM_REASONING_EFFORT={self.config.reasoning_effort}", + f"the {provider} provider reads neither reasoning knob", + ) + ) + if self.config.thinking_budget is not None: + if provider != LLMProvider.GOOGLE: + ignored.append( + ( + f"LLM_THINKING_BUDGET={self.config.thinking_budget}", + f"the {provider} provider reads LLM_REASONING_EFFORT", + ) + ) + elif _reads_thinking_level(self.config.model_name): + ignored.append( + ( + f"LLM_THINKING_BUDGET={self.config.thinking_budget}", + f"{self.config.model_name} is a Gemini 3+ model, where " + "the thinking budget is superseded by the thinking " + "level -- set LLM_REASONING_EFFORT instead", + ) + ) + if self.config.prompt_cache_key is not None and provider != LLMProvider.OPENAI: + ignored.append( + ( + "LLM_PROMPT_CACHE_KEY", + f"the {provider} provider has no prompt-cache routing hint", + ) + ) + for knob, reason in ignored: + logger.warning("%s is ignored: %s", knob, reason) + def _cache_config_dict(self, **extra: Any) -> dict[str, Any]: """Cache-key config for this tool's settings; see :func:`llm_cache_config`.""" return dict(llm_cache_config(self.config, **extra)) @@ -666,6 +939,14 @@ async def _invoke_cached( bt = self._current_budget_tracker() if bt is not None: bt.incr("llm/timeouts") + bt.incr("llm/calls_failed") + # The provider received and worked on the prompt; only the + # answer was abandoned. Charged as a call that received + # nothing, so calls_count and chars_sent cover every + # request the provider processed rather than only those + # that returned -- otherwise a run of timeouts reads as a + # run of few, cheap calls. + bt.add_usage(len(prompt_str), 0) # Re-raised as a plain error so the unit loop's handler treats # it as a failed render rather than a cancellation: letting a # bare TimeoutError escape asyncio.gather would abort the whole @@ -674,6 +955,56 @@ async def _invoke_cached( f"LLM request exceeded {timeout}s " f"({self.config.provider}/{self.config.model_name})" ) from exc + except Exception as exc: + # A provider throttle that survived the SDK's own retries + # surfaces as a failed render; without a counter it is + # indistinguishable from a model failure in the telemetry, + # which is how a throttled arm once read as a quality + # regression. Detected by exception shape rather than type so + # no provider SDK is imported here. Re-raised unchanged -- + # this layer deliberately does not retry (see + # agent/common.py): raise LLM_MAX_RETRIES or lower + # LLM_REQUESTS_PER_SECOND instead. + bt = self._current_budget_tracker() + if bt is not None: + # Every raised call, whatever the cause; llm/timeouts and + # llm/rate_limited are its attributed subsets. Not charged + # as usage: a rejected or dropped request cost nothing. + bt.incr("llm/calls_failed") + if _is_rate_limit_error(exc): + if bt is not None: + bt.incr("llm/rate_limited") + logger.warning( + "Provider rate limit hit (%s/%s): %s -- pace with " + "LLM_REQUESTS_PER_SECOND / LLM_MAX_INFLIGHT, or raise " + "LLM_MAX_RETRIES", + self.config.provider, + self.config.model_name, + exc, + ) + raise + if _is_llm_configuration_error(exc): + # Not isolated as a unit failure: every other unit is about + # to make the same rejected call. Re-typed here, at the one + # funnel every provider call passes through, so the unit + # loops can let exactly this class through their + # ``except Exception``. Raised before the cache write, so + # nothing about the rejection is persisted. + if bt is not None: + bt.incr("llm/calls_rejected") + logger.error( + "Provider rejected the request (%s/%s): %s -- this is " + "the configuration, not the document: every call will " + "be rejected the same way", + self.config.provider, + self.config.model_name, + exc, + ) + raise LLMConfigurationError( + f"{self.config.provider}/{self.config.model_name} " + f"rejected the request: {exc}" + ) from exc + raise finally: self.record_span("llm/provider", time.perf_counter() - provider_start) @@ -749,7 +1080,7 @@ def _prompt_to_string(self, prompt) -> str: return str(content_attr) return str(prompt) - async def complete(self, prompt: str, **kwargs) -> str: + async def complete(self, prompt: str, **kwargs: Any) -> str: """Generate a completion for the given prompt. Args: @@ -762,7 +1093,7 @@ async def complete(self, prompt: str, **kwargs) -> str: response = await self._invoke_cached(prompt, **kwargs) return _content_to_str(response.content) - async def extract(self, prompt: str, output_schema: Type[T], **kwargs) -> T: + async def extract(self, prompt: str, output_schema: Type[T], **kwargs: Any) -> T: """Extract structured data from the prompt according to a schema. Args: diff --git a/ontocast/tool/onto.py b/ontocast/tool/onto.py index a59536d3..6058d3cb 100644 --- a/ontocast/tool/onto.py +++ b/ontocast/tool/onto.py @@ -5,6 +5,8 @@ for tool implementations. """ +from typing import Any + from ontocast.onto.model import BasePydanticModel @@ -13,13 +15,12 @@ class Tool(BasePydanticModel): This class serves as the foundation for all tools in the OntoCast system. It provides common functionality and interface that all tools must implement. - Tools should inherit from this class and implement their specific functionality. - - Attributes: - Inherits all attributes from BasePydanticModel. + Tools should inherit from this class and implement their specific + functionality. All attributes are inherited from + :class:`~ontocast.onto.model.BasePydanticModel`; this class adds none. """ - def __init__(self, **kwargs): + def __init__(self, **kwargs: Any): """Initialize the tool. Args: diff --git a/ontocast/tool/ontology_manager.py b/ontocast/tool/ontology_manager.py index 784d3b68..cebe0976 100644 --- a/ontocast/tool/ontology_manager.py +++ b/ontocast/tool/ontology_manager.py @@ -10,7 +10,7 @@ from collections import OrderedDict from collections.abc import Sequence from datetime import datetime, timezone -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from pydantic import Field from rdflib import URIRef @@ -20,6 +20,7 @@ from ..onto.ontology_header import OntologyHeader from ..onto.rdfgraph import RDFGraph from ..onto.util import normalize_ontology_iri +from .facts_validation.terms import collect_catalog_terms from .onto import Tool from .triple_manager.core import TripleStoreManager from .triple_manager.util import dedupe_terminal_ontologies @@ -37,6 +38,8 @@ _GRAPH_CACHE_MAX_ENTRIES = 64 if TYPE_CHECKING: + import networkx as nx + from ontocast.tool.vector_store.patch_retriever import OntologyPatchRetriever @@ -55,7 +58,7 @@ class OntologyManager(Tool): ontology_versions: dict[str, list[Ontology]] = Field(default_factory=dict) - def __init__(self, **kwargs): + def __init__(self, **kwargs: Any): """Initialize the ontology manager. Args: @@ -91,6 +94,10 @@ def __init__(self, **kwargs): self._graph_cache_misses = 0 self._merged_cache_hits = 0 self._merged_cache_misses = 0 + # Union of every served ontology's IRIs, keyed by the served version + # set so it is rebuilt exactly when the catalog changes. Read per unit + # by the deterministic repairs; see :meth:`catalog_terms`. + self._catalog_terms_memo: tuple[frozenset[str], set[str]] | None = None @staticmethod def _primary_ontology_id(ontology: Ontology) -> str: @@ -220,7 +227,7 @@ def preferred_namespace_prefixes(self) -> dict[str, str]: """Namespace URI → author prefix for sanitize preference.""" return dict(self._namespace_to_author_prefix) - def __contains__(self, item): + def __contains__(self, item: object) -> bool: """Check if an item (IRI or alias) is in the ontology manager. Args: @@ -498,6 +505,39 @@ def catalog_cache_stats(self) -> dict[str, int]: "catalog_merge_cache_misses": self._merged_cache_misses, } + def catalog_terms(self) -> set[str]: + """Every IRI the held ontologies declare or reference, as one set. + + The union of :func:`collect_catalog_terms` over every ontology this + manager holds -- the terminal versions registered in memory and the + graphs read through the triple store and resident in the graph cache. + Built lazily and memoised on the content-addressed identifiers of that + set, so it is recomputed only when an ontology is added, superseded, + removed, or newly read from the store. + + The per-unit repairs read it for membership: a term present here is a + real catalog term even when a unit's retrieved snapshot omits it, and + must never be rewritten toward a look-alike the snapshot does carry. + + Returns: + set[str]: Shared across units -- treat as read-only. + """ + held: dict[str, Ontology] = { + ontology.versioned_iri: ontology + for ontology in self.get_terminal_ontologies_by_iri(None) + } + for graph_uri, ontology in self._graph_cache.items(): + held.setdefault(graph_uri, ontology) + key = frozenset(held) + memo = self._catalog_terms_memo + if memo is not None and memo[0] == key: + return memo[1] + terms: set[str] = set() + for ontology in held.values(): + terms |= collect_catalog_terms(ontology.graph) + self._catalog_terms_memo = (key, terms) + return terms + def _cache_graph(self, ontology: Ontology, graph_uri: str | None = None) -> None: """Register a store-read ``ontology`` under the graph URI it was read from. @@ -832,7 +872,7 @@ def get_ontology_versions(self, ontology_id: str) -> list[Ontology]: return [] return self.get_ontology_versions_by_iri(iri) - def get_lineage_graph_by_iri(self, iri: str): + def get_lineage_graph_by_iri(self, iri: str) -> "nx.DiGraph | None": """Get the lineage graph for a specific IRI. Args: @@ -846,7 +886,7 @@ def get_lineage_graph_by_iri(self, iri: str): return Ontology.build_lineage_graph(self.ontology_versions[iri]) - def get_lineage_graph(self, ontology_id: str): + def get_lineage_graph(self, ontology_id: str) -> "nx.DiGraph | None": """Get the lineage graph for a specific ontology_id, alias, or IRI. Args: @@ -984,25 +1024,3 @@ def ontologies(self) -> list[Ontology]: break return result - - def update_ontology(self, ontology_id: str, ontology_addendum: RDFGraph): - """Update an existing ontology with additional triples. - - Note: This method is deprecated. Use add_ontology() with a new version - that has the current hash in parent_hashes instead. - - Args: - ontology_id: The short name of the ontology to update. - ontology_addendum: The RDF graph containing additional triples to add. - """ - logger.warning( - "update_ontology() is deprecated. Use add_ontology() with version tracking instead." - ) - terminals = self.get_terminal_ontologies(ontology_id) - if terminals: - terminals[0] += ontology_addendum - # Update cache for the IRI (though this method is deprecated) - iri = terminals[0].iri - freshest = self.get_freshest_terminal_ontology_by_iri(iri) - if freshest and freshest.hash: - self._cached_ontologies[iri] = freshest.hash diff --git a/ontocast/tool/shapes_catalog.py b/ontocast/tool/shapes_catalog.py index 6c542756..36b06b8a 100644 --- a/ontocast/tool/shapes_catalog.py +++ b/ontocast/tool/shapes_catalog.py @@ -33,6 +33,10 @@ logger = logging.getLogger(__name__) +#: Bound on cached per-unit shape selections; distinct selections per run +#: are at most the unit count, so this is generous. +_SELECTION_CACHE_MAX = 64 + #: Every triple in the shapes partition, merged. SHACL evaluates one shapes #: graph, and shapes documents are independent, so the union is the whole answer. _ALL_SHAPES_QUERY = """ @@ -91,6 +95,16 @@ def __init__(self, **kwargs): super().__init__(**kwargs) self._triple_store_manager: TripleStoreManager | None = None self._graph: RDFGraph | None = None + # Prompt-contract memo, keyed on the merged graph's identity: every + # (re)materialization builds a new graph object, so the key + # invalidates itself without each assignment site knowing about it. + self._contract_key: tuple[int, int] | None = None + self._contract_requirements: tuple = () + self._contract_chapter: str = "" + self._contract_terms: tuple[str, ...] = () + # Per-unit selections, keyed by the selected anchor set; bounded and + # dropped whenever the contract memo rebuilds. + self._selection_cache: dict[tuple[str, ...], str] = {} def register_triple_store(self, manager: TripleStoreManager | None) -> None: """Register the triple store holding the shapes partition.""" @@ -109,6 +123,81 @@ def graph(self) -> RDFGraph | None: """ return self._graph if self._graph is not None and len(self._graph) else None + def _contract(self, max_lines: int): + graph = self.graph() + if graph is None: + return (), "", () + key = (id(graph), max_lines) + if self._contract_key != key: + from ontocast.prompt.shapes_contract import ( + contract_terms, + derive_shape_requirements, + format_conformance_chapter, + ) + + self._contract_requirements = tuple(derive_shape_requirements(graph)) + self._contract_chapter = format_conformance_chapter( + self._contract_requirements, max_lines=max_lines + ) + self._contract_terms = contract_terms(graph) + self._contract_key = key + self._selection_cache = {} + return self._contract_requirements, self._contract_chapter, self._contract_terms + + def conformance_chapter(self, *, max_lines: int) -> str: + """The whole catalog rendered as a prompt chapter; "" without shapes. + + Memoized per merged graph -- run-constant, shared by every unit of + every document in a tenancy. + """ + return self._contract(max_lines)[1] + + def prompt_contract_terms(self, *, max_lines: int) -> tuple[str, ...]: + """IRIs the shapes require of the output (the exemption set). + + Deliberately the FULL catalog's terms whatever selection does to the + chapter: exemptions protect legitimate catalog IRIs from + UNKNOWN_TERM, and the gate validates against every shape. + """ + return self._contract(max_lines)[2] + + def needs_selection(self, *, max_lines: int) -> bool: + """Whether the catalog's rule lines exceed the prompt cap. + + Below the cap the whole-catalog chapter is strictly better than any + selection: run-constant, memoized once, no per-unit variance. Above + it, rendering everything means blind truncation in document order, + and per-unit selection takes over. + """ + requirements, _, _ = self._contract(max_lines) + return sum(len(r.lines) for r in requirements) > max_lines + + def selected_chapter(self, context_terms: set[str], *, max_lines: int) -> str: + """The chapter for one unit, joined on its ontology-context IRIs. + + A shape is included iff its own terms (targets, paths, classes) + intersect ``context_terms``. Distinct selections per run are bounded + by the unit count, so rendered chapters are cached by selected-anchor + set (bounded; oldest evicted first). + """ + requirements, _, _ = self._contract(max_lines) + if not requirements: + return "" + from ontocast.prompt.shapes_contract import ( + format_conformance_chapter, + select_requirements, + ) + + selected = select_requirements(requirements, context_terms) + key = tuple(r.anchor for r in selected) + cached = self._selection_cache.get(key) + if cached is None: + cached = format_conformance_chapter(selected, max_lines=max_lines) + if len(self._selection_cache) >= _SELECTION_CACHE_MAX: + self._selection_cache.pop(next(iter(self._selection_cache))) + self._selection_cache[key] = cached + return cached + async def sync(self, shapes_dir: str | None = None) -> None: """Seed from ``shapes_dir`` when needed, then materialize the merged graph. diff --git a/ontocast/tool/sparql.py b/ontocast/tool/sparql.py index 685d2223..ee7d681c 100644 --- a/ontocast/tool/sparql.py +++ b/ontocast/tool/sparql.py @@ -11,10 +11,8 @@ from rdflib import BNode, Literal, Namespace, URIRef from rdflib.namespace import OWL, RDF, RDFS, SKOS -from rdflib.plugins.sparql import prepareQuery from ontocast.onto.constants import COMMON_PREFIXES, WELL_KNOWN_PREFIXES -from ontocast.onto.enum import SPARQLOperationType from ontocast.onto.graph_prune import ( GENERIC_INDIVIDUAL_TYPES as _GENERIC_INDIVIDUAL_TYPES, ) @@ -50,7 +48,6 @@ ) from ontocast.onto.ontology import Ontology from ontocast.onto.rdfgraph import RDFGraph -from ontocast.onto.sparql_models import SPARQLOperationModel from ontocast.tool.representation_text import ROLE_PREDICATE from ontocast.tool.triple_manager.core import TripleStoreManager from ontocast.tool.triple_manager.util import LineageT @@ -1476,240 +1473,8 @@ def __init__(self, triple_store_manager: TripleStoreManager | None = None): triple_store_manager: Optional triple store manager for persistent storage. """ self.triple_store_manager = triple_store_manager - self.operation_history = [] self.last_finalize_metrics: dict[str, int] = {} - def execute_operations( - self, graph: RDFGraph, operations: list[SPARQLOperationModel] - ) -> RDFGraph: - """Execute a list of SPARQL operations on a graph. - - Args: - graph: The RDF graph to operate on. - operations: List of SPARQL operations to execute. - - Returns: - RDFGraph: Updated graph after applying operations. - """ - logger.info(f"Executing {len(operations)} SPARQL operations") - - for operation in operations: - try: - self._execute_single_operation(graph, operation) - self.operation_history.append(operation) - logger.debug( - f"Executed {operation.operation_type} operation: {operation.description}" - ) - except Exception as e: - logger.error( - f"Failed to execute {operation.operation_type} operation: {str(e)}" - ) - raise - - return graph - - def execute_operation(self, operation: SPARQLOperationModel) -> None: - """Execute a single SPARQL operation. - - Args: - operation: The SPARQL operation to execute. - """ - # For now, we'll use a simple approach - in a real implementation, - # you might want to track which graph this operation should be applied to - logger.info( - f"Executing {operation.operation_type} operation: {operation.description}" - ) - # This is a placeholder - in practice, you'd need to specify which graph to operate on - # or maintain a default graph in the tool - - def _execute_single_operation( - self, graph: RDFGraph, operation: SPARQLOperationModel - ): - """Execute a single SPARQL operation. - - Args: - graph: The RDF graph to operate on. - operation: The SPARQL operation to execute. - """ - if operation.operation_type == SPARQLOperationType.INSERT: - self._execute_insert(graph, operation) - elif operation.operation_type == SPARQLOperationType.DELETE: - self._execute_delete(graph, operation) - elif operation.operation_type == SPARQLOperationType.UPDATE: - self._execute_update(graph, operation) - else: - raise ValueError(f"Unknown operation type: {operation.operation_type}") - - def _execute_insert(self, graph: RDFGraph, operation: SPARQLOperationModel): - """Execute INSERT operation. - - Args: - graph: The RDF graph to operate on. - operation: The INSERT operation to execute. - """ - # Parse the INSERT query - query = prepareQuery(operation.query) - - # For INSERT DATA, we need to parse the triples and add them to the graph - if "INSERT DATA" in operation.query.upper(): - # Extract triples from INSERT DATA query - triples = self._parse_insert_data_triples(operation.query) - for triple in triples: - graph.add(triple) - else: - # For other INSERT queries, execute against the graph - graph.query(query) - # INSERT queries typically don't return results, but we execute them - - def _execute_delete(self, graph: RDFGraph, operation: SPARQLOperationModel): - """Execute DELETE operation. - - Args: - graph: The RDF graph to operate on. - operation: The DELETE operation to execute. - """ - # Parse the DELETE query - query = prepareQuery(operation.query) - - # For DELETE DATA, we need to parse the triples and remove them from the graph - if "DELETE DATA" in operation.query.upper(): - # Extract triples from DELETE DATA query - triples = self._parse_delete_data_triples(operation.query) - for triple in triples: - graph.remove(triple) - else: - # For other DELETE queries, execute against the graph - graph.query(query) - # DELETE queries typically don't return results, but we execute them - - def _execute_update(self, graph: RDFGraph, operation: SPARQLOperationModel): - """Execute UPDATE operation. - - Args: - graph: The RDF graph to operate on. - operation: The UPDATE operation to execute. - """ - # Parse the UPDATE query - query = prepareQuery(operation.query) - - # Execute the UPDATE query - graph.query(query) - # UPDATE queries typically don't return results, but we execute them - - def _parse_insert_data_triples(self, query: str) -> list[tuple]: - """Parse triples from INSERT DATA query. - - Args: - query: The INSERT DATA query string. - - Returns: - List of triples to insert. - """ - # This is a simplified parser - in practice, you'd want a more robust parser - triples = [] - - # Extract the content between INSERT DATA { ... } - start = query.upper().find("INSERT DATA {") - if start == -1: - return triples - - start += len("INSERT DATA {") - end = query.rfind("}") - - if end == -1: - return triples - - data_content = query[start:end].strip() - - # Split by lines and parse each triple - lines = [line.strip() for line in data_content.split("\n") if line.strip()] - - for line in lines: - if line.endswith("."): - line = line[:-1] # Remove trailing period - - # Parse the triple (simplified - assumes standard N3 format) - parts = line.split() - if len(parts) >= 3: - subject = self._parse_term(parts[0]) - predicate = self._parse_term(parts[1]) - object_part = self._parse_term(" ".join(parts[2:])) - - if subject and predicate and object_part: - triples.append((subject, predicate, object_part)) - - return triples - - def _parse_delete_data_triples(self, query: str) -> list[tuple]: - """Parse triples from DELETE DATA query. - - Args: - query: The DELETE DATA query string. - - Returns: - List of triples to delete. - """ - # Similar to INSERT DATA parsing - return self._parse_insert_data_triples( - query.replace("DELETE DATA", "INSERT DATA") - ) - - def _parse_term(self, term: str): - """Parse a SPARQL term (subject, predicate, or object). - - Args: - term: The term string to parse. - - Returns: - Parsed RDF term (URIRef, Literal, or BNode). - """ - term = term.strip() - - if term.startswith("<") and term.endswith(">"): - # URI - return URIRef(term[1:-1]) - elif term.startswith('"') and term.endswith('"'): - # Literal - return Literal(term[1:-1]) - elif term.startswith("_:"): - # Blank node - return BNode(term[2:]) - elif term.startswith('"') and '"^^' in term: - # Typed literal - value, datatype = term.split('"^^') - return Literal(value[1:], datatype=URIRef(datatype)) - else: - # Assume it's a URI without angle brackets - return URIRef(term) - - def validate_operation(self, operation: SPARQLOperationModel) -> bool: - """Validate a SPARQL operation. - - Args: - operation: The operation to validate. - - Returns: - bool: True if valid, False otherwise. - """ - try: - prepareQuery(operation.query) - return True - except Exception as e: - logger.error(f"Invalid SPARQL operation: {str(e)}") - return False - - def get_operation_history(self) -> list[SPARQLOperationModel]: - """Get the history of executed operations. - - Returns: - List of executed operations. - """ - return self.operation_history.copy() - - def clear_history(self): - """Clear the operation history.""" - self.operation_history.clear() - @staticmethod def _build_induced_subgraph( ontologies: list[Ontology], @@ -2129,57 +1894,3 @@ async def aget_induced_subgraph( ) self.last_finalize_metrics = metrics return result - - def create_insert_operation( - self, query: str, description: str = "" - ) -> SPARQLOperationModel: - """Create an INSERT operation. - - Args: - query: The SPARQL INSERT query. - description: Optional description of the operation. - - Returns: - SPARQLOperationModel: The created operation. - """ - return SPARQLOperationModel( - operation_type=SPARQLOperationType.INSERT, - query=query, - description=description, - ) - - def create_delete_operation( - self, query: str, description: str = "" - ) -> SPARQLOperationModel: - """Create a DELETE operation. - - Args: - query: The SPARQL DELETE query. - description: Optional description of the operation. - - Returns: - SPARQLOperationModel: The created operation. - """ - return SPARQLOperationModel( - operation_type=SPARQLOperationType.DELETE, - query=query, - description=description, - ) - - def create_update_operation( - self, query: str, description: str = "" - ) -> SPARQLOperationModel: - """Create an UPDATE operation. - - Args: - query: The SPARQL UPDATE query. - description: Optional description of the operation. - - Returns: - SPARQLOperationModel: The created operation. - """ - return SPARQLOperationModel( - operation_type=SPARQLOperationType.UPDATE, - query=query, - description=description, - ) diff --git a/ontocast/tool/triple_manager/core.py b/ontocast/tool/triple_manager/core.py index 56c1fe99..7030bceb 100644 --- a/ontocast/tool/triple_manager/core.py +++ b/ontocast/tool/triple_manager/core.py @@ -8,6 +8,7 @@ import asyncio import os from collections.abc import Sequence +from typing import Any from pydantic import Field from rdflib import RDF, Graph @@ -41,7 +42,7 @@ class TripleStoreManager(Tool): triple store backends (e.g., Fuseki, In-Memory). """ - def __init__(self, **kwargs): + def __init__(self, **kwargs: Any): """Initialize the triple store manager. Args: @@ -66,20 +67,20 @@ async def afetch_ontologies(self) -> list[Ontology]: return await asyncio.to_thread(self.fetch_ontologies) @abc.abstractmethod - def serialize_graph(self, graph: Graph, **kwargs) -> bool: + def serialize_graph(self, graph: Graph, **kwargs: Any) -> bool: """Store an RDF graph in the triple store.""" pass - async def aserialize_graph(self, graph: Graph, **kwargs) -> bool: + async def aserialize_graph(self, graph: Graph, **kwargs: Any) -> bool: """Async serialize helper for backends without native async I/O.""" return await asyncio.to_thread(self.serialize_graph, graph, **kwargs) @abc.abstractmethod - def serialize(self, o: Ontology | RDFGraph, **kwargs) -> bool: + def serialize(self, o: Ontology | RDFGraph, **kwargs: Any) -> bool: """Store an Ontology or RDFGraph in the triple store.""" pass - async def aserialize(self, o: Ontology | RDFGraph, **kwargs) -> bool: + async def aserialize(self, o: Ontology | RDFGraph, **kwargs: Any) -> bool: """Async serialize helper for backends without native async I/O.""" return await asyncio.to_thread(self.serialize, o, **kwargs) @@ -335,7 +336,14 @@ class TripleStoreManagerWithAuth(TripleStoreManager): default=None, description="Triple store authentication tuple (user, password)" ) - def __init__(self, uri=None, auth=None, env_uri=None, env_auth=None, **kwargs): + def __init__( + self, + uri: str | None = None, + auth: tuple[str, str] | str | None = None, + env_uri: str | None = None, + env_auth: str | None = None, + **kwargs: Any, + ): """Initialize the triple store manager with authentication. This method handles loading URI and authentication credentials from diff --git a/ontocast/tool/triple_manager/fuseki.py b/ontocast/tool/triple_manager/fuseki.py index 1532c3a8..249e671a 100644 --- a/ontocast/tool/triple_manager/fuseki.py +++ b/ontocast/tool/triple_manager/fuseki.py @@ -8,6 +8,7 @@ import asyncio import logging from collections.abc import Sequence +from typing import Any from urllib.parse import quote, urlparse, urlunparse import httpx @@ -114,12 +115,12 @@ class FusekiTripleStoreManager(TripleStoreManagerWithAuth): def __init__( self, - uri=None, - auth=None, - dataset=None, - ontologies_dataset=None, - shapes_dataset=None, - **kwargs, + uri: str | None = None, + auth: tuple[str, str] | str | None = None, + dataset: str | None = None, + ontologies_dataset: str | None = None, + shapes_dataset: str | None = None, + **kwargs: Any, ): """Initialize the Fuseki triple store manager. @@ -187,8 +188,12 @@ async def async_init(self) -> None: async def _initialize_datasets(self) -> None: """Create the configured facts/ontologies/shapes datasets when missing.""" - await self.init_dataset(self.dataset) - seen = {self.dataset} + # The constructor always resolves a facts dataset name; the field is + # optional only because the constructor parameter is. Falling back here + # keeps a None from reaching Fuseki as a dataset literally named "None". + dataset = self.dataset or DEFAULT_DATASET + await self.init_dataset(dataset) + seen = {dataset} for name in (self.ontologies_dataset, self.shapes_dataset): if name not in seen: seen.add(name) @@ -409,7 +414,7 @@ async def _clean_dataset_by_name(self, dataset_name: str) -> None: logger.error(f"Failed to clean dataset '{dataset_name}': {e}") raise - async def init_dataset(self, dataset_name): + async def init_dataset(self, dataset_name: str) -> None: """Initialize a Fuseki dataset. This method creates a new dataset in Fuseki if it doesn't already exist. @@ -781,7 +786,7 @@ async def _fetch_ontologies_async(self) -> list[Ontology]: ) return ontologies - def serialize_graph(self, graph: Graph, **kwargs) -> bool: + def serialize_graph(self, graph: Graph, **kwargs: Any) -> bool: """Synchronous wrapper for serialize_graph. For async usage, use aserialize_graph() instead. @@ -796,14 +801,14 @@ def serialize_graph(self, graph: Graph, **kwargs) -> bool: ) return asyncio.run(self._serialize_graph_with_cleanup(graph, **kwargs)) - async def aserialize_graph(self, graph: Graph, **kwargs) -> bool: + async def aserialize_graph(self, graph: Graph, **kwargs: Any) -> bool: """Async version of serialize_graph. This is the preferred method when running in an async context. """ return await self._serialize_graph_async(graph, **kwargs) - async def _serialize_graph_with_cleanup(self, graph: Graph, **kwargs) -> bool: + async def _serialize_graph_with_cleanup(self, graph: Graph, **kwargs: Any) -> bool: """Wrapper that ensures proper cleanup when using asyncio.run(). This method creates a temporary client and ensures it's properly closed @@ -821,7 +826,7 @@ async def _serialize_graph_with_cleanup(self, graph: Graph, **kwargs) -> bool: # Restore original client self._client = original_client - async def _serialize_graph_async(self, graph: Graph, **kwargs) -> bool: + async def _serialize_graph_async(self, graph: Graph, **kwargs: Any) -> bool: """Store an RDF graph as a named graph in a specific Fuseki dataset. This is a private helper method that handles the common logic for storing @@ -870,7 +875,7 @@ async def _serialize_graph_async(self, graph: Graph, **kwargs) -> bool: logger.error(f"Response: {response.text}") return False - def serialize(self, o: Ontology | RDFGraph, **kwargs) -> bool: + def serialize(self, o: Ontology | RDFGraph, **kwargs: Any) -> bool: """Synchronous wrapper for serialize. For async usage, use aserialize() instead. @@ -885,14 +890,16 @@ def serialize(self, o: Ontology | RDFGraph, **kwargs) -> bool: ) return asyncio.run(self._serialize_with_cleanup(o, **kwargs)) - async def aserialize(self, o: Ontology | RDFGraph, **kwargs) -> bool: + async def aserialize(self, o: Ontology | RDFGraph, **kwargs: Any) -> bool: """Async version of serialize. This is the preferred method when running in an async context. """ return await self._serialize_async(o, **kwargs) - async def _serialize_with_cleanup(self, o: Ontology | RDFGraph, **kwargs) -> bool: + async def _serialize_with_cleanup( + self, o: Ontology | RDFGraph, **kwargs: Any + ) -> bool: """Wrapper that ensures proper cleanup when using asyncio.run(). This method creates a temporary client and ensures it's properly closed @@ -910,7 +917,7 @@ async def _serialize_with_cleanup(self, o: Ontology | RDFGraph, **kwargs) -> boo # Restore original client self._client = original_client - async def _serialize_async(self, o: Ontology | RDFGraph, **kwargs) -> bool: + async def _serialize_async(self, o: Ontology | RDFGraph, **kwargs: Any) -> bool: """Store an RDF graph as a named graph in Fuseki. This method stores the given RDF graph as a named graph in Fuseki. diff --git a/ontocast/tool/triple_manager/in_memory.py b/ontocast/tool/triple_manager/in_memory.py index 6ce2e119..a2f0b9e7 100644 --- a/ontocast/tool/triple_manager/in_memory.py +++ b/ontocast/tool/triple_manager/in_memory.py @@ -6,10 +6,12 @@ import logging from collections.abc import Sequence from dataclasses import dataclass, field +from typing import Literal, overload import pyoxigraph as ox from oxrdflib._converter import to_ox from rdflib import Graph, URIRef +from rdflib.term import Node from ontocast.onto.ontology import Ontology from ontocast.onto.ontology_header import OntologyHeader @@ -56,16 +58,93 @@ def _clear_named_graph(store: ox.Store, graph_ctx: ox.NamedNode) -> None: store.remove(quad) +#: What each triple position accepts once converted. ``to_ox`` maps +#: ``urn:x-rdflib:default`` to :class:`ox.DefaultGraph` and ``None`` to +#: ``None``, neither of which is a term, so a graph carrying either in a triple +#: position reaches oxigraph as a type error rather than as a diagnosis. +_OX_POSITION_TYPES: dict[str, tuple[type, ...]] = { + "subject": (ox.NamedNode, ox.BlankNode), + "predicate": (ox.NamedNode,), + "object": (ox.NamedNode, ox.BlankNode, ox.Literal), +} + + +@overload +def _to_ox_term( + term: Node, position: Literal["subject"], graph_ctx: ox.NamedNode +) -> ox.NamedNode | ox.BlankNode: ... + + +@overload +def _to_ox_term( + term: Node, position: Literal["predicate"], graph_ctx: ox.NamedNode +) -> ox.NamedNode: ... + + +@overload +def _to_ox_term( + term: Node, position: Literal["object"], graph_ctx: ox.NamedNode +) -> ox.NamedNode | ox.BlankNode | ox.Literal: ... + + +def _to_ox_term( + term: Node, + position: Literal["subject", "predicate", "object"], + graph_ctx: ox.NamedNode, +) -> ox.NamedNode | ox.BlankNode | ox.Literal: + """Convert one rdflib term, naming the graph and position when it cannot be. + + Args: + term: The rdflib term to convert. + position: Which triple position ``term`` occupies. + graph_ctx: Target named graph, reported so the caller knows which graph + carried the bad term. + + Returns: + The pyoxigraph term for ``term``, valid in ``position``. + + Raises: + TypeError: The term does not convert to something valid in ``position``. + """ + try: + converted = to_ox(term) + except ValueError as exc: + raise TypeError( + f"cannot serialize {position} {term!r} " + f"(rdflib type {type(term).__name__}) of graph <{graph_ctx.value}>: {exc}" + ) from exc + # Two checks, not one: the first narrows the union for the type checker, + # the second enforces what this particular position accepts. + if not isinstance( + converted, (ox.NamedNode, ox.BlankNode, ox.Literal) + ) or not isinstance(converted, _OX_POSITION_TYPES[position]): + raise TypeError( + f"{position} {term!r} (rdflib type {type(term).__name__}) of graph " + f"<{graph_ctx.value}> converts to {type(converted).__name__}, which " + f"is not valid in {position} position" + ) + return converted + + def _rdflib_graph_to_quads(graph: Graph, graph_ctx: ox.NamedNode) -> list[ox.Quad]: + """Convert an rdflib graph to oxigraph quads in ``graph_ctx``. + + Raises: + TypeError: A term does not convert to something valid in its position. + Previously three bare ``assert``s, which carried no message -- so + the failure named neither the term, the position nor the graph -- + and which vanish entirely under ``python -O``. + """ quads: list[ox.Quad] = [] for subject, predicate, object_ in graph: - s_ox = to_ox(subject) - p_ox = to_ox(predicate) - o_ox = to_ox(object_) - assert isinstance(s_ox, (ox.NamedNode, ox.BlankNode, ox.Triple)) - assert isinstance(p_ox, ox.NamedNode) - assert isinstance(o_ox, (ox.NamedNode, ox.BlankNode, ox.Literal, ox.Triple)) - quads.append(ox.Quad(s_ox, p_ox, o_ox, graph_ctx)) + quads.append( + ox.Quad( + _to_ox_term(subject, "subject", graph_ctx), + _to_ox_term(predicate, "predicate", graph_ctx), + _to_ox_term(object_, "object", graph_ctx), + graph_ctx, + ) + ) return quads diff --git a/ontocast/tool/validate.py b/ontocast/tool/validate.py index 45e10433..c667eabe 100644 --- a/ontocast/tool/validate.py +++ b/ontocast/tool/validate.py @@ -428,7 +428,7 @@ def validate_connectivity(self) -> ConnectivityResult: return result - def make_graph_connected(self, chunk_iri) -> RDFGraph: + def make_graph_connected(self, chunk_iri: str) -> RDFGraph: """Make a disconnected graph connected by adding bridging relationships. Args: @@ -460,7 +460,7 @@ def make_graph_connected(self, chunk_iri) -> RDFGraph: return connected_graph def _connect_via_chunk_hub( - self, graph: RDFGraph, components: list[set[URIRef]], chunk_iri + self, graph: RDFGraph, components: list[set[URIRef]], chunk_iri: str ) -> RDFGraph: """Connect components by creating a chunk hub entity. diff --git a/ontocast/tool/vector_store/embedding.py b/ontocast/tool/vector_store/embedding.py index b3397462..41f7cdab 100644 --- a/ontocast/tool/vector_store/embedding.py +++ b/ontocast/tool/vector_store/embedding.py @@ -61,6 +61,50 @@ def embed_query(self, texts: list[str]) -> list[list[float]]: def _apply(prefix: str, texts: list[str]) -> list[str]: return texts if not prefix else [f"{prefix}{text}" for text in texts] + @property + def sequence_limit(self) -> int | None: + """Tokens this provider accepts before it silently truncates, if known. + + Truncation is the failure mode with no symptom: the provider returns a + vector of the right shape for a prefix of the text, and the caller cannot + tell that the tail was dropped. Exposing the limit is what lets a caller + report it instead of discovering it as unexplained recall loss. + + Returns: + int | None: The limit, or None where the provider does not state one. + """ + return None + + def token_lengths(self, texts: list[str]) -> list[int] | None: + """Word pieces each text costs this encoder, or None if unknowable. + + Lengths rather than a count of overflows, because the two answer different + questions: a count says how many queries were cut, while the distribution + says whether a budget is nearly right or wildly wrong -- and only the + latter can be used to size one. + + Returns: + list[int] | None: One length per text, or None where the provider + exposes no tokenizer. A caller must read None as "cannot tell", never + as zero. + """ + return None + + def count_over_limit(self, texts: list[str]) -> int | None: + """How many of ``texts`` exceed :attr:`sequence_limit`. + + Returns: + int | None: The count, or None when the limit or the tokenizer is + unknown. + """ + limit = self.sequence_limit + if limit is None or not texts: + return None + lengths = self.token_lengths(texts) + if lengths is None: + return None + return sum(1 for length in lengths if length > limit) + def embed_one(self, text: str) -> list[float]: """Return a vector for one query text.""" vectors = self.embed_query([text]) @@ -107,6 +151,37 @@ def _embed_raw(self, texts: list[str]) -> list[list[float]]: ) return [vector.tolist() for vector in vectors] + @property + def sequence_limit(self) -> int | None: + """The checkpoint's ``max_seq_length``. + + Frequently far below what the tokenizer's own ``model_max_length`` reports, + and it is this value that governs: sentence-transformers truncates to it + before the model sees the text. + """ + try: + limit = self._get_embedder().model.max_seq_length + except Exception: # noqa: BLE001 - a missing attribute must not break retrieval + return None + return int(limit) if limit else None + + def token_lengths(self, texts: list[str]) -> list[int] | None: + """Word pieces per text, from the checkpoint's own tokenizer. + + Tokenizes without encoding, which is cheap beside the forward pass this + accompanies. Prefixes are applied first, because an instruction prefix + counts against the same budget as the text it introduces. + """ + if not texts: + return [] + try: + tokenizer = self._get_embedder().model.tokenizer + prefixed = self._apply(self.config.query_prefix, texts) + encoded = tokenizer(prefixed, add_special_tokens=True)["input_ids"] + except Exception: # noqa: BLE001 - telemetry must never break retrieval + return None + return [len(ids) for ids in encoded] + class _LangChainEmbeddingTool(EmbeddingTool): """Base adapter for LangChain embedding clients.""" diff --git a/ontocast/tool/vector_store/lancedb.py b/ontocast/tool/vector_store/lancedb.py index d5505cd0..658cb0bb 100644 --- a/ontocast/tool/vector_store/lancedb.py +++ b/ontocast/tool/vector_store/lancedb.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any +import pyarrow as pa from pydantic import Field, PrivateAttr, model_validator from ontocast.config import EmbeddingConfig, LanceDBConfig, VectorStoreConfig @@ -36,6 +37,7 @@ atom_scope_fingerprint, collection_embedding_metadata, dedupe_hits_by_identity, + effective_bm25_top_k, effective_top_k, iter_batches, normalized_fusion_weights, @@ -307,6 +309,46 @@ def _ensure_indexes(self, table: Any) -> None: except Exception as exc: logger.debug("LanceDB FTS index not created: %s", exc) + def _atom_table_schema(self) -> pa.Schema: + """Arrow schema for the ontology atom table. + + Declared rather than inferred. ``create_table(data=...)`` types each column + from the first batch written, so a field that happens to be empty + throughout the first ontology -- ``ontology_version`` and ``entity_role`` + are optional, and an ontology may carry no lexical triggers at all -- is + typed ``null``. Every later ontology that *does* populate it then fails the + merge with "Unsupported cast from string to null", which makes indexing + order a correctness concern and silently caps a multi-ontology catalog at + whatever the first ontology happened to describe. + + Returns: + pa.Schema: Column types for atom payloads plus both dense vectors. + """ + dimension = self._dense_dimension() + return pa.schema( + [ + pa.field("point_id", pa.string()), + pa.field("atom_id", pa.string()), + pa.field("ontology_iri", pa.string()), + pa.field("ontology_id", pa.string()), + pa.field("ontology_hash", pa.string()), + pa.field("ontology_version", pa.string()), + pa.field("iri", pa.string()), + pa.field("entity_role", pa.string()), + pa.field("core_representation", pa.string()), + pa.field("minimal_representation", pa.string()), + pa.field("neighborhood_representation", pa.string()), + pa.field("lexical_triggers", pa.list_(pa.string())), + pa.field("symbol_surfaces", pa.list_(pa.string())), + pa.field("created_at", pa.string()), + pa.field("core_vector", pa.list_(pa.float32(), list_size=dimension)), + pa.field( + "neighborhood_vector", + pa.list_(pa.float32(), list_size=dimension), + ), + ] + ) + def _record_from_atom( self, atom: GraphAtom, @@ -345,7 +387,10 @@ def index_ontology(self, ontology: Ontology) -> int: table_name = self._ontology_table_name() tables = self._list_tables(db) if table_name not in tables: - db.create_table(table_name, data=records) + db.create_table( + table_name, + data=pa.Table.from_pylist(records, schema=self._atom_table_schema()), + ) self._write_embedding_meta() table = db.open_table(table_name) self._ensure_indexes(table) @@ -356,7 +401,7 @@ def index_ontology(self, ontology: Ontology) -> int: table.merge_insert( "point_id" ).when_matched_update_all().when_not_matched_insert_all().execute( # type: ignore[attr-defined] - records + pa.Table.from_pylist(records, schema=self._atom_table_schema()) ) self._register_lexical_triggers(atoms) return len(records) @@ -511,7 +556,9 @@ def search_hits_by_vector( bm25_hits: list[OntologySearchHit] = [] if bm25_query is not None: bm25_hits = self._search_bm25_channel( - bm25_query, limit=eff_top_k, where=where + bm25_query, + limit=effective_bm25_top_k(self.store_config, top_k), + where=where, ) if self.store_config.dedup_query_hits_by_iri: core_hits = dedupe_hits_by_identity( @@ -573,6 +620,7 @@ def search_patch_hits( core_weight=cw, neighborhood_weight=nw, bm25_weight=bw, + rank_constant=self.store_config.fusion_rank_constant, limit=eff_top_k, ) diff --git a/ontocast/tool/vector_store/patch_retriever.py b/ontocast/tool/vector_store/patch_retriever.py index 6a7ec334..48a7dc84 100644 --- a/ontocast/tool/vector_store/patch_retriever.py +++ b/ontocast/tool/vector_store/patch_retriever.py @@ -6,7 +6,7 @@ import logging import math from collections import defaultdict -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from typing import Any from pydantic import Field, PrivateAttr @@ -263,34 +263,6 @@ def _drop_module_contribution(graph: RDFGraph, module_graph: RDFGraph) -> None: graph.remove((subject, None, None)) -def _filter_hits_by_relative_floor( - hits: list[OntologySearchHit], - *, - score_ratio: float, - min_query_best_score: float, -) -> list[OntologySearchHit]: - """Relative score gating within one channel/query hit list. - - The floor is expressed as a distance below the best score rather than as - ``best * score_ratio``. The multiplicative form is equivalent for positive scores but - inverts once ``best`` is negative, which Qdrant cosine can return: at ``best = -0.2`` - it puts the floor at ``-0.16``, *above* the best hit, so the channel returns nothing. - A ratio of 0 also has to short-circuit — otherwise the documented "0 disables" default - computes a floor of exactly 0.0 and silently drops every negative-scoring hit. - """ - if score_ratio < 0.0 or score_ratio > 1.0: - raise ValueError("score_ratio must be in [0, 1]") - if not hits: - return [] - best = max(h.score for h in hits) - if min_query_best_score > 0.0 and best < min_query_best_score: - return [] - if score_ratio <= 0.0: - return list(hits) - floor = best - ((1.0 - score_ratio) * abs(best)) - return [hit for hit in hits if hit.score >= floor] - - def _normalize_relevance_scores(atoms: list[GraphAtom]) -> list[GraphAtom]: """Scale atom scores to [0, 1] for MMR relevance term.""" if not atoms: @@ -389,6 +361,10 @@ def _select_hits_round_robin_by_ontology( score order. ``<= 0`` disables each mechanism; ``max_atoms <= 0`` means no total cap. + Order is taken from *ranked_hits*, which is the caller's preference order + rather than necessarily a score order: the first hit of an ontology decides + where that ontology sits in the round-robin. + Ontologies are visited best-scoring first. Visiting them in IRI order instead made allocation alphabetical whenever the cap bound before every ontology was served — which is the common case, since the cap does not grow with catalog size. @@ -472,90 +448,25 @@ def _round_robin_fill(per_ontology_cap: int) -> None: return selected -def _merge_hits_across_queries_hybrid( - collected: list[OntologySearchHit], - *, - max_atoms_tier1: int, - per_ontology_seed_quota: int, - min_entity_score: float, - max_atoms_total: int, -) -> list[OntologySearchHit]: - """Tier-1 strong global seeds, tier-2 per-ontology coverage.""" - best_by_iri = _best_hit_by_entity_iri(collected) - if not best_by_iri: - return [] - - tier1_candidates = sorted( - best_by_iri.values(), - key=lambda hit: (hit.score, hit.atom.iri or ""), - reverse=True, - ) - tier1_limit = len(tier1_candidates) if max_atoms_tier1 <= 0 else max_atoms_tier1 - tier1 = tier1_candidates[:tier1_limit] - selected_iris = {hit.atom.iri for hit in tier1 if hit.atom.iri} - - by_ontology: dict[str, list[OntologySearchHit]] = defaultdict(list) - for hit in best_by_iri.values(): - onto_iri = hit.atom.ontology_iri - entity_iri = hit.atom.iri - if not onto_iri or not entity_iri or entity_iri in selected_iris: - continue - if hit.score >= min_entity_score: - by_ontology[onto_iri].append(hit) - - tier2: list[OntologySearchHit] = [] - quota = per_ontology_seed_quota if per_ontology_seed_quota > 0 else 9999 - for onto_iri in sorted(by_ontology.keys()): - candidates = sorted( - by_ontology[onto_iri], - key=lambda hit: (hit.score, hit.atom.iri or ""), - reverse=True, - ) - added = 0 - for hit in candidates: - if hit.atom.iri in selected_iris: - continue - tier2.append(hit) - selected_iris.add(hit.atom.iri) - added += 1 - if added >= quota: - break - - merged = tier1 + tier2 - if max_atoms_total > 0: - merged = merged[:max_atoms_total] - return merged - - def _merge_hits_across_queries( collected: list[OntologySearchHit], *, merge_mode: CrossQueryMergeMode, - max_atoms_tier1: int, per_ontology_seed_quota: int, - min_entity_score: float, max_atoms_total: int, ) -> list[OntologySearchHit]: - if merge_mode in (CrossQueryMergeMode.MAX_SCORE, CrossQueryMergeMode.SUM_SCORE): - merged = ( - _merge_hits_across_queries_max_score(collected) - if merge_mode == CrossQueryMergeMode.MAX_SCORE - else _merge_hits_across_queries_sum_score(collected) - ) - if max_atoms_total > 0: - return _select_hits_round_robin_by_ontology( - merged, - per_ontology_seed_quota=per_ontology_seed_quota, - max_atoms=max_atoms_total, - ) - return merged - return _merge_hits_across_queries_hybrid( - collected, - max_atoms_tier1=max_atoms_tier1, - per_ontology_seed_quota=per_ontology_seed_quota, - min_entity_score=min_entity_score, - max_atoms_total=max_atoms_total, + merged = ( + _merge_hits_across_queries_max_score(collected) + if merge_mode == CrossQueryMergeMode.MAX_SCORE + else _merge_hits_across_queries_sum_score(collected) ) + if max_atoms_total > 0: + return _select_hits_round_robin_by_ontology( + merged, + per_ontology_seed_quota=per_ontology_seed_quota, + max_atoms=max_atoms_total, + ) + return merged def _select_atoms_round_robin_by_ontology( @@ -586,51 +497,43 @@ def _filter_and_merge_patch_hits( *, store_config: VectorStoreConfig, patch_config: PatchRetrievalConfig, - per_query_core_score_ratio: float, - per_query_neighborhood_score_ratio: float, - per_query_bm25_score_ratio: float, - min_core_query_best_score: float, - min_neighborhood_query_best_score: float, - min_bm25_query_best_score: float, min_merged_max_score: float, max_atoms_total: int = 0, + stats: dict[str, int] | None = None, ) -> list[GraphAtom]: - """Filter each channel per query, then merge across queries.""" + """Fuse the three channels per query, then merge across queries. + + Args: + stats: Optional sink for counts taken *before* the score gates, which + the returned list can no longer distinguish: an empty result reads + the same whether search found nothing or a threshold rejected + everything, and the diagnostic downstream used to guess wrong. + Filled with ``candidate_hits`` and ``threshold_rejected``. + """ cw, nw, bw = normalized_fusion_weights(store_config) collected: list[OntologySearchHit] = [] for query_hits in hits_by_query: - filtered_core = _filter_hits_by_relative_floor( - query_hits.core_hits, - score_ratio=per_query_core_score_ratio, - min_query_best_score=min_core_query_best_score, - ) - filtered_neighborhood = _filter_hits_by_relative_floor( - query_hits.neighborhood_hits, - score_ratio=per_query_neighborhood_score_ratio, - min_query_best_score=min_neighborhood_query_best_score, - ) - filtered_bm25 = _filter_hits_by_relative_floor( - query_hits.bm25_hits, - score_ratio=per_query_bm25_score_ratio, - min_query_best_score=min_bm25_query_best_score, - ) collected.extend( rank_fuse_channel_hits( - filtered_core, - filtered_neighborhood, - filtered_bm25, + query_hits.core_hits, + query_hits.neighborhood_hits, + query_hits.bm25_hits, core_weight=cw, neighborhood_weight=nw, bm25_weight=bw, + rank_constant=store_config.fusion_rank_constant, limit=max( - len(filtered_core) - + len(filtered_neighborhood) - + len(filtered_bm25), + len(query_hits.core_hits) + + len(query_hits.neighborhood_hits) + + len(query_hits.bm25_hits), 1, ), ) ) + if stats is not None: + stats["candidate_hits"] = len(collected) + stats["threshold_rejected"] = 0 if not collected: return [] @@ -638,16 +541,29 @@ def _filter_and_merge_patch_hits( # max-score merging the merged top score is that same number, so this is unchanged; # under sum-score merging the merged total grows with window count and an absolute # threshold against it would be meaningless. + # + # The threshold is a fraction of the best score a window could possibly + # achieve, not an absolute number on the fused scale. The lane weights are + # normalized to sum to 1, so an atom ranked first in every lane scores + # `1 / (1 + rank_constant)` -- which means the constant rescales the whole + # scale an absolute threshold would be read against, and a threshold + # calibrated without smoothing rejected every candidate once smoothing was + # on. The symptom of that was an empty ontology context, which reads as a + # retrieval failure rather than a miscalibration. Scaling the gate by the + # same factor makes the setting mean one thing at every constant; at the + # default constant of 0 the factor is 1 and this is the previous behaviour + # exactly. best_window_score = max(hit.score for hit in collected) - if min_merged_max_score > 0.0 and best_window_score < min_merged_max_score: + gate = min_merged_max_score / (1.0 + store_config.fusion_rank_constant) + if min_merged_max_score > 0.0 and best_window_score < gate: + if stats is not None: + stats["threshold_rejected"] = len(collected) return [] merged_hits = _merge_hits_across_queries( collected, merge_mode=patch_config.cross_query_merge_mode, - max_atoms_tier1=patch_config.max_atoms_tier1, per_ontology_seed_quota=patch_config.per_ontology_seed_quota, - min_entity_score=patch_config.min_entity_score, max_atoms_total=max_atoms_total, ) if not merged_hits: @@ -1229,22 +1145,60 @@ def _schema_axiom_graph( return None async def _apply_small_module_closure( - self, graph: RDFGraph, hit_ontology_iris: list[str] + self, + graph: RDFGraph, + hit_ontology_iris: list[str], + relevance_by_ontology: Mapping[str, float] | None = None, ) -> None: """Merge whole small modules into the snapshot (header-stripped). A vocabulary small enough to fit entirely (e.g. a qualified-quantity - module of ~20 terms) is included wholesale once any of its atoms is - admitted: partial inclusion of a tiny module is what pushes the - renderer to improvise near-miss property names. + module of ~20 terms) is included wholesale once its atoms are admitted: + partial inclusion of a tiny module is what pushes the renderer to + improvise near-miss property names. + + Whole-module inclusion can be most of a facts prompt, and the prompt is + paid on every call of every unit, so + ``small_module_closure_max_total_triples`` can cap what it contributes. + The cap is spent in order of **relevance** -- the best score any of a + module's admitted atoms achieved -- and a module too large for what is + left is skipped rather than ending the pass, so a smaller one behind it + still gets in. + + Relevance and not seed count, deliberately. A module can win at most as + many seeds as it has terms, so ranking by count ranks by size, and would + exclude exactly the small, sharply relevant vocabulary this closure + exists to admit whole -- a seventeen-triple module that is precisely the + document's subject can never out-count a large peripheral one. Ordering + by best score is scale-free, and filling a budget best-first keeps the + ordinary case working, which is a *combination* of modules rather than + a single winner. """ closure_max = self.patch.small_module_closure_max_triples if closure_max <= 0: return modules = await self._asmall_module_candidates(hit_ontology_iris) + relevance = relevance_by_ontology or {} + # Best-first, ties broken by IRI so the same retrieval closes the same + # modules on every run -- the snapshot is a prompt, and an unstable + # prompt is uncacheable. + candidates = sorted( + ( + (onto_iri, ontology) + for onto_iri, ontology in modules + if len(ontology.graph) <= closure_max + ), + key=lambda item: (-relevance.get(item[0], 0.0), item[0]), + ) + budget = self.patch.small_module_closure_max_total_triples + remaining = float("inf") if budget is None else budget closed: list[str] = [] - for onto_iri, ontology in modules: - if len(ontology.graph) > closure_max: + declined: list[str] = [] + spent = 0 + for onto_iri, ontology in candidates: + size = len(ontology.graph) + if size > remaining: + declined.append(onto_iri) continue module_graph = Ontology.strip_ontology_header_triples(ontology.graph.copy()) _drop_module_contribution(graph, module_graph) @@ -1252,8 +1206,15 @@ async def _apply_small_module_closure( for prefix, namespace_uri in ontology.graph.namespaces(): graph.bind(prefix, namespace_uri) closed.append(onto_iri) + remaining -= size + spent += size if closed: self._last_retrieval_metrics["module_closure_iris"] = closed + self._last_retrieval_metrics["module_closure_triples"] = spent + if declined: + # Named, not merely counted: a question about a missing term is + # answered by knowing which module the budget kept out. + self._last_retrieval_metrics["module_closure_declined_iris"] = declined async def _asmall_module_candidates( self, hit_ontology_iris: list[str] @@ -1605,17 +1566,13 @@ async def aretrieve_ensemble( sc = self.vector_store.store_config pc = self.patch eff_max_atoms = pc.effective_max_atoms(len(queries)) + merge_stats: dict[str, int] = {} merged = _filter_and_merge_patch_hits( hits_by_query, store_config=sc, patch_config=pc, - per_query_core_score_ratio=pc.per_query_core_score_ratio, - per_query_neighborhood_score_ratio=pc.per_query_neighborhood_score_ratio, - per_query_bm25_score_ratio=pc.per_query_bm25_score_ratio, - min_core_query_best_score=pc.min_core_query_best_score, - min_neighborhood_query_best_score=pc.min_neighborhood_query_best_score, - min_bm25_query_best_score=pc.min_bm25_query_best_score, min_merged_max_score=pc.min_merged_max_score, + stats=merge_stats, max_atoms_total=0, ) atoms_after_dedupe = len(merged) @@ -1630,6 +1587,14 @@ async def aretrieve_ensemble( ranked_before_cut = list(merged) + # The atom floors are guarantees -- a small module and the predicate + # role each keep a minimum of the cap. They used to hold only under the + # max_score/sum_score merge modes: `hybrid` fell through to a bare slice + # and silently dropped a guarantee nothing had turned off. MMR replaces + # the selection wholesale and is incompatible with them by construction, + # which ToolConfig now rejects rather than resolving here -- a reserve + # taken in score order would leave MMR nothing to choose whenever one + # ontology supplies the candidates, which is the common case. if merged and pc.mmr_lambda < 1.0: merged = _normalize_relevance_scores(merged) vectors = await self.vector_store.afetch_vectors( @@ -1644,10 +1609,10 @@ async def aretrieve_ensemble( core_weight=core_w, neighborhood_weight=neigh_w, ) - elif pc.cross_query_merge_mode in ( - CrossQueryMergeMode.MAX_SCORE, - CrossQueryMergeMode.SUM_SCORE, - ): + else: + # With both floors and the quota at 0 this is `merged[:cap]`, and + # with no cap it is the identity -- so the previously separate + # hybrid branch is this one, now with the floors applied. merged = _select_atoms_round_robin_by_ontology( merged, per_ontology_seed_quota=pc.per_ontology_seed_quota, @@ -1655,8 +1620,6 @@ async def aretrieve_ensemble( per_ontology_atom_floor=pc.per_ontology_atom_floor, per_role_atom_floor=pc.per_role_atom_floor, ) - elif eff_max_atoms > 0: - merged = merged[:eff_max_atoms] trigger_source = trigger_source or " ".join(queries) trigger_atoms = await asyncio.to_thread( @@ -1680,6 +1643,8 @@ async def aretrieve_ensemble( "query_count": len(queries), "top_k": eff_top_k, "effective_max_atoms": eff_max_atoms, + "candidate_hits": merge_stats.get("candidate_hits", 0), + "threshold_rejected": merge_stats.get("threshold_rejected", 0), "atoms_after_dedupe": atoms_after_dedupe, "atoms_final": 0, "seed_iris": [], @@ -1699,20 +1664,35 @@ async def aretrieve_ensemble( source_iris = _source_iris_from_atoms(merged) seeds_by_ontology: dict[str, int] = defaultdict(int) + # Best score any of a module's admitted atoms achieved. Unlike the seed + # count -- which a module cannot exceed the size of, so it ranks modules + # by how many terms they have -- this says how well the module's best + # term matched, which is what "relevant to this unit" means. + relevance_by_ontology: dict[str, float] = {} for atom in merged: - if atom.ontology_iri: - seeds_by_ontology[atom.ontology_iri] += 1 + if not atom.ontology_iri: + continue + seeds_by_ontology[atom.ontology_iri] += 1 + score = atom.score + if score is not None: + relevance_by_ontology[atom.ontology_iri] = max( + relevance_by_ontology.get(atom.ontology_iri, float("-inf")), + float(score), + ) self._last_retrieval_metrics = { "query_count": len(queries), "top_k": eff_top_k, "effective_max_atoms": eff_max_atoms, "merge_mode": pc.cross_query_merge_mode.value, + "candidate_hits": merge_stats.get("candidate_hits", 0), + "threshold_rejected": merge_stats.get("threshold_rejected", 0), "atoms_after_dedupe": atoms_after_dedupe, "atoms_final": len(merged), "seed_iris": [atom.iri for atom in merged if atom.iri], "source_ontology_iris": source_iris, "seeds_by_ontology": dict(seeds_by_ontology), + "relevance_by_ontology": dict(relevance_by_ontology), "lexical_trigger_hits": len(trigger_atoms), "lexical_trigger_atom_ids": [a.atom_id for a in trigger_atoms], "lexical_trigger_iris": [a.iri for a in trigger_atoms if a.iri], @@ -1720,6 +1700,18 @@ async def aretrieve_ensemble( "lexical_trigger_appended": trigger_appended, "symbol_case_penalized": symbol_case_penalized, } + # Truncation has no symptom of its own: the encoder returns a correctly + # shaped vector for a prefix of the query and nothing downstream can tell + # the tail was dropped. Counting it here is what turns a silent recall loss + # into a number a deployment can see before it tunes anything else. + embedding_tool = getattr(self.vector_store, "embedding", None) + if embedding_tool is not None: + limit = embedding_tool.sequence_limit + if limit is not None: + self._last_retrieval_metrics["query_sequence_limit"] = limit + over = embedding_tool.count_over_limit(queries) + if over is not None: + self._last_retrieval_metrics["queries_truncated"] = over if pc.dump_ontology_ranks: self._last_retrieval_metrics["ontology_rank_diagnostics"] = ( build_ontology_rank_diagnostics( @@ -1831,7 +1823,11 @@ async def aretrieve_ensemble( entity_groups=entity_groups, extra_description_predicates=symbol_predicates, ) - await self._apply_small_module_closure(graph, hit_ontology_iris) + await self._apply_small_module_closure( + graph, + hit_ontology_iris, + self._last_retrieval_metrics.get("relevance_by_ontology"), + ) self._last_retrieval_metrics["snapshot_triple_count"] = len(graph) self._last_retrieval_metrics["ontology_iris_for_expansion"] = ontology_iris diff --git a/ontocast/tool/vector_store/qdrant.py b/ontocast/tool/vector_store/qdrant.py index 0e2c4bb3..d49f2bb8 100644 --- a/ontocast/tool/vector_store/qdrant.py +++ b/ontocast/tool/vector_store/qdrant.py @@ -44,6 +44,7 @@ atom_scope_fingerprint, collection_embedding_metadata, dedupe_hits_by_identity, + effective_bm25_top_k, effective_top_k, embedding_contract_help, identity_key_for_atom, @@ -523,6 +524,7 @@ def search_patch_hits( core_weight=cw, neighborhood_weight=nw, bm25_weight=bw, + rank_constant=self.store_config.fusion_rank_constant, limit=eff_top_k, ) @@ -741,6 +743,7 @@ def search_by_vector( core_weight=cw, neighborhood_weight=nw, bm25_weight=bw, + rank_constant=self.store_config.fusion_rank_constant, limit=eff_top_k, ) return [hit.atom for hit in fused_hits] @@ -783,7 +786,7 @@ def search_hits_by_vector( bm25_hits_raw = self._query_named_vector( vector_name=BM25_VECTOR_NAME, vector=bm25_query_vector, - limit=eff_top_k, + limit=effective_bm25_top_k(self.store_config, top_k), search_filter=search_filter, ) core_typed_hits = self._points_to_hits(core_hits) diff --git a/ontocast/tool/vector_store/util.py b/ontocast/tool/vector_store/util.py index 887be609..fac31d9e 100644 --- a/ontocast/tool/vector_store/util.py +++ b/ontocast/tool/vector_store/util.py @@ -281,30 +281,53 @@ def rank_fuse_channel_hits( neighborhood_weight: float, bm25_weight: float, limit: int, + rank_constant: float = 0.0, ) -> list[OntologySearchHit]: + """Fuse three ranked channels into one list by weighted reciprocal rank. + + Each channel contributes ``weight / (rank_constant + rank)`` per atom, summed + across channels. Raw channel scores never enter the fused score -- they are only + a tiebreak -- which is what lets an uncalibrated BM25 scale sit beside cosine + without either dominating by units alone. + + ``rank_constant`` is the smoothing term. At 0 (the default, and the historical + behaviour) a rank-2 hit is worth exactly half a rank-1 hit and rank 3 a third, + so the fused order is decided almost entirely by which channel put what first; + a deep list of weak matches still hands out ranks 1..N at full lane weight. + Raising it flattens that decay, so agreement *across* channels outweighs + position *within* one -- which is the property reciprocal-rank fusion is + usually chosen for. + + Args: + core_hits: Core-lane hits, best first. + neighborhood_hits: Neighborhood-lane hits, best first. + bm25_hits: Sparse-lane hits, best first. + core_weight: Normalized weight for the core lane. + neighborhood_weight: Normalized weight for the neighborhood lane. + bm25_weight: Normalized weight for the sparse lane. + limit: Maximum hits to return. + rank_constant: Added to each rank before the reciprocal. + + Returns: + list[OntologySearchHit]: Fused hits, best first, each carrying the fused + score in place of its channel score. + """ rank_scores: dict[str, float] = {} best_hit_by_id: dict[str, OntologySearchHit] = {} - for rank, hit in enumerate(core_hits, start=1): - atom_id = hit.atom.atom_id - rank_scores[atom_id] = rank_scores.get(atom_id, 0.0) + (core_weight / rank) - prev = best_hit_by_id.get(atom_id) - if prev is None or hit.score > prev.score: - best_hit_by_id[atom_id] = hit - for rank, hit in enumerate(neighborhood_hits, start=1): - atom_id = hit.atom.atom_id - rank_scores[atom_id] = rank_scores.get(atom_id, 0.0) + ( - neighborhood_weight / rank - ) - prev = best_hit_by_id.get(atom_id) - if prev is None or hit.score > prev.score: - best_hit_by_id[atom_id] = hit - for rank, hit in enumerate(bm25_hits, start=1): - atom_id = hit.atom.atom_id - rank_scores[atom_id] = rank_scores.get(atom_id, 0.0) + (bm25_weight / rank) - prev = best_hit_by_id.get(atom_id) - if prev is None or hit.score > prev.score: - best_hit_by_id[atom_id] = hit + def fold(hits: list[OntologySearchHit], weight: float) -> None: + for rank, hit in enumerate(hits, start=1): + atom_id = hit.atom.atom_id + rank_scores[atom_id] = rank_scores.get(atom_id, 0.0) + ( + weight / (rank_constant + rank) + ) + prev = best_hit_by_id.get(atom_id) + if prev is None or hit.score > prev.score: + best_hit_by_id[atom_id] = hit + + fold(core_hits, core_weight) + fold(neighborhood_hits, neighborhood_weight) + fold(bm25_hits, bm25_weight) ranked_atom_ids = sorted( rank_scores.keys(), @@ -479,6 +502,24 @@ def effective_top_k(store_config: VectorStoreConfig, top_k: int | None) -> int: return store_config.top_k +def effective_bm25_top_k(store_config: VectorStoreConfig, top_k: int | None) -> int: + """Depth of the sparse lane, which need not match the dense lanes'. + + Fusion is by reciprocal rank, so a channel's *depth* is a weight in disguise: a + sparse list of length N hands out ranks 1..N at full lane weight however weak its + tail is. The dense and sparse lanes fail differently -- dense retrieval degrades + gracefully into topical near-misses, lexical retrieval into unrelated documents + that share a token -- so the depth at which each stops being useful is not the + same number, and tying them together means tuning one mis-tunes the other. + + Returns: + int: ``bm25_top_k`` when set, else whatever the dense lanes use. + """ + if store_config.bm25_top_k is not None: + return store_config.bm25_top_k + return effective_top_k(store_config, top_k) + + def iter_batches(items: list[Any], batch_size: int) -> list[list[Any]]: batches: list[list[Any]] = [] for index in range(0, len(items), batch_size): diff --git a/ontocast/toolbox.py b/ontocast/toolbox.py index 9d7fb263..f87280ca 100644 --- a/ontocast/toolbox.py +++ b/ontocast/toolbox.py @@ -6,14 +6,15 @@ from langchain_core.output_parsers import PydanticOutputParser from langchain_core.prompts import PromptTemplate +from rdflib.namespace import OWL, RDF from ontocast.config import Config from ontocast.onto.constants import ONTOLOGY_NULL_IRI -from ontocast.onto.enum import OntologyContextMode +from ontocast.onto.enum import OntologyContextMode, RenderMode from ontocast.onto.ontology import Ontology, OntologyProperties from ontocast.onto.ontology_access import document_ontology_access from ontocast.onto.rdfgraph import RDFGraph -from ontocast.onto.state import AgentState +from ontocast.onto.state import AgentState, BudgetTracker from ontocast.onto.tenancy import TenancyScope from ontocast.runtime import ToolBoxRuntime from ontocast.tool import ( @@ -49,6 +50,38 @@ logger = logging.getLogger(__name__) +def warn_if_workers_exceed_inflight(config: Config) -> bool: + """Warn when PARALLEL_WORKERS cannot all reach the provider at once. + + A unit worker never issues two LLM calls concurrently, so the provider + concurrency one document can reach is ``min(PARALLEL_WORKERS, + LLM_MAX_INFLIGHT)``. Workers past the in-flight cap do not run: they queue + behind it -- the wait lands in ``llm/inflight_wait`` -- while each still + holds a unit slot and its memory. Said once, where the LLM client is built, + rather than at every call that queues. + + Args: + config: Fully resolved configuration. + + Returns: + Whether a warning was emitted. + """ + workers = config.server.parallel_workers + inflight = config.get_tool_config().llm_config.llm_max_inflight + if workers <= inflight: + return False + logger.warning( + "PARALLEL_WORKERS=%d exceeds LLM_MAX_INFLIGHT=%d: at most %d units of a " + "document reach the provider at once and the rest queue " + "(budget.node_durations['llm/inflight_wait']). Lower PARALLEL_WORKERS, " + "or raise LLM_MAX_INFLIGHT to what the provider tier allows.", + workers, + inflight, + inflight, + ) + return True + + async def update_ontology_properties(o: Ontology, llm_tool: LLMTool): """Update ontology properties using LLM analysis, only if missing. @@ -140,6 +173,7 @@ async def acreate(cls, config: Config) -> "ToolBox": A ready ToolBox. Call :meth:`initialize` to sync ontologies and prepare backend schema. """ + warn_if_workers_exceed_inflight(config) runtime = await ToolBoxRuntime.acreate(config) return cls(config, runtime=runtime) @@ -169,6 +203,10 @@ def __init__( # Tools that do not vary by tenant live on the runtime, so a registry of # scoped ToolBoxes shares one LLM client, converter and embedding model. + if runtime is None: + # A process-level fact, said where the process builds its LLM + # client -- not once per tenancy scope over a shared runtime. + warn_if_workers_exceed_inflight(config) self.runtime = runtime or ToolBoxRuntime(config, llm=llm) # Create triple store manager: Fuseki when configured, otherwise in-memory. @@ -204,15 +242,22 @@ def __init__( # Set by attach_registry() when this ToolBox fronts a multi-tenant host. self._registry: "ToolBoxRegistry | None" = None - # Graph algorithms over graphs it is handed; it does not fetch. - self.sparql_tool: SPARQLTool = SPARQLTool( - triple_store_manager=self.triple_store_manager - ) + # Graph algorithms over graphs it is handed; it does not fetch. Built + # only when something consumes it: its sole consumer is + # OntologyPatchRetriever, which exists only alongside a vector store, + # so constructing it unconditionally made the dependency graph claim + # "always needed" where the truth is "needed with vector retrieval". + self.sparql_tool: SPARQLTool | None = None self.vector_store: VectorStoreManager | None = None self.patch_retriever: OntologyPatchRetriever | None = None self.vector_store_ready: bool = False self.vector_store_last_error: Exception | None = None + # Seed ontologies the last sync had to supply because the partition + # served no graph for them. Read by the tenancy switch, which must + # materialize exactly those and nothing else -- rewriting a whole + # catalog on every switch is what scoped ToolBoxes exist to avoid. + self._last_seed_repairs: list[Ontology] = [] # The factory owns backend selection, including resolving AUTO and # returning None when the backend is explicitly disabled. Both @@ -230,6 +275,9 @@ def __init__( ) if vector_store is not None: self.vector_store = vector_store + self.sparql_tool = SPARQLTool( + triple_store_manager=self.triple_store_manager + ) self.patch_retriever = OntologyPatchRetriever( vector_store=vector_store, sparql_tool=self.sparql_tool, @@ -361,7 +409,7 @@ def get_entity_aligner( else tool_config.aggregation.similarity_threshold, ) - async def get_llm_tool(self, budget_tracker): + async def get_llm_tool(self, budget_tracker: BudgetTracker) -> LLMTool: """Return the shared LLM tool, charging usage to ``budget_tracker``. Args: @@ -585,21 +633,29 @@ async def _update_tenancy_with_vector_mode_locked( # ``None`` means this is the first assignment, which happens at startup # before ``initialize()``; leave the population to it rather than # fetching twice. Any later switch must repopulate -- including when the - # partition we are leaving was empty. Seed TTLs are deliberately not - # replayed here: writing them into a different tenant as a side effect - # of a query parameter would be a surprise. + # partition we are leaving was empty. is_first_assignment = self._active_tenancy is None self.ontology_manager.reset_catalog() self.shapes_catalog.reset() self._active_tenancy = (t, p) if not is_first_assignment and triple is not None: - for ontology in await triple.afetch_ontologies(): + # Seed TTLs are replayed into the new partition, but only where + # it serves no graph of its own: a scope whose catalog is empty + # for want of a bootstrap is the same fault as a startup one, + # and answering it with "the seeds are startup-only" left the + # tenant extracting against no vocabulary at all. A partition + # that already serves an ontology keeps it -- the seed never + # overwrites a terminal a previous run evolved. + for ontology in await self._synchronize_ontologies(): self.ontology_manager.add_ontology(ontology, skip_vector_index=True) - # Seed TTLs are not replayed into a new tenant (see above), so - # the new scope's shapes are whatever its partition already - # holds -- possibly nothing, which correctly reads as "SHACL - # never checked" rather than "conforms". - await self.shapes_catalog.sync() + for seed in self._last_seed_repairs: + await self._materialize_ontology(seed) + # Shapes are still whatever the partition holds plus the + # configured directory, and may be nothing -- which correctly + # reads as "SHACL never checked" rather than "conforms". + await self.shapes_catalog.sync( + self.config.tool_config.facts_validation.shapes_dir + ) if self.vector_store is not None: # No config copy-back: `create_vector_store_manager` passes @@ -656,14 +712,85 @@ async def clean_tenancy_data( vector = self.vector_store if vector is not None and vector.supports_tenancy_partition(): + was_ready = self.vector_store_ready + # `clean_tenancy` drops the collections and their embedding + # metadata. Leaving the store marked ready would point every + # later search in this process at a collection that no longer + # exists, so readiness is dropped first and only restored by a + # successful recreate -- the same wipe/recreate order the + # startup path uses. Nothing is reindexed: the flush emptied + # the catalog this index would have been built from. + self.vector_store_ready = False await vector.clean_tenancy(t, p) + if was_ready: + try: + await vector.initialize() + self.vector_store_ready = True + self.vector_store_last_error = None + except Exception as exc: + self.vector_store_last_error = exc + logger.warning( + "Vector store could not be recreated after flushing " + "%s/%s; vector retrieval is off until it is " + "reinitialized: %s", + t, + p, + exc, + ) if include_shapes and (t, p) == self._active_tenancy: self.shapes_catalog.reset() def get_atomic_tools(self) -> AtomicToolBox: - """Return the minimal toolbox used by atomic render/critic paths.""" - return self.atomic_tools + """Return the minimal toolbox used by atomic render/critic paths. + + The runtime's :class:`AtomicToolBox` is shared by every scope; what is + handed out here is a shallow copy bound to *this* scope's ontology + catalog, so the per-unit repairs can ask the whole catalog whether a + term exists rather than only the unit's retrieved snapshot. The copy + is per call and carries no state of its own -- the catalog-term memo + lives on the catalog -- so a tool replaced on the shared instance is + seen by the next unit. + """ + return self.atomic_tools.scoped_to_catalog(self.ontology_manager) + + def shapes_prompt_contract(self) -> tuple[str, tuple[str, ...], bool]: + """Conformance chapter, term exemptions, and the selection flag. + + ``("", (), False)`` when the contract is off or the shapes partition + is empty -- the prompt is then byte-identical to a shape-less + deployment's. Full-catalog modes return the memoized whole chapter + with the flag False. When per-unit selection is in force (mode + ``context``, or ``auto`` with a catalog that outgrew the line cap) + the chapter comes back empty with the flag True, and the unit loop + fills it via :meth:`shapes_chapter_for_context` once the unit's + ontology snapshot exists. Exemption terms are the full catalog's in + every mode -- the gate validates against every shape, and the terms + are catalog IRIs either way. + """ + cfg = self.config.tool_config.facts_validation + mode = cfg.shapes_prompt_contract + if mode == "off": + return "", (), False + max_lines = cfg.shapes_prompt_max_lines + terms = self.shapes_catalog.prompt_contract_terms(max_lines=max_lines) + select = mode == "context" or ( + mode == "auto" and self.shapes_catalog.needs_selection(max_lines=max_lines) + ) + if select: + return "", terms, True + return ( + self.shapes_catalog.conformance_chapter(max_lines=max_lines), + terms, + False, + ) + + def shapes_chapter_for_context(self, context_terms: set[str]) -> str: + """Per-unit conformance chapter, joined on the unit's context IRIs.""" + cfg = self.config.tool_config.facts_validation + return self.shapes_catalog.selected_chapter( + context_terms, max_lines=cfg.shapes_prompt_max_lines + ) def serialize(self, state: AgentState) -> None: """Persist the document's ontologies and facts. @@ -716,6 +843,186 @@ def should_initialize_vector_store( def is_vector_store_ready(self) -> bool: return self.vector_store is not None and self.vector_store_ready + async def _check_catalog_index_agreement( + self, synchronized_ontologies: list[Ontology] + ) -> None: + """Refuse to start when the vector index knows ontologies the store lost. + + The two halves of retrieval can disagree, and when they do the pipeline + does not notice: vector search selects atoms from the index, then the + induced subgraph is built over the *catalog*, so an index that still + holds a vocabulary the triple store no longer serves yields perfectly + healthy retrieval metrics -- the expected seed IRIs, the expected atom + count -- and an empty graph. Every unit then renders against an empty + ontology chapter, falls back on generic vocabulary, and passes a + conformance check that has no node left to constrain. + + Only the unambiguous case is fatal here: an index with content beside + a catalog with none. Extra indexed IRIs alongside a populated catalog + are ordinary staleness (that is what orphan pruning is for) and only + warn. The mirror case -- an empty index, which a wipe guarantees and + which this check therefore cannot see -- belongs to + :meth:`_check_catalog_ready`, after materialization has had its chance + to refill it. + + This does not consult ``ONTOLOGY_CONTEXT_REQUIRED``. That setting says + whether a run wants a catalog; this says the two halves of retrieval + disagree about which ontologies exist, which no configuration asks for. + A run that deliberately extracts without a catalog does not thereby ask + for a stale index to select atoms nothing can expand. Nor does it fire + during a legitimate bootstrap, where index and catalog are both empty. + + Args: + synchronized_ontologies: What the catalog actually served. + + Raises: + EmptyOntologyContextError: The catalog is empty and the index is + not. + """ + # Deferred: retrieval_capabilities imports ToolBox, so a module-level + # import here would be circular. + from ontocast.onto.retrieval_capabilities import EmptyOntologyContextError + + if not self.is_vector_store_ready() or self.vector_store is None: + return + try: + indexed = await asyncio.to_thread( + self.vector_store.list_indexed_ontology_iris + ) + except Exception as exc: # noqa: BLE001 - diagnostics must not break startup + logger.warning("Could not inspect the vector index: %s", exc) + return + if not indexed: + return + catalog = {o.iri for o in synchronized_ontologies if o.iri} + if not catalog: + message = ( + "The ontology catalog is empty but the vector index holds " + f"{len(indexed)} ontology IRI(s): {sorted(indexed)}. Retrieval " + "would select atoms the triple store cannot expand, so every " + "unit would render against an empty ontology and the " + "conformance gate would report a vacuous pass. Load the " + "ontologies into the triple store, or wipe the " + "index (VECTOR_STORE_WIPE_ON_INIT / --wipe-vector-store)." + ) + raise EmptyOntologyContextError(message) + orphans = indexed - catalog + if orphans: + logger.warning( + "Vector index holds %d ontology IRI(s) absent from the " + "catalog: %s. Retrieval can select atoms the induced subgraph " + "cannot expand. Enable orphan pruning or reindex.", + len(orphans), + sorted(orphans), + ) + + def _catalog_sources_description(self) -> str: + """Name the places a catalog could have come from, for an error message.""" + directory = self.config.tool_config.path_config.ontology_directory + sources = [ + f"ontology_directory={directory}" + if directory is not None + else "ontology_directory is unset" + ] + triple = self.triple_store_manager + if isinstance(triple, FusekiTripleStoreManager): + sources.append(f"triple-store dataset={triple.ontologies_dataset!r}") + elif triple is not None: + sources.append(f"triple store={type(triple).__name__}") + return "; ".join(sources) + + async def _check_catalog_ready( + self, synchronized_ontologies: list[Ontology], *, required: bool + ) -> None: + """Refuse to run against a catalog nothing can be extracted from. + + Two failures reach this point looking like success, because the wipe in + :meth:`initialize` is unconditional while the refill that follows it is + not: + + 1. The sync found no ontologies at all, so materialization had nothing + to write and the index a wipe just emptied stays empty. + 2. The sync found ontologies but every one of them indexed to nothing, + so retrieval has a catalog it can expand and no atoms to select. + + Either way every content unit resolves an empty ontology context. Under + ``ONTOLOGY_CONTEXT_REQUIRED`` that is fatal per unit anyway -- failing + here instead names the subsystem, costs no provider calls, and happens + before a document is converted. + + The two are gated differently. An empty catalog may be exactly what + the operator meant -- a server filled later over HTTP, or a render mode + that builds its own vocabulary -- so it is fatal only where the caller + says it cannot be. An empty index over a *populated* catalog is never + meant: it says materialization ran and produced nothing, which no + configuration asks for, so it does not consult + ``ONTOLOGY_CONTEXT_REQUIRED`` either. + + Args: + synchronized_ontologies: What the sync resolved. + required: Whether an empty catalog is fatal. False for a server that + legitimately starts empty and is filled through ``POST + /ontologies``, and for any run whose render mode creates + ontologies; True for a facts-only batch run, which can only + produce an ungrounded graph without one. + + Raises: + EmptyOntologyContextError: The catalog is empty and this entry + point requires one, or the index is empty over a populated + catalog. + """ + # Deferred: retrieval_capabilities imports ToolBox, so a module-level + # import here would be circular. + from ontocast.onto.retrieval_capabilities import EmptyOntologyContextError + + fatal = required and self.config.server.ontology_context_required + + def _report(message: str) -> None: + if fatal: + raise EmptyOntologyContextError(message) + logger.warning(message) + + if not synchronized_ontologies: + if self.config.server.render_mode != RenderMode.FACTS: + # The starting point of an ontology-rendering run, not a fault: + # each unit with no context is sent to render_ontology_fresh, + # which mints a catalog ontology from the text. + logger.info( + "The ontology catalog is empty (%s); render_mode=%s will " + "create ontologies from the corpus.", + self._catalog_sources_description(), + self.config.server.render_mode.value, + ) + return + _report( + "The ontology catalog resolved to zero ontologies " + f"({self._catalog_sources_description()}) and render_mode=" + f"{self.config.server.render_mode.value} creates none. Every " + "content unit would render against an empty ontology, fall " + "back on generic vocabulary, and pass a conformance check with " + "no node left to constrain. Seed the catalog, or render " + "ontologies as well as facts." + ) + return + + if not self.is_vector_store_ready() or self.vector_store is None: + return + try: + indexed = await asyncio.to_thread( + self.vector_store.list_indexed_ontology_iris + ) + except Exception as exc: # noqa: BLE001 - diagnostics must not break startup + logger.warning("Could not inspect the vector index: %s", exc) + return + if not indexed: + raise EmptyOntologyContextError( + f"The ontology catalog holds {len(synchronized_ontologies)} " + "ontolog(ies) but the vector index is empty after " + "materialization, so vector retrieval has nothing to select. " + "Check for indexing errors above, or switch " + "ONTOLOGY_CONTEXT_MODE away from selected_vector_search_ontology." + ) + async def initialize( self, *, @@ -723,6 +1030,7 @@ async def initialize( fail_on_vector_store_error: bool = True, wipe_vector_store: bool | None = None, prune_orphan_iris: bool | None = None, + require_populated_catalog: bool = False, ) -> None: """Initialize the toolbox with ontologies and their properties. @@ -738,6 +1046,17 @@ async def initialize( ``None`` uses ``VECTOR_STORE_WIPE_ON_INIT`` (default False). prune_orphan_iris: Delete indexed IRIs absent from the sync catalog. ``None`` uses ``VECTOR_STORE_PRUNE_ORPHAN_IRIS_ON_INIT`` (default True). + require_populated_catalog: Fail rather than warn when the catalog or + its index comes out empty. A batch run sets this -- it has no + later chance to be given ontologies, so an empty catalog can + only yield an ungrounded graph. A server leaves it False: + starting empty and being filled through ``POST /ontologies`` is + a supported way to run. + + Raises: + EmptyOntologyContextError: ``require_populated_catalog`` and + ``ONTOLOGY_CONTEXT_REQUIRED`` are both set and the catalog or + its index is empty once synchronization has finished. """ import asyncio import time @@ -754,8 +1073,34 @@ async def initialize( if self.triple_store_manager is not None: await self.triple_store_manager.async_init() + # The wipe is honoured whatever the context mode is. It used to sit + # inside the mode-gated branch below, so under any non-vector mode a + # destructive flag was accepted, did nothing, and said nothing -- and a + # request that meant to clear a partition before reindexing left the + # old vectors in place. + vector_store = self.vector_store + if do_wipe: + if vector_store is None: + logger.warning( + "A vector store wipe was requested but no vector store is " + "configured; nothing to wipe" + ) + else: + logger.warning( + "Wiping vector store partition before initialize " + "(wipe_vector_store=True)" + ) + try: + await vector_store.wipe_store() + except Exception as exc: + self.vector_store_last_error = exc + if fail_on_vector_store_error: + raise + logger.warning("Vector store wipe failed: %s", exc) + finally: + self.vector_store_ready = False + if self.should_initialize_vector_store(ontology_context_mode): - vector_store = self.vector_store if vector_store is None: self.vector_store_ready = False self.vector_store_last_error = RuntimeError( @@ -768,12 +1113,6 @@ async def initialize( ) else: try: - if do_wipe: - logger.warning( - "Wiping vector store partition before initialize " - "(wipe_vector_store=True)" - ) - await vector_store.wipe_store() await vector_store.initialize() self.vector_store_ready = True self.vector_store_last_error = None @@ -830,6 +1169,8 @@ async def initialize( orphans, ) + await self._check_catalog_index_agreement(synchronized_ontologies) + for ontology in synchronized_ontologies: self.ontology_manager.add_ontology(ontology, skip_vector_index=True) @@ -839,11 +1180,12 @@ async def initialize( async def _materialize_one(ontology: Ontology) -> None: async with semaphore: onto_started = time.perf_counter() - await self._materialize_ontology(ontology) + indexed = await self._materialize_ontology(ontology) logger.info( - "Materialized ontology %s in %.2fs", + "Materialized ontology %s in %.2fs (%d atom(s) indexed)", ontology.iri, time.perf_counter() - onto_started, + indexed, ) materialize_started = time.perf_counter() @@ -860,16 +1202,44 @@ async def _materialize_one(ontology: Ontology) -> None: time.perf_counter() - init_started, ) + # After materialization, not before: the wipe above is unconditional and + # the refill is not, so an empty index is only evidence of a fault once + # the reindex has had its turn. + await self._check_catalog_ready( + synchronized_ontologies, required=require_populated_catalog + ) + def _load_seed_ontologies_from_directory(self) -> list[Ontology]: - """Load seed ontologies from ``ontology_directory`` (*.ttl).""" + """Load seed ontologies from ``ontology_directory`` (*.ttl). + + Every way this returns nothing says so at INFO or louder. A silent + empty list here is indistinguishable downstream from a healthy run + against a catalog that happens to live in the triple store, and it is + the difference between a five-minute diagnosis and a long one. + """ ontology_dir = self.config.tool_config.path_config.ontology_directory if ontology_dir is None: + logger.info( + "No ontology_directory configured; seed ontologies come from " + "the triple store only" + ) return [] directory = pathlib.Path(ontology_dir).expanduser() if not directory.is_dir(): + logger.warning( + "Configured ontology_directory %s is not a directory (resolved " + "from %s); no seed ontologies will be loaded", + directory.absolute(), + ontology_dir, + ) return [] ontologies: list[Ontology] = [] - for path in sorted(directory.glob("*.ttl")): + paths = sorted(directory.glob("*.ttl")) + if not paths: + logger.warning( + "No *.ttl files in ontology_directory %s", directory.absolute() + ) + for path in paths: try: ontologies.append(Ontology.from_file(path)) logger.debug("Loaded seed ontology from %s", path) @@ -877,17 +1247,64 @@ def _load_seed_ontologies_from_directory(self) -> list[Ontology]: logger.error("Failed to load seed ontology %s: %s", path, exc) return ontologies + @staticmethod + def _defines_terms(ontology: Ontology) -> bool: + """Whether an ontology carries terms rather than only its own header. + + The header is not the ontology. A catalog read builds one of these from + the ``owl:Ontology`` subject and fills in the graph separately, so an + ontology whose graph never arrived still answers with three triples + about itself -- a non-empty graph that defines nothing, and that no + amount of retrieval can expand into context. + """ + header_subjects = { + subject + for subject, _, _ in ontology.graph.triples((None, RDF.type, OWL.Ontology)) + } + return any(subject not in header_subjects for subject, _, _ in ontology.graph) + + @classmethod + def _seed_ontologies_missing_from( + cls, served: list[Ontology], seeds: list[Ontology] + ) -> list[Ontology]: + """Seeds the partition serves no usable graph for. + + The test is the graph, not the IRI. A store that *lists* an ontology but + serves no terms for it -- a lost dataset, a partition that was never + populated, a graph dropped out from under a still-registered header -- + used to be indistinguishable from a healthy one, so the seed that could + have repaired it was skipped and the catalog stayed empty. + + A served ontology that does define terms always wins. Ontology-mode runs + write evolved terminals back to the store, and re-materializing an older + seed over one of those would silently revert the catalog to whatever is + on disk. + """ + served_by_iri = {ontology.iri: ontology for ontology in served if ontology.iri} + missing: list[Ontology] = [] + for seed in seeds: + existing = served_by_iri.get(seed.iri) + if existing is not None and cls._defines_terms(existing): + continue + missing.append(seed) + return missing + async def _synchronize_ontologies(self) -> list[Ontology]: - """Synchronize seed ontologies from disk into the triple store.""" + """Resolve the catalog from the triple store, repaired from disk. + + Records the seeds it had to supply on ``_last_seed_repairs`` so a caller + that is not :meth:`initialize` -- which materializes everything anyway -- + can write back exactly those. + """ import asyncio seed_ontologies = await asyncio.to_thread( self._load_seed_ontologies_from_directory ) - if seed_ontologies: - logger.info( - "Found %d seed ontologies in ontology_directory", len(seed_ontologies) - ) + logger.info( + "Found %d seed ontolog(ies) in ontology_directory", + len(seed_ontologies), + ) triple_store_ontologies: list[Ontology] = [] if self.triple_store_manager is not None: @@ -898,27 +1315,51 @@ async def _synchronize_ontologies(self) -> list[Ontology]: "Found %d ontologies in triple store", len(triple_store_ontologies) ) - triple_store_iris = {o.iri for o in triple_store_ontologies} - for seed_onto in seed_ontologies: - if seed_onto.iri not in triple_store_iris: - logger.info( - "Syncing seed ontology to triple store: %s (version: %s)", - seed_onto.iri, - seed_onto.version, - ) - triple_store_ontologies.append(seed_onto) + repairs = self._seed_ontologies_missing_from( + triple_store_ontologies, seed_ontologies + ) + self._last_seed_repairs = repairs + repaired_iris = {seed.iri for seed in repairs} + resolved = [ + ontology + for ontology in triple_store_ontologies + if ontology.iri not in repaired_iris + ] + for seed_onto in repairs: + logger.info( + "Syncing seed ontology to triple store: %s (version: %s)", + seed_onto.iri, + seed_onto.version, + ) + resolved.append(seed_onto) + + return resolved - return triple_store_ontologies + async def _materialize_ontology(self, ontology: Ontology) -> int: + """Write ontology to the triple store and rebuild vector atoms. - async def _materialize_ontology(self, ontology: Ontology) -> None: - """Write ontology to the triple store and rebuild vector atoms.""" + Returns: + int: Atoms indexed. Zero when no vector store is ready, and zero + when the ontology atomized to nothing -- which the reindex cannot + distinguish, having already deleted the previous atoms, so it is + reported here rather than left to look like a successful pass. + """ import asyncio if self.triple_store_manager is not None: await self.triple_store_manager.aserialize(ontology) - if self.is_vector_store_ready() and self.vector_store is not None: - await asyncio.to_thread(self.vector_store.reindex_ontology, ontology) + if not (self.is_vector_store_ready() and self.vector_store is not None): + return 0 + indexed = await asyncio.to_thread(self.vector_store.reindex_ontology, ontology) + if not indexed: + logger.warning( + "Ontology %s indexed 0 atoms (%d triples). Retrieval cannot " + "select any of its terms.", + ontology.iri, + len(ontology.graph), + ) + return indexed async def ingest_ontology_ttl( self, ttl: bytes, *, filename: str | None = None @@ -1024,7 +1465,9 @@ async def delete_shapes_by_uri(self, graph_uri: str) -> None: await self.shapes_catalog.delete(graph_uri) -async def render_ontology_summary(ontology: Ontology, llm_tool) -> OntologyProperties: +async def render_ontology_summary( + ontology: Ontology, llm_tool: LLMTool +) -> OntologyProperties: """Generate a summary of ontology properties using LLM analysis. This function uses the LLM tool to analyze an RDF graph and generate diff --git a/ontocast/util/measurement_lexicon.py b/ontocast/util/measurement_lexicon.py new file mode 100644 index 00000000..aff5c593 --- /dev/null +++ b/ontocast/util/measurement_lexicon.py @@ -0,0 +1,456 @@ +"""Unit-adjacent numbers in free text: the measurement side of the inventory. + +A number standing next to a unit token ("96 meV", "8.5 ± 0.5 nm", "0.5 %", +"77 K") is a stated measurement; a bare number is not classifiable from the +text alone -- it may be a value whose unit sits elsewhere in the sentence, or +a citation, page, figure or equation token. The numeric-coverage lane and the +density-aware chunk split both need that distinction, and it has to be drawn +the same way in both places, so the lexicon and the scanner live here with no +dependency on either caller. + +Two vocabularies feed the match: the built-in lexicon below (SI base and +derived units, the scale prefixes a scientific text uses, percent forms, +time words) and whatever unit surfaces the caller passes in -- typically the +labels and symbols of the unit individuals in the unit's ontology snapshot, +so a catalog-specific unit ("sun", "cycles") counts once the catalog +declares it. Compound tokens ("mW/cm2", "cm⁻¹", "g/mol") are matched +structurally: every factor, stripped of its exponent, must be a known +surface, so the lexicon does not have to enumerate products. + +Latin-script and English-centric, like the query-side signal it mirrors +(``tool/vector_store/query_signals.number_adjacent_tokens``): the plural rule +strips a trailing "s" and the time words are English. Both only widen the +match, so a non-English corpus loses recall on this lane rather than +misclassifying. +""" + +from __future__ import annotations + +import re +from collections.abc import Collection +from dataclasses import dataclass + +#: Characters of context kept on each side of a mention. +CONTEXT_CHARS = 40 + +#: Unit surfaces a measurement may be written with. Exact-case entries; the +#: matcher adds a case-insensitive lookup for tokens of three or more letters +#: and a singular form for plurals, so "Days"/"days"/"day" all reach "day" +#: while "m"/"M" and "mV"/"MV" stay distinct. +BUILTIN_UNIT_SURFACES: frozenset[str] = frozenset( + { + # SI base + "m", + "kg", + "s", + "A", + "K", + "mol", + "cd", + # SI derived and their common scales + "Hz", + "kHz", + "MHz", + "GHz", + "THz", + "N", + "mN", + "kN", + "Pa", + "hPa", + "kPa", + "MPa", + "GPa", + "bar", + "mbar", + "atm", + "Torr", + "mTorr", + "psi", + "J", + "mJ", + "µJ", + "μJ", + "uJ", + "kJ", + "MJ", + "nJ", + "pJ", + "W", + "mW", + "µW", + "μW", + "uW", + "kW", + "MW", + "nW", + "C", + "mC", + "µC", + "μC", + "nC", + "pC", + "V", + "mV", + "µV", + "μV", + "kV", + "MV", + "F", + "mF", + "µF", + "μF", + "nF", + "pF", + "Ω", + "ohm", + "Ohm", + "kΩ", + "MΩ", + "mΩ", + "kOhm", + "MOhm", + "S", + "mS", + "µS", + "μS", + "T", + "mT", + "µT", + "μT", + "G", + "kG", + "Wb", + "H", + "mH", + "µH", + "μH", + "nH", + "lm", + "lx", + "Bq", + "Gy", + "Sv", + "mSv", + "µSv", + "μSv", + "kat", + # energy + "eV", + "meV", + "keV", + "MeV", + "GeV", + "µeV", + "μeV", + "cal", + "kcal", + "Wh", + "kWh", + "mAh", + "Ah", + # length + "Å", + "pm", + "nm", + "µm", + "μm", + "um", + "mm", + "cm", + "dm", + "km", + "inch", + "ft", + "mi", + # time + "fs", + "ps", + "ns", + "µs", + "μs", + "us", + "ms", + "sec", + "second", + "min", + "minute", + "h", + "hr", + "hour", + "d", + "day", + "wk", + "week", + "month", + "yr", + "year", + # mass and amount + "ng", + "µg", + "μg", + "ug", + "mg", + "g", + "t", + "u", + "Da", + "kDa", + "mmol", + "µmol", + "μmol", + "umol", + "nmol", + "pmol", + "M", + "mM", + "µM", + "μM", + "uM", + "nM", + "pM", + # volume + "L", + "l", + "mL", + "ml", + "µL", + "μL", + "uL", + "nL", + "pL", + "dL", + # temperature + "°C", + "°F", + "°", + "degC", + "deg", + # dimensionless forms + "%", + "‰", + "wt%", + "wt.%", + "mol%", + "at%", + "at.%", + "vol%", + "v/v", + "w/w", + "w/v", + "ppm", + "ppb", + "ppt", + "rpm", + "dB", + "dBm", + "px", + "fold", + # current + "mA", + "µA", + "μA", + "uA", + "nA", + "pA", + "kA", + # frequently used compound/other surfaces + "cm-1", + "cm⁻¹", + "cm−1", + "cm^-1", + "sun", + "suns", + "cycle", + "cycles", + "rad", + "sr", + "mrad", + } +) + +_EXPONENT_TAIL = re.compile(r"(?:\^?[-−]?\d+|[⁻⁺]?[⁰¹²³⁴⁵⁶⁷⁸⁹]+)$") +_FACTOR_SPLIT = re.compile(r"[/·⋅*]") + +#: One number, mirroring the inventory's pattern so both lanes read the same +#: digit groups: an integer or decimal with an optional exponent, not glued to +#: a word character on either side ("CsPbBr3" and "3D" carry no number). +_NUMBER = r"\d+(?:\.\d+)?(?:[eE][+-]?\d+)?" +_NUMBER_RE = re.compile(_NUMBER) + +#: A run of numbers joined by range/uncertainty separators, then the unit +#: token they share. The token shape follows the query-side pattern: percent +#: and degree glyphs, or a letter-led token of up to twelve characters that +#: may carry scale letters, digits, factor separators and exponent glyphs. +_MENTION = re.compile( + rf"(?{_NUMBER}" + rf"(?:\s*(?:[-–—−~]|±|\+/-|to)\s*{_NUMBER})*)" + r"(?:\s*[-‑]\s*|\s*)" + r"(?P[%‰]|°[A-Za-z]?|" + r"[A-Za-zµμΩÅ][A-Za-z0-9µμΩÅ/·⋅*⁻⁺⁰¹²³⁴⁵⁶⁷⁸⁹%°^\-−.]{0,11})" +) + + +@dataclass(frozen=True) +class Mention: + """One number written next to a unit. + + Attributes: + value: The number as written (unsigned, verbatim lexical form). + unit: The unit token as written. + start: Offset of the number in the text. + end: Offset just past the unit token. + context: Whitespace-collapsed window of ``CONTEXT_CHARS`` on each side. + """ + + value: str + unit: str + start: int + end: int + context: str + + +def _lookup_keys(token: str) -> list[str]: + """Exact form first, then the case-folded and singular forms it may stand for.""" + keys = [token] + if len(token) >= 3 and token.isalpha(): + lowered = token.lower() + keys.append(lowered) + if len(lowered) > 3 and lowered.endswith("s"): + keys.append(lowered[:-1]) + return keys + + +def _surface_matches( + token: str, surfaces: Collection[str], folded: Collection[str] +) -> bool: + for key in _lookup_keys(token): + if key in surfaces or key in folded: + return True + return False + + +def _fold(surfaces: Collection[str]) -> frozenset[str]: + return frozenset(s.lower() for s in surfaces if len(s) >= 3 and s.isalpha()) + + +def is_unit_surface(token: str, extra_surfaces: Collection[str] = frozenset()) -> bool: + """Whether ``token`` names a unit, built-in or supplied. + + A compound token is accepted when every factor -- split on ``/``, ``·``, + ``⋅`` or ``*`` and stripped of a trailing exponent -- is itself a known + surface, so "mW/cm2" and "g·mol⁻¹" pass without being listed. + + Args: + token: Candidate unit token as it appears after a number. + extra_surfaces: Additional surfaces, usually the labels/symbols of the + unit individuals in the caller's ontology context. + + Returns: + True when the token is a unit surface. + """ + token = token.strip().rstrip(".,;:") + if not token: + return False + surfaces: Collection[str] = ( + BUILTIN_UNIT_SURFACES + if not extra_surfaces + else BUILTIN_UNIT_SURFACES | frozenset(extra_surfaces) + ) + folded = _fold(surfaces) + if _surface_matches(token, surfaces, folded): + return True + factors = [factor for factor in _FACTOR_SPLIT.split(token) if factor] + if not factors: + return False + for factor in factors: + bare = _EXPONENT_TAIL.sub("", factor) + if not bare or not _surface_matches(bare, surfaces, folded): + return False + return True + + +#: A number that labels a figure, table or equation rather than measuring +#: anything: "Figure 2A", "Fig. 3a", "Table 2 K". Read from the words just +#: before the number. +_LABEL_HEAD = re.compile( + r"(?:fig(?:ure|s)?\.?|tables?|eq(?:uation)?s?\.?|schemes?|sections?|sec\.|" + r"refs?\.?|chapters?|panels?|steps?)\s*$", + re.IGNORECASE, +) +#: A single-letter unit followed by an initial: "Smith,1 A. B. Jones" is a +#: fused citation superscript and an author, not one ampere. +_INITIAL_TAIL = re.compile(r"\.\s+[A-Z]") + + +def _is_label_number(text: str, numbers_start: int) -> bool: + head = text[max(0, numbers_start - 12) : numbers_start] + return _LABEL_HEAD.search(head) is not None + + +def _is_initial(text: str, unit: str, end: int) -> bool: + return ( + len(unit) == 1 and unit.isalpha() and _INITIAL_TAIL.match(text, end) is not None + ) + + +def _context(text: str, start: int, end: int) -> str: + window = text[max(0, start - CONTEXT_CHARS) : min(len(text), end + CONTEXT_CHARS)] + return " ".join(window.split()) + + +def _token_candidates(token: str) -> list[str]: + """The token as written, then progressively less of a hyphenated tail. + + "10 nm-thick" reads as the unit "nm" followed by prose; the tail is only + kept when it is an exponent ("cm-2"), which the surface matcher handles. + """ + stripped = token.rstrip(".,;:") + candidates = [stripped] + head = re.split(r"[-−](?!\d)", stripped, maxsplit=1)[0] + if head and head != stripped: + candidates.append(head) + return candidates + + +def unit_adjacent_numbers( + text: str, extra_surfaces: Collection[str] = frozenset() +) -> list[Mention]: + """Scan ``text`` for numbers written next to a unit, in text order. + + A range or uncertainty pair ("10-15 meV", "8.5 ± 0.5 nm") yields one + mention per number, all carrying the shared unit, because each side is a + value the graph is expected to hold. + + Args: + text: Source text of a unit or window. + extra_surfaces: Unit surfaces beyond the built-in lexicon. + + Returns: + Mentions in order of appearance. + """ + mentions: list[Mention] = [] + for match in _MENTION.finditer(text): + token = match.group("token") + unit = next( + (c for c in _token_candidates(token) if is_unit_surface(c, extra_surfaces)), + None, + ) + if unit is None: + continue + numbers_start = match.start("numbers") + end = match.start("token") + len(unit) + if _is_label_number(text, numbers_start) or _is_initial(text, unit, end): + continue + for number in _NUMBER_RE.finditer(match.group("numbers")): + start = numbers_start + number.start() + mentions.append( + Mention( + value=number.group(0), + unit=unit, + start=start, + end=end, + context=_context(text, start, end), + ) + ) + return mentions diff --git a/ontocast/util/numeric_inventory.py b/ontocast/util/numeric_inventory.py index 76152e7c..4112856d 100644 --- a/ontocast/util/numeric_inventory.py +++ b/ontocast/util/numeric_inventory.py @@ -11,11 +11,20 @@ import logging import re +from collections.abc import Collection +from dataclasses import dataclass, field from decimal import Decimal, InvalidOperation +from weakref import WeakKeyDictionary -from rdflib import Literal +from rdflib import RDF, RDFS, SKOS, Graph, Literal, URIRef +from rdflib.namespace import DCTERMS from ontocast.onto.rdfgraph import RDFGraph +from ontocast.util.measurement_lexicon import ( + Mention, + _lookup_keys, + unit_adjacent_numbers, +) logger = logging.getLogger(__name__) @@ -41,10 +50,39 @@ def canonical_number(text: str) -> str | None: return format(value.normalize(), "f") +#: Characters that join a digit group to the rest of an identifier rather than +#: to a magnitude. Deliberately narrow. A decimal point is excluded because the +#: number pattern already consumes "3.14" as one match, and a hyphen is +#: excluded because "10-15 meV" is a range whose sides are both real values -- +#: the module reads each side separately by design. +_IDENTIFIER_ADJACENT = frozenset("/:") + + +def _is_identifier_fragment(text: str, start: int, end: int) -> bool: + """Whether the digit group at ``text[start:end]`` belongs to an identifier. + + The signal is adjacency to an identifier separator: "600/92" is one file + number, "10.1234/example" one DOI. A magnitude with its unit ("8.5 nm") is + untouched, and so is a range. + + Args: + text: The text the match came from. + start: Match start offset. + end: Match end offset. + + Returns: + True when the digit group sits against an identifier separator. + """ + before = text[start - 1] if start > 0 else "" + after = text[end] if end < len(text) else "" + return before in _IDENTIFIER_ADJACENT or after in _IDENTIFIER_ADJACENT + + def extract_numeric_tokens( text: str, *, ignore_year_like: bool = True, + ignore_identifier_fragments: bool = False, ) -> set[str]: """Extract canonical numeric tokens from free text. @@ -53,6 +91,11 @@ def extract_numeric_tokens( ignore_year_like: Drop bare integers in the 1900-2100 range (years, citation artifacts). Values that also occur with a decimal point are kept. + ignore_identifier_fragments: Drop digit groups sitting against an + identifier separator -- see :func:`_is_identifier_fragment`. A + digit group standing alone as its own token is *not* covered: + nothing around it distinguishes a file-number component from a + small quantity, and guessing there would cost real values. Returns: Set of canonical decimal strings. @@ -60,6 +103,10 @@ def extract_numeric_tokens( tokens: set[str] = set() for match in _NUMBER_PATTERN.finditer(text): raw = match.group(1) + if ignore_identifier_fragments and _is_identifier_fragment( + text, match.start(1), match.end(1) + ): + continue canonical = canonical_number(raw) if canonical is None: continue @@ -75,17 +122,40 @@ def extract_numeric_tokens( return tokens -def numeric_literals_in_graph(graph: RDFGraph) -> set[str]: - """Collect canonical numeric values appearing anywhere in graph literals. +#: Predicates whose literals describe a node rather than assert a value. A +#: number inside one of these is prose, not data. +_ANNOTATION_PREDICATES = frozenset({RDFS.label, RDFS.comment, DCTERMS.description}) +_SKOS_NAMESPACE = str(SKOS) + + +def _is_annotation(predicate: object) -> bool: + return predicate in _ANNOTATION_PREDICATES or str(predicate).startswith( + _SKOS_NAMESPACE + ) - Numbers inside label/comment strings count as present: coverage findings - target numbers missing from the graph entirely, while structuring - label-only numbers is the critic's judgement call. + +def numeric_literals_in_graph( + graph: RDFGraph, *, include_annotations: bool = False +) -> set[str]: + """Collect canonical numeric values appearing in graph literals. + + By default numbers inside labels, comments, SKOS notes and descriptions + do **not** count as present. Counting them let a placeholder node + labelled with the missing number silence the coverage finding that asked + for it, so the lane measured whether a number had been *mentioned* + rather than whether it had been extracted; a value that exists only + inside a label is invisible to every query and to SHACL alike. + + Args: + graph: The graph to inventory. + include_annotations: Count numbers inside annotation literals too. """ values: set[str] = set() - for _, _, obj in graph: + for _, predicate, obj in graph: if not isinstance(obj, Literal): continue + if not include_annotations and _is_annotation(predicate): + continue text = str(obj) canonical = canonical_number(text.strip()) if canonical is not None: @@ -98,30 +168,445 @@ def numeric_literals_in_graph(graph: RDFGraph) -> set[str]: return values -def missing_numeric_mentions( +@dataclass(frozen=True) +class NumericInventory: + """Numbers stated in a text, split by whether a unit stands next to them. + + ``measurements`` are unit-adjacent mentions in text order, one per + distinct ``(value, unit)``, each carrying the unit token and the phrase + it occurs in; a stated measurement is a fact the graph is expected to + hold, and the context is what lets a later pass place it. + ``unclassified`` are the bare numbers, shortest-first: a value whose + unit sits elsewhere in the sentence, or typography, and nothing in the + text alone says which. + """ + + measurements: list[Mention] = field(default_factory=list) + unclassified: list[str] = field(default_factory=list) + + @property + def is_empty(self) -> bool: + return not self.measurements and not self.unclassified + + def measurement_values(self) -> list[str]: + """Canonical values of the measurements, in text order.""" + values = [canonical_number(m.value) for m in self.measurements] + return [value for value in values if value is not None] + + +def inventory_numeric_mentions( + text: str, + *, + unit_surfaces: Collection[str] = frozenset(), + ignore_year_like: bool = True, + ignore_identifier_fragments: bool = False, +) -> NumericInventory: + """Split the numbers of ``text`` into measurements and bare numbers. + + A number is a measurement when a unit surface stands next to it -- from + the built-in lexicon or from ``unit_surfaces``, typically the labels and + symbols of the unit individuals in the unit's ontology context. The + year-like and identifier guards apply to the bare numbers only: a number + written with its unit is a measurement whatever its magnitude. + + Args: + text: Source text of the unit. + unit_surfaces: Extra unit surfaces beyond the built-in lexicon. + ignore_year_like: Drop bare integers in the publication-year span. + ignore_identifier_fragments: Drop bare digit groups that are parts of + an identifier. + + Returns: + The inventory; measurements in text order, bare numbers shortest-first. + """ + measurements: list[Mention] = [] + seen: set[tuple[str, str]] = set() + measurement_numbers: set[str] = set() + for mention in unit_adjacent_numbers(text, unit_surfaces): + canonical = canonical_number(mention.value) + if canonical is None: + continue + key = (canonical, mention.unit) + if key in seen: + continue + seen.add(key) + measurement_numbers.add(canonical) + measurements.append(mention) + bare = ( + extract_numeric_tokens( + text, + ignore_year_like=ignore_year_like, + ignore_identifier_fragments=ignore_identifier_fragments, + ) + - measurement_numbers + ) + return NumericInventory( + measurements=measurements, + unclassified=sorted(bare, key=lambda value: (len(value), value)), + ) + + +def measurement_pairs_in_graph( + graph: RDFGraph, + ontology_graph: Graph | None = None, + *, + numeric_value_properties: Collection[str] = (), + unit_properties: Collection[str] = (), +) -> set[tuple[str, str]]: + """``(canonical_number, unit_surface_key)`` pairs structured in ``graph``. + + A subject contributes pairs only when it carries both a configured + numeric-value literal and a configured unit object IRI -- a bare number + elsewhere in the graph does not cover a unit-adjacent mention. Unit IRIs + map to surfaces through :func:`unit_surface_index`; each surface is + expanded with :func:`~ontocast.util.measurement_lexicon._lookup_keys` so + the same case and plural rules that recognise a mention also recognise + its extracted counterpart. When the ontology has no surface for an IRI, + the IRI's local name is tried as a last resort. + + Args: + graph: The extracted facts graph. + ontology_graph: The unit's ontology context (for unit surfaces). + numeric_value_properties: IRIs of the numeric-value role properties. + unit_properties: IRIs of the unit-role properties. + + Returns: + Pairs keyed for membership tests against mention lookup keys. + """ + if not numeric_value_properties or not unit_properties: + return set() + numeric_props = set(numeric_value_properties) + unit_props = set(unit_properties) + iri_to_surfaces: dict[str, set[str]] = {} + for surface, iris in unit_surface_index(ontology_graph, unit_properties).items(): + for iri in iris: + iri_to_surfaces.setdefault(iri, set()).add(surface) + + pairs: set[tuple[str, str]] = set() + subjects = { + subject + for subject, predicate, _ in graph + if isinstance(subject, URIRef) and str(predicate) in numeric_props + } + for subject in subjects: + numbers: set[str] = set() + unit_iris: set[str] = set() + for _, predicate, obj in graph.triples((subject, None, None)): + pred = str(predicate) + if pred in numeric_props and isinstance(obj, Literal): + if _is_annotation(predicate): + continue + canonical = canonical_number(str(obj).strip()) + if canonical is not None: + numbers.add(canonical) + elif pred in unit_props and isinstance(obj, URIRef): + unit_iris.add(str(obj)) + if not numbers or not unit_iris: + continue + for unit_iri in unit_iris: + surfaces = iri_to_surfaces.get(unit_iri) or {_local(unit_iri)} + for surface in surfaces: + keys = _lookup_keys(surface.strip().rstrip(".,;:")) + for number in numbers: + for key in keys: + pairs.add((number, key)) + return pairs + + +def _measurement_covered(mention: Mention, present_pairs: set[tuple[str, str]]) -> bool: + """Whether a unit-adjacent mention is covered by a structured graph pair.""" + canonical = canonical_number(mention.value) + if canonical is None: + return False + token = mention.unit.strip().rstrip(".,;:") + return any((canonical, key) in present_pairs for key in _lookup_keys(token)) + + +def missing_numeric_inventory( text: str, graph: RDFGraph, *, + unit_surfaces: Collection[str] = frozenset(), + ontology_graph: Graph | None = None, + numeric_value_properties: Collection[str] = (), + unit_properties: Collection[str] = (), ignore_year_like: bool = True, + ignore_identifier_fragments: bool = False, limit: int = 30, -) -> list[str]: - """Return canonical numbers stated in text but absent from the graph. +) -> NumericInventory: + """The inventory of ``text`` restricted to values absent from the graph. + + Measurements are judged against structured ``(number, unit)`` pairs in + the graph: a bare numeric literal does not clear a unit-adjacent mention. + Bare numbers still use the number-only presence set. Capped at ``limit`` + over both lists, measurements first: they are the numbers a later pass + can act on, so when the cap bites it is the bare numbers that are + dropped. A warning records how many were. + + Args: + text: Source text for the unit. + graph: Graph extracted from that text. + unit_surfaces: Extra unit surfaces beyond the built-in lexicon. + ontology_graph: Ontology context used to map unit IRIs to surfaces. + numeric_value_properties: IRIs of the numeric-value role properties. + unit_properties: IRIs of the unit-role properties. + ignore_year_like: Drop bare integers in the publication-year span. + ignore_identifier_fragments: Drop bare digit groups that are parts of + an identifier. Offering them invites the critic to structure a + file number or a citation into numeric properties, which the + downstream multi-value check then flags. + limit: Maximum mentions across both lists. - The result is capped at ``limit``; when it truncates, a warning records how - many mentions were dropped. Ordering is shortest-first, which is a stable - presentation order, not a relevance ranking -- the caller is advisory - telemetry, so the cap bounds prompt size rather than selecting the most - important gaps. + Returns: + The missing measurements in text order and the missing bare numbers + shortest-first. """ - missing = extract_numeric_tokens( - text, ignore_year_like=ignore_year_like - ) - numeric_literals_in_graph(graph) - ordered = sorted(missing, key=lambda value: (len(value), value)) - if len(ordered) > limit: + present_numbers = numeric_literals_in_graph(graph) + present_pairs = measurement_pairs_in_graph( + graph, + ontology_graph, + numeric_value_properties=numeric_value_properties, + unit_properties=unit_properties, + ) + inventory = inventory_numeric_mentions( + text, + unit_surfaces=unit_surfaces, + ignore_year_like=ignore_year_like, + ignore_identifier_fragments=ignore_identifier_fragments, + ) + measurements = [ + mention + for mention in inventory.measurements + if not _measurement_covered(mention, present_pairs) + ] + unclassified = [ + value for value in inventory.unclassified if value not in present_numbers + ] + total = len(measurements) + len(unclassified) + if total > limit: logger.warning( - "Numeric coverage: %d missing mention(s) truncated to %d for the " - "repair prompt", - len(ordered), + "Numeric coverage: %d missing mention(s) truncated to %d for the prompt", + total, limit, ) - return ordered[:limit] + measurements = measurements[:limit] + unclassified = unclassified[: max(0, limit - len(measurements))] + return NumericInventory(measurements=measurements, unclassified=unclassified) + + +def missing_numeric_mentions( + text: str, + graph: RDFGraph, + *, + ignore_year_like: bool = True, + ignore_identifier_fragments: bool = False, + limit: int = 30, + unit_surfaces: Collection[str] = frozenset(), + ontology_graph: Graph | None = None, + numeric_value_properties: Collection[str] = (), + unit_properties: Collection[str] = (), +) -> list[str]: + """Return canonical numbers stated in text but absent from the graph. + + Measurements come first in text order, then bare numbers shortest-first; + see :func:`missing_numeric_inventory` for the split and the cap. + + Args: + text: Source text for the unit. + graph: Graph extracted from that text. + ignore_year_like: Drop bare integers in the publication-year span. + ignore_identifier_fragments: Drop bare digit groups that are parts of + an identifier. + limit: Maximum mentions returned. + unit_surfaces: Extra unit surfaces beyond the built-in lexicon. + ontology_graph: Ontology context used to map unit IRIs to surfaces. + numeric_value_properties: IRIs of the numeric-value role properties. + unit_properties: IRIs of the unit-role properties. + + Returns: + Canonical decimal strings, capped at ``limit``. + """ + inventory = missing_numeric_inventory( + text, + graph, + unit_surfaces=unit_surfaces, + ontology_graph=ontology_graph, + numeric_value_properties=numeric_value_properties, + unit_properties=unit_properties, + ignore_year_like=ignore_year_like, + ignore_identifier_fragments=ignore_identifier_fragments, + limit=limit, + ) + return inventory.measurement_values() + inventory.unclassified + + +#: Surface-bearing predicates a unit individual is named by. Names every RDF +#: vocabulary shares, plus the code/symbol predicates recognised by local +#: name so no catalog vocabulary is compiled in. +_NAME_PREDICATES = (RDFS.label, SKOS.prefLabel, SKOS.altLabel, SKOS.notation) +_CODE_LOCAL_NAMES = ("symbol", "code", "notation", "abbreviation") +#: Longest surface worth matching against a number-adjacent token. +_MAX_SURFACE_CHARS = 12 + +_SurfaceIndex = dict[str, tuple[str, ...]] +_surface_memo: WeakKeyDictionary[Graph, tuple[int, frozenset[str], _SurfaceIndex]] = ( + WeakKeyDictionary() +) + + +def _local(iri: str) -> str: + for separator in ("#", "/"): + head, sep, local = iri.rpartition(separator) + if sep and local: + return local + return iri + + +def _unit_classes(graph: Graph, unit_properties: Collection[str]) -> set[URIRef]: + """Classes whose individuals are units: unit-property ranges, ``*Unit`` + classes, and everything below them.""" + seeds: set[URIRef] = set() + for prop in unit_properties: + for rng in graph.objects(URIRef(prop), RDFS.range): + if isinstance(rng, URIRef): + seeds.add(rng) + for cls in set(graph.objects(None, RDF.type)) | set( + graph.subjects(RDFS.subClassOf) + ): + if isinstance(cls, URIRef) and _local(str(cls)).endswith("Unit"): + seeds.add(cls) + closure = set(seeds) + frontier = list(seeds) + while frontier: + current = frontier.pop() + for sub in graph.subjects(RDFS.subClassOf, current): + if isinstance(sub, URIRef) and sub not in closure: + closure.add(sub) + frontier.append(sub) + return closure + + +def _build_surface_index( + graph: Graph, unit_properties: Collection[str] +) -> _SurfaceIndex: + classes = _unit_classes(graph, unit_properties) + if not classes: + return {} + individuals = { + subject + for cls in classes + for subject in graph.subjects(RDF.type, cls) + if isinstance(subject, URIRef) + } + index: dict[str, set[str]] = {} + for individual in individuals: + for predicate, value in graph.predicate_objects(individual): + if not isinstance(value, Literal): + continue + local = _local(str(predicate)).lower() + if predicate not in _NAME_PREDICATES and not any( + token in local for token in _CODE_LOCAL_NAMES + ): + continue + text = str(value).strip() + if ( + not text + or len(text) > _MAX_SURFACE_CHARS + or any(ch.isspace() for ch in text) + or canonical_number(text) is not None + ): + continue + index.setdefault(text, set()).add(str(individual)) + return {surface: tuple(sorted(iris)) for surface, iris in index.items()} + + +def unit_surface_index( + ontology_graph: Graph | None, unit_properties: Collection[str] = () +) -> _SurfaceIndex: + """Surface form -> unit individuals declaring it, for one ontology graph. + + Unit individuals are found through the ranges of the configured unit-role + properties and through classes named ``*Unit``, with their subclasses. + Surfaces are labels, notations and code/symbol literals short enough to + stand next to a number. Memoised per graph object (validated by size, so + a graph mutated in place is re-indexed), because the snapshot is shared + by reference across a whole fan-out and this walks it whole. + + Args: + ontology_graph: The unit's ontology context; ``None`` yields nothing. + unit_properties: IRIs of the unit-role properties (``qudt:unit``). + + Returns: + Surface -> sorted unit IRIs. Treat as read-only. + """ + if ontology_graph is None or len(ontology_graph) == 0: + return {} + key = frozenset(unit_properties) + try: + cached = _surface_memo.get(ontology_graph) + except TypeError: + cached = None + if cached is not None and cached[0] == len(ontology_graph) and cached[1] == key: + return cached[2] + index = _build_surface_index(ontology_graph, key) + try: + _surface_memo[ontology_graph] = (len(ontology_graph), key, index) + except TypeError: + pass + return index + + +def unit_surfaces_in_ontology( + ontology_graph: Graph | None, unit_properties: Collection[str] = () +) -> frozenset[str]: + """The unit surfaces of an ontology graph; see :func:`unit_surface_index`.""" + return frozenset(unit_surface_index(ontology_graph, unit_properties)) + + +def unit_symbol_index( + ontology_graph: Graph | None, unit_properties: Collection[str] = () +) -> dict[str, frozenset[str]]: + """Unit individual -> the *symbol* surfaces it declares, case preserved. + + The subset of :func:`unit_surface_index` that comes from code, symbol + and notation predicates rather than labels. Symbols are case-significant + by definition -- two units can differ by letter case alone -- while a + label is prose and is not. Kept separate so a check on symbol case never + fires on a label spelt with a capital. + + Args: + ontology_graph: The unit's ontology context; ``None`` yields nothing. + unit_properties: IRIs of the unit-role properties (``qudt:unit``). + + Returns: + Unit IRI -> its declared symbol surfaces. Empty when the graph + declares no symbols. + """ + if ontology_graph is None or len(ontology_graph) == 0: + return {} + classes = _unit_classes(ontology_graph, unit_properties) + if not classes: + return {} + symbols: dict[str, set[str]] = {} + for cls in classes: + for individual in ontology_graph.subjects(RDF.type, cls): + if not isinstance(individual, URIRef): + continue + for predicate, value in ontology_graph.predicate_objects(individual): + if not isinstance(value, Literal): + continue + local = _local(str(predicate)).lower() + if predicate != SKOS.notation and not any( + token in local for token in _CODE_LOCAL_NAMES + ): + continue + text = str(value).strip() + if ( + not text + or len(text) > _MAX_SURFACE_CHARS + or any(ch.isspace() for ch in text) + or canonical_number(text) is not None + ): + continue + symbols.setdefault(str(individual), set()).add(text) + return {iri: frozenset(found) for iri, found in symbols.items()} diff --git a/pyproject.toml b/pyproject.toml index c797bc03..d8dc9c05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ license-files = ["LICENSE"] name = "ontocast" readme = "README.md" requires-python = ">=3.12,<4.0" -version = "0.6.2" +version = "0.6.4" [project.optional-dependencies] # `plot` is deliberately excluded: pygraphviz builds from source against system @@ -272,19 +272,21 @@ select = ["E", "F", "I001", "W"] "ontocast/prompt/*.py" = ["E501"] [tool.ty.analysis] -# The heavy tail of the optional tier: loaded lazily / only when selected, and -# too expensive to install just to type-check. The pre-commit workflow syncs -# only `dev,server,documents,qdrant,graph`, so anything outside that set must be -# listed here -- the two lists together have to cover every third-party import -# under `ontocast/` and `test/`. docling-core is deliberately NOT listed here: -# whitelisting it previously hid the fact that `ontocast.onto.state` imported it -# unguarded while it was only in an extra, which made a base -# `pip install ontocast` unimportable. -allowed-unresolved-imports = [ - "docling.**", - "duckduckgo_search.**", - "hdbscan.**", - "lancedb.**", - "pyshacl.**", - "umap.**" -] +# Deliberately empty, and worth keeping that way. +# +# This used to whitelist the heavy tail of the optional tier (docling, lancedb, +# pyarrow, pyshacl, hdbscan, umap, duckduckgo_search) because the type check ran +# under a partial install that could not import them. Whitelisting an import +# does not merely silence "unresolved" -- it types the module as `Any`, so every +# call into it stops being checked. The whitelist was therefore buying a cheaper +# CI job with a quieter type checker, on exactly the third-party boundaries where +# a type error is most likely. +# +# `ty check ontocast test` now runs in the `unit` job of tests.yml, which already +# installs every extra for the test run, so nothing needs whitelisting and the +# list is empty with the full suite passing. Before adding an entry here, check +# whether the import is simply missing from an extra -- that is the more common +# cause, and the honest fix. docling-core in particular must never be listed: +# whitelisting it once hid `ontocast.onto.state` importing it unguarded while it +# was only in an extra, which made a base `pip install ontocast` unimportable. +allowed-unresolved-imports = [] diff --git a/shell.nix b/shell.nix index 77e14d52..c533ce5c 100644 --- a/shell.nix +++ b/shell.nix @@ -61,16 +61,25 @@ pkgs.mkShell { echo "Creating uv virtualenv $UV_PROJECT_ENVIRONMENT..." uv venv --python "$UV_PYTHON" "$UV_PROJECT_ENVIRONMENT" fi + # A venv built on some other interpreter needs no handling here: UV_PYTHON + # is exported above, so the sync below rebuilds the environment on the Nix + # python by itself. # "Activate" without sourcing: just put the venv first in PATH export VIRTUAL_ENV="$UV_PROJECT_ENVIRONMENT" export PATH="$VIRTUAL_ENV/bin:$PATH" - # Sync project deps + # Sync project deps. `dev` is an extra in pyproject.toml, not a dependency + # group, and --all-extras already covers it; `--group dev` aborts the sync + # outright, which left every shell with an unsynced venv. echo "Syncing dependencies with uv..." - uv sync --group dev --all-extras + if ! uv sync --all-extras; then + # A failed sync scrolls past between the other banners, and the shell + # still opens, so the next command fails somewhere unrelated. + echo "warning: uv sync failed -- the venv is incomplete." >&2 + fi - echo "🐍 ontocast_api uv dev environment ready" + echo "🐍 ontocast uv dev environment ready" echo "Python: $(python --version)" echo "uv: $(uv --version)" ''; diff --git a/test/aggregation/test_aggregate_pipeline.py b/test/aggregation/test_aggregate_pipeline.py index d7ff6131..a9684fa7 100644 --- a/test/aggregation/test_aggregate_pipeline.py +++ b/test/aggregation/test_aggregate_pipeline.py @@ -10,6 +10,8 @@ from ontocast.util.hash import render_text_hash from test.embedding_test_helpers import distinct_encoder, install_fake_encoder +pytestmark = pytest.mark.unit + def make_fact_unit( text: str, diff --git a/test/aggregation/test_clustering.py b/test/aggregation/test_clustering.py index 34879a0e..f4b5cd72 100644 --- a/test/aggregation/test_clustering.py +++ b/test/aggregation/test_clustering.py @@ -1,6 +1,7 @@ from typing import cast from unittest.mock import Mock +import pytest from rdflib import URIRef from ontocast.onto.constants import DEFAULT_IRI @@ -9,6 +10,8 @@ ) from ontocast.tool.agg.normalizer import EntityRepresentation +pytestmark = pytest.mark.unit + # Model sharing and locking across subsystems live in test/test_shared_encoder.py; # this module covers clustering behaviour only. diff --git a/test/aggregation/test_inert_threshold_warning.py b/test/aggregation/test_inert_threshold_warning.py new file mode 100644 index 00000000..62bae48d --- /dev/null +++ b/test/aggregation/test_inert_threshold_warning.py @@ -0,0 +1,74 @@ +"""``AGG_SIMILARITY_THRESHOLD`` is not the in-pipeline aggregator's threshold. + +The aggregator clusters and gates at ``AGG_CANDIDATE_SIMILARITY_THRESHOLD``; +``AGG_SIMILARITY_THRESHOLD`` drives only the cross-graph ``EntityAligner``. +Tuning the former while the latter sits at its default changes nothing in a +pipeline run, so the aggregator says so at construction time -- and stays +quiet when the caller pins the candidate threshold itself, which is how the +aligner builds its compatibility aggregator. +""" + +import logging + +import pytest + +from ontocast.config import AggregationConfig +from ontocast.tool import EmbeddingBasedAggregator + +pytestmark = pytest.mark.unit + +LOGGER = "ontocast.tool.agg.aggregate" + + +@pytest.fixture(autouse=True) +def _no_threshold_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("AGG_SIMILARITY_THRESHOLD", raising=False) + monkeypatch.delenv("AGG_CANDIDATE_SIMILARITY_THRESHOLD", raising=False) + + +def _warnings(caplog: pytest.LogCaptureFixture) -> list[str]: + return [ + record.getMessage() + for record in caplog.records + if record.levelno >= logging.WARNING and record.name == LOGGER + ] + + +def test_warns_when_only_the_aligner_threshold_is_tuned( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.WARNING, logger=LOGGER) + EmbeddingBasedAggregator(AggregationConfig(similarity_threshold=0.9)) + + messages = _warnings(caplog) + assert len(messages) == 1 + assert "AGG_SIMILARITY_THRESHOLD=0.9" in messages[0] + assert "AGG_CANDIDATE_SIMILARITY_THRESHOLD" in messages[0] + + +def test_quiet_when_the_candidate_threshold_is_tuned_too( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.WARNING, logger=LOGGER) + EmbeddingBasedAggregator( + AggregationConfig(similarity_threshold=0.9, candidate_similarity_threshold=0.8) + ) + assert _warnings(caplog) == [] + + +def test_quiet_at_defaults(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.WARNING, logger=LOGGER) + EmbeddingBasedAggregator(AggregationConfig()) + assert _warnings(caplog) == [] + + +def test_quiet_when_the_caller_pins_the_candidate_threshold( + caplog: pytest.LogCaptureFixture, +) -> None: + """The aligner path: the override *is* the threshold, nothing is inert.""" + caplog.set_level(logging.WARNING, logger=LOGGER) + EmbeddingBasedAggregator( + AggregationConfig(similarity_threshold=0.9), + candidate_similarity_threshold=0.9, + ) + assert _warnings(caplog) == [] diff --git a/test/aggregation/test_merge_guards.py b/test/aggregation/test_merge_guards.py index ae5da551..38eea7b9 100644 --- a/test/aggregation/test_merge_guards.py +++ b/test/aggregation/test_merge_guards.py @@ -5,6 +5,7 @@ recoverable redundancy. Fixtures mirror observed failure shapes. """ +import pytest from rdflib import Literal, URIRef from rdflib.namespace import XSD @@ -19,6 +20,8 @@ harvest_max_one_predicates, ) +pytestmark = pytest.mark.unit + QUDT = "http://qudt.org/schema/qudt/" UNIT = "http://qudt.org/vocab/unit/" QQVAL = "https://growgraph.dev/ontologies/qqval#" diff --git a/test/aggregation/test_merge_regressions.py b/test/aggregation/test_merge_regressions.py index 958e01a8..6f2b7cbb 100644 --- a/test/aggregation/test_merge_regressions.py +++ b/test/aggregation/test_merge_regressions.py @@ -17,6 +17,7 @@ singletons) so no embedding model loads. """ +import pytest from rdflib import RDF, Literal, URIRef from rdflib.namespace import XSD @@ -27,6 +28,8 @@ from ontocast.tool.agg.rewriter import GraphRewriter from ontocast.tool.agg.signatures import labels_differ_only_by_initials +pytestmark = pytest.mark.unit + CD = f"{DEFAULT_IRI}/" DOC = "https://x.org/doc/1" ECHR = "https://x.org/echr#" diff --git a/test/aggregation/test_normalizer.py b/test/aggregation/test_normalizer.py index 079ea8c7..af880029 100644 --- a/test/aggregation/test_normalizer.py +++ b/test/aggregation/test_normalizer.py @@ -1,9 +1,12 @@ +import pytest from rdflib import RDF, RDFS, Literal, Namespace, URIRef from ontocast.onto.constants import DEFAULT_IRI from ontocast.onto.rdfgraph import RDFGraph from ontocast.tool.agg.normalizer import EntityNormalizer, EntityRepresentation +pytestmark = pytest.mark.unit + def test_normalize_string_camel_case(normalizer: EntityNormalizer) -> None: assert normalizer.normalize_string("PLRedShift") == "pl red shift" diff --git a/test/aggregation/test_person_alias_identity.py b/test/aggregation/test_person_alias_identity.py index 78e87cf0..ef083c1b 100644 --- a/test/aggregation/test_person_alias_identity.py +++ b/test/aggregation/test_person_alias_identity.py @@ -11,11 +11,14 @@ they run fast and deterministically. """ +import pytest from rdflib import URIRef from ontocast.onto.rdfgraph import RDFGraph from ontocast.tool.agg.aggregate import EmbeddingBasedAggregator +pytestmark = pytest.mark.unit + SCHEMA = "https://schema.org/" DOC = "https://growgraph.dev/doc/testdoc/" diff --git a/test/aggregation/test_provenance.py b/test/aggregation/test_provenance.py index 268cfc6c..31c75b19 100644 --- a/test/aggregation/test_provenance.py +++ b/test/aggregation/test_provenance.py @@ -15,6 +15,8 @@ from ontocast.tool.agg.rewriter import GraphRewriter from ontocast.tool.triple_manager.core import TripleStoreManager +pytestmark = pytest.mark.unit + def test_merge_graphs_with_provenance_adds_chunk_metadata( graph_rewriter: GraphRewriter, diff --git a/test/aggregation/test_quantity_collapse.py b/test/aggregation/test_quantity_collapse.py index 3244ee7d..42aabfaf 100644 --- a/test/aggregation/test_quantity_collapse.py +++ b/test/aggregation/test_quantity_collapse.py @@ -14,6 +14,7 @@ import pytest from rdflib import Literal, URIRef +from ontocast.onto.constants import DEFAULT_IRI from ontocast.onto.content_unit import ContentUnit, OutputType from ontocast.onto.rdfgraph import RDFGraph from ontocast.tool import EmbeddingBasedAggregator @@ -76,3 +77,55 @@ def test_case4_pre_merge_quantities_stay_distinct( "impurity and photon-propagation 30 meV contributions must remain " "two distinct nodes" ) + + +def _quantity_unit(index: int, value: str, unit_iri: str) -> ContentUnit: + """A unit that mints ``cd:temperature_value`` for its own measurement.""" + graph = RDFGraph() + graph.parse( + data=f""" + @prefix cd: <{DEFAULT_IRI}> . + @prefix qudt: . + @prefix unit: . + @prefix rdfs: . + @prefix xsd: . + cd:sample_a qudt:hasQuantity cd:temperature_value . + cd:temperature_value a qudt:QuantityValue ; + rdfs:label "temperature" ; + qudt:numericValue "{value}"^^xsd:decimal ; + qudt:unit unit:{unit_iri} . + """, + format="turtle", + ) + return ContentUnit( + text=f"measured at {value} {unit_iri}", + index=index, + doc_iri=URIRef("https://growgraph.dev/doc/376c5e808804"), + graph=graph, + type=OutputType.FACTS, + ) + + +def test_cross_unit_same_local_name_quantities_stay_distinct( + aggregator: EmbeddingBasedAggregator, +) -> None: + """Two units minting one local name for two measurements stay two nodes. + + Before unit scoping the two ``cd:temperature_value`` mentions were one + entity by IRI before any guard ran; scoped, they reach the literal and + functional-object guards as a candidate pair and are kept apart. + """ + units = [_quantity_unit(0, "6", "K"), _quantity_unit(1, "10", "DEG_C")] + + merged = aggregator.postprocess_facts_units(units, ontology_graph=RDFGraph()).graph + + value_counts = _distinct_value_counts(merged) + assert len(value_counts) == 2 + assert all(count == 1 for count in value_counts.values()) + assert all(count == 1 for count in _distinct_unit_counts(merged).values()) + assert not any( + "__u" in str(term) + for triple in merged + for term in triple + if isinstance(term, URIRef) + ) diff --git a/test/aggregation/test_rewriter.py b/test/aggregation/test_rewriter.py index 092fc541..6b24dce1 100644 --- a/test/aggregation/test_rewriter.py +++ b/test/aggregation/test_rewriter.py @@ -1,9 +1,12 @@ +import pytest from rdflib import OWL, RDF, Literal, URIRef from ontocast.onto.constants import DEFAULT_IRI from ontocast.onto.rdfgraph import RDFGraph from ontocast.tool.agg.rewriter import GraphRewriter +pytestmark = pytest.mark.unit + def test_apply_mapping_to_triple(graph_rewriter: GraphRewriter) -> None: e1 = URIRef("http://chunk1.org/e1") diff --git a/test/aggregation/test_unit_scope.py b/test/aggregation/test_unit_scope.py new file mode 100644 index 00000000..7e9c6ff8 --- /dev/null +++ b/test/aggregation/test_unit_scope.py @@ -0,0 +1,392 @@ +"""Unit-scoped fact IRIs: a cross-unit name collision is a merge decision. + +Units mint instance IRIs independently, so two units that both wrote +``cd:temperature_value`` used to fuse into one node carrying both values +before any merge guard could look at them -- and the validation gate's +un-merge repair only dissolves clusters of two or more source IRIs, so a +singleton collision could never be split. Scoping suffixes each minted IRI +with its unit index: the pair reaches clustering and the guards as two +entities, and the served graph carries whichever decision they earn. +""" + +from types import SimpleNamespace +from typing import cast + +import pytest +from rdflib import OWL, RDF, Literal, URIRef + +from ontocast.config import AggregationConfig, FactsValidationConfig +from ontocast.onto.constants import DEFAULT_IRI, PROV, RDF_REIFIES +from ontocast.onto.content_unit import ContentUnit, OutputType +from ontocast.onto.iri_policy import split_namespace_local +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.onto.state import AgentState +from ontocast.stategraph.node_factories import ( + make_merge_facts_node, + make_validate_facts_node, +) +from ontocast.tool import EmbeddingBasedAggregator +from ontocast.tool.agg.unit_scope import ( + UNIT_SCOPE_MARKER, + scope_fact_iris, + scope_local_name, + strip_unit_scope, + unit_scope_index, + unscoped_iri, +) +from ontocast.toolbox import ToolBox + +pytestmark = pytest.mark.unit + +CD = DEFAULT_IRI +DOC = "https://x.org/doc/1" +Q = "https://x.org/schema#" +QUDT = "http://qudt.org/schema/qudt/" +UNIT = "http://qudt.org/vocab/unit/" +NUMERIC_VALUE = URIRef(f"{QUDT}numericValue") +QUDT_UNIT = URIRef(f"{QUDT}unit") +HAS_QUANTITY = URIRef(f"{Q}hasQuantity") +PREFIXES = f""" +@prefix cd: <{CD}> . +@prefix doc: <{DOC}/> . +@prefix q: <{Q}> . +@prefix qudt: <{QUDT}> . +@prefix unit: <{UNIT}> . +@prefix owl: . +@prefix rdfs: . +@prefix xsd: . +""" + + +def _graph(body: str) -> RDFGraph: + graph = RDFGraph() + graph.parse(data=PREFIXES + body, format="turtle") + return graph + + +def _unit(index: int, body: str) -> ContentUnit: + return ContentUnit( + text=f"unit {index}", + index=index, + doc_iri=URIRef(DOC), + graph=_graph(body), + type=OutputType.FACTS, + ) + + +def _temperature_unit(index: int, value: str, unit_name: str) -> ContentUnit: + """A unit that mints ``cd:temperature_value`` for its own measurement.""" + return _unit( + index, + f""" + cd:sample_a a q:Sample ; rdfs:label "Sample A" ; + q:hasQuantity cd:temperature_value . + cd:temperature_value a qudt:QuantityValue ; rdfs:label "temperature" ; + qudt:numericValue "{value}"^^xsd:decimal ; + qudt:unit unit:{unit_name} . + """, + ) + + +def _normal_form_aggregator( + monkeypatch: pytest.MonkeyPatch, **overrides +) -> EmbeddingBasedAggregator: + """Cluster by normal form -- what a model does with two identical mentions.""" + aggregator = EmbeddingBasedAggregator(AggregationConfig(**overrides)) + + def cluster_by_normal_form(representations): + clusters: dict[str, list[URIRef]] = {} + for entity, representation in representations.items(): + clusters.setdefault(representation.normal_form, []).append(entity) + return list(clusters.values()), {} + + monkeypatch.setattr( + aggregator.clusterer, "cluster_entities", cluster_by_normal_form + ) + return aggregator + + +def _iris(graph: RDFGraph) -> set[URIRef]: + return {term for triple in graph for term in triple if isinstance(term, URIRef)} + + +def _scoped(graph: RDFGraph) -> set[URIRef]: + return {iri for iri in _iris(graph) if unit_scope_index(str(iri)) is not None} + + +def _values_by_subject(graph: RDFGraph) -> dict[URIRef, set[float]]: + values: dict[URIRef, set[float]] = {} + for subject, _, obj in graph.triples((None, NUMERIC_VALUE, None)): + if isinstance(subject, URIRef) and isinstance(obj, Literal): + values.setdefault(subject, set()).add(float(obj)) + return values + + +# --- suffix helpers ---------------------------------------------------------- + + +def test_scope_and_strip_round_trip() -> None: + scoped = scope_local_name("temperature_value", 3) + assert scoped == f"temperature_value{UNIT_SCOPE_MARKER}3" + assert strip_unit_scope(scoped) == "temperature_value" + assert unit_scope_index(scoped) == 3 + assert unit_scope_index("temperature_value") is None + assert strip_unit_scope("temperature_value") == "temperature_value" + + +def test_rescoping_replaces_instead_of_stacking() -> None: + assert scope_local_name(scope_local_name("x", 1), 2) == "x__u2" + assert scope_local_name(scope_local_name("x", 1), 1) == "x__u1" + + +def test_strip_accepts_full_iris() -> None: + scoped = URIRef(f"{CD}sample_a__u7") + assert strip_unit_scope(str(scoped)) == f"{CD}sample_a" + assert unscoped_iri(scoped) == URIRef(f"{CD}sample_a") + plain = URIRef(f"{CD}sample_a") + assert unscoped_iri(plain) is plain + + +def test_suffix_stays_inside_the_local_name() -> None: + graph = _graph('cd:sample_a a q:Sample ; rdfs:label "Sample A" .') + mapping = scope_fact_iris(graph, 4, [CD, DOC]) + + scoped = mapping[URIRef(f"{CD}sample_a")] + assert split_namespace_local(str(scoped)) == (CD, "sample_a__u4") + + +# --- scope_fact_iris --------------------------------------------------------- + + +def test_scope_fact_iris_rewrites_instances_and_keeps_vocabulary() -> None: + graph = _graph( + """ + cd:hasPart a owl:ObjectProperty . + cd:sample_a a cd:Sample ; rdfs:label "Sample A" ; + cd:hasPart cd:layer_1 ; + q:hasQuantity cd:temperature_value ; + q:seeAlso . + cd:temperature_value qudt:numericValue "6"^^xsd:decimal . + doc:figure_1 rdfs:label "Figure 1" . + """ + ) + before = len(graph) + + mapping = scope_fact_iris(graph, 2, [CD, DOC]) + + assert set(mapping) == { + URIRef(f"{CD}sample_a"), + URIRef(f"{CD}layer_1"), + URIRef(f"{CD}temperature_value"), + URIRef(f"{DOC}/figure_1"), + } + assert all(unit_scope_index(str(target)) == 2 for target in mapping.values()) + assert len(graph) == before + iris = _iris(graph) + # Vocabulary the unit refers to keeps its name in every position. + assert URIRef(f"{CD}Sample") in iris + assert URIRef(f"{CD}hasPart") in iris + assert (URIRef(f"{CD}hasPart"), RDF.type, OWL.ObjectProperty) in graph + assert URIRef("https://other.org/thing") in iris + # Instances are rewritten in every position they occupy. + assert not (set(mapping) & iris) + assert ( + URIRef(f"{CD}sample_a__u2"), + URIRef(f"{CD}hasPart"), + URIRef(f"{CD}layer_1__u2"), + ) in graph + assert ( + URIRef(f"{CD}temperature_value__u2"), + NUMERIC_VALUE, + Literal("6", datatype=URIRef("http://www.w3.org/2001/XMLSchema#decimal")), + ) in graph + + +def test_scope_fact_iris_is_idempotent_for_the_same_unit() -> None: + graph = _graph('cd:sample_a rdfs:label "Sample A" .') + scope_fact_iris(graph, 1, [CD]) + snapshot = set(graph) + + assert scope_fact_iris(graph, 1, [CD]) == {} + assert set(graph) == snapshot + + +def test_scope_fact_iris_ignores_foreign_namespaces_and_empty_input() -> None: + graph = _graph(' rdfs:label "x" .') + assert scope_fact_iris(graph, 0, [CD, ""]) == {} + assert scope_fact_iris(graph, 0, []) == {} + assert scope_fact_iris(RDFGraph(), 0, [CD]) == {} + + +# --- aggregation ------------------------------------------------------------- + + +def test_same_local_name_with_conflicting_literals_stays_apart( + monkeypatch: pytest.MonkeyPatch, +) -> None: + aggregator = _normal_form_aggregator(monkeypatch) + units = [_temperature_unit(0, "6", "K"), _temperature_unit(1, "10", "DEG_C")] + + result = aggregator.postprocess_facts_units(units, RDFGraph()) + + values = _values_by_subject(result.graph) + assert sorted(values.values(), key=sorted) == [{6.0}, {10.0}] + assert len(values) == 2 + assert result.rejected_merge_count >= 1 + assert not _scoped(result.graph) + + +def test_without_scoping_the_collision_fuses_by_name( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The knob off reproduces name-keyed fusion: one node, both values.""" + aggregator = _normal_form_aggregator(monkeypatch, unit_scoped_fact_iris=False) + units = [_temperature_unit(0, "6", "K"), _temperature_unit(1, "10", "DEG_C")] + + result = aggregator.postprocess_facts_units(units, RDFGraph()) + + assert list(_values_by_subject(result.graph).values()) == [{6.0, 10.0}] + + +def test_same_label_and_compatible_content_still_combine( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The subject both units describe merges; its per-unit quantities do not. + + ``sample_a`` points at a different scoped quantity in each unit. That is + the shape scoping creates for every subject mentioned more than once, so + it must not read as a functional-object conflict on the subject. + """ + aggregator = _normal_form_aggregator(monkeypatch) + units = [_temperature_unit(0, "6", "K"), _temperature_unit(1, "10", "DEG_C")] + + result = aggregator.postprocess_facts_units(units, RDFGraph()) + + samples = set(result.graph.subjects(RDF.type, URIRef(f"{Q}Sample"))) + assert len(samples) == 1 + sample = samples.pop() + assert len(set(result.graph.objects(sample, HAS_QUANTITY))) == 2 + members = result.merged_clusters[str(sample)] + assert {unit_scope_index(member) for member in members} == {0, 1} + assert {strip_unit_scope(member) for member in members} == {f"{CD}sample_a"} + + +def test_same_local_name_enrichment_across_units_combines( + monkeypatch: pytest.MonkeyPatch, +) -> None: + aggregator = _normal_form_aggregator(monkeypatch) + units = [ + _unit(0, 'cd:sample_a a q:Sample ; rdfs:label "Sample A" ; q:thickness 100 .'), + _unit(1, 'cd:sample_a a q:Sample ; rdfs:label "Sample A" ; q:growth "MBE" .'), + ] + + result = aggregator.postprocess_facts_units(units, RDFGraph()) + + samples = set(result.graph.subjects(RDF.type, URIRef(f"{Q}Sample"))) + assert len(samples) == 1 + sample = samples.pop() + assert (sample, URIRef(f"{Q}thickness"), Literal(100)) in result.graph + assert (sample, URIRef(f"{Q}growth"), Literal("MBE")) in result.graph + assert not _scoped(result.graph) + + +def test_final_iris_and_provenance_carry_no_scope( + monkeypatch: pytest.MonkeyPatch, +) -> None: + aggregator = _normal_form_aggregator(monkeypatch) + units = [ + _temperature_unit(0, "6", "K"), + _unit(1, 'doc:figure_1 rdfs:label "Figure 1" ; q:shows cd:sample_a .'), + _temperature_unit(2, "10", "DEG_C"), + ] + + result = aggregator.postprocess_facts_units(units, RDFGraph()) + merged = result.graph + + assert not _scoped(merged) + subjects = set(merged.subjects()) + reifiers = list(merged.subjects(RDF_REIFIES, None)) + assert reifiers + for reifier in reifiers: + for quoted in merged.objects(reifier, RDF_REIFIES): + assert isinstance(quoted, tuple) + assert quoted[0] in subjects + assert not any( + unit_scope_index(str(term)) is not None + for term in quoted + if isinstance(term, URIRef) + ) + sources = set(merged.objects(reifier, PROV.wasDerivedFrom)) + assert sources <= {URIRef(unit.iri_absolute) for unit in units} + # Alias records name the source IRI a unit wrote, not its scoped form. + for _, _, alias in merged.triples((None, OWL.sameAs, None)): + assert unit_scope_index(str(alias)) is None + # The unit graphs themselves are what aggregation saw: scoped, in place. + assert all(_scoped(unit.graph) for unit in units) + + +def test_distinct_final_iris_are_minted_in_unit_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: + aggregator = _normal_form_aggregator(monkeypatch) + units = [_temperature_unit(0, "6", "K"), _temperature_unit(1, "10", "DEG_C")] + + result = aggregator.postprocess_facts_units(units, RDFGraph()) + + by_value = { + next(iter(values)): subject + for subject, values in _values_by_subject(result.graph).items() + } + assert str(by_value[6.0]).endswith("/temperatureValue") + assert str(by_value[10.0]).endswith("/temperatureValue_1") + + +# --- the gate can now split what it could not see ---------------------------- + + +def _fake_tools(aggregator: EmbeddingBasedAggregator) -> ToolBox: + return cast( + ToolBox, + SimpleNamespace( + aggregator=aggregator, + shapes_catalog=SimpleNamespace(graph=lambda: None), + shapes_prompt_contract=lambda: ("", (), False), + ontology_manager=SimpleNamespace(catalog_terms=lambda: set()), + config=SimpleNamespace( + get_tool_config=lambda: SimpleNamespace( + facts_validation=FactsValidationConfig() + ) + ), + ), + ) + + +def test_gate_vetoes_split_a_cross_unit_collision( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """With the literal guard ablated the pair fuses -- and the gate repairs it. + + Before scoping the fused node was a singleton cluster (one source IRI), + which the veto repair skips; scoped, the cluster has two members and the + un-merge pass dissolves it. + """ + aggregator = _normal_form_aggregator(monkeypatch, literal_conflict_guard=False) + tools = _fake_tools(aggregator) + state = AgentState() + state.current_domain = "https://x.org" + state.doc_hid = "1" + state.facts_units = [ + _temperature_unit(0, "6", "K"), + _temperature_unit(1, "10", "DEG_C"), + ] + + make_merge_facts_node(tools)(state) + fused = _values_by_subject(state.aggregated_facts) + assert list(fused.values()) == [{6.0, 10.0}] + + make_validate_facts_node(tools)(state) + + assert state.retrieval_metrics["facts_merge_repair_passes"] == 1 + repaired = _values_by_subject(state.aggregated_facts) + assert sorted(repaired.values(), key=sorted) == [{6.0}, {10.0}] + assert not _scoped(state.aggregated_facts) diff --git a/test/aggregation/test_uri_builder.py b/test/aggregation/test_uri_builder.py index 89726b79..f4126f26 100644 --- a/test/aggregation/test_uri_builder.py +++ b/test/aggregation/test_uri_builder.py @@ -1,3 +1,4 @@ +import pytest from rdflib import OWL, RDF, RDFS, URIRef from ontocast.onto.constants import DEFAULT_IRI @@ -14,6 +15,8 @@ to_pascal_case, ) +pytestmark = pytest.mark.unit + def make_representation(uri: str, normal_form: str) -> EntityRepresentation: return EntityRepresentation( diff --git a/test/chunking/test_bibliography_routing.py b/test/chunking/test_bibliography_routing.py index 8f355c4d..1b316246 100644 --- a/test/chunking/test_bibliography_routing.py +++ b/test/chunking/test_bibliography_routing.py @@ -18,6 +18,8 @@ ) from ontocast.toolbox import ToolBox +pytestmark = pytest.mark.unit + _REFERENCE_LIST = """ [1] J. Smith, A. Jones, Perovskite degradation under humidity, Nature Mater. 14, 193-198 (2015). doi:10.1038/nmat4150 diff --git a/test/chunking/test_density_split.py b/test/chunking/test_density_split.py new file mode 100644 index 00000000..525b86ff --- /dev/null +++ b/test/chunking/test_density_split.py @@ -0,0 +1,141 @@ +"""Tests for the measurement-density split of sized units.""" + +import asyncio +from types import SimpleNamespace +from typing import cast + +import pytest + +from ontocast.config import ChunkConfig +from ontocast.tool.chunk.chunker import ChunkerTool +from ontocast.tool.chunk.prepare import PrepareOptions, prepare_content_units +from ontocast.tool.chunk.sizing import split_by_measurement_density +from ontocast.toolbox import ToolBox +from test.docling_test_helpers import doc_from_markdown_lines + +pytestmark = pytest.mark.unit + + +def _dense_sentences(count: int) -> str: + return " ".join( + f"Sample {index} was annealed at {100 + index} °C for {index + 1} h " + f"and then stored under nitrogen for the next stage." + for index in range(count) + ) + + +def test_cap_zero_leaves_text_whole() -> None: + text = _dense_sentences(6) + assert split_by_measurement_density(text, max_measurements=0, min_size=10) == [text] + + +def test_under_cap_leaves_text_whole() -> None: + text = _dense_sentences(2) + assert split_by_measurement_density(text, max_measurements=4, min_size=10) == [text] + + +def test_dense_text_is_split_at_sentence_boundaries_until_under_cap() -> None: + text = _dense_sentences(6) # 12 measurements + pieces = split_by_measurement_density(text, max_measurements=4, min_size=10) + + assert len(pieces) >= 3 + for piece in pieces: + # Every piece is whole sentences and under the cap. + assert piece.endswith("stage.") + assert piece[0].isupper() + # Nothing is lost or reordered. + assert " ".join(pieces) == text + + +def test_split_recurses_on_each_half() -> None: + text = _dense_sentences(12) # 24 measurements + pieces = split_by_measurement_density(text, max_measurements=3, min_size=10) + assert len(pieces) >= 8 + + +def test_pieces_never_go_below_min_size() -> None: + text = _dense_sentences(6) + min_size = 200 + pieces = split_by_measurement_density(text, max_measurements=1, min_size=min_size) + assert len(pieces) > 1 + assert all(len(piece) >= min_size for piece in pieces) + + +def test_no_split_when_floor_cannot_be_met() -> None: + text = _dense_sentences(4) + whole = split_by_measurement_density( + text, max_measurements=1, min_size=len(text) // 2 + 1 + ) + assert whole == [text] + + +def test_no_split_without_a_sentence_boundary() -> None: + text = "at 10 K, 20 K, 30 K, 40 K and 50 K the emission narrowed steadily" + assert split_by_measurement_density(text, max_measurements=2, min_size=1) == [text] + + +def test_paragraph_break_is_a_boundary() -> None: + left = "Emission at 10 K and 20 K was broad" + right = "at 30 K and 40 K it narrowed" + pieces = split_by_measurement_density( + f"{left}\n\n{right}", max_measurements=2, min_size=1 + ) + assert pieces == [left, right] + + +def test_prepare_splits_dense_unit_and_keeps_its_label() -> None: + config = ChunkConfig(min_size=100, max_size=5000, max_measurements_per_unit=4) + tools = cast( + ToolBox, + SimpleNamespace( + chunker=ChunkerTool(chunk_config=config), + config=SimpleNamespace( + chunk_config=config, server=SimpleNamespace(parallel_workers=2) + ), + ), + ) + doc = doc_from_markdown_lines(f"# Methods\n{_dense_sentences(8)}") + + chunks = asyncio.run( + prepare_content_units( + doc, + tools.chunker, + config, + PrepareOptions(summarize_sections=["*"]), + tools, + ) + ) + + assert len(chunks) >= 2 + assert all(chunk.section_label == "methods" for chunk in chunks) + assert all(len(chunk.text) >= config.min_size for chunk in chunks) + + +def test_prepare_leaves_units_whole_when_cap_is_off() -> None: + config = ChunkConfig(min_size=100, max_size=5000) + tools = cast( + ToolBox, + SimpleNamespace( + chunker=ChunkerTool(chunk_config=config), + config=SimpleNamespace( + chunk_config=config, server=SimpleNamespace(parallel_workers=2) + ), + ), + ) + doc = doc_from_markdown_lines(f"# Methods\n{_dense_sentences(8)}") + + chunks = asyncio.run( + prepare_content_units( + doc, + tools.chunker, + config, + PrepareOptions(summarize_sections=["*"]), + tools, + ) + ) + + assert len(chunks) == 1 + + +def test_max_measurements_per_unit_default_is_off() -> None: + assert ChunkConfig().max_measurements_per_unit == 0 diff --git a/test/chunking/test_lexical_trigger.py b/test/chunking/test_lexical_trigger.py index 7d9ece7b..8d12a311 100644 --- a/test/chunking/test_lexical_trigger.py +++ b/test/chunking/test_lexical_trigger.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pytest from rdflib import OWL, RDFS, SKOS, Namespace from ontocast.config import LexicalTriggerFusion @@ -16,6 +17,8 @@ ) from ontocast.tool.vector_store.patch_retriever import _merge_lexical_trigger_atoms +pytestmark = pytest.mark.unit + QUDT = Namespace("http://qudt.org/schema/qudt/") UNIT = Namespace("http://qudt.org/vocab/unit/") UNITS = Namespace("https://growgraph.dev/ontologies/units#") diff --git a/test/chunking/test_non_content_routing.py b/test/chunking/test_non_content_routing.py new file mode 100644 index 00000000..6d40d107 --- /dev/null +++ b/test/chunking/test_non_content_routing.py @@ -0,0 +1,276 @@ +"""Tests for non-content (front/back matter) detection and routing.""" + +import importlib +import logging +from types import SimpleNamespace +from typing import Literal, cast + +import pytest + +from ontocast.config import ChunkConfig +from ontocast.onto.docling_helpers import plain_text_to_docling_doc +from ontocast.onto.state import AgentState +from ontocast.tool.chunk.non_content import ( + NON_CONTENT_TOKEN_SHARE, + has_non_content_heading, + is_non_content_unit, + metadata_token_share, +) +from ontocast.tool.chunk.prepare import PreparedChunk +from ontocast.toolbox import ToolBox + +pytestmark = pytest.mark.unit + +_ORCID_BLOCK = """## ORCID +John Doe: 0000-0001-2345-6789 +Jane Roe: https://orcid.org/0000-0002-3456-789X +""" + +_NOTES_BLOCK = """## Notes +The authors declare no competing financial interest. +""" + +_AUTHOR_BLOCK = """## Authors +J. Smith,1 A. B. Jones,2 and C. Lee1 +1 Department of Chemistry, University X; 2 Institute Y +Email: j.smith@x.edu, a.jones@y.org +""" + +_DATA_AVAILABILITY = """## Data availability +The data supporting this study are available from the corresponding author +upon reasonable request. +""" + +_LICENCE_NOTICE = ( + "This article is licensed under a Creative Commons Attribution 4.0 " + "International License, which permits use, sharing, adaptation, " + "distribution and reproduction in any medium. " + "http://creativecommons.org/licenses/by/4.0/." +) + +_GLYPH_AUTHOR_INFORMATION = """## ■ AUTHOR INFORMATION +## Corresponding Author +Jane Roe − Department of Chemistry; orcid.org/0000-0002-3456-789X; +Email: jane@x.edu +""" + +_RESULTS_PROSE = """## Results +The photoluminescence peak shifted by 10-15 meV after aging for 4 days at +77 K, and the quality factor reached 1200 at 77 K. These changes are +consistent with the formation of larger emissive domains in the film bulk. +""" + +_NOTES_WITH_MEASUREMENTS = """## Notes +Samples were stored at 25 °C for 30 days before measurement. +""" + +_HEADER_FRAGMENT = "1234567890():,; ## ARTICLE OPEN" + +_LICENCE_DISCUSSION = ( + "We discuss Creative Commons licences as a model for data sharing in " + "materials science, where reuse terms shape what gets published and how " + "results are reused by later studies." +) + +_REFERENCE_LIST = """ +[1] J. Smith, A. Jones, Perovskite degradation under humidity, Nature Mater. +14, 193-198 (2015). doi:10.1038/nmat4150 +[2] L. Chen et al., Photoluminescence of CsPbBr3 nanocrystals, Nano Lett. 15, +3692-3696 (2015). doi:10.1021/nl5048779 +[3] M. Garcia, Stability of halide perovskites, Chem. Rev. 119, 3036 (2019). +doi:10.1021/acs.chemrev.8b00539 +[4] K. Tanaka, Exciton dynamics in lead halide perovskites, Phys. Rev. B 92, +045414 (2015). doi:10.1103/PhysRevB.92.045414 +[5] R. Novak, Encapsulation strategies for perovskite devices, Adv. Mater. 30, +1806702 (2018). doi:10.1002/adma.201806702 +""" + + +@pytest.mark.parametrize( + "text", + [ + _ORCID_BLOCK, + _NOTES_BLOCK, + _AUTHOR_BLOCK, + _DATA_AVAILABILITY, + _LICENCE_NOTICE, + _GLYPH_AUTHOR_INFORMATION, + ], + ids=["orcid", "notes", "authors", "data_availability", "licence", "glyph"], +) +def test_front_matter_units_detected(text: str) -> None: + assert is_non_content_unit(text, None, None) + + +@pytest.mark.parametrize( + "text", + [_RESULTS_PROSE, _NOTES_WITH_MEASUREMENTS, _HEADER_FRAGMENT, _LICENCE_DISCUSSION], + ids=["results", "notes_with_measurements", "header_fragment", "licence_prose"], +) +def test_content_units_kept(text: str) -> None: + """A measurement anywhere keeps a unit; prose about licences is prose.""" + assert not is_non_content_unit(text, None, None) + + +def test_heading_matches_via_breadcrumb_when_text_has_no_heading_line() -> None: + body = "Jane Roe and John Doe contributed equally to this work." + assert not has_non_content_heading(body, None) + assert has_non_content_heading(body, ["Results", "Author Contributions"]) + assert is_non_content_unit(body, ["Author Contributions"], None) + + +def test_heading_normalisation_strips_glyphs_and_numbering() -> None: + assert has_non_content_heading("## ■ AUTHOR INFORMATION\nbody", None) + assert has_non_content_heading("7. Conflicts of Interest\nbody", None) + assert has_non_content_heading("**Publisher's Note**\nbody", None) + assert not has_non_content_heading("## Results and Discussion\nbody", None) + + +def test_acknowledgements_label_counts_as_front_matter_heading() -> None: + body = "We thank the beamline staff for their support during the campaign." + assert is_non_content_unit(body, None, "acknowledgements") + assert not is_non_content_unit(body, None, "results") + + +def test_metadata_token_share() -> None: + dense = "J. Smith j.smith@x.edu 0000-0001-2345-6789 https://orcid.org/x A. B." + assert metadata_token_share(dense) >= NON_CONTENT_TOKEN_SHARE + assert metadata_token_share(_RESULTS_PROSE) < NON_CONTENT_TOKEN_SHARE + assert metadata_token_share("") == 0.0 + # Punctuation-only tokens do not dilute the share. + assert metadata_token_share("■ ## j@x.edu -->") == 1.0 + + +def test_metadata_heavy_unit_with_a_measurement_is_kept() -> None: + """A high metadata-token share must not override a real stated measurement. + + The heading and licence-boilerplate branches already check + ``states_measurement`` before routing a unit as non-content; the + metadata-token-share branch has to make the same check, or an author + block that happens to also report a value (a device spec, a calibration + temperature) is dropped alongside genuine front matter. + """ + text = ( + "J. Smith j.smith@uni.edu https://orcid.org/0000-0002-1825-0097 " + "A. B. Jones measured 300 K samples." + ) + assert metadata_token_share(text) >= NON_CONTENT_TOKEN_SHARE + assert not is_non_content_unit(text, None, None) + + +def test_empty_text_is_not_non_content() -> None: + assert not is_non_content_unit(" ", None, None) + + +def test_non_content_mode_default_is_extract() -> None: + assert ChunkConfig().non_content_mode == "extract" + + +# --- routing through chunk_text ----------------------------------------------- + + +async def _run_chunk_text( + mode: Literal["extract", "skip"], + chunks: list[PreparedChunk], + monkeypatch: pytest.MonkeyPatch, + *, + bibliography_mode: Literal["domain_facts", "citations_only", "skip"] = "skip", +) -> AgentState: + """Drive chunk_text with prepared chunks, bypassing docling conversion.""" + chunk_text_module = importlib.import_module("ontocast.agent.chunk_text") + + async def fake_prepare(*_args, **_kwargs): + return list(chunks) + + monkeypatch.setattr(chunk_text_module, "prepare_content_units", fake_prepare) + tools = SimpleNamespace( + chunker=SimpleNamespace( + config=ChunkConfig( + non_content_mode=mode, bibliography_mode=bibliography_mode + ) + ) + ) + state = AgentState(docling_doc=plain_text_to_docling_doc("x", "doc")) + state.current_domain = "https://example.org" + state.doc_hid = "1" + logging.getLogger("ontocast.agent.chunk_text").setLevel(logging.INFO) + return await chunk_text_module.chunk_text(state, cast(ToolBox, tools)) + + +def _chunk(text: str, label: str | None = None) -> PreparedChunk: + return PreparedChunk(text=text, headings=None, section_label=label) + + +@pytest.mark.anyio +async def test_extract_keeps_unit_flagged_and_logs(monkeypatch, caplog) -> None: + chunks = [_chunk(_RESULTS_PROSE, "results"), _chunk(_NOTES_BLOCK)] + with caplog.at_level("INFO"): + state = await _run_chunk_text("extract", chunks, monkeypatch) + + assert [unit.is_non_content for unit in state.content_units] == [False, True] + assert [unit.index for unit in state.content_units] == [0, 1] + assert "routed as non-content" in caplog.text + assert "kept, flagged" in caplog.text + assert state.non_content_units_skipped == 0 + + +@pytest.mark.anyio +async def test_skip_drops_unit_renumbers_and_counts(monkeypatch, caplog) -> None: + chunks = [ + _chunk(_ORCID_BLOCK), + _chunk(_RESULTS_PROSE, "results"), + _chunk(_NOTES_BLOCK), + _chunk(_RESULTS_PROSE, "results"), + ] + with caplog.at_level("INFO"): + state = await _run_chunk_text("skip", chunks, monkeypatch) + + assert len(state.content_units) == 2 + # Dropping chunks must not leave holes in the index sequence. + assert [unit.index for unit in state.content_units] == [0, 1] + assert not any(unit.is_non_content for unit in state.content_units) + assert state.non_content_units_skipped == 2 + assert "Dropped 2 non-content chunk(s)" in caplog.text + + +@pytest.mark.anyio +async def test_bibliography_route_takes_precedence(monkeypatch) -> None: + """A reference list is routed as bibliography, never as non-content.""" + chunks = [_chunk(_REFERENCE_LIST, "references"), _chunk(_RESULTS_PROSE)] + state = await _run_chunk_text( + "skip", chunks, monkeypatch, bibliography_mode="citations_only" + ) + + assert [unit.is_citation_metadata for unit in state.content_units] == [ + True, + False, + ] + assert not any(unit.is_non_content for unit in state.content_units) + assert state.bibliography_units_skipped == 0 + assert state.non_content_units_skipped == 0 + + +@pytest.mark.anyio +async def test_routing_counters_reach_the_manifest_selection_block( + monkeypatch, tmp_path +) -> None: + from ontocast.api.process_helpers import _selection_manifest + from ontocast.config import Config, PathConfig, ToolConfig + + chunks = [_chunk(_NOTES_BLOCK), _chunk(_REFERENCE_LIST, "references")] + state = await _run_chunk_text("skip", chunks, monkeypatch) + ontology_dir = tmp_path / "ontologies" + ontology_dir.mkdir() + config = Config( + tool_config=ToolConfig( + path_config=PathConfig(ontology_directory=ontology_dir), + chunk_config=ChunkConfig(non_content_mode="skip"), + ) + ) + + selection = _selection_manifest(state, config) + + assert selection.non_content_mode == "skip" + assert selection.non_content_units_skipped == 1 + assert selection.bibliography_units_skipped == 1 + assert selection.undersized_units_skipped == 0 diff --git a/test/chunking/test_prepare.py b/test/chunking/test_prepare.py index 34ff29c6..b599b60b 100644 --- a/test/chunking/test_prepare.py +++ b/test/chunking/test_prepare.py @@ -19,6 +19,8 @@ from ontocast.toolbox import ToolBox from test.docling_test_helpers import doc_from_markdown_lines +pytestmark = pytest.mark.unit + _SECTION_OPTS = PrepareOptions(summarize_sections=["*"]) _MULTI_SECTION_DOC = """# Abstract @@ -136,6 +138,7 @@ def test_prepare_sets_section_label_on_chunks() -> None: assert all(chunk.section_label for chunk in labeled) +@pytest.mark.slow def test_prepare_splits_oversized_section() -> None: long_body = "Word " * 400 doc = doc_from_markdown_lines(f"# Results\n{long_body.strip()}") diff --git a/test/chunking/test_schema_detect.py b/test/chunking/test_schema_detect.py index 8daf6684..48568da4 100644 --- a/test/chunking/test_schema_detect.py +++ b/test/chunking/test_schema_detect.py @@ -44,6 +44,8 @@ from ontocast.toolbox import ToolBox from test.docling_test_helpers import doc_from_markdown_lines +pytestmark = pytest.mark.unit + CORPUS_PATH = ( pathlib.Path(__file__).resolve().parents[1] / "data" / "schema_corpus.json" ) diff --git a/test/chunking/test_section_coalesce.py b/test/chunking/test_section_coalesce.py index d7b617f4..060f97de 100644 --- a/test/chunking/test_section_coalesce.py +++ b/test/chunking/test_section_coalesce.py @@ -1,5 +1,7 @@ """Tests for pre-tag segment coalescing.""" +import pytest + from ontocast.config.section_labels import load_section_label_schema from ontocast.tool.chunk.segment import ( PrepareSegment, @@ -8,6 +10,8 @@ merge_into_right, ) +pytestmark = pytest.mark.unit + def _academic_schema(): return load_section_label_schema("academic") diff --git a/test/chunking/test_section_density.py b/test/chunking/test_section_density.py index 6f9e09df..b1fcd8eb 100644 --- a/test/chunking/test_section_density.py +++ b/test/chunking/test_section_density.py @@ -6,9 +6,13 @@ merely unselectable. """ +import pytest + from ontocast.config.section_labels import load_section_label_schema from ontocast.tool.chunk.density import classify_by_density, score_section_labels +pytestmark = pytest.mark.unit + SCHEMA = load_section_label_schema("academic") _REFERENCE_LIST = """ diff --git a/test/chunking/test_section_keywords.py b/test/chunking/test_section_keywords.py index a1cd27b0..6491db1f 100644 --- a/test/chunking/test_section_keywords.py +++ b/test/chunking/test_section_keywords.py @@ -16,6 +16,8 @@ resolve_heading_label, ) +pytestmark = pytest.mark.unit + SCHEMA = load_section_label_schema("academic") RECOGNISED = [ diff --git a/test/chunking/test_section_labels.py b/test/chunking/test_section_labels.py index acf0509c..877b5b84 100644 --- a/test/chunking/test_section_labels.py +++ b/test/chunking/test_section_labels.py @@ -12,6 +12,8 @@ resolve_section_schema_id, ) +pytestmark = pytest.mark.unit + @pytest.fixture(autouse=True) def _clear_caches(): diff --git a/test/chunking/test_section_llm_batch.py b/test/chunking/test_section_llm_batch.py index e638d6b9..0c0d6515 100644 --- a/test/chunking/test_section_llm_batch.py +++ b/test/chunking/test_section_llm_batch.py @@ -11,6 +11,8 @@ from types import SimpleNamespace from typing import cast +import pytest + from ontocast.config import ChunkConfig from ontocast.onto.enum import SectionLabelSource from ontocast.tool.chunk.chunker import ChunkerTool @@ -18,6 +20,8 @@ from ontocast.tool.chunk.segment import PrepareSegment from ontocast.toolbox import ToolBox +pytestmark = pytest.mark.unit + class _RecordingLLM: """Fake LLM that counts calls and replays canned responses.""" diff --git a/test/chunking/test_section_outline.py b/test/chunking/test_section_outline.py index 58254cd0..834e3f4b 100644 --- a/test/chunking/test_section_outline.py +++ b/test/chunking/test_section_outline.py @@ -6,6 +6,8 @@ segments at split time, no later tier could correct it. """ +import pytest + from ontocast.config.section_labels import load_section_label_schema from ontocast.onto.enum import SectionLabelSource from ontocast.tool.chunk.outline import ( @@ -17,6 +19,8 @@ ) from ontocast.tool.chunk.sections import detect_section_spans +pytestmark = pytest.mark.unit + SCHEMA = load_section_label_schema("academic") # Headings the anchored patterns cannot match, between headings they can. diff --git a/test/chunking/test_section_pipeline.py b/test/chunking/test_section_pipeline.py index f525103e..6fd094ba 100644 --- a/test/chunking/test_section_pipeline.py +++ b/test/chunking/test_section_pipeline.py @@ -11,7 +11,7 @@ from ontocast.agent.chunk_text import chunk_text from ontocast.agent.summarize_chunks import should_summarize_unit -from ontocast.api.parse import parse_sections_list_param +from ontocast.api.parse import RequestParamError, parse_sections_list_param from ontocast.api.process_helpers import expand_input_to_states from ontocast.config import Config from ontocast.config.section_labels import ( @@ -36,6 +36,8 @@ from ontocast.toolbox import ToolBox from test.docling_test_helpers import doc_from_markdown_lines +pytestmark = pytest.mark.unit + _SAMPLE_DOC = """# Introduction We survey prior work. @@ -191,6 +193,44 @@ def test_parse_sections_list_param_normalizes() -> None: assert parsed == ["related_work", "methods", "results"] +def test_parse_sections_list_param_keeps_recognised_half() -> None: + """A partial match still expresses an intent, so unknown tokens drop.""" + parsed = parse_sections_list_param("Methods,zzz-not-a-section") + assert parsed == ["methods"] + + +def test_parse_sections_list_param_rejects_all_unrecognised() -> None: + """An all-unknown list must fail rather than read as "no sections". + + An empty result replaces the resolved schema's defaults instead of adding + to them, so failing open would silently disable section handling the caller + never touched. + """ + with pytest.raises(RequestParamError) as excinfo: + parse_sections_list_param( + "zzz-not-a-section,also-not-one", param="exclude_sections" + ) + assert excinfo.value.param == "exclude_sections" + assert "zzz-not-a-section" in str(excinfo.value) + + +def test_parse_sections_list_param_rejects_all_unrecognised_json() -> None: + with pytest.raises(RequestParamError): + parse_sections_list_param('["zzz-not-a-section"]') + + +def test_parse_sections_list_param_rejects_all_unrecognised_list() -> None: + with pytest.raises(RequestParamError): + parse_sections_list_param(["zzz-not-a-section"]) + + +def test_parse_sections_list_param_absent_stays_empty() -> None: + """None/empty mean "not supplied", which is not a caller mistake.""" + assert parse_sections_list_param(None) == [] + assert parse_sections_list_param("") == [] + assert parse_sections_list_param([]) == [] + + def _chunk_tools() -> ToolBox: from ontocast.config import ChunkConfig from ontocast.tool.chunk.chunker import ChunkerTool @@ -267,6 +307,33 @@ async def test_chunk_text_max_chunks_after_prepare() -> None: assert all(unit.section_label == "results" for unit in result.content_units) +@pytest.mark.anyio +async def test_chunk_text_min_unit_chars_disabled_by_default() -> None: + """The floor is opt-in; 0 must keep every unit chunking produced.""" + doc = doc_from_markdown_lines(_SAMPLE_DOC) + tools = _chunk_tools() + assert tools.chunker.config.min_unit_chars == 0 + result = await chunk_text(AgentState(docling_doc=doc), tools) + assert result.status == Status.SUCCESS + baseline = len(result.content_units) + assert baseline > 0 + + +@pytest.mark.anyio +async def test_chunk_text_min_unit_chars_drops_undersized_units(caplog) -> None: + """Sub-floor units are dropped, and each drop is logged rather than silent.""" + doc = doc_from_markdown_lines(_SAMPLE_DOC) + tools = _chunk_tools() + baseline = len((await chunk_text(AgentState(docling_doc=doc), tools)).content_units) + + tools.chunker.config.min_unit_chars = 10_000 + with caplog.at_level(logging.INFO): + result = await chunk_text(AgentState(docling_doc=doc), tools) + assert result.content_units == [] + assert baseline > 0 + assert any("undersized" in record.message for record in caplog.records) + + @pytest.mark.anyio async def test_chunk_prepare_warns_when_section_filter_drops_all(caplog) -> None: doc = doc_from_markdown_lines("# Introduction\nOnly intro.\n") diff --git a/test/chunking/test_semantic_chunker.py b/test/chunking/test_semantic_chunker.py index 776a49f0..41f00a7d 100644 --- a/test/chunking/test_semantic_chunker.py +++ b/test/chunking/test_semantic_chunker.py @@ -3,10 +3,16 @@ This test suite ensures that: 1. Chunks, when joined, reproduce the original text (length and content) 2. If max_size and min_size are provided, all chunks are >= min_size and <= max_size +3. The same text and config always produce the same chunks, in any process """ +import hashlib import re +import subprocess +import sys +from pathlib import Path +import numpy as np import pytest from langchain_core.embeddings import Embeddings @@ -14,6 +20,8 @@ from ontocast.tool.chunk.proposition import SENTENCE_SPLIT_REGEX from ontocast.tool.chunk.util import SemanticChunker +pytestmark = pytest.mark.unit + @pytest.mark.slow class TestSemanticChunker: @@ -98,3 +106,88 @@ def test_chunks_respect_min_and_max_size( f"Original length: {len(long_text)}, Joined length: {len(joined_text)}. " f"First difference at position: {next((i for i, (a, b) in enumerate(zip(long_text, joined_text)) if a != b), min(len(long_text), len(joined_text)))}" ) + + +class HashEmbeddings(Embeddings): + """Deterministic stand-in encoder: each text maps to a fixed random vector. + + Keeps the determinism tests about the reduction and clustering, not the + model, and loads no weights. + """ + + def embed_documents(self, texts: list[str]) -> list[list[float]]: + return [self._vector(text) for text in texts] + + def embed_query(self, text: str) -> list[float]: + return self._vector(text) + + @staticmethod + def _vector(text: str) -> list[float]: + seed = int(hashlib.md5(text.encode()).hexdigest()[:8], 16) + return np.random.default_rng(seed).normal(size=64).tolist() + + +def _topic_text(n_sentences: int) -> str: + topics = ["Alpha", "Beta", "Gamma", "Delta"] + return "".join( + f"{topics[i % len(topics)]} item {i} states a fact about the matter here. " + for i in range(n_sentences) + ) + + +def _split_lengths(n_sentences: int) -> list[int]: + chunker = SemanticChunker( + embeddings=HashEmbeddings(), + chunk_config=ChunkConfig(min_size=800, max_size=4000), + sentence_split_regex=SENTENCE_SPLIT_REGEX, + ) + return [len(chunk) for chunk in chunker.split_text(_topic_text(n_sentences))] + + +class TestSemanticChunkerDeterminism: + """The same text and config must always produce the same unit boundaries.""" + + # 700 sentences is large enough for UMAP's spectral init to vary between + # seeded runs, which is what the PCA init guards against. + @pytest.mark.parametrize("n_sentences", [60, 700]) + def test_split_is_deterministic_across_calls(self, n_sentences: int): + first = _split_lengths(n_sentences) + assert len(first) > 1 + for _ in range(2): + assert _split_lengths(n_sentences) == first + + @pytest.mark.slow + def test_split_is_deterministic_across_processes(self): + # Load this file by path, so the child needs no cwd or package root. + code = ( + "import importlib.util as u;" + f"s = u.spec_from_file_location('chunker_probe', {str(Path(__file__).resolve())!r});" + "m = u.module_from_spec(s); s.loader.exec_module(m);" + "print(m._split_lengths(250))" + ) + outputs = [ + subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + for _ in range(2) + ] + assert outputs[0] + assert outputs[0] == outputs[1] + + def test_few_sentences_over_max_size_are_packed_without_clustering(self): + text = "".join(f"Sentence{i} " + "x" * 600 + ". " for i in range(3)) + embeddings = HashEmbeddings() + chunker = SemanticChunker( + embeddings=embeddings, + chunk_config=ChunkConfig(min_size=10, max_size=1000), + sentence_split_regex=SENTENCE_SPLIT_REGEX, + ) + + chunks = chunker.split_text(text) + + assert "".join(chunks) == text + assert len(chunks) == 3 + assert all(len(chunk) <= 1000 for chunk in chunks) diff --git a/test/chunking/test_sizing.py b/test/chunking/test_sizing.py index 307485f0..54eb90ad 100644 --- a/test/chunking/test_sizing.py +++ b/test/chunking/test_sizing.py @@ -10,6 +10,8 @@ size_text_parts, ) +pytestmark = pytest.mark.unit + def test_hard_cap_parts_splits_at_word_boundary() -> None: parts = ["word " * 100] diff --git a/test/facts/test_complete_facts.py b/test/facts/test_complete_facts.py new file mode 100644 index 00000000..4cd23cc4 --- /dev/null +++ b/test/facts/test_complete_facts.py @@ -0,0 +1,372 @@ +"""The insert-only facts completion pass. + +Runs after the critic loop, only while the numeric-coverage inventory still +lists a measurement -- a number with its unit -- absent from the unit graph. +Each pass's inserts go through the same per-subject regression check a +critic fix goes through (see ``test_critic_patch_pass.py``), so this file +tests what is specific to the completion pass: it proposes ADD-only fixes, +counts recovered measurements, stops once nothing is missing, and never +touches the ontology loop or a deployment that left the pass budget at zero. +""" + +from dataclasses import replace +from types import SimpleNamespace +from typing import cast + +import pytest +from rdflib import Literal, URIRef +from rdflib.namespace import OWL, RDF, RDFS + +from ontocast.onto.content_unit import ContentUnit +from ontocast.onto.enum import OntologyAssemblyMode, RenderMode, Status +from ontocast.onto.model import FactsUnitFinding, FactsUnitFindingKind, TripleFix +from ontocast.onto.ontology_snapshot import OntologySnapshot +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.onto.state import AgentState +from ontocast.onto.unit_states import UnitFactsState, UnitOntologyState +from ontocast.prompt.facts_guidelines import DEFAULT_QUANTITY_FALLBACK_VOCABULARY +from ontocast.stategraph import atomic as atomic_module +from ontocast.stategraph.atomic import ( + FACTS_PHASE, + _run_completion_passes, + facts_loop, + ontology_loop, +) +from ontocast.stategraph.context_resolver import UnitOntologyContext +from ontocast.stategraph.unit_context import UnitLoopContext +from ontocast.tool.atomic import AtomicToolBox +from ontocast.tool.facts_validation import CriticPatchPolicy, ValidationPolicy +from ontocast.toolbox import ToolBox +from test.snapshot_helpers import empty_snapshot + +pytestmark = pytest.mark.unit + +_SUBJECT = URIRef("http://example.org/sample_1") +_POISON = URIRef("http://example.org/poison") +_QUDT = "http://qudt.org/schema/qudt/" +_UNIT_NS = "http://qudt.org/vocab/unit/" +_MEASURED_VALUE = URIRef(f"{_QUDT}numericValue") +_UNIT_PROP = URIRef(f"{_QUDT}unit") +_MEV = URIRef(f"{_UNIT_NS}MilliEV") +_NM = URIRef(f"{_UNIT_NS}NanoM") +_XSD_DECIMAL = URIRef("http://www.w3.org/2001/XMLSchema#decimal") + + +def _quantity_ontology() -> RDFGraph: + """Minimal ontology so coverage can map unit IRIs back to surfaces.""" + onto = RDFGraph() + onto.bind("qudt", _QUDT) + onto.add((URIRef(f"{_QUDT}unit"), RDF.type, OWL.ObjectProperty)) + onto.add((URIRef(f"{_QUDT}unit"), RDFS.range, URIRef(f"{_QUDT}Unit"))) + onto.add((URIRef(f"{_QUDT}Unit"), RDF.type, OWL.Class)) + onto.add((_MEV, RDF.type, URIRef(f"{_QUDT}Unit"))) + onto.add((_MEV, RDFS.label, Literal("meV"))) + onto.add((_NM, RDF.type, URIRef(f"{_QUDT}Unit"))) + onto.add((_NM, RDFS.label, Literal("nm"))) + return onto + + +def _unit_state( + text: str = "a shift of 96 meV", graph: RDFGraph | None = None +) -> UnitFactsState: + facts = graph if graph is not None else RDFGraph() + facts.bind("qudt", _QUDT) + unit = ContentUnit( + text=text, + index=0, + doc_iri=URIRef("https://example.com/doc/d1"), + graph=facts, + ) + snapshot = OntologySnapshot.from_graph( + _quantity_ontology(), + source_iris=["https://example.com/onto/units"], + assembly_mode=OntologyAssemblyMode.FIXED_SINGLE_ONTOLOGY, + ) + state = UnitFactsState(content_unit=unit, ontology_snapshot=snapshot) + state.status = Status.SUCCESS + return state + + +def _atomic(*, completion_passes: int = 1) -> AtomicToolBox: + return cast( + AtomicToolBox, + SimpleNamespace( + facts_completion_passes=completion_passes, + facts_patch_policy=CriticPatchPolicy(), + additional_standard_namespaces=(), + validation_policy=ValidationPolicy( + quantity_fallback_vocabulary=dict(DEFAULT_QUANTITY_FALLBACK_VOCABULARY) + ), + acceptance_policy=None, + numeric_coverage_limit=30, + numeric_coverage_mandatory="off", + catalog_terms=lambda: set(), + ), + ) + + +def _fix(correct: str) -> TripleFix: + return TripleFix( + text_fragment="96 meV", + action="ADD", + severity="minor", + correct_value=correct, + explanation="recovered measurement", + ) + + +def _mev_insert(value: str = "96") -> str: + """JSON-LD insert of a structured quantity with matching unit.""" + return ( + f'{{"@id": "{_SUBJECT}", ' + f'"{_MEASURED_VALUE}": {{"@value": "{value}", "@type": "{_XSD_DECIMAL}"}}, ' + f'"{_UNIT_PROP}": {{"@id": "{_MEV}"}}}}' + ) + + +def _no_findings(_state, _atomic) -> list[FactsUnitFinding]: + return [] + + +def _poison_sensitive_findings(state, _atomic) -> list[FactsUnitFinding]: + """Mandatory finding iff the poison triple is in the graph -- lets a test + control the regression check without depending on the real validator.""" + poisoned = any(p == _POISON for _, p, _ in state.content_unit.graph) + if not poisoned: + return [] + return [ + FactsUnitFinding( + kind=FactsUnitFindingKind.UNKNOWN_TERM, + mandatory=True, + message="poisoned", + ) + ] + + +# --- what one pass does to the graph ----------------------------------------- + + +@pytest.mark.anyio +async def test_a_pass_produces_only_additions(monkeypatch) -> None: + state = _unit_state() + + async def fake_complete(_state, _atomic, _inventory): + return [_fix(_mev_insert())] + + monkeypatch.setattr(atomic_module, "complete_facts", fake_complete) + phase = replace(FACTS_PHASE, collect_findings=_no_findings) + + await _run_completion_passes(state, _atomic(), phase, render_attempt=1) + + assert any( + s == _SUBJECT and p == _MEASURED_VALUE for s, p, _ in state.content_unit.graph + ) + attempt = state.attempt_log[-1] + assert attempt.kind == "completion" + assert attempt.n_fixes_applied == 1 + assert attempt.n_triples_inserted >= 1 + assert attempt.n_triples_deleted == 0 + assert attempt.n_fixes_rolled_back == 0 + + +@pytest.mark.anyio +async def test_a_regressing_insert_is_rolled_back(monkeypatch) -> None: + """The pass reuses the critic patch's own regression check. + + A fix that manufactures a mandatory finding is undone on its own, the + same as a critic fix would be -- see ``test_critic_patch_pass.py``. + """ + state = _unit_state() + poison = f'<{_SUBJECT}> <{_POISON}> "bad" .' + + async def fake_complete(_state, _atomic, _inventory): + return [_fix(poison)] + + monkeypatch.setattr(atomic_module, "complete_facts", fake_complete) + phase = replace(FACTS_PHASE, collect_findings=_poison_sensitive_findings) + + await _run_completion_passes(state, _atomic(), phase, render_attempt=1) + + assert not any( + s == _SUBJECT and p == _POISON for s, p, _ in state.content_unit.graph + ) + attempt = state.attempt_log[-1] + assert attempt.n_fixes_applied == 0 + assert attempt.n_fixes_rolled_back == 1 + assert attempt.rolled_back_fixes[0].reason == "new_mandatory" + assert attempt.n_measurements_recovered == 0 + + +@pytest.mark.anyio +async def test_only_add_fixes_are_kept(monkeypatch) -> None: + """A non-ADD fix is dropped before it ever reaches the patch machinery. + + Defence in depth: ``complete_facts`` itself already filters this, but the + unit loop must not trust it -- the pass is insert-only by contract. + """ + state = _unit_state() + + async def fake_complete(_state, _atomic, _inventory): + return [ + _fix(_mev_insert()), + TripleFix( + text_fragment="x", + action="REMOVE", + severity="minor", + triple_ids=[1], + explanation="should be ignored: not addressable by id here", + ), + ] + + monkeypatch.setattr(atomic_module, "complete_facts", fake_complete) + phase = replace(FACTS_PHASE, collect_findings=_no_findings) + + await _run_completion_passes(state, _atomic(), phase, render_attempt=1) + + # Only the ADD landed; nothing was deleted (there was nothing to delete + # by id since no index was handed to the compiler). + assert state.attempt_log[-1].n_fixes_applied == 1 + assert state.attempt_log[-1].n_triples_deleted == 0 + + +@pytest.mark.anyio +async def test_n_measurements_recovered_counts_precisely(monkeypatch) -> None: + """One of two missing measurements is addressed; the count reflects that.""" + state = _unit_state(text="a shift of 96 meV and a gap of 12 nm") + + async def fake_complete(_state, _atomic, _inventory): + return [_fix(_mev_insert())] + + monkeypatch.setattr(atomic_module, "complete_facts", fake_complete) + phase = replace(FACTS_PHASE, collect_findings=_no_findings) + + await _run_completion_passes( + state, _atomic(completion_passes=1), phase, render_attempt=1 + ) + + assert state.attempt_log[-1].n_measurements_recovered == 1 + + +@pytest.mark.anyio +async def test_it_stops_early_once_measurements_are_covered(monkeypatch) -> None: + """A budget of two passes is not spent once one pass covers everything.""" + state = _unit_state() + calls = {"n": 0} + + async def fake_complete(_state, _atomic, _inventory): + calls["n"] += 1 + return [_fix(_mev_insert())] + + monkeypatch.setattr(atomic_module, "complete_facts", fake_complete) + phase = replace(FACTS_PHASE, collect_findings=_no_findings) + + await _run_completion_passes( + state, _atomic(completion_passes=2), phase, render_attempt=1 + ) + + assert calls["n"] == 1 + assert len([a for a in state.attempt_log if a.kind == "completion"]) == 1 + + +# --- when the pass must not run at all --------------------------------------- + + +def _facts_tools(*, completion_passes: int) -> object: + atomic = cast( + AtomicToolBox, + SimpleNamespace( + facts_critic_passes=0, + facts_patch_policy=CriticPatchPolicy(), + additional_standard_namespaces=(), + validation_policy=None, + acceptance_policy=None, + numeric_coverage_limit=30, + numeric_coverage_mandatory="off", + facts_critic_min_triples=0, + facts_completion_passes=completion_passes, + catalog_terms=lambda: set(), + ), + ) + return SimpleNamespace(get_atomic_tools=lambda: atomic) + + +def _context() -> UnitLoopContext: + return UnitLoopContext.from_agent_state(AgentState(render_mode=RenderMode.FACTS)) + + +def _resolved_context() -> UnitOntologyContext: + return UnitOntologyContext( + snapshot=empty_snapshot(), writable_iris=[], confidence=1.0 + ) + + +@pytest.mark.anyio +async def test_the_pass_never_runs_when_the_budget_is_zero(monkeypatch) -> None: + async def ok_render(state, tools, **kwargs): + state.status = Status.SUCCESS + return state + + async def refuse(*_args, **_kwargs): # pragma: no cover - never reached + raise AssertionError("completion must not run when the budget is zero") + + monkeypatch.setattr(atomic_module, "render_facts", ok_render) + monkeypatch.setattr(atomic_module, "complete_facts", refuse) + + result = await facts_loop( + _unit_state(), + cast(ToolBox, _facts_tools(completion_passes=0)), + _context(), + pre_resolved_context=_resolved_context(), + ) + + assert not [a for a in result.attempt_log if a.kind == "completion"] + + +@pytest.mark.anyio +async def test_the_pass_does_not_run_on_the_ontology_loop(monkeypatch) -> None: + async def render(state, tools, **kwargs): + state.status = Status.SUCCESS + return state + + async def resolve(context, tools, unit, *, can_create_vocabulary=False): + return _resolved_context() + + async def refuse(*_args, **_kwargs): # pragma: no cover - never reached + raise AssertionError("completion is a facts-loop mechanism only") + + monkeypatch.setattr(atomic_module, "render_ontology", render) + monkeypatch.setattr(atomic_module, "resolve_unit_ontology_context", resolve) + monkeypatch.setattr(atomic_module, "complete_facts", refuse) + + state = UnitOntologyState( + content_unit=ContentUnit( + text="a shift of 96 meV", + index=0, + doc_iri=URIRef("https://example.com/doc/d1"), + graph=RDFGraph(), + ), + ontology_snapshot=empty_snapshot(), + ) + tools = cast( + ToolBox, + SimpleNamespace( + get_atomic_tools=lambda: cast( + AtomicToolBox, + SimpleNamespace( + ontology_critic_passes=0, + ontology_patch_policy=CriticPatchPolicy(), + additional_standard_namespaces=(), + validation_policy=None, + ontology_acceptance_policy=None, + # Deliberately no facts_completion_passes attribute: the + # ontology phase must never read it (`phase.name == "facts"` + # short-circuits first), so its absence proves the guard. + ), + ) + ), + ) + + result = await ontology_loop(state, tools, _context()) + + assert result.status != Status.FAILED + assert not [a for a in result.attempt_log if a.kind == "completion"] diff --git a/test/facts/test_context_computed_once.py b/test/facts/test_context_computed_once.py index 09dc7d94..e2a62555 100644 --- a/test/facts/test_context_computed_once.py +++ b/test/facts/test_context_computed_once.py @@ -16,14 +16,22 @@ from rdflib import URIRef from ontocast.onto.content_unit import ContentUnit -from ontocast.onto.enum import OntologyContextMode, Status +from ontocast.onto.enum import ( + OntologyChapterFormat, + OntologyContextMode, + OntologyContextScope, + RetrievalMetric, + Status, +) from ontocast.onto.ontology import Ontology +from ontocast.onto.ontology_condense import TextCaps +from ontocast.onto.ontology_snapshot import OntologySnapshot from ontocast.onto.rdfgraph import RDFGraph from ontocast.onto.state import AgentState from ontocast.stategraph import node_factories from ontocast.toolbox import ToolBox -pytestmark = pytest.mark.anyio +pytestmark = [pytest.mark.anyio, pytest.mark.unit] MERGE_CALLS = "ctx/merge_document_ontology.calls" @@ -65,17 +73,25 @@ def _state(n_units: int) -> AgentState: return state -def _tools() -> ToolBox: +def _tools(*, context_from_units: bool = False) -> ToolBox: + tool_config = SimpleNamespace( + facts_validation=SimpleNamespace(context_from_units=context_from_units) + ) return cast( ToolBox, SimpleNamespace( config=SimpleNamespace( server=SimpleNamespace( parallel_workers=4, - max_critic_visits_per_node=None, ontology_context_max_triples=4000, - ) + ontology_chapter_format=OntologyChapterFormat.INHERIT, + ontology_text_caps=TextCaps(), + ontology_context_scope=OntologyContextScope.UNIT, + fanout_warmup_units=0, + ), + get_tool_config=lambda: tool_config, ), + shapes_prompt_contract=lambda: ("", (), False), ), ) @@ -179,3 +195,137 @@ async def test_facts_only_run_still_resolves_per_unit(monkeypatch) -> None: assert all(entry["pre_resolved"] is None for entry in seen) assert len(state.facts_units) == 3 + + +def _install_per_unit_resolving_facts_loop(monkeypatch, snapshot) -> list[int]: + """Facts loop stub standing in for a facts-only run's per-unit resolution. + + Mirrors ``_apply_facts_ontology_context``: with no merged context handed + down, each unit resolves its own and the result lands on the unit state as + a distinct object with the same contributing catalog IRIs. + """ + snapshot_ids: list[int] = [] + + async def fake_facts_loop(state, tools, document_context, **kwargs): + assert kwargs.get("pre_resolved_context") is None + state.ontology_snapshot = snapshot.model_copy(deep=True) + snapshot_ids.append(id(state.ontology_snapshot)) + state.content_unit.graph = RDFGraph._from_turtle_str( + f""" + @prefix cd: . + cd:unit{state.content_unit.index} a cd:Thing . + """ + ) + state.status = Status.SUCCESS + return state + + monkeypatch.setattr(node_factories, "facts_loop", fake_facts_loop) + return snapshot_ids + + +def _catalog_snapshot() -> OntologySnapshot: + return OntologySnapshot( + graph=RDFGraph._from_turtle_str( + """ + @prefix ex: . + ex:Sample a ex:Class . + ex:hasValue a ex:Property . + """ + ), + source_iris=["https://example.org/onto/catalog"], + ) + + +async def test_facts_only_context_stays_empty_by_default(monkeypatch) -> None: + """The flag is off by default, so today's empty-graph behaviour is kept.""" + _install_per_unit_resolving_facts_loop(monkeypatch, _catalog_snapshot()) + state = _state(3) + state.reduced_ontology_artifacts = [] + + await node_factories.make_render_facts_node(_tools())(state) + + assert len(state.facts_ontology_context) == 0 + ontology_graph, _metadata = node_factories._facts_aggregation_inputs(state) + assert len(ontology_graph) == 0 + + +async def test_facts_only_context_from_units_reaches_merge_and_validate( + monkeypatch, +) -> None: + """With the flag on, the context the units rendered against is handed down. + + Both consumers of ``_facts_aggregation_inputs`` are affected: the + aggregator's guards read the type declarations, and the gate stops + reporting every extracted term as outside the catalog. + """ + snapshot_ids = _install_per_unit_resolving_facts_loop( + monkeypatch, _catalog_snapshot() + ) + state = _state(3) + state.reduced_ontology_artifacts = [] + + await node_factories.make_render_facts_node(_tools(context_from_units=True))(state) + + # Distinct snapshot objects per unit, one union downstream. + assert len(set(snapshot_ids)) == 3 + assert len(state.facts_ontology_context) == 2 + ontology_graph, _metadata = node_factories._facts_aggregation_inputs(state) + assert ontology_graph is state.facts_ontology_context + assert state.retrieval_metrics[RetrievalMetric.ONTOLOGY_SNAPSHOT_TRIPLES] == 2 + + +def test_union_deduplicates_snapshots_sharing_source_iris() -> None: + """One catalog ontology resolved by N units must be merged once. + + Unioning unfiltered would pay a full rdflib merge per unit for a graph that + is already present. + """ + snapshot = _catalog_snapshot() + results = [ + ( + index, + SimpleNamespace(ontology_snapshot=snapshot.model_copy(deep=True)), + "", + [], + None, + ) + for index in range(4) + ] + union = node_factories._union_unit_ontology_context(cast(list, results)) + assert len(union) == len(snapshot.graph) + + +def test_union_keeps_distinct_sources() -> None: + first = _catalog_snapshot() + second = OntologySnapshot( + graph=RDFGraph._from_turtle_str( + """ + @prefix other: . + other:Thing a other:Class . + """ + ), + source_iris=["https://example.org/onto/other"], + ) + results = [ + (0, SimpleNamespace(ontology_snapshot=first), "", [], None), + (1, SimpleNamespace(ontology_snapshot=second), "", [], None), + ] + union = node_factories._union_unit_ontology_context(cast(list, results)) + assert len(union) == len(first.graph) + len(second.graph) + + +def test_union_keeps_namespace_bindings() -> None: + """The gate reads declared namespaces off this graph, not just its triples. + + ``NON_CATALOG_VOCABULARY`` decides what counts as catalog vocabulary from + the bindings, so a union that carried triples but dropped prefixes would + silently change which terms are reported as outside the catalog. + """ + snapshot = _catalog_snapshot() + results = [(0, SimpleNamespace(ontology_snapshot=snapshot), "", [], None)] + union = node_factories._union_unit_ontology_context(cast(list, results)) + assert dict(union.namespaces()).keys() >= {"ex"} + + +def test_union_of_nothing_is_empty() -> None: + assert len(node_factories._union_unit_ontology_context([])) == 0 diff --git a/test/facts/test_critic_patch.py b/test/facts/test_critic_patch.py new file mode 100644 index 00000000..9d764316 --- /dev/null +++ b/test/facts/test_critic_patch.py @@ -0,0 +1,735 @@ +"""Tier-1 compilation of critic fixes: what may be applied without an LLM call.""" + +from typing import Literal as TypingLiteral + +import pytest +from rdflib import Literal, URIRef + +from ontocast.onto.model import TripleFix +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.onto.triple_index import build_triple_index +from ontocast.tool.facts_validation.critic_patch import ( + apply_compiled_patch, + compile_critic_fixes, +) + +CD = "https://growgraph.dev/facts/" +MATSCI = "https://growgraph.dev/ontologies/matsci#" +RDFS_NS = "http://www.w3.org/2000/01/rdf-schema#" + +_TTL = f""" +@prefix cd: <{CD}> . +@prefix matsci: <{MATSCI}> . +@prefix rdfs: . + +cd:sample_1 a matsci:NanocrystalSample ; + rdfs:label "sample 1" ; + matsci:hasAmount cd:amount_1 . +""" + + +def _graph() -> RDFGraph: + graph = RDFGraph() + graph.parse(data=_TTL, format="turtle") + return graph + + +def _fix( + action: TypingLiteral["ADD", "REMOVE", "REPLACE"], + incorrect: str = "", + correct: str = "", + triple_ids: list[int] | None = None, +) -> TripleFix: + return TripleFix( + text_fragment="sample 1", + action=action, + severity="important", + incorrect_value=incorrect, + correct_value=correct, + triple_ids=triple_ids or [], + explanation="test fix", + ) + + +def _id_of(index, predicate_suffix: str) -> int: + """The id of the single statement whose predicate ends with ``suffix``.""" + matches = [ + triple_id + for triple_id, (_, predicate, _) in index.by_id.items() + if str(predicate).endswith(predicate_suffix) + ] + assert len(matches) == 1, f"expected one {predicate_suffix}, got {matches}" + return matches[0] + + +def test_remove_matching_a_present_triple_compiles() -> None: + graph = _graph() + compiled = compile_critic_fixes( + [_fix("REMOVE", incorrect="cd:sample_1 matsci:hasAmount cd:amount_1 .")], + graph, + ) + + assert compiled.applied and not compiled.residual + assert compiled.update is not None + apply_compiled_patch(graph, compiled.update) + assert ( + URIRef(f"{CD}sample_1"), + URIRef(f"{MATSCI}hasAmount"), + URIRef(f"{CD}amount_1"), + ) not in graph + assert (URIRef(f"{CD}sample_1"), None, None) in graph, "only the quoted triple goes" + + +def test_remove_quoting_a_triple_the_graph_does_not_hold_is_residual() -> None: + """A misquoted fix has misunderstood the graph; acting on it deletes blind.""" + graph = _graph() + compiled = compile_critic_fixes( + [_fix("REMOVE", incorrect="cd:sample_1 matsci:hasAmount cd:amount_99 .")], + graph, + ) + + assert compiled.update is None + assert compiled.residual and not compiled.applied + assert len(graph) == len(_graph()), "nothing may be deleted on a miss" + + +def test_add_compiles_and_skips_triples_already_present() -> None: + graph = _graph() + compiled = compile_critic_fixes( + [ + _fix("ADD", correct='cd:sample_1 rdfs:comment "grown in hexane" .'), + _fix("ADD", correct='cd:sample_1 rdfs:label "sample 1" .'), + ], + graph, + ) + + assert len(compiled.applied) == 1 + assert len(compiled.residual) == 1, "an ADD that adds nothing is not applied" + assert compiled.update is not None + apply_compiled_patch(graph, compiled.update) + assert ( + URIRef(f"{CD}sample_1"), + URIRef("http://www.w3.org/2000/01/rdf-schema#comment"), + Literal("grown in hexane"), + ) in graph + + +def test_replace_needs_both_sides_to_resolve() -> None: + graph = _graph() + compiled = compile_critic_fixes( + [ + _fix( + "REPLACE", + incorrect='cd:sample_1 rdfs:label "sample 1" .', + correct='cd:sample_1 rdfs:label "CsPbBr3 sample 1" .', + ), + _fix("REPLACE", incorrect='cd:sample_1 rdfs:label "sample 1" .'), + ], + graph, + ) + + assert len(compiled.applied) == 1 + assert len(compiled.residual) == 1, "a REPLACE with no replacement is residual" + assert compiled.update is not None + apply_compiled_patch(graph, compiled.update) + labels = set(graph.objects(URIRef(f"{CD}sample_1"), None)) + assert Literal("CsPbBr3 sample 1") in labels + assert Literal("sample 1") not in labels + + +def test_unparseable_payloads_fall_through_rather_than_raising() -> None: + """Real critic output is frequently truncated mid-payload.""" + compiled = compile_critic_fixes( + [ + _fix("REMOVE", incorrect='{"@context": {"cd": "https://growgraph'), + _fix("ADD", correct="cd:sample_1 matsci:hasAmount"), + _fix("ADD", correct=""), + ], + _graph(), + ) + + assert compiled.update is None + assert len(compiled.residual) == 3 + + +def test_a_truncated_turtle_fragment_still_compiles() -> None: + """Fragments lifted from a predicate list keep their trailing separator.""" + compiled = compile_critic_fixes( + [_fix("REMOVE", incorrect='cd:sample_1 rdfs:label "sample 1" ;')], + _graph(), + ) + + assert compiled.applied and compiled.update is not None + + +def test_fixes_are_never_silently_dropped() -> None: + """Every fix lands in exactly one of applied/residual -- the whole point.""" + fixes = [ + _fix("REMOVE", incorrect='cd:sample_1 rdfs:label "sample 1" .'), + _fix("ADD", correct='cd:sample_1 rdfs:comment "note" .'), + _fix("REPLACE", incorrect="garbage"), + _fix("ADD", correct="also garbage {"), + ] + compiled = compile_critic_fixes(fixes, _graph()) + + assert len(compiled.applied) + len(compiled.residual) == len(fixes) + + +def test_an_applied_fix_stops_being_a_request() -> None: + """Tier 1 and the repair prompt must not both act on the same fix. + + ``state.suggestions`` feeds the render prompt's improvement block, a second + channel alongside the findings block. A fix already compiled into the graph + left in that channel asks a repair render to fix what was just fixed -- + against a graph that no longer matches the ``incorrect_value`` it quotes, + so the model is being asked to locate something that is gone. + """ + from ontocast.onto.model import Suggestions + + graph = _graph() + fixes = [ + _fix("REMOVE", incorrect='cd:sample_1 rdfs:label "sample 1" .'), + _fix("REPLACE", incorrect="not a triple"), + ] + compiled = compile_critic_fixes(fixes, graph) + assert len(compiled.applied) == 1 and len(compiled.residual) == 1 + + # What the loop does with the split. + remaining = Suggestions(actionable_fixes=list(compiled.residual)) + + assert compiled.applied[0] not in remaining.actionable_fixes + assert remaining.actionable_fixes == compiled.residual + + +# --- addressing statements by id --------------------------------------------- + + +def test_a_removal_cited_by_id_compiles_without_any_requoting() -> None: + """The whole point: no payload to match, so nothing to get wrong.""" + graph = _graph() + index = build_triple_index(graph) + label_id = _id_of(index, "label") + + compiled = compile_critic_fixes( + [_fix("REMOVE", triple_ids=[label_id])], graph, index=index + ) + + assert compiled.update is not None + apply_compiled_patch(graph, compiled.update) + assert not list(graph.triples((None, URIRef(f"{RDFS_NS}label"), None))) + assert len(compiled.applied) == 1 + + +def test_an_id_beats_a_misquoted_incorrect_value() -> None: + """A fix may carry both; the id is the one that cannot be wrong.""" + graph = _graph() + index = build_triple_index(graph) + label_id = _id_of(index, "label") + fix = _fix( + "REMOVE", + incorrect='cd:sample_1 rdfs:label "totally the wrong text" .', + triple_ids=[label_id], + ) + + compiled = compile_critic_fixes([fix], graph, index=index) + + assert compiled.residual == [] + assert compiled.update is not None + + +def test_an_id_the_index_never_issued_sends_the_whole_fix_back() -> None: + """Acting on the half that resolved would delete something unreviewed.""" + graph = _graph() + index = build_triple_index(graph) + label_id = _id_of(index, "label") + + compiled = compile_critic_fixes( + [_fix("REMOVE", triple_ids=[label_id, len(index) + 99])], graph, index=index + ) + + assert compiled.update is None + assert len(compiled.residual) == 1 + assert compiled.bad_index_refs == 1 + + +def test_a_statement_already_gone_is_not_deleted_again() -> None: + graph = _graph() + index = build_triple_index(graph) + label_id = _id_of(index, "label") + graph.remove(index.by_id[label_id]) + + compiled = compile_critic_fixes( + [_fix("REMOVE", triple_ids=[label_id])], graph, index=index + ) + + assert compiled.update is None + assert len(compiled.residual) == 1 + + +def test_a_replace_by_id_swaps_the_statement() -> None: + graph = _graph() + index = build_triple_index(graph) + label_id = _id_of(index, "label") + fix = _fix( + "REPLACE", + correct=f'<{CD}sample_1> <{RDFS_NS}label> "corrected sample 1" .', + triple_ids=[label_id], + ) + + compiled = compile_critic_fixes([fix], graph, index=index) + assert compiled.update is not None + apply_compiled_patch(graph, compiled.update) + + labels = { + str(o) for _, _, o in graph.triples((None, URIRef(f"{RDFS_NS}label"), None)) + } + assert labels == {"corrected sample 1"} + + +def test_a_fix_that_re_adds_exactly_what_it_removes_is_recorded_as_no_change() -> None: + """Measured on real critiques, a third of applicable REPLACEs are this. + + They arrive as a reordering of the same statement. Counting them as fixes + that landed overstates what the critic bought; returning them as residual + would ask the next pass to redo nothing. + """ + graph = _graph() + index = build_triple_index(graph) + label_id = _id_of(index, "label") + fix = _fix( + "REPLACE", + correct=f'<{CD}sample_1> <{RDFS_NS}label> "sample 1" .', + triple_ids=[label_id], + ) + + compiled = compile_critic_fixes([fix], graph, index=index) + + assert compiled.noop == [fix] + assert compiled.applied == [] and compiled.residual == [] + assert compiled.update is None + + +def test_no_fix_is_lost_between_the_three_outcomes() -> None: + graph = _graph() + index = build_triple_index(graph) + label_id = _id_of(index, "label") + fixes = [ + _fix("REMOVE", triple_ids=[label_id]), + _fix("ADD", correct=f'<{CD}sample_1> <{MATSCI}note> "extra" .'), + _fix("REMOVE", incorrect="not a triple at all"), + _fix( + "REPLACE", + correct=f'<{CD}sample_1> <{RDFS_NS}label> "sample 1" .', + triple_ids=[label_id], + ), + ] + + compiled = compile_critic_fixes(fixes, graph, index=index) + + accounted = compiled.applied + compiled.residual + compiled.noop + assert sorted(map(id, accounted)) == sorted(map(id, fixes)) + + +def test_without_an_index_the_requoting_path_still_works() -> None: + """Cached critiques and any caller that cannot build an index keep working.""" + graph = _graph() + compiled = compile_critic_fixes( + [_fix("REMOVE", incorrect=f'<{CD}sample_1> <{RDFS_NS}label> "sample 1" .')], + graph, + ) + assert compiled.update is not None + + +# --- format-tolerant payload parsing ----------------------------------------- +# +# The deployment names one syntax for fix payloads and the model does not +# reliably use it, so the parser accepts what is actually emitted rather than +# what was asked for. None of these rules guesses at meaning: each maps a +# JSON-LD shape onto the Turtle form that means the same thing. + + +def test_a_jsonld_language_object_inside_turtle_is_accepted() -> None: + """The single most common malformed payload in real critic output.""" + graph = _graph() + fix = _fix( + "ADD", + correct=f'<{CD}sample_1> <{RDFS_NS}comment> {{"@value": "a note", "@language": "en"}} .', + ) + + compiled = compile_critic_fixes([fix], graph) + + assert compiled.update is not None + apply_compiled_patch(graph, compiled.update) + assert ( + URIRef(f"{CD}sample_1"), + URIRef(f"{RDFS_NS}comment"), + Literal("a note", lang="en"), + ) in graph + + +def test_a_jsonld_typed_value_inside_turtle_is_accepted() -> None: + graph = _graph() + fix = _fix( + "ADD", + correct=( + f"<{CD}sample_1> <{MATSCI}measuredOn> " + '{"@value": "2024-01-15", "@type": ""} .' + ), + ) + + compiled = compile_critic_fixes([fix], graph) + + assert compiled.update is not None + + +def test_a_bare_absolute_iri_is_bracketed() -> None: + """Turtle needs angle brackets; models routinely omit them.""" + graph = _graph() + fix = _fix("ADD", correct=f"<{CD}sample_1> <{MATSCI}seeAlso> {MATSCI}Other ;") + + compiled = compile_critic_fixes([fix], graph) + + assert compiled.update is not None + apply_compiled_patch(graph, compiled.update) + assert ( + URIRef(f"{CD}sample_1"), + URIRef(f"{MATSCI}seeAlso"), + URIRef(f"{MATSCI}Other"), + ) in graph + + +def test_an_iri_inside_a_quoted_literal_is_left_alone() -> None: + """Bracketing must not reach inside a literal and corrupt its lexical form.""" + graph = _graph() + fix = _fix( + "ADD", + correct=f'<{CD}sample_1> <{RDFS_NS}comment> "see https://example.org/x" .', + ) + + compiled = compile_critic_fixes([fix], graph) + + assert compiled.update is not None + apply_compiled_patch(graph, compiled.update) + assert ( + URIRef(f"{CD}sample_1"), + URIRef(f"{RDFS_NS}comment"), + Literal("see https://example.org/x"), + ) in graph + + +# --- prefixes in JSON-LD payloads ------------------------------------------- +# +# A Turtle fragment gets the unit's @prefix lines prepended; a JSON-LD payload +# used to get nothing, so `cd:sample_1` parsed as an IRI with scheme `cd` and +# the compiled patch inserted a ghost the graph never matched. + + +def test_a_jsonld_payload_in_the_units_prefixes_compiles() -> None: + graph = _graph() + fix = _fix("ADD", correct='{"@id": "cd:sample_1", "matsci:note": "extra"}') + + compiled = compile_critic_fixes([fix], graph) + + assert compiled.unresolved_prefix == 0 + assert compiled.update is not None + apply_compiled_patch(graph, compiled.update) + assert ( + URIRef(f"{CD}sample_1"), + URIRef(f"{MATSCI}note"), + Literal("extra"), + ) in graph + + +def test_a_jsonld_payload_with_an_unbound_prefix_is_refused() -> None: + """Nothing declares `zz:`, so the statement names nothing in the graph.""" + graph = _graph() + fix = _fix( + "ADD", + correct='{"@id": "zz:sample_9", "@type": "zz:Thing", "rdfs:label": "nine"}', + ) + + compiled = compile_critic_fixes([fix], graph) + + assert compiled.update is None + assert compiled.residual == [fix] + assert compiled.unresolved_prefix == 1 + assert len(graph) == len(_graph()), "no ghost subject may be inserted" + + +def test_a_catalog_prefix_the_unit_graph_lacks_still_compiles() -> None: + """The unit loop's catalog map fills prefixes a fix introduces for the first time. + + Render used to clear the catalog map before the critic ran, so a prefix the + unit graph had never bound was reconciled against nothing and the fix was + refused as unresolved. The catalog map under the unit graph's bindings is + what makes a first-use prefix expand. + """ + qqval = "https://growgraph.dev/ontologies/qqval#" + graph = _graph() + previous = RDFGraph.get_known_prefixes() + RDFGraph.set_known_prefixes({"qqval": qqval, "cd": CD, "matsci": MATSCI}) + try: + fix = _fix( + "ADD", + correct=( + '{"@id": "cd:value_1", "@type": "qqval:QualifiedQuantityValue", ' + '"rdfs:label": "ratio"}' + ), + ) + compiled = compile_critic_fixes([fix], graph) + finally: + RDFGraph.set_known_prefixes(previous) + + assert compiled.unresolved_prefix == 0 + assert compiled.update is not None + apply_compiled_patch(graph, compiled.update) + assert ( + URIRef(f"{CD}value_1"), + URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + URIRef(f"{qqval}QualifiedQuantityValue"), + ) in graph + + +def test_the_units_binding_wins_over_a_declared_context() -> None: + """A payload rebinding a prefix the graph binds is corrected, not honoured. + + The critic is re-transcribing namespaces it was shown next to the + statements it is correcting, so a declaration that disagrees with the graph + under repair is a transcription error. Honouring it costs the whole fix: + every term under that prefix becomes an IRI no catalog declares, the + payload trips the unknown-term check, and the patch is rolled back carrying + whatever it proposed. + """ + graph = _graph() + fix = _fix( + "ADD", + correct=( + '{"@context": {"cd": "https://other.example/"}, ' + '"@id": "cd:s", "@type": "matsci:Thing", "rdfs:label": "elsewhere"}' + ), + ) + + compiled = compile_critic_fixes([fix], graph) + + assert compiled.update is not None + apply_compiled_patch(graph, compiled.update) + assert ( + URIRef(f"{CD}s"), + URIRef(f"{RDFS_NS}label"), + Literal("elsewhere"), + ) in graph + assert (URIRef("https://other.example/s"), None, None) not in graph + + +def test_a_turtle_payload_with_a_bracketed_curie_is_refused() -> None: + """`` is a syntactically valid IRI that names nothing; same rule.""" + graph = _graph() + fix = _fix("ADD", correct=' "extra" .') + + compiled = compile_critic_fixes([fix], graph) + + assert compiled.update is None + assert compiled.unresolved_prefix == 1 + + +def test_a_removal_by_id_ignores_an_unbound_prefix_in_correct_value() -> None: + """The id is what the fix acts on; an unused payload cannot veto it.""" + graph = _graph() + index = build_triple_index(graph) + label_id = _id_of(index, "label") + fix = _fix("REMOVE", correct='{"@id": "zz:x", "zz:p": "y"}', triple_ids=[label_id]) + + compiled = compile_critic_fixes([fix], graph, index=index) + + assert compiled.update is not None + assert compiled.unresolved_prefix == 0 + + +# --- literals a datatype cannot hold ------------------------------------------ +# +# The render path quarantines these; the patch path used to hand them straight +# to rdflib, which re-derives the failed value on every later serialization, +# SPARQL compile and validation walk of the unit graph. + + +@pytest.mark.parametrize( + "correct", + [ + f'<{CD}sample_1> <{MATSCI}value> "10^-15"^^' + " .", + '{"@id": "cd:sample_1", "matsci:value": ' + '{"@value": "10^-15", "@type": "xsd:decimal"}}', + ], + ids=["turtle", "jsonld"], +) +def test_a_fix_typing_an_unparseable_number_is_refused(correct: str) -> None: + graph = _graph() + fix = _fix("ADD", correct=correct) + + compiled = compile_critic_fixes([fix], graph) + + assert compiled.update is None + assert compiled.residual == [fix] + assert compiled.quarantined_literal == 1 + assert len(graph) == len(_graph()), "the bad literal never enters the graph" + + +def test_a_valid_typed_number_still_compiles() -> None: + """Only a lexical form its own datatype rejects is refused.""" + graph = _graph() + fix = _fix( + "ADD", + correct=( + f'<{CD}sample_1> <{MATSCI}value> "1.5"^^' + " ." + ), + ) + + compiled = compile_critic_fixes([fix], graph) + + assert compiled.quarantined_literal == 0 + assert compiled.update is not None + + +def test_a_removal_by_id_ignores_a_bad_literal_in_correct_value() -> None: + """The id is what a REMOVE acts on; its unused payload cannot veto it.""" + graph = _graph() + index = build_triple_index(graph) + label_id = _id_of(index, "label") + fix = _fix( + "REMOVE", + correct=( + f'<{CD}sample_1> <{MATSCI}value> "10^-15"^^' + " ." + ), + triple_ids=[label_id], + ) + + compiled = compile_critic_fixes([fix], graph, index=index) + + assert compiled.update is not None + assert compiled.quarantined_literal == 0 + + +# --- placeholders are not fixes ---------------------------------------------- +# +# A coverage finding names a number the critic cannot place, and the critic +# answers with a node for the number itself. That node then carries the number +# in its label, which used to satisfy the very finding that provoked it. + + +@pytest.mark.parametrize( + "subject", + ["ignored_token_12", "typography_artifact_3", "placeholder_1", "token_4"], +) +def test_an_insert_minting_a_placeholder_subject_is_refused(subject: str) -> None: + graph = _graph() + fix = _fix( + "ADD", + correct=( + f'<{CD}{subject}> a <{MATSCI}Token> ; <{RDFS_NS}label> "12" ; ' + f'<{RDFS_NS}comment> "ignored citation marker" .' + ), + ) + + compiled = compile_critic_fixes([fix], graph) + + assert compiled.update is None + assert compiled.residual == [fix] + assert compiled.junk_refused == 1 + + +def test_a_new_node_carrying_only_annotations_is_refused() -> None: + """A subject the graph knows nothing about, typed with nothing, labelled + with the missing number: a fact in name only.""" + graph = _graph() + fix = _fix( + "ADD", + correct=f'<{CD}value_96> <{RDFS_NS}label> "96 meV" ; <{RDFS_NS}comment> "x" .', + ) + + compiled = compile_critic_fixes([fix], graph) + + assert compiled.update is None + assert compiled.junk_refused == 1 + + +def test_annotations_on_an_existing_subject_still_compile() -> None: + """Adding a comment to a described node is a correction, not a placeholder.""" + graph = _graph() + fix = _fix("ADD", correct=f'<{CD}sample_1> <{RDFS_NS}comment> "grown in hexane" .') + + compiled = compile_critic_fixes([fix], graph) + + assert compiled.update is not None + assert compiled.junk_refused == 0 + + +def test_a_typed_new_node_is_not_junk() -> None: + graph = _graph() + fix = _fix( + "ADD", + correct=( + f"<{CD}value_96> a <{MATSCI}QuantityValue> ; " + f'<{MATSCI}numericValue> "96" ; <{RDFS_NS}label> "96 meV" .' + ), + ) + + compiled = compile_critic_fixes([fix], graph) + + assert compiled.update is not None + assert compiled.junk_refused == 0 + + +def test_a_replace_whose_insert_is_junk_goes_back_whole() -> None: + """Carrying out its delete half alone would be the delete-only edit the + rollback exists to undo.""" + graph = _graph() + index = build_triple_index(graph) + label_id = _id_of(index, "label") + fix = _fix( + "REPLACE", + correct=f'<{CD}ignored_token_1> <{RDFS_NS}label> "1" .', + triple_ids=[label_id], + ) + + compiled = compile_critic_fixes([fix], graph, index=index) + + assert compiled.update is None + assert compiled.junk_refused == 1 + assert len(graph) == len(_graph()) + + +def test_a_json_payload_carrying_no_statement_stays_residual() -> None: + """A bare literal or a node reference is not a patch, whatever it meant.""" + graph = _graph() + for payload in ( + '{"@value": "2023-10-23", "@type": "xsd:date"}', + '{"@id": "cd:dallas"}', + ): + compiled = compile_critic_fixes([_fix("ADD", correct=payload)], graph) + assert compiled.update is None, payload + assert len(compiled.residual) == 1 + + +# --- one patch per fix --------------------------------------------------------- + + +def test_each_kept_fix_is_its_own_patch() -> None: + """The combined update and the per-fix patches carry the same content.""" + graph = _graph() + fixes = [ + _fix("ADD", correct=f'<{CD}sample_1> <{MATSCI}note> "one" .'), + _fix("ADD", correct=f'<{CD}sample_1> <{MATSCI}note> "two" .'), + ] + + compiled = compile_critic_fixes(fixes, graph) + + assert [patch.fix for patch in compiled.patches] == fixes + assert [patch.inserts for patch in compiled.patches] == [1, 1] + one_at_a_time = _graph() + for patch in compiled.patches: + apply_compiled_patch(one_at_a_time, patch.update) + assert compiled.update is not None + apply_compiled_patch(graph, compiled.update) + assert set(one_at_a_time) == set(graph) diff --git a/test/facts/test_critic_patch_guards.py b/test/facts/test_critic_patch_guards.py new file mode 100644 index 00000000..acaf4772 --- /dev/null +++ b/test/facts/test_critic_patch_guards.py @@ -0,0 +1,248 @@ +"""What a critic pass is not allowed to destroy. + +A compiled patch is transparent -- both halves are known before anything is +touched -- so these limits are enforced by withholding the delete half, not by +inspecting the damage afterwards. Every rule drops something and counts it. +""" + +from typing import Literal as TypingLiteral + +import pytest +from rdflib import RDF, RDFS, BNode, Literal, Namespace, URIRef + +from ontocast.onto.model import TripleFix +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.onto.triple_index import build_triple_index +from ontocast.tool.facts_validation.critic_patch import ( + CriticPatchPolicy, + compile_critic_fixes, +) + +pytestmark = pytest.mark.unit + +CD = Namespace("https://growgraph.dev/facts/") +MS = Namespace("https://growgraph.dev/ontologies/matsci#") + + +def _graph(extra: int = 0) -> RDFGraph: + graph = RDFGraph() + graph.bind("cd", CD) + graph.bind("ms", MS) + graph.add((CD.sample_1, RDF.type, MS.NanocrystalSample)) + graph.add((CD.sample_1, RDFS.label, Literal("sample 1"))) + graph.add((CD.sample_1, MS.hasAmount, CD.amount_1)) + graph.add((CD.amount_1, RDF.type, MS.Amount)) + graph.add((CD.amount_1, MS.numericValue, Literal("1.5"))) + for n in range(extra): + graph.add((CD.sample_1, URIRef(f"{MS}note{n}"), Literal(f"n{n}"))) + return graph + + +def _fix( + action: TypingLiteral["ADD", "REMOVE", "REPLACE"], + *, + triple_ids=None, + correct: str = "", +) -> TripleFix: + return TripleFix( + text_fragment="sample 1", + action=action, + severity="important", + triple_ids=triple_ids or [], + correct_value=correct, + explanation="test fix", + ) + + +def _ids_for(index, subject) -> list[int]: + return [tid for tid, (s, _, _) in index.by_id.items() if s == subject] + + +def test_a_removal_that_would_empty_a_subject_is_refused() -> None: + """The contract is to correct a statement, not to make a node disappear.""" + graph = _graph() + index = build_triple_index(graph) + amount_ids = _ids_for(index, CD.amount_1) + + compiled = compile_critic_fixes( + [_fix("REMOVE", triple_ids=amount_ids)], graph, index=index + ) + + assert compiled.update is None + assert compiled.deletes_refused == 1 + assert len(compiled.residual) == 1 + + +def test_a_removal_leaving_the_subject_described_is_allowed() -> None: + graph = _graph() + index = build_triple_index(graph) + label_id = _ids_for(index, CD.sample_1) + label_id = [tid for tid in label_id if index.by_id[tid][1] == RDFS.label] + + compiled = compile_critic_fixes( + [_fix("REMOVE", triple_ids=label_id)], graph, index=index + ) + + assert compiled.update is not None + assert compiled.deletes_refused == 0 + + +def test_a_replace_that_writes_about_a_different_subject_keeps_only_its_insert() -> ( + None +): + """A cross-subject REPLACE is a rename. + + Carried out literally it orphans the old node and leaves the new one with + nothing but the one statement the fix happened to write. + """ + graph = _graph() + index = build_triple_index(graph) + label_id = [ + tid + for tid, (s, p, _) in index.by_id.items() + if s == CD.sample_1 and p == RDFS.label + ] + + compiled = compile_critic_fixes( + [ + _fix( + "REPLACE", + triple_ids=label_id, + correct=( + f"<{CD}sample_2> <{RDF.type}> <{MS}NanocrystalSample> ; " + f'<{RDFS.label}> "sample two" .' + ), + ) + ], + graph, + index=index, + ) + + assert compiled.deletes_refused == 1 + assert compiled.update is not None + assert all(op.type == "insert" for op in compiled.update.triple_operations) + assert (CD.sample_1, RDFS.label, Literal("sample 1")) in graph + + +def test_a_rename_is_allowed_when_the_deployment_says_so() -> None: + graph = _graph() + index = build_triple_index(graph) + label_id = [ + tid + for tid, (s, p, _) in index.by_id.items() + if s == CD.sample_1 and p == RDFS.label + ] + + compiled = compile_critic_fixes( + [ + _fix( + "REPLACE", + triple_ids=label_id, + correct=( + f"<{CD}sample_2> <{RDF.type}> <{MS}NanocrystalSample> ; " + f'<{RDFS.label}> "sample two" .' + ), + ) + ], + graph, + index=index, + policy=CriticPatchPolicy(allow_subject_rename=True), + ) + + assert compiled.deletes_refused == 0 + assert compiled.update is not None + assert any(op.type == "delete" for op in compiled.update.triple_operations) + + +def test_deleting_part_of_a_blank_node_takes_the_whole_node() -> None: + """A partial delete leaves the degenerate stub the validator exists to flag.""" + graph = _graph() + restriction = BNode("r1") + graph.add((CD.sample_1, RDFS.subClassOf, restriction)) + graph.add((restriction, RDF.type, MS.Restriction)) + graph.add((restriction, MS.onProperty, MS.hasAmount)) + index = build_triple_index(graph) + one_of_them = [ + tid + for tid, (s, p, _) in index.by_id.items() + if s == restriction and p == MS.onProperty + ] + + compiled = compile_critic_fixes( + [_fix("REMOVE", triple_ids=one_of_them)], graph, index=index + ) + + assert compiled.update is not None + deleted = { + triple + for op in compiled.update.triple_operations + if op.type == "delete" + for triple in op.graph + } + assert (restriction, RDF.type, MS.Restriction) in deleted + assert (CD.sample_1, RDFS.subClassOf, restriction) in deleted + + +def test_a_pass_that_wants_to_remove_most_of_the_graph_sends_its_removals_back() -> ( + None +): + """Past the cap the critique has stopped correcting and started rewriting. + + The removals go back whole rather than being stripped to their insert + halves: a REPLACE without its delete is an ADD of the new value beside + the old one, which is not the edit that was proposed. Pure additions + still land. + """ + graph = _graph(extra=20) + index = build_triple_index(graph) + doomed = [ + tid + for tid, (s, p, _) in index.by_id.items() + if s == CD.sample_1 and p != RDF.type + ] + removals = [_fix("REMOVE", triple_ids=[tid]) for tid in doomed] + replacement = _fix( + "REPLACE", + triple_ids=[doomed[0]], + correct=f'<{CD}sample_1> <{RDFS.label}> "renamed" .', + ) + addition = _fix("ADD", correct=f'<{CD}sample_1> <{MS}note> "kept" .') + + compiled = compile_critic_fixes( + [*removals, replacement, addition], + graph, + index=index, + policy=CriticPatchPolicy(max_delete_share=0.25, min_deletes=2), + ) + + assert compiled.delete_capped is True + assert compiled.applied == [addition] + assert set(map(id, compiled.residual)) == set(map(id, [*removals, replacement])) + assert compiled.update is not None + assert all(op.type == "insert" for op in compiled.update.triple_operations) + assert [patch.fix for patch in compiled.patches] == [addition] + + +def test_the_cap_has_a_floor_so_a_short_unit_stays_correctable() -> None: + """Share alone is strictest where it should be loosest. + + On a five-statement unit a single legitimate correction is already 20% of + the graph, so a bare share cap would block exactly the small, safe fixes. + """ + graph = _graph() + index = build_triple_index(graph) + label_id = [ + tid + for tid, (s, p, _) in index.by_id.items() + if s == CD.sample_1 and p == RDFS.label + ] + + compiled = compile_critic_fixes( + [_fix("REMOVE", triple_ids=label_id)], + graph, + index=index, + policy=CriticPatchPolicy(max_delete_share=0.01, min_deletes=5), + ) + + assert compiled.delete_capped is False + assert compiled.update is not None diff --git a/test/facts/test_critic_patch_pass.py b/test/facts/test_critic_patch_pass.py new file mode 100644 index 00000000..fe3448d2 --- /dev/null +++ b/test/facts/test_critic_patch_pass.py @@ -0,0 +1,450 @@ +"""A critic pass is undone when it leaves the unit worse than it found it. + +The rollback rules were learned from a repair render that answered "this term is +wrong" by deleting the statement: the finding went away because the data went +away. A compiled patch can do the same thing, so the same three signals apply -- +plus one the render path could not have: a pass is now transparent enough that +*creating* mandatory findings is detectable and undone. +""" + +import logging +from dataclasses import replace +from types import SimpleNamespace +from typing import cast + +import pytest +from rdflib import Literal, URIRef + +from ontocast.onto.constants import DEFAULT_IRI +from ontocast.onto.content_unit import ContentUnit +from ontocast.onto.enum import Status +from ontocast.onto.model import FactsUnitFinding, FactsUnitFindingKind, TripleFix +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.onto.triple_index import build_triple_index +from ontocast.onto.unit_states import UnitFactsState +from ontocast.stategraph.atomic import FACTS_PHASE, _apply_critic_patch +from ontocast.tool.atomic import AtomicToolBox +from ontocast.tool.facts_validation import CriticPatchPolicy + +pytestmark = pytest.mark.unit + +_EX_PREDICATE = URIRef("http://example.org/redShiftContribution") +_LABEL = URIRef("http://www.w3.org/2000/01/rdf-schema#label") +_SUBJECT = URIRef(f"{DEFAULT_IRI}sample_1") +_VALUE = URIRef(f"{DEFAULT_IRI}value_1") + + +def _unit_state() -> UnitFactsState: + graph = RDFGraph() + graph.add((_SUBJECT, _EX_PREDICATE, _VALUE)) + graph.add((_SUBJECT, _LABEL, Literal("sample"))) + graph.add((_VALUE, _LABEL, Literal("96 meV"))) + unit = ContentUnit( + text="a shift of 96 meV", + index=0, + doc_iri=URIRef("https://example.com/doc/d1"), + graph=graph, + ) + state = UnitFactsState(content_unit=unit) + state.status = Status.SUCCESS + return state + + +def _atomic() -> AtomicToolBox: + return cast( + AtomicToolBox, + SimpleNamespace( + facts_critic_passes=1, + facts_patch_policy=CriticPatchPolicy(), + additional_standard_namespaces=(), + validation_policy=None, + acceptance_policy=None, + catalog_terms=lambda: set(), + ), + ) + + +def _fix(action, *, triple_ids=None, correct="") -> TripleFix: + return TripleFix( + text_fragment="96 meV", + action=action, + severity="critical", + triple_ids=triple_ids or [], + correct_value=correct, + explanation="test fix", + ) + + +def _run( + state, + findings=None, + *, + mandatory_before=0, + findings_before=None, + phase=FACTS_PHASE, + atomic=None, +): + tools = atomic or _atomic() + collected = findings if findings is not None else [] + phase = replace(phase, collect_findings=lambda _state, _tools: list(collected)) + return _apply_critic_patch( + state, + tools, + phase, + render_attempt=1, + pass_index=1, + mandatory_before=mandatory_before, + findings_before=findings_before, + ) + + +def _finding( + mandatory: bool = True, + kind: FactsUnitFindingKind = FactsUnitFindingKind.UNKNOWN_TERM, +) -> FactsUnitFinding: + return FactsUnitFinding(kind=kind, mandatory=mandatory, message=str(kind)) + + +def test_a_pass_that_only_deletes_is_rolled_back() -> None: + """The statement is gone, so the finding is gone. Nothing was repaired.""" + state = _unit_state() + index = build_triple_index(state.content_unit.graph) + state.prompt_triple_index = index + doomed = [tid for tid, (_, p, _) in index.by_id.items() if p == _EX_PREDICATE] + state.suggestions.actionable_fixes = [_fix("REMOVE", triple_ids=doomed)] + + outcome = _run(state, [_finding()], mandatory_before=1) + + assert outcome.rolled_back == 1 + assert (_SUBJECT, _EX_PREDICATE, _VALUE) in state.content_unit.graph + assert state.critic_fixes_applied == 0 + assert state.critic_fixes_rolled_back == 1 + undone = state.attempt_log[-1].rolled_back_fixes + assert [fix.reason for fix in undone] == ["delete_only"] + assert undone[0].triple_ids == doomed + + +def test_a_pass_that_rewrites_in_place_is_kept() -> None: + """Shrinking is only a problem when nothing was written back.""" + state = _unit_state() + index = build_triple_index(state.content_unit.graph) + state.prompt_triple_index = index + target = [tid for tid, (_, p, _) in index.by_id.items() if p == _EX_PREDICATE] + state.suggestions.actionable_fixes = [ + _fix( + "REPLACE", + triple_ids=target, + correct=f"<{_SUBJECT}> <{_VALUE}> .", + ) + ] + + outcome = _run(state, [], mandatory_before=1) + + assert outcome.rolled_back == 0 + assert outcome.applied == 1 + assert (_SUBJECT, URIRef("http://example.org/shift"), _VALUE) in ( + state.content_unit.graph + ) + + +def test_a_pass_that_creates_mandatory_findings_is_rolled_back() -> None: + """Undone on its own, whatever else the pass fixed. + + The render path could not see this: it compared finding counts and graph + size, and a pass that fixed two things while breaking three scored as + progress. + """ + state = _unit_state() + before = set(state.content_unit.graph) + state.suggestions.actionable_fixes = [ + _fix("ADD", correct=f'<{_SUBJECT}> "x" .') + ] + + outcome = _run(state, [_finding(), _finding()], mandatory_before=1) + + assert outcome.rolled_back == 1 + assert set(state.content_unit.graph) == before + assert state.attempt_log[-1].rolled_back_fixes[0].reason == "new_mandatory" + assert state.attempt_log[-1].rolled_back_fixes[0].mandatory_delta == 1 + + +def test_a_growing_pass_is_never_flagged() -> None: + state = _unit_state() + state.suggestions.actionable_fixes = [ + _fix("ADD", correct=f'<{_SUBJECT}> "x" .') + ] + + outcome = _run(state, [], mandatory_before=0) + + assert outcome.rolled_back == 0 + assert outcome.applied == 1 + + +def test_an_applied_fix_stops_being_a_request() -> None: + """Otherwise the next pass is asked to redo what this one already did.""" + state = _unit_state() + applied = _fix("ADD", correct=f'<{_SUBJECT}> "x" .') + unusable = _fix("REMOVE") + state.suggestions.actionable_fixes = [applied, unusable] + + _run(state, []) + + assert state.suggestions.actionable_fixes == [unusable] + + +def test_the_index_is_cleared_so_a_later_pass_cannot_reuse_it() -> None: + """Ids are paired with the critique they were issued for.""" + state = _unit_state() + state.prompt_triple_index = build_triple_index(state.content_unit.graph) + + _run(state, []) + + assert state.prompt_triple_index is None + + +def test_a_pass_with_nothing_applicable_reports_no_change() -> None: + state = _unit_state() + state.suggestions.actionable_fixes = [_fix("REMOVE")] + + outcome = _run(state, []) + + assert outcome.applied == 0 + assert outcome.residual == 1 + assert outcome.converged is True + + +def test_a_patch_that_clears_every_defect_flips_the_unit_to_success() -> None: + """The critic verdict predates its own patch. + + Acceptance was decided on the pre-patch findings, so a unit whose patch + then resolved everything still left the loop FAILED -- and the reduce + counted it as salvaged from a non-converged loop. Post-patch, status must + reflect the graph that actually ships. + """ + state = _unit_state() + state.set_failure(FACTS_PHASE.critic_stage, "pre-patch rejection") + state.suggestions.actionable_fixes = [ + _fix("ADD", correct=f'<{_SUBJECT}> "x" .') + ] + + # Post-patch findings are clean. + _run(state, [], mandatory_before=1) + + assert state.status == Status.SUCCESS + assert state.failure_stage is None + assert state.failure_reason is None + + +def test_residual_mandatory_findings_keep_the_unit_failed() -> None: + state = _unit_state() + state.suggestions.actionable_fixes = [ + _fix("ADD", correct=f'<{_SUBJECT}> "x" .') + ] + + _run(state, [_finding()], mandatory_before=1) + + assert state.status == Status.FAILED + assert state.failure_stage == FACTS_PHASE.critic_stage + assert "material defect" in (state.failure_reason or "") + + +def test_a_rolled_back_patch_keeps_the_pre_patch_verdict() -> None: + """Rollback restores the graph, so the pre-patch acceptance is what the + re-evaluation recomputes -- an accepted unit stays accepted.""" + state = _unit_state() + index = build_triple_index(state.content_unit.graph) + state.prompt_triple_index = index + doomed = [tid for tid, (_, p, _) in index.by_id.items() if p == _EX_PREDICATE] + state.suggestions.actionable_fixes = [_fix("REMOVE", triple_ids=doomed)] + + outcome = _run(state, [], mandatory_before=0) + + assert outcome.rolled_back == 1 + assert state.status == Status.SUCCESS + + +def test_an_empty_no_update_pass_reevaluates_status_too() -> None: + """The no-update branch is the common path for an accepted render whose + critique compiled to nothing; it must not preserve a stale FAILED.""" + state = _unit_state() + state.set_failure(FACTS_PHASE.critic_stage, "pre-patch rejection") + state.suggestions.actionable_fixes = [] + + _run(state, []) + + assert state.status == Status.SUCCESS + assert state.failure_stage is None + + +# --- "wrote nothing" is what the fix declared, not what the graph gained ------ + + +def _replace_onto_existing(state) -> None: + """A REPLACE whose replacement statement the graph already holds. + + The realistic shape: a correction that removes one wrong property + re-states the rest of the node, and those statements apply as no-ops. + """ + index = build_triple_index(state.content_unit.graph) + state.prompt_triple_index = index + target = [tid for tid, (_, p, _) in index.by_id.items() if p == _EX_PREDICATE] + state.suggestions.actionable_fixes = [ + _fix( + "REPLACE", + triple_ids=target, + correct=f'<{_SUBJECT}> <{_LABEL}> "sample" .', + ) + ] + + +def test_a_replace_whose_replacement_already_exists_is_kept() -> None: + """The graph diff shows only the delete, but the fix wrote its statements. + + Read as ``graph_after - graph_before`` this correction looks like a bare + deletion; judged on the statements it declared, every one of them stands + after it. It resolved a mandatory finding, so it stays. + """ + state = _unit_state() + _replace_onto_existing(state) + + outcome = _run(state, [_finding()], mandatory_before=2) + + assert outcome.applied == 1 + assert outcome.rolled_back == 0 + graph = state.content_unit.graph + assert (_SUBJECT, _EX_PREDICATE, _VALUE) not in graph, "the wrong property went" + assert (_SUBJECT, _LABEL, Literal("sample")) in graph, "the node survives" + + +def test_a_replace_onto_existing_that_resolves_nothing_is_no_progress() -> None: + """Declared inserts excuse it from ``delete_only``, not from earning its + place: nothing resolved and a smaller product is still a regression.""" + state = _unit_state() + _replace_onto_existing(state) + + outcome = _run(state, [_finding()], mandatory_before=1) + + assert outcome.rolled_back == 1 + assert (_SUBJECT, _EX_PREDICATE, _VALUE) in state.content_unit.graph + assert state.attempt_log[-1].rolled_back_fixes[0].reason == "no_progress" + + +def test_a_pure_removal_declares_no_insert_and_stays_delete_only() -> None: + """A REMOVE has nothing to declare, so the rule it exists for still fires + even when the fix resolved a mandatory finding.""" + state = _unit_state() + index = build_triple_index(state.content_unit.graph) + state.prompt_triple_index = index + doomed = [tid for tid, (_, p, _) in index.by_id.items() if p == _EX_PREDICATE] + state.suggestions.actionable_fixes = [_fix("REMOVE", triple_ids=doomed)] + + outcome = _run(state, [], mandatory_before=2) + + assert outcome.rolled_back == 1 + assert (_SUBJECT, _EX_PREDICATE, _VALUE) in state.content_unit.graph + assert state.attempt_log[-1].rolled_back_fixes[0].reason == "delete_only" + + +def test_a_new_mandatory_rollback_names_the_kinds_it_introduced(caplog) -> None: + """Counts alone do not say what went wrong; the kinds do.""" + state = _unit_state() + state.suggestions.actionable_fixes = [ + _fix("ADD", correct=f'<{_SUBJECT}> "x" .') + ] + after = [ + _finding(kind=FactsUnitFindingKind.UNKNOWN_TERM), + _finding(kind=FactsUnitFindingKind.DOMAIN_VIOLATION), + _finding(kind=FactsUnitFindingKind.DOMAIN_VIOLATION), + ] + + with caplog.at_level(logging.WARNING, logger="ontocast.stategraph.atomic"): + outcome = _run( + state, + after, + mandatory_before=1, + findings_before=[_finding(kind=FactsUnitFindingKind.UNKNOWN_TERM)], + ) + + assert outcome.rolled_back == 1 + assert "new_mandatory" in caplog.text + assert "introduced domain_violation x2" in caplog.text + assert "unknown_term" not in caplog.text, "already there before the fix" + + +# --- one fix at a time -------------------------------------------------------- + + +def test_only_the_offending_fix_is_rolled_back() -> None: + """A whole pass used to be undone for one bad fix; the good ones stay. + + Real critiques lost measurements this way: an ADD carrying a value the + render had missed rode in the same pass as a REMOVE that deleted without + writing, and the rollback took both. + """ + state = _unit_state() + index = build_triple_index(state.content_unit.graph) + state.prompt_triple_index = index + doomed = [tid for tid, (_, p, _) in index.by_id.items() if p == _EX_PREDICATE] + good = _fix("ADD", correct=f'<{_SUBJECT}> "x" .') + state.suggestions.actionable_fixes = [good, _fix("REMOVE", triple_ids=doomed)] + + outcome = _run(state, [], mandatory_before=0) + + assert outcome.applied == 1 + assert outcome.rolled_back == 1 + graph = state.content_unit.graph + assert (_SUBJECT, URIRef("http://example.org/new"), Literal("x")) in graph + assert (_SUBJECT, _EX_PREDICATE, _VALUE) in graph, "the removal was undone" + assert state.critic_fixes_applied == 1 + assert state.critic_fixes_rolled_back == 1 + attempt = state.attempt_log[-1] + assert attempt.n_fixes_applied == 1 + assert attempt.n_fixes_rolled_back == 1 + assert attempt.patch_rolled_back is True + assert attempt.rolled_back_fixes[0].triple_ids == doomed + assert attempt.rolled_back_fixes[0].reason == "delete_only" + + +def test_a_capped_replace_does_not_become_an_add() -> None: + """Over the delete cap a REPLACE goes back whole. + + Keeping its insert half put the new value beside the old one -- a + different edit from the one proposed, and one that left the old value + the critic had flagged in place. + """ + state = _unit_state() + index = build_triple_index(state.content_unit.graph) + state.prompt_triple_index = index + label_ids = [ + tid for tid, (s, p, _) in index.by_id.items() if s == _VALUE and p == _LABEL + ] + state.suggestions.actionable_fixes = [ + _fix( + "REPLACE", + triple_ids=label_ids, + correct=f'<{_VALUE}> <{_LABEL}> "96 milli-electronvolt" .', + ) + ] + strict = _atomic() + strict.facts_patch_policy = CriticPatchPolicy(max_delete_share=0.01, min_deletes=0) + + outcome = _run(state, [], mandatory_before=0, atomic=strict) + + graph = state.content_unit.graph + assert outcome.applied == 0 + assert outcome.residual == 1 + assert (_VALUE, _LABEL, Literal("96 meV")) in graph + assert (_VALUE, _LABEL, Literal("96 milli-electronvolt")) not in graph + assert state.attempt_log[-1].patch_delete_capped is True + + +def test_the_finding_walk_per_fix_is_charged_to_deterministic_repair() -> None: + state = _unit_state() + state.suggestions.actionable_fixes = [ + _fix("ADD", correct=f'<{_SUBJECT}> "x" .'), + _fix("ADD", correct=f'<{_SUBJECT}> "y" .'), + ] + + _run(state, []) + + assert "repair/deterministic" in state.budget_tracker.node_durations diff --git a/test/facts/test_document_scoped_context.py b/test/facts/test_document_scoped_context.py new file mode 100644 index 00000000..0f1de059 --- /dev/null +++ b/test/facts/test_document_scoped_context.py @@ -0,0 +1,338 @@ +"""One ontology chapter per document instead of one per unit. + +Per-unit retrieval gives each unit the smallest chapter -- and a different one, +so no two calls in the fan-out share a prompt prefix and a provider's prefix +cache can serve none of them. The document pays the chapter at full price once +per unit. These pin the alternative: resolve once, share it, and run one unit +first so the cache the others read has actually been written. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from typing import cast + +import pytest +from rdflib import URIRef + +from ontocast.onto.content_unit import ContentUnit +from ontocast.onto.enum import ( + OntologyAssemblyMode, + OntologyChapterFormat, + OntologyContextMode, + OntologyContextScope, + Status, +) +from ontocast.onto.ontology_condense import TextCaps +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.onto.state import AgentState +from ontocast.stategraph import context_resolver, node_factories +from ontocast.stategraph.context_resolver import ( + UnitOntologyContext, + build_unioned_document_ontology_context, +) +from ontocast.stategraph.unit_context import UnitLoopContext +from ontocast.toolbox import ToolBox + +pytestmark = [pytest.mark.anyio, pytest.mark.unit] + +UNION_CALLS = "ctx/union_document_ontology.calls" + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +def _unit_graph(term: str) -> RDFGraph: + return RDFGraph._from_turtle_str( + f""" + @prefix ex: . + ex:{term} a ex:Class . + """ + ) + + +def _state(n_units: int) -> AgentState: + state = AgentState( + ontology_context_mode=OntologyContextMode.SELECTED_VECTOR_SEARCH_ONTOLOGY + ) + state.content_units = [ + ContentUnit( + text=f"Unit {index} describes a concept.", + index=index, + doc_iri=URIRef("https://example.org/doc/1"), + ) + for index in range(n_units) + ] + # No ontology stage ran, so there is nothing to merge -- the facts-only + # shape in which every unit otherwise resolves its own context. + state.reduced_ontology_artifacts = [] + return state + + +def _tools(*, scope: OntologyContextScope, warmup: int = 0) -> ToolBox: + tool_config = SimpleNamespace( + facts_validation=SimpleNamespace(context_from_units=False) + ) + return cast( + ToolBox, + SimpleNamespace( + config=SimpleNamespace( + server=SimpleNamespace( + parallel_workers=4, + ontology_context_max_triples=4000, + ontology_chapter_format=OntologyChapterFormat.INHERIT, + ontology_text_caps=TextCaps(), + ontology_context_scope=scope, + fanout_warmup_units=warmup, + ), + get_tool_config=lambda: tool_config, + ), + shapes_prompt_contract=lambda: ("", (), False), + ), + ) + + +def _install_per_unit_resolver(monkeypatch) -> None: + """Each unit retrieves a distinct term, as vector search would.""" + + async def fake_resolve(context, tools, unit, **kwargs): + from ontocast.onto.ontology_snapshot import OntologySnapshot + + return UnitOntologyContext( + snapshot=OntologySnapshot.from_graph( + _unit_graph(f"Term{unit.index}"), + source_iris=[f"https://example.org/onto/{unit.index}"], + assembly_mode=OntologyAssemblyMode.SELECTED_VECTOR_SEARCH_ENSEMBLE, + ), + writable_iris=[f"https://example.org/onto/{unit.index}"], + confidence=1.0, + ) + + monkeypatch.setattr(context_resolver, "resolve_unit_ontology_context", fake_resolve) + + +async def test_union_contains_every_unit_context(monkeypatch) -> None: + """Recall-safe by construction: no unit sees less than it would have. + + That is the whole licence for sharing one chapter. If the union could drop + a term some unit's own retrieval selected, this would be a recall change + dressed up as a cost change. + """ + _install_per_unit_resolver(monkeypatch) + state = _state(4) + context = UnitLoopContext.from_agent_state(state) + + unioned = await build_unioned_document_ontology_context( + context, _tools(scope=OntologyContextScope.DOCUMENT), state.content_units + ) + + assert unioned is not None + terms = {str(term) for triple in unioned.snapshot.graph for term in triple} + for index in range(4): + assert f"https://example.org/onto#Term{index}" in terms + assert context.budget_tracker.counters[UNION_CALLS] == 1 + + +async def test_union_is_stable_across_calls(monkeypatch) -> None: + """An unstable source order is an unstable prompt, which is the thing + document scope exists to avoid.""" + _install_per_unit_resolver(monkeypatch) + state = _state(4) + tools = _tools(scope=OntologyContextScope.DOCUMENT) + + first = await build_unioned_document_ontology_context( + UnitLoopContext.from_agent_state(state), tools, state.content_units + ) + second = await build_unioned_document_ontology_context( + UnitLoopContext.from_agent_state(state), tools, state.content_units + ) + + assert first is not None and second is not None + assert first.snapshot.source_iris == second.snapshot.source_iris + assert first.snapshot.graph.serialize_canonical_turtle() == ( + second.snapshot.graph.serialize_canonical_turtle() + ) + + +async def test_no_units_resolve_leaves_the_per_unit_path(monkeypatch) -> None: + """An empty union must not be handed out as a context. + + Every unit receiving an empty snapshot is worse than every unit resolving + its own: it silently removes the vocabulary instead of narrowing it. + """ + + async def resolve_nothing(context, tools, unit, **kwargs): + from ontocast.onto.ontology_snapshot import OntologySnapshot + + return UnitOntologyContext( + snapshot=OntologySnapshot.from_graph( + RDFGraph(), + source_iris=[], + assembly_mode=OntologyAssemblyMode.SELECTED_VECTOR_SEARCH_ENSEMBLE, + ) + ) + + monkeypatch.setattr( + context_resolver, "resolve_unit_ontology_context", resolve_nothing + ) + state = _state(3) + + unioned = await build_unioned_document_ontology_context( + UnitLoopContext.from_agent_state(state), + _tools(scope=OntologyContextScope.DOCUMENT), + state.content_units, + ) + + assert unioned is None + + +def _install_recording_facts_loop(monkeypatch) -> list[dict]: + order: list[dict] = [] + + async def fake_facts_loop(state, tools, document_context, **kwargs): + pre_resolved = kwargs.get("pre_resolved_context") + if pre_resolved is not None: + state.ontology_snapshot = pre_resolved.snapshot + order.append( + { + "index": state.content_unit.index, + "event": "start", + "graph_id": id(state.ontology_snapshot.graph), + } + ) + await asyncio.sleep(0) + order.append({"index": state.content_unit.index, "event": "end"}) + state.content_unit.graph = RDFGraph._from_turtle_str( + f""" + @prefix cd: . + cd:unit{state.content_unit.index} a cd:Thing . + """ + ) + state.status = Status.SUCCESS + return state + + monkeypatch.setattr(node_factories, "facts_loop", fake_facts_loop) + return order + + +async def test_document_scope_hands_every_unit_the_same_graph(monkeypatch) -> None: + _install_per_unit_resolver(monkeypatch) + order = _install_recording_facts_loop(monkeypatch) + state = _state(5) + + await node_factories.make_render_facts_node( + _tools(scope=OntologyContextScope.DOCUMENT) + )(state) + + graph_ids = {row["graph_id"] for row in order if row["event"] == "start"} + assert len(graph_ids) == 1, "one chapter per document, not one per unit" + + +async def test_unit_scope_is_unchanged(monkeypatch) -> None: + """The default must still resolve per unit -- this is opt-in.""" + _install_per_unit_resolver(monkeypatch) + order = _install_recording_facts_loop(monkeypatch) + state = _state(3) + + await node_factories.make_render_facts_node( + _tools(scope=OntologyContextScope.UNIT) + )(state) + + starts = [row for row in order if row["event"] == "start"] + assert len(starts) == 3 + # Nothing was pre-resolved, so each unit carries its own empty seed graph. + assert len({row["graph_id"] for row in starts}) == 3 + + +async def test_warmup_runs_the_first_unit_before_the_rest_start(monkeypatch) -> None: + """A prefix cache is populated by a request that has already completed. + + Without this the fan-out issues every call before any of them has written + the entry the others would read, and all of them miss. + """ + _install_per_unit_resolver(monkeypatch) + order = _install_recording_facts_loop(monkeypatch) + state = _state(4) + + await node_factories.make_render_facts_node( + _tools(scope=OntologyContextScope.DOCUMENT, warmup=1) + )(state) + + events = [(row["index"], row["event"]) for row in order] + assert events[0] == (0, "start") + assert events[1] == (0, "end"), "unit 0 must finish before any sibling starts" + assert {index for index, event in events[2:] if event == "start"} == {1, 2, 3} + + +async def test_without_warmup_every_unit_starts_before_any_finishes( + monkeypatch, +) -> None: + """The default shape, kept: warm-up costs a serialized call, so it is opt-in.""" + _install_per_unit_resolver(monkeypatch) + order = _install_recording_facts_loop(monkeypatch) + state = _state(4) + + await node_factories.make_render_facts_node( + _tools(scope=OntologyContextScope.DOCUMENT, warmup=0) + )(state) + + first_end = next(i for i, row in enumerate(order) if row["event"] == "end") + starts_before_first_end = sum( + 1 for row in order[:first_end] if row["event"] == "start" + ) + assert starts_before_first_end == 4 + + +async def test_every_unit_still_produces_output_with_warmup(monkeypatch) -> None: + """The warm-up must not drop or duplicate a unit.""" + _install_per_unit_resolver(monkeypatch) + _install_recording_facts_loop(monkeypatch) + state = _state(6) + + await node_factories.make_render_facts_node( + _tools(scope=OntologyContextScope.DOCUMENT, warmup=2) + )(state) + + assert len(state.facts_units) == 6 + assert [unit.index for unit in state.facts_units] == list(range(6)) + + +async def test_a_failing_unit_after_the_warmup_is_named_by_its_own_index( + monkeypatch, caplog +) -> None: + """A warm-up splits the fan-out into two gathers. + + Each gather sees its tasks positionally, so without an offset the second + batch names its failures by position within the batch -- reporting unit 0 + for a unit that is not unit 0, which sends whoever reads the log to the + wrong content unit. + """ + _install_per_unit_resolver(monkeypatch) + + async def failing_facts_loop(state, tools, document_context, **kwargs): + if state.content_unit.index == 3: + raise RuntimeError("unit 3 blew up") + state.content_unit.graph = RDFGraph._from_turtle_str( + f""" + @prefix cd: . + cd:unit{state.content_unit.index} a cd:Thing . + """ + ) + state.status = Status.SUCCESS + return state + + monkeypatch.setattr(node_factories, "facts_loop", failing_facts_loop) + state = _state(5) + + with caplog.at_level("ERROR"): + await node_factories.make_render_facts_node( + _tools(scope=OntologyContextScope.DOCUMENT, warmup=1) + )(state) + + assert any("Unit 3 raised" in record.message for record in caplog.records), [ + record.message for record in caplog.records + ] + assert len(state.facts_units) == 4 diff --git a/test/facts/test_domain_violation.py b/test/facts/test_domain_violation.py index 4e25a1a4..2689734a 100644 --- a/test/facts/test_domain_violation.py +++ b/test/facts/test_domain_violation.py @@ -15,6 +15,8 @@ domain_violation_findings, ) +pytestmark = pytest.mark.unit + ONTOLOGY = """ @prefix owl: . @prefix rdfs: . diff --git a/test/facts/test_gate.py b/test/facts/test_gate.py index 56c70ce1..31642460 100644 --- a/test/facts/test_gate.py +++ b/test/facts/test_gate.py @@ -9,6 +9,7 @@ from types import SimpleNamespace from typing import cast +import pytest from rdflib import OWL, RDF, RDFS, Literal, URIRef from rdflib.namespace import XSD @@ -26,10 +27,15 @@ make_validate_facts_node, ) from ontocast.tool import EmbeddingBasedAggregator -from ontocast.tool.agg.aggregate import build_merged_clusters +from ontocast.tool.agg.aggregate import ( + build_cross_unit_object_pairs, + build_merged_clusters, +) from ontocast.tool.facts_validation import validate_aggregated_facts from ontocast.toolbox import ToolBox +pytestmark = pytest.mark.unit + CD = f"{DEFAULT_IRI}/" Q = "https://x.org/schema#" @@ -322,6 +328,8 @@ def _fake_tools( SimpleNamespace( aggregator=aggregator, shapes_catalog=SimpleNamespace(graph=lambda: shapes), + shapes_prompt_contract=lambda: ("", (), False), + ontology_manager=SimpleNamespace(catalog_terms=lambda: set()), config=SimpleNamespace( get_tool_config=lambda: SimpleNamespace( facts_validation=facts_validation @@ -501,3 +509,150 @@ def test_described_and_external_objects_are_not_dangling() -> None: finding.kind is FactsValidationFindingKind.DANGLING_REFERENCE for finding in report.findings ) + + +# --------------------------------------------------------------------------- +# Cross-unit evidence for the IRI branch of SUSPECT_MULTI_VALUE +# --------------------------------------------------------------------------- + + +def _joint_agents_graph() -> tuple[RDFGraph, URIRef, URIRef]: + """One application with two agents, plus single-agent subjects around it. + + The neighbours are what make the predicate "dominantly single-valued", so + the IRI branch fires on the two-object subject. + """ + graph = RDFGraph() + subject = URIRef(CD + "application1") + predicate = URIRef("https://schema.org/agent") + graph.add((subject, predicate, URIRef(CD + "personBeer"))) + graph.add((subject, predicate, URIRef(CD + "personRegan"))) + for index in range(3): + graph.add( + ( + URIRef(f"{CD}other{index}"), + predicate, + URIRef(f"{CD}soleAgent{index}"), + ) + ) + return graph, subject, predicate + + +def test_iri_multi_value_is_an_error_without_provenance() -> None: + """Unchanged default: no evidence supplied means the old behaviour.""" + graph, subject, predicate = _joint_agents_graph() + + report = validate_aggregated_facts(graph, None, fact_namespaces=[CD]) + + errors = [ + finding + for finding in report.error_findings + if finding.kind == FactsValidationFindingKind.SUSPECT_MULTI_VALUE + and finding.subject == str(subject) + ] + assert len(errors) == 1 + + +def test_single_unit_multi_value_is_only_a_warning() -> None: + """Two objects one unit asserted cannot be the residue of a merge. + + Reporting it as an error drives the un-merge repair, which dissolves a + correct joint statement. + """ + graph, subject, predicate = _joint_agents_graph() + + report = validate_aggregated_facts( + graph, None, fact_namespaces=[CD], cross_unit_pairs=[] + ) + + suspects = [ + finding + for finding in report.findings + if finding.kind == FactsValidationFindingKind.SUSPECT_MULTI_VALUE + and finding.subject == str(subject) + ] + assert len(suspects) == 1 + assert suspects[0].severity == "warning" + assert not report.error_findings + + +def test_cross_unit_multi_value_stays_an_error() -> None: + """Objects from different units could have been brought together.""" + graph, subject, predicate = _joint_agents_graph() + + report = validate_aggregated_facts( + graph, + None, + fact_namespaces=[CD], + cross_unit_pairs=[(str(subject), str(predicate))], + ) + + errors = [ + finding + for finding in report.error_findings + if finding.kind == FactsValidationFindingKind.SUSPECT_MULTI_VALUE + and finding.subject == str(subject) + ] + assert len(errors) == 1 + + +def test_numeric_branch_ignores_cross_unit_evidence() -> None: + """Two quantities on one node are a defect whatever their provenance.""" + graph = RDFGraph() + subject = URIRef(CD + "q1") + predicate = URIRef(Q + "numericValue") + graph.add((subject, predicate, Literal("12.5", datatype=XSD.double))) + graph.add((subject, predicate, Literal("96", datatype=XSD.double))) + + report = validate_aggregated_facts( + graph, None, fact_namespaces=[CD], cross_unit_pairs=[] + ) + assert [f.kind for f in report.error_findings] == [ + FactsValidationFindingKind.SUSPECT_MULTI_VALUE + ] + + +def test_build_cross_unit_object_pairs_separates_the_two_cases() -> None: + subject = URIRef(CD + "application1") + predicate = "https://schema.org/agent" + shared = URIRef(CD + "sharedSubject") + + joint_unit = _fact_unit( + 0, + f""" + <{subject}> <{predicate}> <{CD}personBeer> , <{CD}personRegan> . + """, + ) + first = _fact_unit(1, f"<{shared}> <{predicate}> <{CD}alpha> .") + second = _fact_unit(2, f"<{shared}> <{predicate}> <{CD}beta> .") + + pairs = build_cross_unit_object_pairs([joint_unit, first, second], {}) + + # Two objects from one unit: not merge-created. + assert (str(subject), predicate) not in pairs + # One object each from two units: a merge could have produced this. + assert (str(shared), predicate) in pairs + + +def test_build_cross_unit_object_pairs_canonicalizes_subjects() -> None: + """Keys must match the post-merge graph the gate actually validates.""" + predicate = "https://schema.org/agent" + raw_left = URIRef(CD + "app_a") + raw_right = URIRef(CD + "app_b") + canonical = URIRef(CD + "application") + + left = _fact_unit(0, f"<{raw_left}> <{predicate}> <{CD}alpha> .") + right = _fact_unit(1, f"<{raw_right}> <{predicate}> <{CD}beta> .") + + pairs = build_cross_unit_object_pairs( + [left, right], {raw_left: canonical, raw_right: canonical} + ) + assert (str(canonical), predicate) in pairs + + +def test_build_cross_unit_object_pairs_ignores_literals() -> None: + unit = _fact_unit( + 0, + f'<{CD}s> "a" , "b" .', + ) + assert build_cross_unit_object_pairs([unit], {}) == [] diff --git a/test/facts/test_gate_repairs.py b/test/facts/test_gate_repairs.py index 4b458b47..c664e80a 100644 --- a/test/facts/test_gate_repairs.py +++ b/test/facts/test_gate_repairs.py @@ -28,6 +28,8 @@ ) from ontocast.toolbox import ToolBox +pytestmark = pytest.mark.unit + CD = f"{DEFAULT_IRI}/" Q = "https://x.org/schema#" @@ -205,6 +207,8 @@ def _fake_tools(aggregator, *, shapes=None, **overrides) -> ToolBox: SimpleNamespace( aggregator=aggregator, shapes_catalog=SimpleNamespace(graph=lambda: shapes), + shapes_prompt_contract=lambda: ("", (), False), + ontology_manager=SimpleNamespace(catalog_terms=lambda: set()), config=SimpleNamespace( get_tool_config=lambda: SimpleNamespace( facts_validation=facts_validation @@ -244,6 +248,7 @@ def postprocess_facts_units(self, **kwargs): graph=conflicting_graph(), merged_clusters={}, key_supported_clusters=[], + cross_unit_object_pairs=[], ) aggregator = _StubAggregator() @@ -440,6 +445,50 @@ def test_placeholder_node_is_pruned_with_its_incoming_edge() -> None: assert result.violations_after == 0 +def test_prune_cascades_to_the_referrer_it_empties() -> None: + """Pruning a placeholder must not leave its subject behind as a stub. + + An observation whose only content is the edge onto a placeholder value is + itself a placeholder once that edge goes. Leaving it is the worst outcome + available: the record of an attempted extraction is gone and the subject's + own violation is still in the report, because the pass computed its + violations before it pruned anything. + """ + graph = RDFGraph() + placeholder = URIRef(CD + "v_empty") + observation = URIRef(CD + "obs") + graph.add((placeholder, RDF.type, VALUE_CLASS)) + graph.add((placeholder, RDFS.label, Literal("efficiency"))) + graph.add((observation, RDF.type, URIRef(Q + "Observation"))) + graph.add((observation, RDFS.label, Literal("efficiency observation"))) + graph.add((observation, URIRef(Q + "hasValue"), placeholder)) + + result = _repair(graph) + + assert len(result.graph) == 0, "the stub observation goes with its value" + assert [record.source for record in result.records] == [ + str(placeholder), + str(observation), + ] + + +def test_prune_stops_at_a_referrer_that_asserts_something() -> None: + """A subject with data of its own survives its placeholder value.""" + graph = RDFGraph() + placeholder = URIRef(CD + "v_empty") + observation = URIRef(CD + "obs") + feature = URIRef(CD + "sample_1") + graph.add((placeholder, RDF.type, VALUE_CLASS)) + graph.add((observation, RDF.type, URIRef(Q + "Observation"))) + graph.add((observation, URIRef(Q + "featureOfInterest"), feature)) + graph.add((observation, URIRef(Q + "hasValue"), placeholder)) + + result = _repair(graph) + + assert [record.source for record in result.records] == [str(placeholder)] + assert (observation, URIRef(Q + "featureOfInterest"), feature) in result.graph + + def test_node_with_data_is_never_pruned_and_never_invented() -> None: """Missing a required property is reported, not filled in or deleted.""" graph = RDFGraph() diff --git a/test/facts/test_grounding_guards.py b/test/facts/test_grounding_guards.py new file mode 100644 index 00000000..12e6d0df --- /dev/null +++ b/test/facts/test_grounding_guards.py @@ -0,0 +1,200 @@ +"""Guards against a finished run that never had a vocabulary. + +The failure these cover shipped once: every content unit rendered against an +empty ontology chapter, the extractor fell back on generic vocabulary, and the +SHACL gate reported ``conforms: true`` because no node in the output matched +any shape target. The run looked clean at every checkpoint. +""" + +import pytest + +from ontocast.onto.model import FactsValidationFinding, FactsValidationFindingKind +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.tool.facts_validation import ( + count_shacl_focus_nodes, + summarize_conformance, +) +from ontocast.tool.facts_validation.terms import collect_catalog_terms +from ontocast.tool.facts_validation.unit_findings import ( + _domain_adherence_findings, + domain_vocabulary_share, +) + +MATSCI = "https://growgraph.dev/ontologies/matsci#" +CD = "https://growgraph.dev/facts/" + +_SHAPES = f""" +@prefix sh: . +@prefix matsci: <{MATSCI}> . +matsci:SampleShape a sh:NodeShape ; sh:targetClass matsci:NanocrystalSample . +""" + +_ONTOLOGY = f""" +@prefix owl: . +@prefix rdfs: . +@prefix matsci: <{MATSCI}> . +matsci:NanocrystalSample a owl:Class ; rdfs:label "Nanocrystal sample" . +matsci:SpecialSample a owl:Class ; rdfs:subClassOf matsci:NanocrystalSample . +matsci:hasAmount a owl:ObjectProperty ; rdfs:label "has amount" . +""" + +_GROUNDED = f""" +@prefix cd: <{CD}> . +@prefix matsci: <{MATSCI}> . +cd:s1 a matsci:NanocrystalSample ; matsci:hasAmount cd:a1 . +""" + +_UNGROUNDED = f""" +@prefix cd: <{CD}> . +@prefix schema: . +cd:s1 a schema:Product ; schema:additionalProperty cd:pv1 . +cd:pv1 a schema:PropertyValue ; schema:value "30" ; schema:unitText "meV" . +""" + + +def _g(ttl: str) -> RDFGraph: + graph = RDFGraph() + graph.parse(data=ttl, format="turtle") + return graph + + +def test_conformance_over_zero_focus_nodes_is_not_a_pass() -> None: + summary = summarize_conformance([], shacl_evaluated=True, focus_nodes=0) + + assert summary["shacl_vacuous"] is True + assert summary["conforms"] is None, "no violations over nothing is not conformance" + + +def test_conformance_over_real_focus_nodes_still_passes() -> None: + summary = summarize_conformance([], shacl_evaluated=True, focus_nodes=7) + + assert summary["shacl_vacuous"] is False + assert summary["conforms"] is True + assert summary["shacl_focus_nodes"] == 7 + + +def test_violations_still_fail_a_non_vacuous_run() -> None: + finding = FactsValidationFinding( + kind=FactsValidationFindingKind.SHACL, message="missing unit", severity="error" + ) + summary = summarize_conformance([finding], shacl_evaluated=True, focus_nodes=3) + + assert summary["conforms"] is False + + +def test_focus_nodes_counts_targeted_instances_and_subclasses() -> None: + shapes = _g(_SHAPES) + data = _g(_ONTOLOGY + _GROUNDED) + + assert count_shacl_focus_nodes(data, shapes) == 1 + + subclassed = _g( + _ONTOLOGY + f"@prefix cd: <{CD}> .\n@prefix matsci: <{MATSCI}> .\n" + "cd:s2 a matsci:SpecialSample .\n" + ) + assert count_shacl_focus_nodes(subclassed, shapes) == 1, ( + "sh:targetClass is subclass-aware, so a subclass instance is in scope" + ) + + +def test_a_generic_graph_matches_no_shape_and_is_reported_vacuous() -> None: + """The exact shape of the shipped failure.""" + focus = count_shacl_focus_nodes(_g(_UNGROUNDED), _g(_SHAPES)) + summary = summarize_conformance([], shacl_evaluated=True, focus_nodes=focus) + + assert focus == 0 + assert summary["conforms"] is None + assert summary["shacl_vacuous"] is True + + +def test_focus_nodes_is_none_without_shapes() -> None: + assert count_shacl_focus_nodes(_g(_GROUNDED), None) is None + + +@pytest.mark.parametrize( + ("ttl", "expect_finding"), + [(_GROUNDED, False), (_UNGROUNDED, True)], +) +def test_domain_adherence_flags_a_wholly_generic_render( + ttl: str, expect_finding: bool +) -> None: + catalog = collect_catalog_terms(_g(_ONTOLOGY)) + findings = _domain_adherence_findings(_g(ttl), catalog, [CD], 0.15) + + assert bool(findings) is expect_finding + if expect_finding: + assert findings[0].mandatory, "a vocabulary-less render must drive a repair" + + +def test_domain_adherence_is_silent_without_a_catalog() -> None: + """A deployment that deliberately extracts without one is not spammed.""" + assert _domain_adherence_findings(_g(_UNGROUNDED), set(), [CD], 0.15) == [] + + +def test_domain_adherence_can_be_disabled() -> None: + catalog = collect_catalog_terms(_g(_ONTOLOGY)) + assert _domain_adherence_findings(_g(_UNGROUNDED), catalog, [CD], 0.0) == [] + + +_FRONT_MATTER = f""" +@prefix cd: <{CD}> . +@prefix schema: . +cd:doi_1 a schema:PropertyValue ; schema:value "10.1000/xyz" . +cd:author_1 a schema:Person . +""" + + +def test_domain_adherence_needs_a_minimum_denominator() -> None: + """Two generic terms are not a render that abandoned the catalog. + + A front-matter unit (an identifier, an author) types a couple of nodes + with generic vocabulary and has nothing to say in the catalog's terms. + Judging a share over that denominator raised a mandatory finding the + critic answered by retyping the identifier as a quantity value. + """ + catalog = collect_catalog_terms(_g(_ONTOLOGY)) + graph = _g(_FRONT_MATTER) + _, total = domain_vocabulary_share(graph, catalog, [CD]) + assert total < 4 + + assert _domain_adherence_findings(graph, catalog, [CD], 0.15) == [] + assert _domain_adherence_findings(graph, catalog, [CD], 0.15, min_terms=1), ( + "the floor, not the share, is what silenced it" + ) + + +def test_domain_adherence_is_not_judged_on_citation_metadata() -> None: + """A reference list is rendered with the bibliographic vocabulary by + instruction, so its catalog share is zero by design.""" + from ontocast.tool.facts_validation import collect_unit_findings + + def findings(is_citation_metadata: bool): + return [ + finding + for finding in collect_unit_findings( + graph=_g(_UNGROUNDED), + ontology_graph=_g(_ONTOLOGY), + quarantined=[], + extraction_text="", + fact_namespaces=[CD], + coverage_limit=0, + is_citation_metadata=is_citation_metadata, + ) + if finding.kind == "domain_adherence" + ] + + assert findings(False), "the same graph is flagged as a content unit" + assert findings(True) == [] + + +def test_share_excludes_plumbing_and_minted_instances() -> None: + """rdf:type and rdfs:label are in every graph and in most catalogs. + + Counting them would let any graph clear the floor for free. + """ + catalog = collect_catalog_terms(_g(_ONTOLOGY)) + from_catalog, total = domain_vocabulary_share(_g(_GROUNDED), catalog, [CD]) + + assert (from_catalog, total) == (2, 2), ( + "matsci:NanocrystalSample + matsci:hasAmount" + ) diff --git a/test/facts/test_literal_repair.py b/test/facts/test_literal_repair.py index 73672990..327e57e9 100644 --- a/test/facts/test_literal_repair.py +++ b/test/facts/test_literal_repair.py @@ -7,10 +7,15 @@ against a closed individual range. """ +import pytest from rdflib import RDF, Literal, URIRef from rdflib.namespace import XSD -from ontocast.onto.model import FactsUnitFindingKind, format_findings_for_prompt +from ontocast.onto.model import ( + FactsUnitFinding, + FactsUnitFindingKind, + format_findings_for_prompt, +) from ontocast.onto.rdfgraph import RDFGraph, RejectedLiteralTriple from ontocast.tool.facts_validation import ( collect_unit_findings, @@ -19,6 +24,8 @@ repair_property_aliases, ) +pytestmark = pytest.mark.unit + QUDT = "http://qudt.org/schema/qudt/" QQVAL = "https://growgraph.dev/ontologies/qqval#" FACTS = "https://growgraph.dev/facts/" @@ -111,6 +118,167 @@ def test_repair_property_aliases_rewrites_qqval_bound() -> None: ) in graph +DOM = "https://example.com/domain#" + + +def _domain_ontology(*locals_: str) -> RDFGraph: + """A snapshot declaring only the given properties in the domain namespace.""" + graph = RDFGraph() + declarations = "\n".join(f"dom:{local} a owl:ObjectProperty ." for local in locals_) + graph.parse( + data=f""" + @prefix owl: . + @prefix dom: <{DOM}> . + {declarations} + """, + format="turtle", + ) + return graph + + +def _alias_findings(findings) -> dict[str, FactsUnitFinding]: + return { + finding.predicate: finding + for finding in findings + if finding.kind is FactsUnitFindingKind.PROPERTY_ALIAS + } + + +def test_alias_repair_refuses_token_substitution() -> None: + """A name that differs by one token is a different property, not a typo. + + The snapshot retrieved one member of a family of look-alike properties; + the render used its siblings. Rewriting them to the retrieved one would + silently substitute one property for another, so however high the string + similarity they stay untouched and are reported instead. + """ + graph = _facts( + f""" + @prefix dom: <{DOM}> . + cd:m dom:hasASiteComponent cd:cs ; + dom:hasXSiteComponent cd:br ; + dom:hasPurity cd:p . + """ + ) + ontology = _domain_ontology("hasBSiteComponent", "hasImpurity") + + # A permissive floor makes the point: the ratio is never sufficient. + rewritten, findings, applied = repair_property_aliases( + graph, ontology, min_ratio=0.5 + ) + + assert rewritten == 0 + assert applied == [] + for local in ("hasASiteComponent", "hasXSiteComponent", "hasPurity"): + assert (URIRef(f"{FACTS}m"), URIRef(f"{DOM}{local}"), None) in graph + assert (None, URIRef(f"{DOM}hasBSiteComponent"), None) not in graph + assert (None, URIRef(f"{DOM}hasImpurity"), None) not in graph + flagged = _alias_findings(findings) + assert set(flagged) == { + f"{DOM}hasASiteComponent", + f"{DOM}hasXSiteComponent", + f"{DOM}hasPurity", + } + assert all(finding.mandatory for finding in flagged.values()) + assert f"{DOM}hasBSiteComponent" in flagged[f"{DOM}hasASiteComponent"].suggestions + assert f"{DOM}hasImpurity" in flagged[f"{DOM}hasPurity"].suggestions + + +def test_alias_repair_skips_terms_declared_elsewhere_in_catalog() -> None: + """A term the full catalog declares is real even if the snapshot lacks it.""" + graph = _facts( + f""" + @prefix dom: <{DOM}> . + cd:m dom:hasASiteComponent cd:cs . + """ + ) + ontology = _domain_ontology("hasBSiteComponent") + + rewritten, findings, applied = repair_property_aliases( + graph, + ontology, + full_catalog_terms={f"{DOM}hasASiteComponent", f"{DOM}hasBSiteComponent"}, + ) + + assert rewritten == 0 + assert applied == [] + assert _alias_findings(findings) == {} + assert ( + URIRef(f"{FACTS}m"), + URIRef(f"{DOM}hasASiteComponent"), + URIRef(f"{FACTS}cs"), + ) in graph + + +def test_alias_repair_full_catalog_membership_outranks_containment() -> None: + """Even a candidate that qualifies by containment loses to catalog membership.""" + graph = _facts( + """ + cd:a qudt:numericValue "1"^^xsd:decimal . + cd:b qudt:numericValue "2"^^xsd:decimal . + cd:c qudt:value "375"^^xsd:decimal . + """ + ) + + rewritten, findings, _applied = repair_property_aliases( + graph, _ontology(), full_catalog_terms={f"{QUDT}value"} + ) + + assert rewritten == 0 + assert not findings + assert (URIRef(f"{FACTS}c"), URIRef(f"{QUDT}value"), None) in graph + + +def test_alias_repair_ratio_breaks_ties_only_among_qualifying_candidates() -> None: + """Two candidates qualify; the ratio picks the one that spells the same.""" + graph = _facts( + f""" + @prefix dom: <{DOM}> . + cd:r dom:lowerBound "10"^^xsd:decimal . + """ + ) + # ``lowerbound`` qualifies by folded spelling, ``hasLowerBound`` by token + # containment; only the former is a near-perfect string match. + ontology = _domain_ontology("hasLowerBound", "lowerbound") + + rewritten, findings, applied = repair_property_aliases(graph, ontology) + + assert rewritten == 1 + assert not findings + assert (URIRef(f"{FACTS}r"), URIRef(f"{DOM}lowerbound"), None) in graph + assert [record.target for record in applied] == [f"{DOM}lowerbound"] + + +def test_alias_repair_ambiguous_containment_is_a_finding_under_the_floor() -> None: + """Several containing candidates with no clear string winner are reported. + + Lowering the floor is the knob that lets the ratio settle such a tie. + """ + body = f""" + @prefix dom: <{DOM}> . + cd:r dom:lowerBound "10"^^xsd:decimal . + """ + ontology = _domain_ontology("hasLowerBound", "hasLowerBoundInclusive") + + graph = _facts(body) + rewritten, findings, _applied = repair_property_aliases(graph, ontology) + assert rewritten == 0 + flagged = _alias_findings(findings) + assert set(flagged) == {f"{DOM}lowerBound"} + assert set(flagged[f"{DOM}lowerBound"].suggestions) >= { + f"{DOM}hasLowerBound", + f"{DOM}hasLowerBoundInclusive", + } + + graph = _facts(body) + rewritten, findings, _applied = repair_property_aliases( + graph, ontology, min_ratio=0.8 + ) + assert rewritten == 1 + assert not findings + assert (URIRef(f"{FACTS}r"), URIRef(f"{DOM}hasLowerBound"), None) in graph + + def test_repair_literal_type_objects_coerces_compact_and_absolute() -> None: # The failure shape: `a "domain-ontology:Material"^^xsd:string` # instead of `a domain-ontology:Material`. @@ -242,6 +410,7 @@ def test_closed_range_suggestions_match_case_exactly() -> None: def test_collect_unit_findings_numeric_coverage_is_advisory() -> None: + """A bare number leaves its unit-adjacent mention missing, still advisory.""" graph = _facts('cd:v qudt:numericValue "96"^^xsd:decimal .') findings = collect_unit_findings( graph=graph, @@ -257,9 +426,9 @@ def test_collect_unit_findings_numeric_coverage_is_advisory() -> None: ] assert len(coverage) == 1 assert not coverage[0].mandatory + assert "96" in coverage[0].value assert "12.5" in coverage[0].message assert "77" in coverage[0].message - assert "96" not in coverage[0].value def test_format_findings_for_prompt_sections() -> None: diff --git a/test/facts/test_loop_repair.py b/test/facts/test_loop_repair.py deleted file mode 100644 index 2b97e8d5..00000000 --- a/test/facts/test_loop_repair.py +++ /dev/null @@ -1,208 +0,0 @@ -"""Deterministic repair loop tests for the per-unit facts loop. - -At the default MAX_VISITS=1 the LLM critic never runs; the repair loop is -what guarantees machine-found violations (quarantined literals, unknown -terms) still get a bounded fix chance, with findings injected as MANDATORY -prompt items. -""" - -from types import SimpleNamespace -from typing import cast - -import pytest -from rdflib import Literal, URIRef - -from ontocast.onto.constants import DEFAULT_IRI -from ontocast.onto.content_unit import ContentUnit -from ontocast.onto.enum import FailureStage, Status -from ontocast.onto.model import FactsUnitFindingKind -from ontocast.onto.rdfgraph import RDFGraph -from ontocast.onto.unit_states import UnitFactsState -from ontocast.stategraph import atomic as atomic_module -from ontocast.stategraph.atomic import _run_finding_driven_repair -from ontocast.tool.atomic import AtomicToolBox - -_EX_PREDICATE = URIRef("http://example.org/redShiftContribution") -_GOOD_PREDICATE = URIRef("https://schema.org/measurement") - - -def _unit_state_with_violation() -> UnitFactsState: - graph = RDFGraph() - subject = URIRef(f"{DEFAULT_IRI}sample_1") - graph.add((subject, _EX_PREDICATE, URIRef(f"{DEFAULT_IRI}value_1"))) - graph.add( - ( - subject, - URIRef("http://www.w3.org/2000/01/rdf-schema#label"), - Literal("sample"), - ) - ) - unit = ContentUnit( - text="a shift of 96 meV", - index=0, - doc_iri=URIRef("https://example.com/doc/d1"), - graph=graph, - ) - state = UnitFactsState(content_unit=unit) - state.status = Status.SUCCESS - return state - - -def _atomic_tools(repair_visits: int = 1) -> AtomicToolBox: - return cast( - AtomicToolBox, - SimpleNamespace( - facts_llm_repair_visits=repair_visits, - additional_standard_namespaces=(), - validation_policy=None, - acceptance_policy=None, - ), - ) - - -@pytest.mark.anyio -async def test_repair_fires_and_findings_reach_render(monkeypatch) -> None: - seen_findings: list = [] - - async def fake_render(state, tools, supplemental_ontologies=None): - seen_findings.append(list(state.deterministic_findings)) - # The "LLM" fixes the violation: replace the ex: predicate. - for s, p, o in list( - state.content_unit.graph.triples((None, _EX_PREDICATE, None)) - ): - state.content_unit.graph.remove((s, p, o)) - state.content_unit.graph.add((s, _GOOD_PREDICATE, o)) - state.deterministic_findings = [] - state.status = Status.SUCCESS - return state - - monkeypatch.setattr(atomic_module, "render_facts", fake_render) - state = _unit_state_with_violation() - - result = await _run_finding_driven_repair( - state, _atomic_tools(), [], render_attempt=1 - ) - - assert len(seen_findings) == 1 - kinds = {finding.kind for finding in seen_findings[0]} - assert FactsUnitFindingKind.UNKNOWN_TERM in kinds - # Coverage finding for "96" rides along as advisory. - assert FactsUnitFindingKind.NUMERIC_COVERAGE in kinds - # Post-repair validation is clean except coverage (96 still missing). - residual_kinds = {finding.kind for finding in result.deterministic_findings} - assert FactsUnitFindingKind.UNKNOWN_TERM not in residual_kinds - repair_attempts = [a for a in result.attempt_log if a.kind == "llm_repair"] - assert len(repair_attempts) == 1 - # The record carries the residual AFTER the repair render, not the - # pre-render count the repair was asked to fix. - assert repair_attempts[0].n_deterministic_findings == len( - result.deterministic_findings - ) - assert repair_attempts[0].n_deterministic_findings < len(seen_findings[0]) - assert repair_attempts[0].n_mandatory_findings == 0 - - -@pytest.mark.anyio -async def test_repair_skipped_when_no_findings(monkeypatch) -> None: - calls = {"render": 0} - - async def fake_render(state, tools, supplemental_ontologies=None): - calls["render"] += 1 - return state - - monkeypatch.setattr(atomic_module, "render_facts", fake_render) - graph = RDFGraph() - graph.add( - ( - URIRef(f"{DEFAULT_IRI}v"), - URIRef("http://qudt.org/schema/qudt/numericValue"), - Literal("96"), - ) - ) - unit = ContentUnit( - text="a shift of 96 meV", - index=0, - doc_iri=URIRef("https://example.com/doc/d1"), - graph=graph, - ) - state = UnitFactsState(content_unit=unit) - state.status = Status.SUCCESS - - result = await _run_finding_driven_repair( - state, _atomic_tools(), [], render_attempt=1 - ) - - assert calls["render"] == 0 - assert result.attempt_log == [] - - -@pytest.mark.anyio -async def test_failed_repair_render_keeps_graph_and_success(monkeypatch) -> None: - async def fake_render(state, tools, supplemental_ontologies=None): - state.set_failure(FailureStage.GENERATE_GRAPH_UPDATE_FOR_FACTS, "provider 503") - return state - - monkeypatch.setattr(atomic_module, "render_facts", fake_render) - state = _unit_state_with_violation() - triples_before = len(state.content_unit.graph) - - result = await _run_finding_driven_repair( - state, _atomic_tools(), [], render_attempt=1 - ) - - assert result.status == Status.SUCCESS - assert len(result.content_unit.graph) == triples_before - - -@pytest.mark.anyio -async def test_failed_repair_preserves_the_diagnosis(monkeypatch) -> None: - """The unit stays SUCCESS, but *why* the repair failed must survive. - - ``clear_failure()`` used to wipe stage and reason together with the status, - leaving ``repair_failed=True`` and no way to tell a provider outage from an - unparseable response. - """ - - async def fake_render(state, tools, supplemental_ontologies=None): - state.set_failure(FailureStage.GENERATE_GRAPH_UPDATE_FOR_FACTS, "provider 503") - return state - - monkeypatch.setattr(atomic_module, "render_facts", fake_render) - - result = await _run_finding_driven_repair( - _unit_state_with_violation(), _atomic_tools(), [], render_attempt=1 - ) - - assert result.status == Status.SUCCESS - failed = [record for record in result.attempt_log if record.repair_failed] - assert failed, "a failed repair must leave an attempt record" - assert failed[-1].failure_reason == "provider 503" - assert failed[-1].failure_stage == str(FailureStage.GENERATE_GRAPH_UPDATE_FOR_FACTS) - - -@pytest.mark.anyio -async def test_repair_budget_bounds_visits(monkeypatch) -> None: - calls = {"render": 0} - - async def fake_render(state, tools, supplemental_ontologies=None): - # Never fixes anything: findings persist each iteration. - calls["render"] += 1 - state.status = Status.SUCCESS - return state - - monkeypatch.setattr(atomic_module, "render_facts", fake_render) - state = _unit_state_with_violation() - - result = await _run_finding_driven_repair( - state, _atomic_tools(repair_visits=2), [], render_attempt=1 - ) - - assert calls["render"] == 2 - assert any( - finding.kind == FactsUnitFindingKind.UNKNOWN_TERM - for finding in result.deterministic_findings - ) - # A repair that fixed nothing records a non-zero mandatory residual. - repair_attempts = [a for a in result.attempt_log if a.kind == "llm_repair"] - assert repair_attempts - assert all(a.n_mandatory_findings > 0 for a in repair_attempts) diff --git a/test/facts/test_non_catalog_vocabulary.py b/test/facts/test_non_catalog_vocabulary.py index a4471016..cf6d026b 100644 --- a/test/facts/test_non_catalog_vocabulary.py +++ b/test/facts/test_non_catalog_vocabulary.py @@ -11,10 +11,14 @@ from __future__ import annotations +import pytest + from ontocast.onto.model import FactsValidationFinding, FactsValidationFindingKind from ontocast.onto.rdfgraph import RDFGraph from ontocast.tool.facts_validation import validate_aggregated_facts +pytestmark = pytest.mark.unit + FACTS = "https://growgraph.dev/facts/" MATSCI = "https://growgraph.dev/ontologies/matsci#" @@ -122,6 +126,40 @@ def test_chunk_metadata_predicates_are_not_flagged() -> None: assert flagged == {} +def test_document_metadata_predicates_are_not_flagged() -> None: + """The pipeline's document-node scaffolding is not renderer vocabulary. + + ``apply_document_metadata_provenance`` types the document as + ``foaf:Document`` and may attach ``dcterms:title`` / ``dcterms:issued`` / + ``dcterms:source`` / ``dcterms:identifier``. Those terms were reported as + retrieval misses on every unit because they are not in the retrieved + snapshot either — the exemption must come from the same constant that + names what the pipeline writes. + """ + facts = f""" + @prefix rdfs: . + @prefix xsd: . + @prefix prov: . + @prefix foaf: . + @prefix dcterms: . + @prefix matsci: <{MATSCI}> . + @prefix cd: <{FACTS}> . + + cd:sl_sample_1 a matsci:SuperlatticeSample ; + rdfs:label "sample" ; + matsci:describesMaterial cd:cspbbr3 . + + <{FACTS}doc/paper> a foaf:Document, prov:Entity ; + dcterms:title "A perovskite aging study" ; + dcterms:issued "2019" ; + dcterms:identifier "10.1234/example" ; + dcterms:source . + """ + flagged = _non_catalog(_report(facts)) + + assert flagged == {} + + def test_findings_are_warnings_not_errors() -> None: """Telemetry about context assembly must not drive the un-merge repair.""" for finding in _non_catalog(_report()).values(): diff --git a/test/facts/test_patch_prefix_reconciliation.py b/test/facts/test_patch_prefix_reconciliation.py new file mode 100644 index 00000000..f726bb12 --- /dev/null +++ b/test/facts/test_patch_prefix_reconciliation.py @@ -0,0 +1,147 @@ +"""A model-declared prefix never overrides the binding it was shown. + +A fix payload carries its own prefix declarations, and a model writing one is +re-transcribing namespaces it read in the prompt. When the transcription +disagrees with the graph under repair, every term under that prefix expands to +an IRI no catalog declares: the payload fails the term check and is rolled back +with whatever it carried -- so a measurement can be produced by the model and +still be absent from the output, while the subject that referenced it survives +as a stub. +""" + +from rdflib import Literal, URIRef + +from ontocast.onto.model import TripleFix +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.tool.facts_validation.critic_patch import compile_critic_fixes + +CD = "https://growgraph.dev/facts/" +QQVAL = "https://growgraph.dev/ontologies/qqval#" +OBS = "https://growgraph.dev/ontologies/observation#" +QUDT = "http://qudt.org/schema/qudt/" + +_TTL = f""" +@prefix cd: <{CD}> . +@prefix qqval: <{QQVAL}> . +@prefix obs: <{OBS}> . +@prefix qudt: <{QUDT}> . +@prefix rdfs: . + +cd:red_shift_obs a obs:QuantitativeObservation ; + rdfs:label "PL red shift observation" . +""" + + +def _inserted(update) -> set[tuple]: + """Every triple the compiled patch would insert, as ``(s, p, o)``.""" + return { + (str(s), str(p), o) + for op in update.triple_operations + if op.type == "insert" + for s, p, o in op.graph + } + + +def _graph() -> RDFGraph: + graph = RDFGraph() + graph.parse(data=_TTL, format="turtle") + return graph + + +def _add(correct: str) -> TripleFix: + return TripleFix( + text_fragment="a red shift of ~15 meV", + action="ADD", + severity="important", + incorrect_value="", + correct_value=correct, + triple_ids=[], + explanation="add the measurement", + ) + + +_JSONLD_WRONG_QQVAL = ( + '{"@context": {"qqval": "%s", "qudt": "%s", "cd": "%s"}, ' + '"@id": "cd:red_shift_value", "@type": "qqval:QualifiedQuantityValue", ' + '"qudt:numericValue": {"@value": "15", "@type": "http://www.w3.org/2001/XMLSchema#decimal"}}' +) % (OBS, QUDT, CD) + + +def test_jsonld_payload_rebinding_a_known_prefix_is_corrected() -> None: + graph = _graph() + compiled = compile_critic_fixes([_add(_JSONLD_WRONG_QQVAL)], graph) + + assert compiled.applied, "the fix must compile, not be discarded" + assert compiled.update is not None + inserted = _inserted(compiled.update) + assert ( + f"{CD}red_shift_value", + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + URIRef(f"{QQVAL}QualifiedQuantityValue"), + ) in inserted, "the unit graph's qqval binding wins over the payload's" + assert not any( + str(obj) == f"{OBS}QualifiedQuantityValue" for _, _, obj in inserted + ), "the hallucinated namespace must not reach the graph" + + +def test_jsonld_payload_keeps_an_undeclared_new_namespace() -> None: + """Overriding is for prefixes the graph binds; a new one is left alone.""" + graph = _graph() + payload = ( + '{"@context": {"ext": "https://example.org/ext#", "cd": "%s"}, ' + '"@id": "cd:red_shift_value", "@type": "ext:Custom"}' % CD + ) + compiled = compile_critic_fixes([_add(payload)], graph) + + assert compiled.update is not None + objects = {str(obj) for _, _, obj in _inserted(compiled.update)} + assert "https://example.org/ext#Custom" in objects + + +def test_jsonld_term_definitions_are_not_treated_as_prefixes() -> None: + """A context entry mapping a term to a CURIE is not a namespace binding.""" + graph = _graph() + payload = ( + '{"@context": {"cd": "%s", "qqval": "%s", ' + '"rdfs": "http://www.w3.org/2000/01/rdf-schema#", "label": "rdfs:label"}, ' + '"@id": "cd:red_shift_value", "@type": "qqval:QualifiedQuantityValue", ' + '"label": "15 meV"}' % (CD, QQVAL) + ) + compiled = compile_critic_fixes([_add(payload)], graph) + + assert compiled.update is not None + inserted = _inserted(compiled.update) + assert ( + f"{CD}red_shift_value", + "http://www.w3.org/2000/01/rdf-schema#label", + Literal("15 meV"), + ) in inserted + + +def test_turtle_payload_redeclaring_a_known_prefix_is_corrected() -> None: + graph = _graph() + payload = ( + f"@prefix qqval: <{OBS}> .\n" + "cd:red_shift_value a qqval:QualifiedQuantityValue .\n" + ) + compiled = compile_critic_fixes([_add(payload)], graph) + + assert compiled.update is not None + objects = {str(obj) for _, _, obj in _inserted(compiled.update)} + assert f"{QQVAL}QualifiedQuantityValue" in objects + assert f"{OBS}QualifiedQuantityValue" not in objects + + +def test_known_prefix_map_overrides_a_conflicting_jsonld_context() -> None: + """The render path applies the same rule through ``set_known_prefixes``.""" + RDFGraph.set_known_prefixes({"qqval": QQVAL, "cd": CD}) + try: + graph = RDFGraph._from_str(_JSONLD_WRONG_QQVAL) + finally: + RDFGraph.set_known_prefixes(None) + + assert ( + URIRef(f"{CD}red_shift_value"), + URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + URIRef(f"{QQVAL}QualifiedQuantityValue"), + ) in graph diff --git a/test/facts/test_repair_rollback.py b/test/facts/test_repair_rollback.py deleted file mode 100644 index 41859b14..00000000 --- a/test/facts/test_repair_rollback.py +++ /dev/null @@ -1,189 +0,0 @@ -"""A delete-only repair render is rolled back, not kept. - -The findings prompt orders every MANDATORY item to be fixed by rewriting in -place. A model that answers it by deleting the offending statements resolves -nothing and destroys extracted data, which was observed -happening at scale: of 58 cached repair responses carrying the false -``qudt:numericValue`` finding, 25 deleted valid values outright and 28 -re-encoded scalars as equal-bound fake ranges. - -The loop already detected the signature (graph shrank, mandatory count did not -fall) and kept the shrunken graph anyway, logging a warning. That made the -detector a bystander to the loss. It now restores the pre-repair graph, which by -construction has the same mandatory-finding count and strictly more data. -""" - -from types import SimpleNamespace -from typing import cast - -import pytest -from rdflib import Literal, URIRef - -from ontocast.onto.constants import DEFAULT_IRI -from ontocast.onto.content_unit import ContentUnit -from ontocast.onto.enum import Status -from ontocast.onto.rdfgraph import RDFGraph -from ontocast.onto.unit_states import UnitFactsState -from ontocast.stategraph import atomic as atomic_module -from ontocast.stategraph.atomic import _run_finding_driven_repair -from ontocast.tool.atomic import AtomicToolBox - -pytestmark = pytest.mark.unit - -_EX_PREDICATE = URIRef("http://example.org/redShiftContribution") -_LABEL = URIRef("http://www.w3.org/2000/01/rdf-schema#label") - - -def _unit_state_with_violation() -> UnitFactsState: - """A unit carrying one mandatory UNKNOWN_TERM plus recoverable data.""" - graph = RDFGraph() - subject = URIRef(f"{DEFAULT_IRI}sample_1") - graph.add((subject, _EX_PREDICATE, URIRef(f"{DEFAULT_IRI}value_1"))) - graph.add((subject, _LABEL, Literal("sample"))) - graph.add((URIRef(f"{DEFAULT_IRI}value_1"), _LABEL, Literal("96 meV"))) - unit = ContentUnit( - text="a shift of 96 meV", - index=0, - doc_iri=URIRef("https://example.com/doc/d1"), - graph=graph, - ) - state = UnitFactsState(content_unit=unit) - state.status = Status.SUCCESS - return state - - -def _atomic_tools(repair_visits: int = 1) -> AtomicToolBox: - return cast( - AtomicToolBox, - SimpleNamespace( - facts_llm_repair_visits=repair_visits, - additional_standard_namespaces=(), - validation_policy=None, - acceptance_policy=None, - ), - ) - - -@pytest.mark.anyio -async def test_delete_only_repair_is_rolled_back(monkeypatch) -> None: - """Deleting the offending triple resolves the finding by destroying it.""" - - async def deleting_render(state, tools, supplemental_ontologies=None): - for triple in list(state.content_unit.graph.triples((None, None, None))): - state.content_unit.graph.remove(triple) - state.status = Status.SUCCESS - return state - - monkeypatch.setattr(atomic_module, "render_facts", deleting_render) - state = _unit_state_with_violation() - triples_before = len(state.content_unit.graph) - - result = await _run_finding_driven_repair( - state, _atomic_tools(), [], render_attempt=1 - ) - - assert len(result.content_unit.graph) == triples_before, ( - "a delete-only repair must leave the pre-repair graph in place" - ) - assert (None, _LABEL, Literal("96 meV")) in result.content_unit.graph, ( - "the extracted value must survive a repair that only deleted" - ) - repairs = [a for a in result.attempt_log if a.kind == "llm_repair"] - assert len(repairs) == 1 - assert repairs[0].repair_delete_only is True - assert repairs[0].triple_count == triples_before - - -@pytest.mark.anyio -async def test_a_rewrite_that_shrinks_the_graph_is_kept( - monkeypatch, -) -> None: - """The guard keys on whether anything was written back, not on size. - - A rewrite that collapses two statements into one legitimately shrinks the - graph. It must not be mistaken for the delete-only pathology, or every real - repair that removes a duplicate would be reverted. - """ - - async def collapsing_rewrite(state, tools, supplemental_ontologies=None): - graph = state.content_unit.graph - good = URIRef("https://schema.org/measurement") - subject = URIRef(f"{DEFAULT_IRI}sample_1") - for s, _p, o in list(graph.triples((None, _EX_PREDICATE, None))): - graph.remove((s, _EX_PREDICATE, o)) - graph.add((s, good, o)) - # Also drop a duplicate label, so the net triple count falls while the - # render still wrote the corrected statement. - graph.remove((subject, _LABEL, Literal("sample"))) - state.status = Status.SUCCESS - return state - - monkeypatch.setattr(atomic_module, "render_facts", collapsing_rewrite) - state = _unit_state_with_violation() - triples_before = len(state.content_unit.graph) - - result = await _run_finding_driven_repair( - state, _atomic_tools(), [], render_attempt=1 - ) - - assert len(result.content_unit.graph) < triples_before - repairs = [a for a in result.attempt_log if a.kind == "llm_repair"] - assert repairs[0].repair_delete_only is False, ( - "the render wrote the corrected statement back, so this is a rewrite " - "that happens to shrink the graph, not data destruction" - ) - - -@pytest.mark.anyio -async def test_deleting_the_flagged_triple_is_still_a_deletion(monkeypatch) -> None: - """The case the previous detector structurally could not see. - - Removing the statement the finding points at makes the mandatory count - *fall*, so a guard keyed on ``mandatory_after >= mandatory_before`` scores - it as a successful repair. It is the dominant failure mode on record. - """ - - async def delete_the_flagged_triple(state, tools, supplemental_ontologies=None): - graph = state.content_unit.graph - for triple in list(graph.triples((None, _EX_PREDICATE, None))): - graph.remove(triple) - state.status = Status.SUCCESS - return state - - monkeypatch.setattr(atomic_module, "render_facts", delete_the_flagged_triple) - state = _unit_state_with_violation() - triples_before = len(state.content_unit.graph) - - result = await _run_finding_driven_repair( - state, _atomic_tools(), [], render_attempt=1 - ) - - assert len(result.content_unit.graph) == triples_before - assert (None, _EX_PREDICATE, None) in result.content_unit.graph - repairs = [a for a in result.attempt_log if a.kind == "llm_repair"] - assert repairs[0].repair_delete_only is True - - -@pytest.mark.anyio -async def test_a_growing_repair_is_never_flagged(monkeypatch) -> None: - """The guard must not fire on a repair that adds the corrected statement.""" - - async def rewriting_render(state, tools, supplemental_ontologies=None): - graph = state.content_unit.graph - good = URIRef("https://schema.org/measurement") - for s, _p, o in list(graph.triples((None, _EX_PREDICATE, None))): - graph.remove((s, _EX_PREDICATE, o)) - graph.add((s, good, o)) - graph.add((s, _LABEL, Literal("rewritten"))) - state.status = Status.SUCCESS - return state - - monkeypatch.setattr(atomic_module, "render_facts", rewriting_render) - state = _unit_state_with_violation() - - result = await _run_finding_driven_repair( - state, _atomic_tools(), [], render_attempt=1 - ) - - repairs = [a for a in result.attempt_log if a.kind == "llm_repair"] - assert repairs[0].repair_delete_only is False diff --git a/test/facts/test_shapes_catalog.py b/test/facts/test_shapes_catalog.py index 8e011164..4ff3ed9b 100644 --- a/test/facts/test_shapes_catalog.py +++ b/test/facts/test_shapes_catalog.py @@ -20,6 +20,8 @@ from ontocast.tool.triple_manager.in_memory import InMemoryTripleStoreManager from ontocast.toolbox import ToolBox +pytestmark = pytest.mark.unit + SHAPES_TTL = """ @prefix owl: . @prefix sh: . diff --git a/test/facts/test_shapes_contract.py b/test/facts/test_shapes_contract.py new file mode 100644 index 00000000..4000372a --- /dev/null +++ b/test/facts/test_shapes_contract.py @@ -0,0 +1,225 @@ +"""The shapes-derived conformance chapter: domain knowledge in, no domain +strings of our own. + +Every line of the chapter must come from the deployment's shapes graph -- +``sh:message`` verbatim where the author wrote one, a synthesized structural +line otherwise. A SPARQL constraint without a message cannot be summarized +and is omitted (warned), never guessed at. +""" + +import pytest +from rdflib import Graph + +from ontocast.prompt.shapes_contract import ( + CHAPTER_HEADING, + contract_terms, + derive_shape_requirements, + format_conformance_chapter, +) + +pytestmark = pytest.mark.unit + +SHAPES_TTL = """ +@prefix sh: . +@prefix xsd: . +@prefix qqval: . +@prefix qudt: . + +qqval:ValueShape a sh:NodeShape ; + sh:targetClass qqval:QualifiedQuantityValue ; + sh:property [ + sh:path qqval:epistemicQualifier ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:class qqval:EpistemicQualifier ; + sh:message "QualifiedQuantityValue requires exactly one epistemicQualifier." + ] , + [ + sh:path qudt:unit ; + sh:minCount 1 ; + ] ; + sh:sparql [ + sh:message "Exact values forbid bounds." ; + sh:select "SELECT $this WHERE {}" + ] . + +qqval:SilentSparqlShape a sh:NodeShape ; + sh:targetClass qqval:QuantityRange ; + sh:sparql [ + sh:select "SELECT $this WHERE {}" + ] . + +qqval:EdgeShape a sh:NodeShape ; + sh:targetSubjectsOf qudt:hasResult ; + sh:property [ + sh:path qudt:hasResult ; + sh:class qqval:QualifiedQuantityValue ; + sh:message "hasResult must point at a QualifiedQuantityValue." + ] . +""" + + +def _shapes() -> Graph: + graph = Graph() + graph.parse(data=SHAPES_TTL, format="turtle") + return graph + + +def test_messages_render_verbatim_and_synthesis_fills_gaps() -> None: + requirements = derive_shape_requirements(_shapes()) + by_anchor = {r.anchor: r for r in requirements} + value = by_anchor["qqval:QualifiedQuantityValue"] + assert ( + "QualifiedQuantityValue requires exactly one epistemicQualifier." in value.lines + ) + # The message-less qudt:unit property gets a synthesized structural line. + assert any("qudt:unit" in line and "at least 1" in line for line in value.lines) + # The SPARQL constraint's message is rendered. + assert "Exact values forbid bounds." in value.lines + + +def test_message_less_sparql_constraint_is_omitted() -> None: + requirements = derive_shape_requirements(_shapes()) + anchors = [r.anchor for r in requirements] + # QuantityRange's only constraint is an unrenderable SPARQL one. + assert "qqval:QuantityRange" not in anchors + + +def test_target_subjects_of_shapes_render() -> None: + requirements = derive_shape_requirements(_shapes()) + edge = next(r for r in requirements if "subjects of" in r.anchor) + assert "hasResult must point at a QualifiedQuantityValue." in edge.lines + + +def test_chapter_caps_and_notes_truncation() -> None: + requirements = derive_shape_requirements(_shapes()) + full = format_conformance_chapter(requirements, max_lines=60) + assert full.startswith(CHAPTER_HEADING) + assert "Never mint a placeholder node" in full + capped = format_conformance_chapter(requirements, max_lines=1) + assert "(Further rules exist" in capped + + +def test_empty_shapes_render_nothing() -> None: + assert format_conformance_chapter([]) == "" + assert derive_shape_requirements(Graph()) == [] + + +def test_contract_terms_cover_targets_paths_and_classes() -> None: + terms = set(contract_terms(_shapes())) + assert "https://example.org/qqval#QualifiedQuantityValue" in terms + assert "https://example.org/qqval#epistemicQualifier" in terms + assert "https://example.org/qqval#EpistemicQualifier" in terms + assert "https://example.org/qudt#hasResult" in terms + + +def test_contract_terms_are_exempt_from_unknown_term() -> None: + """The exemption guardrail: the validator must never order removal of a + term the conformance chapter required.""" + from rdflib import Literal, URIRef + + from ontocast.onto.model import FactsUnitFindingKind + from ontocast.onto.rdfgraph import RDFGraph + from ontocast.tool.facts_validation import ( + ValidationPolicy, + collect_unit_findings, + ) + + subject = URIRef("https://example.com/doc/d1/value_1") + qualifier = URIRef("https://vocab.test/qqval#epistemicQualifier") + graph = RDFGraph() + graph.add((subject, qualifier, URIRef("https://vocab.test/qqval#Exact"))) + graph.add( + ( + subject, + URIRef("http://www.w3.org/2000/01/rdf-schema#label"), + Literal("v"), + ) + ) + # The retrieved context declares the qqval namespace (another term of it + # was retrieved) but NOT epistemicQualifier -- the exact situation where + # UNKNOWN_TERM would order removal of what the chapter required. + ontology = RDFGraph() + ontology.add( + ( + URIRef("https://vocab.test/qqval#QualifiedQuantityValue"), + URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + URIRef("http://www.w3.org/2002/07/owl#Class"), + ) + ) + + def unknown_terms(policy): + return { + f.predicate + for f in collect_unit_findings( + graph=graph, + ontology_graph=ontology, + quarantined=[], + extraction_text="a value", + fact_namespaces=["https://example.com/doc/d1/"], + policy=policy, + ) + if f.kind == FactsUnitFindingKind.UNKNOWN_TERM + } + + assert str(qualifier) in unknown_terms(ValidationPolicy()) + assert str(qualifier) not in unknown_terms( + ValidationPolicy(contract_exempt_terms=(str(qualifier),)) + ) + + +def test_per_shape_terms_are_extracted() -> None: + requirements = derive_shape_requirements(_shapes()) + by_anchor = {r.anchor: r for r in requirements} + value_terms = set(by_anchor["qqval:QualifiedQuantityValue"].terms) + assert "https://example.org/qqval#QualifiedQuantityValue" in value_terms + assert "https://example.org/qqval#epistemicQualifier" in value_terms + assert "https://example.org/qudt#unit" in value_terms + edge = next(r for r in requirements if "subjects of" in r.anchor) + assert "https://example.org/qudt#hasResult" in set(edge.terms) + + +def test_select_requirements_joins_on_context_terms() -> None: + from ontocast.prompt.shapes_contract import select_requirements + + requirements = derive_shape_requirements(_shapes()) + # A context carrying only the edge predicate selects only the edge shape. + selected = select_requirements(requirements, {"https://example.org/qudt#hasResult"}) + assert [r.anchor for r in selected] == ["subjects of qudt:hasResult"] + # A superclass IRI appearing as a snapshot *object* (schema closure) + # joins the shape targeting it. + selected = select_requirements( + requirements, {"https://example.org/qqval#QualifiedQuantityValue"} + ) + anchors = {r.anchor for r in selected} + assert "qqval:QualifiedQuantityValue" in anchors + # Order is preserved and the empty context selects nothing. + assert select_requirements(requirements, set()) == [] + assert select_requirements(requirements, {"https://elsewhere/#X"}) == [] + + +def test_shapes_catalog_selection_modes() -> None: + """needs_selection flips on the cap; selected chapters are cached.""" + from ontocast.onto.rdfgraph import RDFGraph + from ontocast.tool.shapes_catalog import ShapesCatalog + + catalog = ShapesCatalog() + graph = RDFGraph() + graph.parse(data=SHAPES_TTL, format="turtle") + catalog._graph = graph # materialized state, unit-test shortcut + + # 5 renderable rule lines in the fixture: under a cap of 60, over 2. + assert catalog.needs_selection(max_lines=60) is False + assert catalog.needs_selection(max_lines=2) is True + + full = catalog.conformance_chapter(max_lines=60) + assert "qqval:QualifiedQuantityValue" in full + + context = {"https://example.org/qudt#hasResult"} + selected = catalog.selected_chapter(context, max_lines=60) + assert "hasResult must point at a QualifiedQuantityValue." in selected + assert "epistemicQualifier" not in selected + # Identical selection -> cached object. + assert catalog.selected_chapter(set(context), max_lines=60) is selected + # Empty context -> empty chapter. + assert catalog.selected_chapter(set(), max_lines=60) == "" diff --git a/test/facts/test_shapes_selection.py b/test/facts/test_shapes_selection.py new file mode 100644 index 00000000..57e35b1c --- /dev/null +++ b/test/facts/test_shapes_selection.py @@ -0,0 +1,131 @@ +"""Per-unit conformance-chapter selection: the context join in the loop. + +Selection happens where the snapshot exists -- inside the unit loop, after +context resolution -- because the fan-out builds unit states before any +retrieval has run. The flag on the state is what keeps the loop from +touching the shapes catalog for deployments (and test doubles) that never +asked for selection. +""" + +from types import SimpleNamespace +from typing import cast + +import pytest +from rdflib import RDF, URIRef + +from ontocast.onto.content_unit import ContentUnit +from ontocast.onto.enum import OntologyAssemblyMode +from ontocast.onto.ontology_snapshot import OntologySnapshot +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.onto.unit_states import UnitFactsState +from ontocast.stategraph.atomic import _select_conformance_chapter +from ontocast.toolbox import ToolBox + +pytestmark = pytest.mark.unit + +_TARGET = URIRef("https://vocab.test/qqval#QualifiedQuantityValue") + + +def _unit_state(*, pending: bool, with_context: bool) -> UnitFactsState: + graph = RDFGraph() + if with_context: + graph.add( + ( + _TARGET, + RDF.type, + URIRef("http://www.w3.org/2002/07/owl#Class"), + ) + ) + snapshot = OntologySnapshot.from_graph( + graph, + source_iris=[], + assembly_mode=OntologyAssemblyMode.SELECTED_VECTOR_SEARCH_ENSEMBLE, + title="t", + description="d", + ) + unit = ContentUnit( + text="a value of 96 meV", + index=0, + doc_iri=URIRef("https://example.com/doc/d1"), + graph=RDFGraph(), + ) + return UnitFactsState( + content_unit=unit, + ontology_snapshot=snapshot, + conformance_selection_pending=pending, + ) + + +def test_selection_joins_snapshot_terms() -> None: + seen: dict = {} + + def chapter_for(context_terms): + seen["terms"] = set(context_terms) + return "# CONFORMANCE REQUIREMENTS\n- selected" + + tools = cast(ToolBox, SimpleNamespace(shapes_chapter_for_context=chapter_for)) + state = _unit_state(pending=True, with_context=True) + _select_conformance_chapter(state, tools) + + assert str(_TARGET) in seen["terms"] + assert state.conformance_chapter.startswith("# CONFORMANCE REQUIREMENTS") + assert state.conformance_selection_pending is False + + +def test_empty_snapshot_selects_nothing() -> None: + tools = cast( + ToolBox, + SimpleNamespace(shapes_chapter_for_context=lambda terms: ""), + ) + state = _unit_state(pending=True, with_context=False) + _select_conformance_chapter(state, tools) + assert state.conformance_chapter == "" + assert state.conformance_selection_pending is False + + +def _toolbox_stub(mode: str, *, oversized: bool) -> ToolBox: + """The real ToolBox methods over stubbed config + catalog state.""" + catalog = SimpleNamespace( + prompt_contract_terms=lambda *, max_lines: ("https://vocab.test/t",), + needs_selection=lambda *, max_lines: oversized, + conformance_chapter=lambda *, max_lines: "# FULL CHAPTER", + selected_chapter=lambda terms, *, max_lines: "# SELECTED", + ) + stub = SimpleNamespace( + config=SimpleNamespace( + tool_config=SimpleNamespace( + facts_validation=SimpleNamespace( + shapes_prompt_contract=mode, + shapes_prompt_max_lines=60, + ) + ) + ), + shapes_catalog=catalog, + ) + stub.shapes_prompt_contract = ToolBox.shapes_prompt_contract.__get__(stub) + stub.shapes_chapter_for_context = ToolBox.shapes_chapter_for_context.__get__(stub) + return cast(ToolBox, stub) + + +@pytest.mark.parametrize( + ("mode", "oversized", "expect_chapter", "expect_pending"), + [ + ("off", False, "", False), + ("full", True, "# FULL CHAPTER", False), + ("context", False, "", True), + ("auto", False, "# FULL CHAPTER", False), + ("auto", True, "", True), + ], +) +def test_mode_resolution(mode, oversized, expect_chapter, expect_pending) -> None: + tools = _toolbox_stub(mode, oversized=oversized) + chapter, terms, pending = tools.shapes_prompt_contract() + assert chapter == expect_chapter + assert pending is expect_pending + # Exemption terms are full-catalog in every mode that has shapes. + assert terms == ((), ("https://vocab.test/t",))[mode != "off"] + + +def test_chapter_for_context_delegates() -> None: + tools = _toolbox_stub("context", oversized=True) + assert tools.shapes_chapter_for_context({"x"}) == "# SELECTED" diff --git a/test/facts/test_suggestions_lifecycle.py b/test/facts/test_suggestions_lifecycle.py index 48f86453..04295ffb 100644 --- a/test/facts/test_suggestions_lifecycle.py +++ b/test/facts/test_suggestions_lifecycle.py @@ -16,7 +16,7 @@ Two writers had to be fixed, because the loop can reach the repair with stale suggestions by two different routes: -* a render consumes them (``render_facts_update``), and +* a render consumes them (the facts render), and * the critic *accepts* on a later attempt of the same render, after an external-evidence search, with no render in between to consume them. """ @@ -76,6 +76,7 @@ def _tools() -> AtomicToolBox: web_grounding_enabled_for_node=lambda _node: False, object_property_literal_check=True, property_alias_min_ratio=0.85, + catalog_terms=lambda: set(), code_predicates=(), citation_vocabulary={}, quantity_fallback_vocabulary=None, @@ -133,42 +134,58 @@ async def test_a_rejecting_critic_records_suggestions( @pytest.mark.anyio -async def test_an_accepting_critic_leaves_no_suggestions_for_the_repair( +async def test_an_accepting_critic_keeps_its_own_fixes( monkeypatch: pytest.MonkeyPatch, ) -> None: - """The exit path that has no render between rejection and repair. - - A critic that rejects, requests an evidence search, and then accepts on the - retry reaches ``_run_finding_driven_repair`` directly. Without this reset - the repair render inherits the rejected attempt's suggestions. + """Accepting means "no defect worth another render", not "no critique". + + This used to assert the opposite -- that acceptance cleared the fixes -- + which discarded the critique of every accepted render. Since a ``REMOVE`` + fix can never make a render blocking, and most renders are accepted, that + was the bulk of everything the critic produced. The fixes now survive so + the repair lane can compile the mechanical ones with no LLM call and record + the rest as residual. + + The stale-suggestion hazard the old reset guarded is still covered: the + accepted attempt's own fixes replace the rejected attempt's, so no repair + ever sees a superseded critique. """ state = _unit_state() _stub_critique(monkeypatch, _critique(success=False, score=55)) state = await criticise_facts_module.criticise_facts(state, _tools()) - assert state.suggestions.actionable_fixes + rejected_fixes = list(state.suggestions.actionable_fixes) + assert rejected_fixes - _stub_critique(monkeypatch, _critique(success=True, score=98, severity="minor")) + _stub_critique( + monkeypatch, + _critique(success=True, score=98, severity="minor"), + ) state = await criticise_facts_module.criticise_facts(state, _tools()) assert state.status == Status.SUCCESS - assert not state.suggestions.actionable_fixes, ( - "an accepting critic has no outstanding requests; the repair render " - "must not inherit the rejected attempt's suggestions" + assert state.suggestions.actionable_fixes, ( + "an accepting critic's fixes must survive for the repair lane to " + "compile; discarding them threw away most of the critic's output" ) - assert not state.suggestions.systemic_critique_summary + assert all( + fix + in _critique(success=True, score=98, severity="minor").actionable_triple_fixes + for fix in state.suggestions.actionable_fixes + ), "the accepted attempt's fixes must replace the rejected attempt's" -def test_empty_suggestions_render_no_improvement_instruction() -> None: - """The reset must actually silence the improvement block, not just empty it. +def test_facts_stage_has_no_suggestions_template() -> None: + """The facts render has no improvement slot, so the stage must be refused. - ``render_suggestions_prompt`` returns the whole "advisory ... think - independently ... proactively fix additional problems" template whenever - either field is non-empty, so a reset that left one populated would keep - the contradiction alive. + The facts prompt template carried ``{improvement_instruction}`` and + ``{fact_chapter}`` slots that only ever received ``""`` -- the facts + correction pass was removed when the critic gained its own compiled patch. + Refusing the stage keeps a caller from re-introducing a template that + nothing renders. """ from ontocast.agent.common import render_suggestions_prompt from ontocast.onto.enum import WorkflowNode - rendered = render_suggestions_prompt(Suggestions(), WorkflowNode.TEXT_TO_FACTS) - assert rendered == "" + with pytest.raises(ValueError, match="not supported"): + render_suggestions_prompt(Suggestions(), WorkflowNode.TEXT_TO_FACTS) diff --git a/test/facts/test_term_policy.py b/test/facts/test_term_policy.py index 1993f915..bd49477a 100644 --- a/test/facts/test_term_policy.py +++ b/test/facts/test_term_policy.py @@ -9,6 +9,7 @@ degenerate bound pairs. """ +import pytest from rdflib import URIRef from ontocast.onto.model import FactsUnitFindingKind, format_findings_for_prompt @@ -24,6 +25,8 @@ ) from ontocast.util.graph_metrics import facts_graph_shape_metrics +pytestmark = pytest.mark.unit + QUDT = "http://qudt.org/schema/qudt/" QQ = "https://x.org/qqval#" CD = "https://growgraph.dev/facts#" @@ -72,11 +75,11 @@ def _graph(data: str) -> RDFGraph: def _unit_findings(facts: RDFGraph, ontology: RDFGraph, **kwargs): + kwargs.setdefault("extraction_text", "") return collect_unit_findings( graph=facts, ontology_graph=ontology, quarantined=[], - extraction_text="", fact_namespaces=[CD], coverage_limit=0, **kwargs, @@ -189,6 +192,33 @@ def test_open_namespace_is_no_contradiction(self) -> None: ) assert contradictions == [] + def test_a_term_the_snapshot_omitted_is_not_a_contradiction(self) -> None: + """The gate sees the union of retrieved snapshots, not the catalog. + + A shape may require a property the catalog declares but no unit + retrieved. The unit validator judges unknown terms against the whole + catalog, so the gate's check must too, or it reports a contradiction + no unit ever raised. + """ + ontology = _graph(ONTOLOGY + "\nqudt:ucumCode a owl:DatatypeProperty .") + contradictions = shacl_catalog_contradictions( + _graph(self.SHAPES), + ontology, + catalog_terms={f"{QUDT}numericValue"}, + ) + assert contradictions == [] + + def test_contract_exemption_clears_the_contradiction(self) -> None: + """Shape-required terms are exempt from UNKNOWN_TERM per unit, so + the same exemption must apply here.""" + ontology = _graph(ONTOLOGY + "\nqudt:ucumCode a owl:DatatypeProperty .") + contradictions = shacl_catalog_contradictions( + _graph(self.SHAPES), + ontology, + policy=ValidationPolicy(contract_exempt_terms=(f"{QUDT}numericValue",)), + ) + assert contradictions == [] + class TestLabelOnlyNumber: def test_unit_bearing_node_with_label_number_is_mandatory(self) -> None: @@ -321,3 +351,100 @@ def test_components_and_isolated(self) -> None: assert metrics.isolated_nodes == 2 assert metrics.components == 4 assert metrics.edges == 1 + + +class TestUnitSymbolCaseMismatch: + """Two catalog units whose symbols differ only by letter case.""" + + ONTOLOGY = f""" + @prefix owl: . + @prefix rdfs: . + @prefix xsd: . + @prefix qudt: <{QUDT}> . + @prefix qq: <{QQ}> . + + qq:QualifiedValue a owl:Class . + qq:Unit a owl:Class . + qudt:unit a owl:ObjectProperty ; rdfs:range qq:Unit . + qudt:numericValue a owl:DatatypeProperty ; rdfs:range xsd:decimal . + qq:small a qq:Unit ; rdfs:label "small unit" ; qudt:symbol "xU" . + qq:big a qq:Unit ; rdfs:label "big unit" ; qudt:symbol "XU" . + """ + + def _facts(self, unit: str) -> RDFGraph: + return _graph( + f""" + @prefix xsd: . + @prefix qudt: <{QUDT}> . + @prefix qq: <{QQ}> . + @prefix cd: <{CD}> . + cd:v1 a qq:QualifiedValue ; + qudt:numericValue "96"^^xsd:decimal ; + qudt:unit qq:{unit} . + """ + ) + + def _findings(self, unit: str, text: str): + return [ + item + for item in _unit_findings( + self._facts(unit), + _graph(self.ONTOLOGY), + policy=ValidationPolicy(quantity_fallback_vocabulary=VOCAB), + extraction_text=text, + ) + if item.kind == FactsUnitFindingKind.UNIT_SYMBOL_CASE_MISMATCH + ] + + def test_case_folded_symbol_match_is_mandatory_and_suggests_the_exact_unit( + self, + ) -> None: + (finding,) = self._findings("big", "a shift of 96 xU was measured") + assert finding.mandatory + assert finding.value == "xU" + assert finding.suggestions == [f"{QQ}small"] + assert "case" in finding.message + + def test_exact_symbol_match_is_silent(self) -> None: + assert self._findings("big", "a shift of 96 XU was measured") == [] + + def test_value_absent_from_the_text_is_silent(self) -> None: + assert self._findings("big", "a shift of 12 xU was measured") == [] + + def test_a_case_variant_with_no_competing_unit_is_a_spelling_not_an_error( + self, + ) -> None: + """Only one unit declares the symbol: the text merely spelt it in + another case, and there is no other unit the value could belong to.""" + ontology = _graph( + self.ONTOLOGY.replace( + 'qq:small a qq:Unit ; rdfs:label "small unit" ; qudt:symbol "xU" .', "" + ) + ) + findings = [ + item + for item in _unit_findings( + self._facts("big"), + ontology, + policy=ValidationPolicy(quantity_fallback_vocabulary=VOCAB), + extraction_text="a shift of 96 xU was measured", + ) + if item.kind == FactsUnitFindingKind.UNIT_SYMBOL_CASE_MISMATCH + ] + assert findings == [] + + def test_a_label_spelt_with_a_capital_is_not_a_symbol(self) -> None: + ontology = _graph( + self.ONTOLOGY.replace('qudt:symbol "XU"', 'rdfs:altLabel "XU"') + ) + findings = [ + item + for item in _unit_findings( + self._facts("big"), + ontology, + policy=ValidationPolicy(quantity_fallback_vocabulary=VOCAB), + extraction_text="a shift of 96 xU was measured", + ) + if item.kind == FactsUnitFindingKind.UNIT_SYMBOL_CASE_MISMATCH + ] + assert findings == [] diff --git a/test/facts/test_unit_findings.py b/test/facts/test_unit_findings.py index d1fe544a..263879fa 100644 --- a/test/facts/test_unit_findings.py +++ b/test/facts/test_unit_findings.py @@ -1,5 +1,6 @@ """Tests for deterministic literal-object checks on rendered facts graphs.""" +import pytest from rdflib import Literal, Namespace from rdflib.namespace import OWL, RDF, RDFS, XSD @@ -10,6 +11,8 @@ partition_object_property_literal_triples, ) +pytestmark = pytest.mark.unit + QUDT = Namespace("http://qudt.org/schema/qudt/") UNIT = Namespace("http://qudt.org/vocab/unit/") CD = Namespace("https://growgraph.dev/facts/") @@ -109,3 +112,239 @@ def test_validate_predicates_accepts_literal_on_datatype_range() -> None: result = RDFGraphConnectivityValidator(graph).validate_predicates() assert result.domain_range_consistent + + +# --------------------------------------------------------------------------- +# UNKNOWN_TERM against the full catalog, not the unit's retrieved snapshot +# --------------------------------------------------------------------------- + + +#: A catalog namespace of its own: ``example.org`` is the placeholder +#: namespace the validator forbids outright, which is a different finding. +MS = Namespace("https://vocab.test/matsci#") + + +def _site_snapshot() -> RDFGraph: + """A retrieved snapshot that carries the B-site property but not the A-site one.""" + onto = RDFGraph() + onto.bind("ms", MS) + onto.add((MS.Perovskite, RDF.type, OWL.Class)) + onto.add((MS.hasBSiteComponent, RDF.type, OWL.ObjectProperty)) + onto.add((MS.hasBSiteComponent, RDFS.domain, MS.Perovskite)) + return onto + + +def _site_facts() -> RDFGraph: + facts = RDFGraph() + facts.bind("ms", MS) + facts.add((CD.p1, RDF.type, MS.Perovskite)) + facts.add((CD.p1, MS.hasASiteComponent, CD.cs)) + facts.add((CD.p1, MS.hasBSiteComponent, CD.pb)) + return facts + + +def _unknown_terms(findings) -> dict[str, list[str]]: + from ontocast.onto.model import FactsUnitFindingKind + + return { + finding.predicate: finding.suggestions + for finding in findings + if finding.kind == FactsUnitFindingKind.UNKNOWN_TERM + } + + +def test_a_term_the_full_catalog_declares_is_not_unknown() -> None: + """The snapshot is a retrieved subset; a term it omitted is still a term. + + Reporting it unknown -- with the look-alike the snapshot does carry as the + suggested replacement -- hands the critic the very substitution the + LLM-free alias repair refuses to make. + """ + from ontocast.tool.facts_validation import collect_unit_findings + + def unknown(full_catalog_terms): + return _unknown_terms( + collect_unit_findings( + graph=_site_facts(), + ontology_graph=_site_snapshot(), + quarantined=[], + extraction_text="", + fact_namespaces=[str(CD)], + coverage_limit=0, + full_catalog_terms=full_catalog_terms, + ) + ) + + assert str(MS.hasASiteComponent) in unknown(None), ( + "without the catalog the snapshot is all there is to check against" + ) + assert unknown({str(MS.hasASiteComponent)}) == {} + + +def test_a_genuinely_unknown_term_is_never_offered_a_token_swap() -> None: + """Suggestions qualify by token containment, exactly as the repair does. + + ``hasASiteComponent`` and ``hasBSiteComponent`` differ in one token and + score high on string similarity; neither contains the other, so the + B-site property must not be suggested for the A-site one. + """ + from ontocast.tool.facts_validation import collect_unit_findings + + unknown = _unknown_terms( + collect_unit_findings( + graph=_site_facts(), + ontology_graph=_site_snapshot(), + quarantined=[], + extraction_text="", + fact_namespaces=[str(CD)], + coverage_limit=0, + ) + ) + + assert unknown[str(MS.hasASiteComponent)] == [] + + +def test_a_containing_candidate_is_still_suggested() -> None: + """``value`` is contained in ``numericValue``: that is a near-miss.""" + from ontocast.tool.facts_validation import collect_unit_findings + + onto = RDFGraph() + onto.add((MS.numericValue, RDF.type, OWL.DatatypeProperty)) + facts = RDFGraph() + facts.add((CD.v1, MS.value, Literal("3"))) + facts.add((CD.v2, MS.value, Literal("4"))) + + unknown = _unknown_terms( + collect_unit_findings( + graph=facts, + ontology_graph=onto, + quarantined=[], + extraction_text="", + fact_namespaces=[str(CD)], + coverage_limit=0, + ) + ) + + assert unknown[str(MS.value)] == [str(MS.numericValue)] + + +# --------------------------------------------------------------------------- +# Numeric coverage: measurements and bare numbers as separate findings +# --------------------------------------------------------------------------- + + +def _coverage(text: str, graph: RDFGraph, **kwargs): + from ontocast.onto.model import FactsUnitFindingKind + from ontocast.tool.facts_validation import collect_unit_findings + + return { + finding.facet: finding + for finding in collect_unit_findings( + graph=graph, + ontology_graph=None, + quarantined=[], + extraction_text=text, + fact_namespaces=[str(CD)], + **kwargs, + ) + if finding.kind == FactsUnitFindingKind.NUMERIC_COVERAGE + } + + +_TEXT = "a shift of 96 meV was seen in 3 samples at 77 K" + + +def test_coverage_is_split_into_measurements_and_bare_numbers() -> None: + findings = _coverage(_TEXT, RDFGraph()) + + assert set(findings) == {"measurements", "unclassified"} + measurements = findings["measurements"] + assert measurements.value == "96, 77" + assert "96 meV" in measurements.message and "77 K" in measurements.message + assert "a shift of 96 meV" in measurements.message, "context is carried" + assert findings["unclassified"].value == "3" + assert not measurements.mandatory and not findings["unclassified"].mandatory + + +@pytest.mark.parametrize( + ("mode", "expected"), + [ + ("off", (False, False)), + ("measurements", (True, False)), + ("all", (True, True)), + (False, (False, False)), + (True, (True, True)), + ], +) +def test_coverage_mode_sets_which_finding_is_mandatory(mode, expected) -> None: + findings = _coverage(_TEXT, RDFGraph(), coverage_mandatory=mode) + + assert ( + findings["measurements"].mandatory, + findings["unclassified"].mandatory, + ) == expected + + +def test_a_label_number_no_longer_silences_the_measurement_finding() -> None: + """The placeholder-with-a-label answer to a coverage finding.""" + graph = RDFGraph() + graph.add((CD.ignored_token_1, RDFS.label, Literal("96"))) + + findings = _coverage(_TEXT, graph) + + assert "96" in findings["measurements"].value + + +def test_a_bare_extracted_value_does_not_clear_the_measurement_finding() -> None: + """A number without its unit is not the measurement the text stated.""" + graph = RDFGraph() + graph.add((CD.v1, QUDT.numericValue, Literal("96", datatype=XSD.decimal))) + graph.add((CD.v2, QUDT.numericValue, Literal("77", datatype=XSD.decimal))) + + findings = _coverage(_TEXT, graph) + + assert findings["measurements"].value == "96, 77" + assert findings["unclassified"].value == "3" + + +def test_a_structured_quantity_with_matching_unit_clears_the_finding() -> None: + """Coverage keys the measurement lane on ``(number, unit)``.""" + from ontocast.onto.model import FactsUnitFindingKind + from ontocast.prompt.facts_guidelines import DEFAULT_QUANTITY_FALLBACK_VOCABULARY + from ontocast.tool.facts_validation import collect_unit_findings + from ontocast.tool.facts_validation.terms import ValidationPolicy + + ontology = RDFGraph() + ontology.bind("qudt", QUDT) + ontology.add((QUDT.unit, RDF.type, OWL.ObjectProperty)) + ontology.add((QUDT.unit, RDFS.range, QUDT.Unit)) + ontology.add((QUDT.Unit, RDF.type, OWL.Class)) + ontology.add((UNIT.MilliEV, RDF.type, QUDT.Unit)) + ontology.add((UNIT.MilliEV, RDFS.label, Literal("meV"))) + ontology.add((UNIT.K, RDF.type, QUDT.Unit)) + ontology.add((UNIT.K, RDFS.label, Literal("K"))) + + graph = RDFGraph() + graph.bind("qudt", QUDT) + graph.add((CD.v1, QUDT.numericValue, Literal("96", datatype=XSD.decimal))) + graph.add((CD.v1, QUDT.unit, UNIT.MilliEV)) + graph.add((CD.v2, QUDT.numericValue, Literal("77", datatype=XSD.decimal))) + graph.add((CD.v2, QUDT.unit, UNIT.K)) + + findings = { + finding.facet: finding + for finding in collect_unit_findings( + graph=graph, + ontology_graph=ontology, + quarantined=[], + extraction_text=_TEXT, + fact_namespaces=[str(CD)], + policy=ValidationPolicy( + quantity_fallback_vocabulary=dict(DEFAULT_QUANTITY_FALLBACK_VOCABULARY) + ), + ) + if finding.kind == FactsUnitFindingKind.NUMERIC_COVERAGE + } + + assert "measurements" not in findings + assert findings["unclassified"].value == "3" diff --git a/test/ontology/test_access.py b/test/ontology/test_access.py index 80b1b56e..1b0a7a54 100644 --- a/test/ontology/test_access.py +++ b/test/ontology/test_access.py @@ -1,5 +1,6 @@ """Tests for ontocast.onto.ontology_access.""" +import pytest from rdflib import Namespace, URIRef from ontocast.onto.constants import WELL_KNOWN_PREFIXES @@ -19,6 +20,8 @@ from ontocast.onto.unit_states import UnitFactsState, UnitOntologyState from test.snapshot_helpers import empty_snapshot, snapshot_from_ontology +pytestmark = pytest.mark.unit + def _source_unit() -> SourceUnit: return SourceUnit( diff --git a/test/ontology/test_condense.py b/test/ontology/test_condense.py index bf1c4b57..08ec6fe7 100644 --- a/test/ontology/test_condense.py +++ b/test/ontology/test_condense.py @@ -9,6 +9,7 @@ import logging +import pytest from rdflib import Literal, URIRef from rdflib.namespace import DCTERMS, OWL, RDF, RDFS, SKOS @@ -20,6 +21,8 @@ ) from ontocast.onto.rdfgraph import RDFGraph +pytestmark = pytest.mark.unit + EX = "https://example.org/onto#" diff --git a/test/ontology/test_context_mode.py b/test/ontology/test_context_mode.py index 635863d8..b89bd697 100644 --- a/test/ontology/test_context_mode.py +++ b/test/ontology/test_context_mode.py @@ -22,6 +22,8 @@ from ontocast.tool.chunk.proposition import split_proposition_windows from ontocast.toolbox import ToolBox +pytestmark = pytest.mark.unit + @pytest.mark.anyio async def test_full_ttl_does_not_invoke_ensemble_path(monkeypatch) -> None: diff --git a/test/ontology/test_critic_patch_delta.py b/test/ontology/test_critic_patch_delta.py new file mode 100644 index 00000000..80a3da58 --- /dev/null +++ b/test/ontology/test_critic_patch_delta.py @@ -0,0 +1,119 @@ +"""An ontology unit's product is its delta, not its scratchpad graph. + +``build_delta`` replays the unit's recorded GraphUpdates onto a fresh copy of +the snapshot and diffs. A critic patch written straight into ``working_graph`` +would therefore be reported by no delta and dropped at reduce time -- applied, +visible for the rest of the loop, and absent from the output. These tests pin +the channel that avoids that, and the rollback that has to undo both halves. +""" + +import pytest +from rdflib import RDF, RDFS, Literal, Namespace, URIRef + +from ontocast.onto.ontology_snapshot import OntologySnapshot +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.onto.sparql_models import GraphUpdate, TripleOp +from ontocast.onto.unit_states import UnitOntologyState + +pytestmark = pytest.mark.unit + +ONTO = Namespace("https://growgraph.dev/ontologies/demo#") + + +def _snapshot_graph() -> RDFGraph: + graph = RDFGraph() + graph.bind("demo", ONTO) + graph.add((ONTO.Material, RDF.type, RDFS.Class)) + graph.add((ONTO.Material, RDFS.label, Literal("Material"))) + return graph + + +def _state() -> UnitOntologyState: + from ontocast.onto.content_unit import ContentUnit + + snapshot = OntologySnapshot(graph=_snapshot_graph()) + state = UnitOntologyState( + content_unit=ContentUnit( + text="demo", index=0, doc_iri=URIRef("https://example.org/doc") + ), + ontology_snapshot=snapshot, + ) + state.working_graph = snapshot.graph.copy() + return state + + +def _insert(*triples) -> GraphUpdate: + graph = RDFGraph() + for triple in triples: + graph.add(triple) + return GraphUpdate(triple_operations=[TripleOp(type="insert", graph=graph)]) + + +def test_a_patch_reaches_the_delta_not_just_the_scratchpad() -> None: + """The whole hazard: applied in the loop, present in the output.""" + state = _state() + assert state.apply_patch(_insert((ONTO.Alloy, RDF.type, RDFS.Class))) is True + + assert (ONTO.Alloy, RDF.type, RDFS.Class) in state.working_graph + assert (ONTO.Alloy, RDF.type, RDFS.Class) in state.build_delta().inserts + assert state.all_updates, "the patch must be recorded for the reduce step" + + +def test_a_rolled_back_patch_leaves_neither_the_graph_nor_the_delta() -> None: + """Restoring the graph alone would let the delta replay the patch back in.""" + state = _state() + token = state.snapshot_for_rollback() + state.apply_patch(_insert((ONTO.Alloy, RDF.type, RDFS.Class))) + + state.restore(token) + + assert (ONTO.Alloy, RDF.type, RDFS.Class) not in state.working_graph + assert (ONTO.Alloy, RDF.type, RDFS.Class) not in state.build_delta().inserts + assert state.all_updates == [] + + +def test_a_patch_over_the_size_backstop_is_refused_with_the_graph_intact() -> None: + """A refusal must not half-apply, and must not leave a pending update. + + Otherwise ``all_updates`` would replay into a delta the working graph never + held -- the same silent divergence, arriving by the other direction. + """ + state = _state() + state.ontology_max_triples = 2 + before = set(state.working_graph) + + applied = state.apply_patch( + _insert( + (ONTO.Alloy, RDF.type, RDFS.Class), + (ONTO.Ceramic, RDF.type, RDFS.Class), + (ONTO.Polymer, RDF.type, RDFS.Class), + ) + ) + + assert applied is False + assert set(state.working_graph) == before + assert state.all_updates == [] + assert ( + state.build_delta().inserts == RDFGraph() + or len(state.build_delta().inserts) == 0 + ) + + +def test_the_product_count_measures_the_delta_not_the_working_graph() -> None: + """The working graph is snapshot + delta, so its size barely moves. + + A rollback rule keyed on it would never fire. + """ + state = _state() + assert state.product_triple_count() == 0 + + state.apply_patch(_insert((ONTO.Alloy, RDF.type, RDFS.Class))) + + assert state.product_triple_count() == 1 + assert len(state.working_graph) == len(_snapshot_graph()) + 1 + + +def test_the_patch_target_is_the_scratchpad_graph() -> None: + """Ids are resolved against what the critic was shown.""" + state = _state() + assert state.patch_target_graph() is state.working_graph diff --git a/test/ontology/test_identity.py b/test/ontology/test_identity.py index 784c5594..4a25498f 100644 --- a/test/ontology/test_identity.py +++ b/test/ontology/test_identity.py @@ -9,6 +9,8 @@ from ontocast.onto.util import derive_ontology_id, normalize_ontology_iri from ontocast.tool.ontology_manager import OntologyManager +pytestmark = pytest.mark.unit + def test_derive_ontology_id_skos_conventional() -> None: assert derive_ontology_id("http://www.w3.org/2004/02/skos/core#") == "skos" diff --git a/test/ontology/test_lineage_refresh.py b/test/ontology/test_lineage_refresh.py index 88fe08e9..883f6ec5 100644 --- a/test/ontology/test_lineage_refresh.py +++ b/test/ontology/test_lineage_refresh.py @@ -1,6 +1,7 @@ from datetime import datetime from typing import cast +import pytest from rdflib import DCTERMS, OWL, RDF, XSD, Literal, URIRef from ontocast.agent.normalize_ontology import normalize_ontology_units @@ -10,6 +11,8 @@ from ontocast.onto.rdfgraph import RDFGraph from ontocast.toolbox import ToolBox +pytestmark = pytest.mark.unit + def _make_base_ontology() -> Ontology: base_iri = URIRef("https://example.org/onto") diff --git a/test/ontology/test_loop_telemetry.py b/test/ontology/test_loop_telemetry.py index 27ccd124..e2fd83ad 100644 --- a/test/ontology/test_loop_telemetry.py +++ b/test/ontology/test_loop_telemetry.py @@ -33,6 +33,7 @@ from ontocast.stategraph.context_resolver import UnitOntologyContext from ontocast.stategraph.unit_context import UnitLoopContext from ontocast.tool.atomic import AtomicToolBox +from ontocast.tool.facts_validation import CriticPatchPolicy, FactsAcceptancePolicy from ontocast.tool.ontology_manager import OntologyManager from ontocast.toolbox import ToolBox @@ -80,6 +81,16 @@ def _tools() -> AtomicToolBox: SimpleNamespace( get_llm_tool=_llm_tool, web_grounding_enabled_for_node=lambda _node: False, + ontology_acceptance_policy=FactsAcceptancePolicy( + blocking_finding_kinds=frozenset( + { + "foreign_delete", + "foreign_namespace", + "subclass_cycle", + "role_confusion", + } + ) + ), ), ) @@ -131,10 +142,13 @@ async def test_a_rejection_is_recorded_with_its_evidence( ) -> None: _stub(monkeypatch, _critique(success=False, score=72)) state = _unit_state() + # FOREIGN_DELETE, not MISSING_LABEL: the blocking set is the destructive or + # lossy subset only. An unlabelled new term is routine output, and gating on + # it would be a permanent per-unit tax rather than a defect signal. state.deterministic_findings = [ OntologyUnitFinding( - kind=OntologyUnitFindingKind.MISSING_LABEL, - message="new term has no label", + kind=OntologyUnitFindingKind.FOREIGN_DELETE, + message="this update deletes catalog content", ) ] @@ -145,7 +159,7 @@ async def test_a_rejection_is_recorded_with_its_evidence( assert attempt.kind == "critic" assert attempt.score == 72 assert attempt.success is False - assert attempt.accept_reason == "incumbent_rejected" + assert attempt.accept_reason == "mandatory_findings" assert attempt.severity_counts == {"critical": 1, "important": 1} assert attempt.n_actionable_fixes == 2 assert attempt.n_deterministic_findings == 1 @@ -157,19 +171,28 @@ async def test_a_rejection_is_recorded_with_its_evidence( @pytest.mark.anyio @pytest.mark.parametrize( - ("success", "score", "reason"), - [(True, 40, "incumbent_success"), (False, 95, "incumbent_score")], + ("success", "score", "incumbent"), + [(True, 40, True), (False, 95, True), (False, 72, False)], ) -async def test_an_acceptance_records_which_clause_fired( - monkeypatch: pytest.MonkeyPatch, success: bool, score: float, reason: str +async def test_an_acceptance_records_what_the_retired_gate_would_have_said( + monkeypatch: pytest.MonkeyPatch, success: bool, score: float, incumbent: bool ) -> None: - _stub(monkeypatch, _critique(success=success, score=score)) + """Both verdicts, so the gate change can be judged from artifacts. + + Replacing a gate deserves a distribution rather than an argument, and the + ontology critic has never run on recorded data -- so the incumbent's answer + is recorded alongside the one that now decides. + """ + # No fixes: the default set carries a *critical* one, which blocks on its + # own severity whatever the score says. + _stub(monkeypatch, _critique(success=success, score=score, fixes=[])) state = await criticise_ontology_module.criticise_ontology(_unit_state(), _tools()) assert state.status == Status.SUCCESS [attempt] = state.attempt_log assert attempt.success is True - assert attempt.accept_reason == reason + assert attempt.accept_reason == "clean" + assert attempt.incumbent_accepted is incumbent @pytest.mark.anyio @@ -205,7 +228,13 @@ async def test_findings_are_injected_into_the_critic_prompt( async def test_loop_collects_findings_even_when_no_critic_runs( monkeypatch: pytest.MonkeyPatch, ) -> None: - """At MAX_VISITS=1 the critic is skipped; the residual must exist anyway.""" + """With no critic passes configured, the residual must exist anyway. + + The document-level residual metric sums this field over units, so a unit + that never reached a critic still has to report what the machine found -- + otherwise the denominator quietly counts only the criticised units. + """ + ontology_critic_passes = 0 foreign = URIRef("https://elsewhere.example/vocab#Widget") async def fake_render(state: UnitOntologyState, tools, **kwargs): @@ -217,7 +246,7 @@ async def fake_render(state: UnitOntologyState, tools, **kwargs): state.status = Status.SUCCESS return state - async def fake_resolve(_state, _tools, _unit): + async def fake_resolve(_state, _tools, _unit, **_kwargs): return UnitOntologyContext( snapshot=OntologySnapshot( graph=_snapshot_graph(), source_iris=["https://example.com/onto"] @@ -234,7 +263,13 @@ async def fake_resolve(_state, _tools, _unit): ToolBox, SimpleNamespace( get_atomic_tools=lambda: cast( - AtomicToolBox, SimpleNamespace(validation_policy=None) + AtomicToolBox, + SimpleNamespace( + validation_policy=None, + ontology_critic_passes=ontology_critic_passes, + ontology_patch_policy=CriticPatchPolicy(), + ontology_acceptance_policy=None, + ), ), ontology_manager=OntologyManager(), ), diff --git a/test/ontology/test_manager.py b/test/ontology/test_manager.py index f68f0e07..ecb55897 100644 --- a/test/ontology/test_manager.py +++ b/test/ontology/test_manager.py @@ -17,6 +17,8 @@ from ontocast.onto.rdfgraph import RDFGraph from ontocast.tool import OntologyManager +pytestmark = pytest.mark.unit + @pytest.fixture def ontology_manager(): diff --git a/test/ontology/test_max_triples.py b/test/ontology/test_max_triples.py index 0c9d5ea4..f319e5a3 100644 --- a/test/ontology/test_max_triples.py +++ b/test/ontology/test_max_triples.py @@ -7,6 +7,7 @@ deletions were rejected too. It now rejects only updates that grow the graph. """ +import pytest from rdflib import Literal, URIRef from rdflib.namespace import RDFS @@ -15,6 +16,8 @@ from ontocast.onto.sparql_models import GraphUpdate, TripleOp from ontocast.onto.state import AgentState +pytestmark = pytest.mark.unit + EX = "https://example.org/onto#" _TURTLE_CTX = {"llm_graph_format": LLMGraphFormat.TURTLE} _PREFIX = f"@prefix ex: <{EX}> .\n" diff --git a/test/ontology/test_merge.py b/test/ontology/test_merge.py index 884ca36d..15b7aa8d 100644 --- a/test/ontology/test_merge.py +++ b/test/ontology/test_merge.py @@ -10,6 +10,8 @@ from ontocast.onto.rdfgraph import RDFGraph from ontocast.tool.ontology_manager import OntologyManager +pytestmark = pytest.mark.unit + logger = logging.getLogger(__name__) diff --git a/test/ontology/test_selection_user_instruction.py b/test/ontology/test_selection_user_instruction.py index c2d98565..715aa719 100644 --- a/test/ontology/test_selection_user_instruction.py +++ b/test/ontology/test_selection_user_instruction.py @@ -21,6 +21,8 @@ from ontocast.tool.ontology_manager import OntologyManager from ontocast.toolbox import ToolBox +pytestmark = pytest.mark.unit + class _FakeOntologyManager(OntologyManager): def __init__(self, ontologies: list[Ontology]) -> None: diff --git a/test/ontology/test_snapshot_apply.py b/test/ontology/test_snapshot_apply.py index 1b05f3c4..42d2b117 100644 --- a/test/ontology/test_snapshot_apply.py +++ b/test/ontology/test_snapshot_apply.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pytest from rdflib import OWL, RDF, URIRef from ontocast.agent.normalize_ontology import normalize_ontology_units @@ -16,6 +17,8 @@ from ontocast.onto.rdfgraph import RDFGraph from ontocast.tool import OntologyManager +pytestmark = pytest.mark.unit + def _onto_graph(iri: str, local: str, prefix: str) -> RDFGraph: g = RDFGraph() diff --git a/test/ontology/test_suggestions_lifecycle.py b/test/ontology/test_suggestions_lifecycle.py index cea67499..24c8aa7c 100644 --- a/test/ontology/test_suggestions_lifecycle.py +++ b/test/ontology/test_suggestions_lifecycle.py @@ -24,11 +24,18 @@ from ontocast.onto.content_unit import ContentUnit from ontocast.onto.enum import Status -from ontocast.onto.model import OntologyCritiqueReport, Suggestions, TripleFix +from ontocast.onto.model import ( + OntologyCritiqueReport, + OntologyUnitFinding, + OntologyUnitFindingKind, + Suggestions, + TripleFix, +) from ontocast.onto.rdfgraph import RDFGraph from ontocast.onto.sparql_models import GraphUpdate, TripleOp from ontocast.onto.unit_states import UnitOntologyState from ontocast.tool.atomic import AtomicToolBox +from ontocast.tool.facts_validation import FactsAcceptancePolicy from test.snapshot_helpers import empty_snapshot criticise_ontology_module = importlib.import_module("ontocast.agent.criticise_ontology") @@ -74,6 +81,16 @@ def _tools() -> AtomicToolBox: SimpleNamespace( get_llm_tool=_llm_tool, web_grounding_enabled_for_node=lambda _node: False, + ontology_acceptance_policy=FactsAcceptancePolicy( + blocking_finding_kinds=frozenset( + { + "foreign_delete", + "foreign_namespace", + "subclass_cycle", + "role_confusion", + } + ) + ), ), ) @@ -104,37 +121,64 @@ async def fake_call_llm_with_retry(*args, **kwargs): @pytest.mark.anyio -async def test_a_rejecting_critic_still_hands_over_its_fixes( +async def test_a_low_score_alone_no_longer_rejects( monkeypatch: pytest.MonkeyPatch, ) -> None: - """The mechanism must keep working: a rejection reaches the next render.""" + """The gate is the deterministic findings, not the model's self-assessment. + + The incumbent rule rejected anything scoring 90 or below, while the prompt's + own rubric calls 70-89 "Good" -- so it rejected ontologies its instructions + considered good, and did it on a number the model was never shown a scale + for. The verdict is still recorded for comparison. + """ _stub(monkeypatch, _critique(success=False, score=40)) state = await criticise_ontology_module.criticise_ontology(_unit_state(), _tools()) - assert state.status == Status.FAILED - assert state.suggestions.actionable_fixes + assert state.status == Status.SUCCESS + assert state.attempt_log[-1].incumbent_accepted is False + assert state.attempt_log[-1].score == 40 @pytest.mark.anyio -async def test_an_accepting_critic_leaves_no_stale_suggestions( +async def test_a_blocking_finding_rejects_whatever_the_score( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Accepting after an evidence search must not carry the rejection forward.""" + """A destructive delta is a defect the critic's optimism cannot override.""" state = _unit_state() + state.deterministic_findings = [ + OntologyUnitFinding( + kind=OntologyUnitFindingKind.FOREIGN_DELETE, + mandatory=True, + message="deletes catalog statements", + ) + ] - _stub(monkeypatch, _critique(success=False, score=40)) + _stub(monkeypatch, _critique(success=True, score=95)) state = await criticise_ontology_module.criticise_ontology(state, _tools()) - assert state.suggestions.actionable_fixes + + assert state.status == Status.FAILED + assert state.attempt_log[-1].accept_reason == "mandatory_findings" + assert state.attempt_log[-1].incumbent_accepted is True + + +@pytest.mark.anyio +async def test_an_accepting_critic_keeps_its_own_fixes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Acceptance says the unit may leave, not that the critique is worthless. + + Clearing here discarded every fix attached to an accepted render -- which, + since a REMOVE can never by itself cause a rejection, was most of them. The + patch pass reads exactly this field. + """ + state = _unit_state() _stub(monkeypatch, _critique(success=True, score=95)) state = await criticise_ontology_module.criticise_ontology(state, _tools()) assert state.status == Status.SUCCESS - assert not state.suggestions.actionable_fixes, ( - "an accepting critic has no outstanding requests; a later render must " - "not inherit the rejected attempt's suggestions" - ) - assert not state.suggestions.systemic_critique_summary + assert state.suggestions.actionable_fixes + assert state.suggestions.systemic_critique_summary == "labels are thin" def test_update_ontology_reports_that_it_applied() -> None: diff --git a/test/retrieval_gt.py b/test/retrieval_gt.py new file mode 100644 index 00000000..0b6b0eb4 --- /dev/null +++ b/test/retrieval_gt.py @@ -0,0 +1,648 @@ +"""Ground-truth sets and funnel accounting for ontology retrieval recall. + +Retrieval quality was previously unmeasured: every dense embedding in the suite is a +hash-based fake, so no test could tell whether a relevant catalog term actually reached +the prompt snapshot. This module supplies the missing ``{text -> expected IRIs}`` maps +plus the per-stage accounting needed to attribute a miss to a specific pipeline stage. + +Two ground-truth tiers: + +* **Text2KGBench** — an external corpus (see ``ontocast-validation``) where every row + pairs a sentence with the triples it expresses. Each triple's ``rel`` is an + ``rdfs:label`` of an ontology term, so the sentence-to-IRI map falls out by + construction with no hand labelling. Large enough (29 ontologies, ~13.5k relation + mentions) to discriminate between embedding models and to stress multi-ontology + allocation. +* **Synthetic anchors** — in-repo fixtures whose labels contain nonsense phrases that + also appear verbatim in the paired document. Small and near-verbatim, so they mostly + exercise plumbing and the lexical lane, but they need no external data and are + deterministic. + +Retrieval is scored permissively: a term counts as retrieved when *any* IRI carrying the +expected label is found. Text2KGBench labels are occasionally shared by a class and a +property (e.g. ``composer``), and surfacing either means retrieval did its job; requiring +class/property disambiguation would measure something else. +""" + +from __future__ import annotations + +import json +import os +import re +from collections import defaultdict +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from rdflib import RDFS, URIRef + +from ontocast.onto.ontology import Ontology +from ontocast.onto.rdfgraph import RDFGraph + +DEFAULT_TEXT2KGBENCH_ROOT = Path.home() / "data" / "ontocast" / "validation" +TEXT2KGBENCH_ROOT_ENV = "ONTOCAST_RECALL_ROOT" + +CORPUS_ROOT_ENV = "ONTOCAST_RECALL_CORPUS" + +# Extra ontologies to load alongside the corpus catalog, os.pathsep-separated; each +# entry is a .ttl file or a directory of them. Exists so that "does indexing a large +# external vocabulary help or dilute?" is a one-env-var flip rather than a corpus edit: +# copying the vocabulary into /ontologies/ would make the ablation a file move +# and permanently change the corpus every arm is compared against. +EXTRA_ONTOLOGIES_ENV = "ONTOCAST_RECALL_EXTRA_ONTOLOGIES" + +FIXTURES_DIR = Path(__file__).parent / "manual" / "fixtures" + +_GT_TEXT_SUBDIRS = ("wikidata_tekgen", "dbpedia_webnlg") + + +def owner_index(ontologies: list[Ontology]) -> list[tuple[str, str]]: + """Namespace-to-ontology-IRI index, longest namespace first. + + Mirrors the namespace-containment step production uses to attribute an entity to a + catalog ontology (``patch_retriever._ontology_iri_for_entity``). Duplicated rather + than imported: the harness must keep measuring term ownership the same way even if + the production resolver changes, otherwise a recall regression and an attribution + change would be indistinguishable. + + Args: + ontologies: Catalog ontologies loaded for the run. + + Returns: + list[tuple[str, str]]: ``(namespace_stem, ontology_iri)`` pairs, longest first + so a nested namespace wins over its parent. + """ + pairs = { + (ontology.namespace or ontology.iri or "").rstrip("#/"): ontology.iri + for ontology in ontologies + if (ontology.namespace or ontology.iri) + } + return sorted(pairs.items(), key=lambda kv: -len(kv[0])) + + +def owner_of( + iri: str, + candidates: list[tuple[str, str]], + ontologies: list[Ontology] | None = None, +) -> str | None: + """Resolve which ontology owns ``iri`` by namespace containment. + + Mirrors production's two-pass resolution: namespace containment first, then + subject membership. The fallback matters for vocabularies whose + ``owl:Ontology`` header IRI is not a prefix of their term IRIs (e.g. QUDT + heads at ``…/2.1/vocab/unit`` while its units live under ``…/vocab/unit/``) + — without it every such term reads as unowned and silently vanishes from + per-ontology attribution. + + Args: + iri: Entity IRI to attribute. + candidates: Output of :func:`owner_index`. + ontologies: Catalog ontologies for the subject-membership fallback; + omit to restrict resolution to namespace containment. + + Returns: + str | None: Owning ontology IRI, or None when neither namespace + containment nor subject membership matches. + """ + for namespace, ontology_iri in candidates: + if ( + iri == namespace + or iri.startswith(f"{namespace}#") + or iri.startswith(f"{namespace}/") + ): + return ontology_iri + if ontologies: + ref = URIRef(iri) + for ontology in ontologies: + if not ontology.iri: + continue + if next(ontology.graph.triples((ref, None, None)), None) is not None: + return ontology.iri + return None + + +@dataclass(frozen=True) +class RecallCase: + """One query with the ontology terms retrieval is expected to surface. + + Args: + case_id: Stable identifier, used to label failures. + text: The query text handed to the retriever. + expected_iris: IRIs that satisfy the case; surfacing any one counts as a hit. + ontology_iri: Catalog ontology the expected terms belong to. + """ + + case_id: str + text: str + expected_iris: frozenset[str] + ontology_iri: str + + +@dataclass +class StageCounts: + """Funnel counters accumulated over a run of cases. + + Field names mirror the keys already emitted by + ``OntologyPatchRetriever.last_retrieval_metrics`` and ``SPARQLTool.last_finalize_metrics`` + so the harness adds no new instrumentation to production code. + """ + + cases: int = 0 + seed_hits: int = 0 + snapshot_hits: int = 0 + expected_terms: int = 0 + seed_terms: int = 0 + snapshot_terms: int = 0 + atoms_after_dedupe: int = 0 + atoms_final: int = 0 + snapshot_triple_count: int = 0 + snapshot_pruned_uri_count: int = 0 + snapshot_uri_components: int = 0 + empty_snapshots: int = 0 + ontologies_per_case: int = 0 + on_topic_subjects: int = 0 + total_subjects: int = 0 + #: Query windows the encoder cut before embedding them, and the limit it cut + #: them at. A truncated query returns a correctly shaped vector for a prefix + #: of the text, so nothing downstream can tell -- which makes this the one + #: number that separates "this encoder retrieves worse" from "this encoder + #: never saw half the query". Absent (``None``) where the provider does not + #: state a limit. + queries_truncated: int = 0 + queries_measured_for_truncation: int = 0 + query_sequence_limit: int | None = None + seeds_by_ontology: dict[str, int] = field(default_factory=lambda: defaultdict(int)) + expected_terms_by_ontology: dict[str, int] = field( + default_factory=lambda: defaultdict(int) + ) + seed_terms_by_ontology: dict[str, int] = field( + default_factory=lambda: defaultdict(int) + ) + snapshot_terms_by_ontology: dict[str, int] = field( + default_factory=lambda: defaultdict(int) + ) + #: Outcome per (case, expected term), so two arms can be compared as the paired + #: measurements they are. Aggregate recall alone forces an unpaired test between + #: two proportions, which at these corpus sizes cannot resolve the effects the + #: sweep is looking for -- while the arms in fact differ on the *same* terms. + term_outcomes: dict[str, bool] = field(default_factory=dict) + + def observe( + self, + *, + expected: frozenset[str], + seed_iris: set[str], + snapshot_subjects: set[str], + metrics: dict[str, Any], + on_topic_subjects: int = 0, + total_subjects: int = 0, + expected_owner: Mapping[str, str] | None = None, + case_id: str | None = None, + ) -> None: + """Fold one case's outcome and retrieval metrics into the running totals. + + Both a case-level and a term-level view are accumulated. Case level asks whether + *any* expected term survived, which saturates once a case carries several expected + terms; term level counts each one, and is the sensitive measure. + """ + self.cases += 1 + self.on_topic_subjects += on_topic_subjects + self.total_subjects += total_subjects + + seed_found = expected & seed_iris + snapshot_found = expected & snapshot_subjects + self.seed_hits += int(bool(seed_found)) + self.snapshot_hits += int(bool(snapshot_found)) + self.expected_terms += len(expected) + self.seed_terms += len(seed_found) + self.snapshot_terms += len(snapshot_found) + if case_id is not None: + for iri in expected: + self.term_outcomes[f"{case_id}\t{iri}"] = iri in snapshot_found + + # Aggregate recall hides a vocabulary that is never retrieved: a cross-cutting + # ontology contributes few expected terms, so losing all of them barely moves + # the total. Attribute each term to its owning ontology to expose that. + if expected_owner: + for iri in expected: + owner = expected_owner.get(iri) + if not owner: + continue + self.expected_terms_by_ontology[owner] += 1 + if iri in seed_found: + self.seed_terms_by_ontology[owner] += 1 + if iri in snapshot_found: + self.snapshot_terms_by_ontology[owner] += 1 + + self.atoms_after_dedupe += int(metrics.get("atoms_after_dedupe", 0)) + self.atoms_final += int(metrics.get("atoms_final", 0)) + triple_count = int(metrics.get("snapshot_triple_count", 0)) + self.snapshot_triple_count += triple_count + if triple_count == 0: + self.empty_snapshots += 1 + self.snapshot_pruned_uri_count += int( + metrics.get("snapshot_pruned_uri_count", 0) + ) + self.snapshot_uri_components += int(metrics.get("snapshot_uri_components", 0)) + limit = metrics.get("query_sequence_limit") + if limit is not None: + self.query_sequence_limit = int(limit) + truncated = metrics.get("queries_truncated") + if truncated is not None: + self.queries_truncated += int(truncated) + self.queries_measured_for_truncation += 1 + + by_ontology = metrics.get("seeds_by_ontology") or {} + self.ontologies_per_case += len(by_ontology) + for iri, count in by_ontology.items(): + self.seeds_by_ontology[iri] += int(count) + + @property + def seed_recall(self) -> float: + """Fraction of cases whose expected term reached the final seed set.""" + return self.seed_hits / self.cases if self.cases else 0.0 + + @property + def snapshot_recall(self) -> float: + """Fraction of cases whose expected term is defined in the returned graph.""" + return self.snapshot_hits / self.cases if self.cases else 0.0 + + @property + def seed_term_recall(self) -> float: + """Share of *all* expected terms that reached the final seed set.""" + return self.seed_terms / self.expected_terms if self.expected_terms else 0.0 + + @property + def snapshot_term_recall(self) -> float: + """Share of *all* expected terms defined in the returned graph.""" + return self.snapshot_terms / self.expected_terms if self.expected_terms else 0.0 + + @property + def on_topic_precision(self) -> float: + """Share of snapshot subjects owned by the case's own ontology. + + Recall alone cannot detect a snapshot that improved by simply including more of + everything. This is a noise proxy, not a correctness measure: a genuinely useful + snapshot may legitimately pull in parent terms from another ontology. + """ + return ( + self.on_topic_subjects / self.total_subjects if self.total_subjects else 0.0 + ) + + def ontologies_with_expected_but_no_seed_terms(self) -> set[str]: + """Ontologies that were expected to contribute a term and contributed none. + + The stable signal in this harness. Recall percentages move run to run, but + "this vocabulary was never once retrieved" does not, so it is the condition + worth asserting on and the one that survives comparison across ablation arms. + """ + return { + iri + for iri, expected in self.expected_terms_by_ontology.items() + if expected > 0 and self.seed_terms_by_ontology.get(iri, 0) == 0 + } + + def as_dict(self) -> dict[str, Any]: + """Flat, machine-readable view of the funnel. + + ``render`` is for a person reading one run. A sweep compares hundreds, so + the same numbers have to leave the process as data -- a printed table that + has to be re-parsed is how a measurement stops being comparable across arms. + + Returns: + dict[str, Any]: Rates, raw counters, and the per-ontology breakdown. + """ + return { + "cases": self.cases, + "seed_recall": self.seed_recall, + "snapshot_recall": self.snapshot_recall, + "seed_term_recall": self.seed_term_recall, + "snapshot_term_recall": self.snapshot_term_recall, + "on_topic_precision": self.on_topic_precision, + "expected_terms": self.expected_terms, + "seed_terms": self.seed_terms, + "snapshot_terms": self.snapshot_terms, + "graph_stage_net": self.snapshot_terms - self.seed_terms, + "mean_atoms_after_dedupe": self._mean(self.atoms_after_dedupe), + "mean_atoms_final": self._mean(self.atoms_final), + "mean_ontologies_per_case": self._mean(self.ontologies_per_case), + "mean_snapshot_triples": self._mean(self.snapshot_triple_count), + "mean_pruned_uris": self._mean(self.snapshot_pruned_uri_count), + "mean_uri_components": self._mean(self.snapshot_uri_components), + "empty_snapshots": self.empty_snapshots, + "queries_truncated": self.queries_truncated, + "mean_queries_truncated": self._mean(self.queries_truncated), + "query_sequence_limit": self.query_sequence_limit, + "seeds_by_ontology": dict(self.seeds_by_ontology), + "expected_terms_by_ontology": dict(self.expected_terms_by_ontology), + "seed_terms_by_ontology": dict(self.seed_terms_by_ontology), + "snapshot_terms_by_ontology": dict(self.snapshot_terms_by_ontology), + "term_outcomes": dict(self.term_outcomes), + "starved_ontologies": sorted( + self.ontologies_with_expected_but_no_seed_terms() + ), + } + + def _mean(self, total: int) -> float: + return total / self.cases if self.cases else 0.0 + + def render(self, title: str) -> str: + """Human-readable funnel report. + + The seed-to-snapshot gap isolates damage done by induced-subgraph expansion + (budget truncation, component pruning) from damage done by vector-stage filtering. + """ + lines = [ + f"=== {title} ===", + f"cases {self.cases}", + f"seed recall {self.seed_recall:.1%} ({self.seed_hits}/{self.cases})", + f"snapshot recall {self.snapshot_recall:.1%} ({self.snapshot_hits}/{self.cases})", + # Case level saturates as soon as cases carry several expected terms; these + # two are the numbers to compare variants on. + f"seed TERM recall {self.seed_term_recall:.1%} ({self.seed_terms}/{self.expected_terms})", + f"snapshot TERM recall {self.snapshot_term_recall:.1%} ({self.snapshot_terms}/{self.expected_terms})", + # Expansion can also *recover* a term that was never a seed, by pulling it in + # over subClassOf/domain/range, so this delta is signed rather than a pure loss. + f"graph stage net {self.snapshot_terms - self.seed_terms:+d} terms", + f"on-topic precision {self.on_topic_precision:.1%} (snapshot subjects from the case ontology)", + f"mean atoms_after_dedupe {self._mean(self.atoms_after_dedupe):.1f}", + f"mean atoms_final {self._mean(self.atoms_final):.1f}", + f"mean ontologies per case {self._mean(self.ontologies_per_case):.1f}", + f"mean snapshot triples {self._mean(self.snapshot_triple_count):.1f}", + f"mean pruned URIs {self._mean(self.snapshot_pruned_uri_count):.2f}", + f"mean URI components {self._mean(self.snapshot_uri_components):.2f}", + f"empty snapshots {self.empty_snapshots}", + ] + if self.query_sequence_limit is not None: + lines.append( + f"queries truncated {self.queries_truncated} " + f"(encoder limit {self.query_sequence_limit} tokens)" + ) + else: + lines.append("queries truncated unknown (provider states no limit)") + if self.seeds_by_ontology: + top = sorted(self.seeds_by_ontology.items(), key=lambda kv: -kv[1])[:8] + lines.append("seeds by ontology (top 8):") + lines.extend(f" {count:6d} {iri}" for iri, count in top) + if self.expected_terms_by_ontology: + lines.append("per-ontology TERM recall (expected terms owned by each):") + lines.append(f" {'ontology':<48}{'seed':>14}{'snapshot':>14} status") + ordered = sorted( + self.expected_terms_by_ontology.items(), key=lambda kv: -kv[1] + ) + starved = self.ontologies_with_expected_but_no_seed_terms() + for iri, expected_count in ordered: + seeded = self.seed_terms_by_ontology.get(iri, 0) + snapped = self.snapshot_terms_by_ontology.get(iri, 0) + seed_cell = f"{seeded}/{expected_count} {seeded / expected_count:.0%}" + snap_cell = f"{snapped}/{expected_count} {snapped / expected_count:.0%}" + status = "NO TERMS RETRIEVED" if iri in starved else "" + short = iri if len(iri) <= 47 else "…" + iri[-46:] + lines.append( + f" {short:<48}{seed_cell:>14}{snap_cell:>14} {status}" + ) + return "\n".join(lines) + + +def _disambiguate_prefixes(graph: RDFGraph, stem: str) -> None: + """Rebind generic author prefixes to a per-ontology unique prefix. + + Every Text2KGBench ontology binds ``onto:``, but ``OntologyManager`` registers the + author prefix as a catalog alias and rejects a second ontology claiming one already + bound. That is a property of this corpus rather than of the ontologies under test, so + the collision is normalised away here instead of being worked around in the catalog. + """ + unique = re.sub(r"[^a-z0-9]+", "", stem.lower()) or "onto" + for prefix, namespace in list(graph.namespaces()): + if prefix in {"onto", "ns1", ""}: + graph.bind(unique, namespace, replace=True, override=True) + + +def _label_index(graph: RDFGraph) -> dict[str, set[str]]: + """Map lowercased ``rdfs:label`` to the IRIs carrying it.""" + index: dict[str, set[str]] = defaultdict(set) + for subject, _, obj in graph.triples((None, RDFS.label, None)): + index[str(obj).strip().lower()].add(str(subject)) + return index + + +def text2kgbench_root() -> Path | None: + """Corpus root from ``ONTOCAST_RECALL_ROOT``, else the conventional location.""" + override = os.getenv(TEXT2KGBENCH_ROOT_ENV) + root = Path(override).expanduser() if override else DEFAULT_TEXT2KGBENCH_ROOT + return root if (root / "a_ontologies").is_dir() else None + + +def _ground_truth_path(root: Path, stem: str) -> Path | None: + for subdir in _GT_TEXT_SUBDIRS: + candidate = root / "b_gt_text" / subdir / f"{stem}_ground_truth.jsonl" + if candidate.exists(): + return candidate + return None + + +def load_text2kgbench( + root: Path, + *, + max_ontologies: int = 6, + max_cases_per_ontology: int = 20, +) -> tuple[list[RecallCase], list[Ontology]]: + """Load ``(cases, ontologies)`` from a Text2KGBench-style corpus. + + Only rows whose relation labels all resolve to ontology IRIs become cases, so a + failure is always a retrieval failure and never a ground-truth gap. + + Args: + root: Corpus root containing ``a_ontologies/`` and ``b_gt_text/``. + max_ontologies: Cap on ontologies loaded into the catalog. + max_cases_per_ontology: Cap on sentences taken from each ontology's GT file. + """ + cases: list[RecallCase] = [] + ontologies: list[Ontology] = [] + + for onto_path in sorted((root / "a_ontologies").glob("ont_*.ttl"))[:max_ontologies]: + stem = onto_path.stem + gt_path = _ground_truth_path(root, stem) + if gt_path is None: + continue + + graph = RDFGraph() + graph.parse(str(onto_path), format="turtle") + _disambiguate_prefixes(graph, stem) + ontology = Ontology(graph=graph) + labels = _label_index(graph) + + taken = 0 + with gt_path.open() as handle: + for line in handle: + if taken >= max_cases_per_ontology: + break + row = json.loads(line) + sentence = (row.get("sent") or "").strip() + triples = row.get("triples") or [] + if not sentence or not triples: + continue + expected: set[str] = set() + for triple in triples: + expected |= labels.get( + (triple.get("rel") or "").strip().lower(), set() + ) + if not expected: + continue + cases.append( + RecallCase( + case_id=row.get("id") or f"{stem}_{taken}", + text=sentence, + expected_iris=frozenset(expected), + ontology_iri=ontology.iri, + ) + ) + taken += 1 + + if taken: + ontologies.append(ontology) + + return cases, ontologies + + +def corpus_root() -> Path | None: + """Prebuilt corpus directory from ``ONTOCAST_RECALL_CORPUS``, if it looks valid.""" + raw = os.getenv(CORPUS_ROOT_ENV) + if not raw: + return None + root = Path(raw).expanduser() + return root if (root / "cases.jsonl").is_file() else None + + +def extra_ontology_paths() -> list[Path]: + """Turtle files named by ``ONTOCAST_RECALL_EXTRA_ONTOLOGIES``. + + Accepts ``os.pathsep``-separated files and directories. Missing entries raise + rather than being skipped: an arm that silently ran without the vocabulary it was + supposed to measure is indistinguishable from one where the vocabulary did not help. + + Returns: + list[Path]: Sorted, de-duplicated Turtle paths; empty when the var is unset. + """ + raw = os.getenv(EXTRA_ONTOLOGIES_ENV) + if not raw: + return [] + found: list[Path] = [] + for entry in raw.split(os.pathsep): + if not entry.strip(): + continue + path = Path(entry).expanduser() + if path.is_dir(): + found.extend(sorted(path.glob("*.ttl"))) + elif path.is_file(): + found.append(path) + else: + raise ValueError( + f"{EXTRA_ONTOLOGIES_ENV}: {path} is neither a file nor a directory" + ) + return sorted(dict.fromkeys(found)) + + +def load_corpus(root: Path) -> tuple[list[RecallCase], list[Ontology]]: + """Load ``(cases, ontologies)`` from a prebuilt, domain-neutral recall corpus. + + Layout, as emitted by ``ontocast-validation/run/build_recall_corpus.py``:: + + /ontologies/*.ttl + /cases.jsonl # {"id", "text", "expected_iris": [...], "ontology_iri"} + + Rows may carry extra keys (the builder writes a ``review`` block); they are ignored. + Ground-truth defects raise rather than deflating recall silently: a case naming an + ontology absent from the catalog would otherwise look like a retrieval miss. + + ``ONTOCAST_RECALL_EXTRA_ONTOLOGIES`` appends further ontologies to the catalog + without touching the corpus on disk, so the same corpus can be scored with and + without a large external vocabulary and the two runs stay comparable. + + Args: + root: Corpus directory containing ``ontologies/`` and ``cases.jsonl``. + """ + ontology_dir = root / "ontologies" + if not ontology_dir.is_dir(): + raise ValueError(f"{root}: missing ontologies/ directory") + + ontologies: list[Ontology] = [] + for path in [*sorted(ontology_dir.glob("*.ttl")), *extra_ontology_paths()]: + graph = RDFGraph() + graph.parse(str(path), format="turtle") + ontologies.append(Ontology(graph=graph)) + if not ontologies: + raise ValueError(f"{ontology_dir}: no .ttl ontologies found") + + known_iris = {ontology.iri for ontology in ontologies} + cases: list[RecallCase] = [] + with (root / "cases.jsonl").open() as handle: + for line_number, line in enumerate(handle, start=1): + if not line.strip(): + continue + row = json.loads(line) + case_id = str(row.get("id") or f"case_{line_number}") + text = (row.get("text") or "").strip() + expected = {str(iri) for iri in row.get("expected_iris") or []} + ontology_iri = str(row.get("ontology_iri") or "") + if not text or not expected: + raise ValueError(f"{case_id}: needs both 'text' and 'expected_iris'") + if ontology_iri not in known_iris: + raise ValueError( + f"{case_id}: ontology_iri {ontology_iri!r} is not in the catalog" + ) + cases.append( + RecallCase( + case_id=case_id, + text=text, + expected_iris=frozenset(expected), + ontology_iri=ontology_iri, + ) + ) + + return cases, ontologies + + +def load_anchor_cases() -> tuple[list[RecallCase], list[Ontology]]: + """Load in-repo anchor fixtures as ``(cases, ontologies)``. + + Each fixture document is split per line; a line becomes a case when it contains a + phrase that is also an ``rdfs:label`` in the paired ontology. + """ + cases: list[RecallCase] = [] + ontologies: list[Ontology] = [] + + for domain in ("biomed", "finance"): + onto_path = FIXTURES_DIR / f"{domain}_integration_ontology.ttl" + doc_path = FIXTURES_DIR / f"{domain}_source_document.txt" + if not onto_path.exists() or not doc_path.exists(): + continue + + graph = RDFGraph() + graph.parse(str(onto_path), format="turtle") + ontology = Ontology(graph=graph) + labels = _label_index(graph) + ontologies.append(ontology) + + for index, line in enumerate(doc_path.read_text().splitlines()): + sentence = line.strip() + if not sentence: + continue + haystack = re.sub(r"[^a-z0-9 ]+", " ", sentence.lower()) + haystack = re.sub(r"\s+", " ", haystack) + expected: set[str] = set() + for label, iris in labels.items(): + if len(label) > 3 and label in haystack: + expected |= iris + if expected: + cases.append( + RecallCase( + case_id=f"{domain}_{index}", + text=sentence, + expected_iris=frozenset(expected), + ontology_iri=ontology.iri, + ) + ) + + return cases, ontologies diff --git a/test/retrieval_runner.py b/test/retrieval_runner.py new file mode 100644 index 00000000..d783ad4d --- /dev/null +++ b/test/retrieval_runner.py @@ -0,0 +1,181 @@ +"""Toolbox wiring and scoring loop shared by the recall tests and the sweep. + +The pytest tiers in :mod:`test.test_retrieval_recall` answer "is retrieval working"; +:mod:`test.retrieval_sweep` answers "which parameters work best". Both need the same +three things -- a toolbox on real embeddings, an indexed catalog, and one scoring pass +over a corpus -- so they live here rather than being duplicated, where they would drift +and quietly stop measuring the same pipeline. + +**No LLM call is made anywhere in this module.** Retrieval is embeddings and graph work +only. The toolbox still constructs an LLM client eagerly, so the provider is pinned to +one that needs no API key. +""" + +from __future__ import annotations + +import os +import uuid +from pathlib import Path +from typing import Any + +from ontocast.config import ( + Config, + EmbeddingConfig, + FusekiConfig, + LanceDBConfig, + LLMConfig, + PathConfig, + QdrantConfig, + ToolConfig, +) +from ontocast.config.settings import LLMProvider +from ontocast.onto.ontology import Ontology +from ontocast.tool.chunk.proposition import split_proposition_windows +from ontocast.toolbox import ToolBox +from test.retrieval_gt import RecallCase, StageCounts, owner_index, owner_of + +COLLECTION_SUFFIX_ENV = "ONTOCAST_RECALL_COLLECTION_SUFFIX" +SKIP_INDEX_ENV = "ONTOCAST_RECALL_SKIP_INDEX" + + +def pinned_suffix() -> str: + """Collection/table suffix pinning a reusable index, or ``""``.""" + return os.getenv(COLLECTION_SUFFIX_ENV, "").strip() + + +def skip_index() -> bool: + """Whether to reuse a pinned index instead of re-embedding the catalog.""" + return os.getenv(SKIP_INDEX_ENV, "").strip().lower() in {"1", "true", "yes"} + + +def lancedb_store_config(data_dir: Path, run_id: str | None = None) -> dict[str, Any]: + """``ToolConfig`` overrides naming an embedded LanceDB index. + + Args: + data_dir: Directory handed to ``lancedb.connect``. + run_id: Table-name suffix; a pinned suffix, else a fresh one. + + Returns: + dict[str, Any]: ``lancedb`` and ``qdrant`` keyword overrides. + """ + suffix = run_id or pinned_suffix() or uuid.uuid4().hex[:8] + config = LanceDBConfig(enabled=True).model_copy( + update={ + "data_dir": data_dir, + "ontology_table": f"ontocast_recall_{suffix}_ontologies", + "facts_table": f"ontocast_recall_{suffix}_facts", + } + ) + return {"lancedb": config, "qdrant": QdrantConfig(uri=None)} + + +def build_toolbox(store_config: dict[str, Any], ontology_dir: Path) -> ToolBox: + """ToolBox on real embeddings, a real vector store, in-memory triple store.""" + tool_config = ToolConfig( + # No LLM call is ever made, but the runtime builds a client eagerly and the + # default provider refuses to construct without an API key. Ollama needs + # none, so this stays runnable with no credentials in the environment. + llm_config=LLMConfig(provider=LLMProvider.OLLAMA), + path_config=PathConfig(ontology_directory=ontology_dir), + # EmbeddingConfig() resolves the configured production model, not a fake. + embedding=EmbeddingConfig(), + # Isolate from host Fuseki so the in-memory triple store is used. + fuseki=FusekiConfig(uri=None, auth=None), + **store_config, + ) + return ToolBox(Config(tool_config=tool_config)) + + +async def index_catalog(tools: ToolBox, ontologies: list[Ontology]) -> None: + """Bring the vector store up and index every ontology into the catalog. + + ``ONTOCAST_RECALL_SKIP_INDEX=1`` reuses whatever a pinned index already holds. + Only meaningful with a pinned suffix; the catalog itself is still registered + in-process, so scoring and attribution are unaffected. + """ + assert tools.vector_store is not None + await tools.vector_store.initialize() + tools.vector_store_ready = True + tools.vector_store_last_error = None + reuse = skip_index() + for ontology in ontologies: + if reuse: + # Register in the catalog without re-embedding: the vectors are already + # in the pinned index from the arm that built it. The triple store is + # in-memory per run, so the graph stage still needs the ontology written + # -- without it every induced subgraph is silently empty. + if tools.triple_store_manager is not None: + await tools.triple_store_manager.aserialize(ontology) + tools.ontology_manager.add_ontology(ontology, skip_vector_index=True) + continue + ttl = ontology.graph.serialize(format="turtle").encode("utf-8") + await tools.ingest_ontology_ttl(ttl) + + +async def score_cases( + tools: ToolBox, ontologies: list[Ontology], cases: list[RecallCase] +) -> StageCounts: + """Run every case through the production retrieval path and fold the outcomes.""" + retriever = tools.patch_retriever + assert retriever is not None + store_config = tools.config.tool_config.vector_store + counts = StageCounts() + owners = owner_index(ontologies) + + for case in cases: + # Window exactly as production does, so multi-sentence cases exercise the + # cross-window merge and the window budget. A single sentence yields one + # window, leaving the sentence-level tiers unchanged. + queries = split_proposition_windows( + case.text, + max_sentences=store_config.proposition_window_sentences, + max_windows=store_config.proposition_max_windows, + stride=store_config.proposition_window_stride, + max_chars=store_config.proposition_window_max_chars, + max_tokens=store_config.proposition_window_max_tokens, + token_counter=tools.embedding_tool.token_lengths, + abbreviation_aware=store_config.proposition_abbreviation_aware, + measurement_aware=store_config.proposition_measurement_aware, + overlap=store_config.proposition_window_overlap, + ) + graph, _sources = await retriever.aretrieve_ensemble( + queries=queries, + top_k=store_config.top_k, + expand_sparql=True, + subgraph_depth=store_config.induced_subgraph_depth, + max_total_triples=store_config.induced_subgraph_max_total_triples, + estimated_triples_per_query=( + store_config.induced_subgraph_estimated_triples_per_query + ), + trigger_text=case.text, + ) + metrics = dict(retriever.last_retrieval_metrics) + seed_iris = {str(iri) for iri in metrics.get("seed_iris", [])} + # Subject position, not mere mention: pruning drops triples by subject but + # leaves object-position references, so a named term may carry no usable + # definition. + subjects = {str(subject) for subject in graph.subjects()} + http_subjects = {s for s in subjects if s.startswith("http")} + on_topic = sum( + 1 + for s in http_subjects + if owner_of(s, owners, ontologies) == case.ontology_iri + ) + # Unowned expected IRIs are attributed explicitly rather than silently + # dropped, so a vocabulary whose header namespace excludes its own terms + # reads as "(unattributed)" instead of "contributed nothing". + counts.observe( + expected=case.expected_iris, + seed_iris=seed_iris, + snapshot_subjects=subjects, + metrics=metrics, + on_topic_subjects=on_topic, + total_subjects=len(http_subjects), + expected_owner={ + iri: owner_of(iri, owners, ontologies) or "(unattributed)" + for iri in case.expected_iris + }, + case_id=case.case_id, + ) + + return counts diff --git a/test/retrieval_sweep.py b/test/retrieval_sweep.py new file mode 100644 index 00000000..5aa5f704 --- /dev/null +++ b/test/retrieval_sweep.py @@ -0,0 +1,222 @@ +"""In-process sweep over retrieval parameters against a recall corpus. + +The pytest tiers answer "is retrieval working". This answers "which settings work +best", and it exists because that question cannot be answered one knob at a time: the +seed budget, the expansion ceiling and the closure budgets interact, so the useful +object is a *frontier* over combinations rather than a ranking of single deltas. + +Everything here is embeddings and graph work -- **no LLM call is made**, so a sweep of +several hundred points costs nothing but wall-clock. That is the whole point: whether a +term the answer needs is present in the snapshot is decidable without generating +anything, and a term absent from the snapshot cannot be used downstream. Snapshot +recall is therefore an upper bound on what any model can link, which makes a cheap +sweep able to *reject* a configuration outright and leaves generation to rank only the +survivors. + +The catalog is embedded once and every point scores against that same index: each +swept knob is applied at merge or expansion time, so none of them joins the embedding +fingerprint. Re-indexing per point would dominate the runtime and measure nothing. + +Usage:: + + ONTOCAST_RECALL_CORPUS= LANCEDB_ENABLED=true \\ + uv run python -m test.retrieval_sweep --grid grid.json --out results.json + +The grid is a JSON list of points, each naming the fields it overrides:: + + [{"id": "baseline"}, + {"id": "tight", "vector_store": {"top_k": 10}, + "patch_retrieval": {"small_module_closure_max_total_triples": 450}}] +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from pydantic import TypeAdapter + +from ontocast.toolbox import ToolBox +from test.retrieval_gt import RecallCase, corpus_root, load_corpus +from test.retrieval_runner import ( + build_toolbox, + index_catalog, + lancedb_store_config, + score_cases, +) + +_SECTIONS = ("vector_store", "patch_retrieval") + + +def _apply(tools: ToolBox, point: dict[str, Any]) -> dict[str, Any]: + """Apply one point's overrides to the live toolbox. + + Returns the settings actually in force, read back from the config rather than + echoed from the request: a knob that silently failed to apply would otherwise be + reported as though it had, and the whole sweep would attribute its neighbour's + effect to it. + + Args: + tools: Toolbox whose retriever and vector-store config are mutated in place. + point: Grid entry; keys ``vector_store`` and ``patch_retrieval`` hold field + names mapped to values. + + Returns: + dict[str, Any]: The applied values, per section. + """ + tool_config = tools.config.tool_config + retriever = tools.patch_retriever + assert retriever is not None + targets = { + "vector_store": tool_config.vector_store, + # The retriever holds its own reference; mutate the object it actually reads. + "patch_retrieval": retriever.patch, + } + applied: dict[str, Any] = {} + for section in _SECTIONS: + overrides = point.get(section) or {} + target = targets[section] + for field, value in overrides.items(): + info = type(target).model_fields.get(field) + if info is None: + raise ValueError(f"{section}: no such setting {field!r}") + # Validate through the field's own annotation rather than assigning the + # raw JSON value. A grid is plain JSON, so an enum arrives as a string + # and reaches code that expects the enum -- which fails at the far end + # of a long run, on whichever point happened to draw it. + setattr(target, field, TypeAdapter(info.annotation).validate_python(value)) + applied[section] = {field: getattr(target, field) for field in overrides} + return applied + + +def _defaults(tools: ToolBox, grid: list[dict[str, Any]]) -> dict[str, Any]: + """Starting value of every field the grid touches, for restoring between points. + + A point overriding fewer fields than its predecessor must not inherit the + predecessor's values, or the sweep measures a running accumulation instead of the + combinations it lists. + """ + tool_config = tools.config.tool_config + retriever = tools.patch_retriever + assert retriever is not None + targets = { + "vector_store": tool_config.vector_store, + "patch_retrieval": retriever.patch, + } + seen: dict[str, dict[str, Any]] = {section: {} for section in _SECTIONS} + for point in grid: + for section in _SECTIONS: + for field in point.get(section) or {}: + if field not in seen[section]: + seen[section][field] = getattr(targets[section], field) + return seen + + +def _restore(tools: ToolBox, defaults: dict[str, Any]) -> None: + retriever = tools.patch_retriever + assert retriever is not None + targets = { + "vector_store": tools.config.tool_config.vector_store, + "patch_retrieval": retriever.patch, + } + for section, fields in defaults.items(): + for field, value in fields.items(): + setattr(targets[section], field, value) + + +async def _sweep( + tools: ToolBox, + ontologies: list[Any], + cases: list[RecallCase], + grid: list[dict[str, Any]], + on_row: Callable[[list[dict[str, Any]]], None] | None = None, +) -> list[dict[str, Any]]: + await index_catalog(tools, ontologies) + defaults = _defaults(tools, grid) + rows: list[dict[str, Any]] = [] + for index, point in enumerate(grid, start=1): + point_id = str(point.get("id") or f"p{index:04d}") + _restore(tools, defaults) + applied = _apply(tools, point) + started = time.perf_counter() + counts = await score_cases(tools, ontologies, cases) + row = { + "id": point_id, + "applied": applied, + "seconds": round(time.perf_counter() - started, 2), + **counts.as_dict(), + } + rows.append(row) + # Persist after every point. A sweep is long enough that losing it to an + # interrupted session costs more than the write does, and a partial + # frontier is still readable -- the points are independent. + if on_row is not None: + on_row(rows) + print( + f"[{index:>4}/{len(grid)}] {point_id:<24} " + f"seed {row['seed_term_recall']:6.1%} " + f"snapshot {row['snapshot_term_recall']:6.1%} " + f"triples {row['mean_snapshot_triples']:8.1f} " + f"on-topic {row['on_topic_precision']:6.1%}", + flush=True, + ) + return rows + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--grid", required=True, type=Path, help="JSON list of points") + parser.add_argument("--out", required=True, type=Path, help="JSON results file") + parser.add_argument( + "--index-dir", + type=Path, + default=None, + help="LanceDB directory; reused across points (default: a temp dir)", + ) + args = parser.parse_args(argv) + + root = corpus_root() + if root is None: + parser.error("set ONTOCAST_RECALL_CORPUS to a corpus directory") + cases, ontologies = load_corpus(root) + grid = json.loads(args.grid.read_text()) + if not isinstance(grid, list) or not grid: + parser.error(f"{args.grid}: expected a non-empty JSON list of points") + + index_dir = args.index_dir or (args.out.parent / ".sweep-index") + index_dir.mkdir(parents=True, exist_ok=True) + ontology_dir = index_dir / "ontologies" + ontology_dir.mkdir(exist_ok=True) + + tools = build_toolbox(lancedb_store_config(index_dir, "sweep"), ontology_dir) + args.out.parent.mkdir(parents=True, exist_ok=True) + + def write(rows: list[dict[str, Any]]) -> None: + args.out.write_text( + json.dumps( + { + "corpus": root.name, + "cases": len(cases), + "ontologies": len(ontologies), + "requested_points": len(grid), + "points": rows, + }, + indent=2, + sort_keys=True, + ) + ) + + rows = asyncio.run(_sweep(tools, ontologies, cases, grid, on_row=write)) + write(rows) + print(f"\n{len(rows)} points -> {args.out}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/test_agent_facts.py b/test/test_agent_facts.py index 5c547ff6..1d1afc8a 100644 --- a/test/test_agent_facts.py +++ b/test/test_agent_facts.py @@ -10,7 +10,6 @@ from ontocast.onto.model import ( FactsCritiqueReport, FactsRenderReport, - GraphUpdateRenderReport, TripleFix, ) from ontocast.onto.ontology import Ontology @@ -19,6 +18,8 @@ from ontocast.tool.atomic import AtomicToolBox from test.snapshot_helpers import snapshot_from_ontology +pytestmark = pytest.mark.unit + criticise_facts_module = importlib.import_module("ontocast.agent.criticise_facts") render_facts_module = importlib.import_module("ontocast.agent.render_facts") @@ -67,6 +68,7 @@ async def get_llm_tool(_budget_tracker): # duplicated literal default, so a rename fails loudly here # instead of silently reverting the configured ratio. property_alias_min_ratio=0.85, + catalog_terms=lambda: set(), code_predicates=(), citation_vocabulary={}, quantity_fallback_vocabulary=None, @@ -76,29 +78,31 @@ async def get_llm_tool(_budget_tracker): @pytest.mark.anyio -async def test_render_facts_routes_to_fresh_when_graph_is_empty(monkeypatch) -> None: - calls = {"fresh": 0, "update": 0} +@pytest.mark.parametrize("with_graph", [False, True]) +async def test_render_facts_has_exactly_one_mode(monkeypatch, with_graph: bool) -> None: + """Facts rendering does not branch on prior output any more. + + It used to dispatch to an update renderer when the unit graph was non-empty. + Nothing populates that graph except a successful render, after which the + render loop is already finished -- so the branch was unreachable, and a + non-empty graph must not resurrect it. + """ + calls = {"fresh": 0} async def fake_fresh(state: UnitFactsState, tools, **kwargs) -> UnitFactsState: calls["fresh"] += 1 return state - async def fake_update(state: UnitFactsState, tools, **kwargs) -> UnitFactsState: - calls["update"] += 1 - return state - monkeypatch.setattr(render_facts_module, "render_facts_fresh", fake_fresh) - monkeypatch.setattr(render_facts_module, "render_facts_update", fake_update) state = UnitFactsState( - content_unit=_build_content_unit(with_graph=False), + content_unit=_build_content_unit(with_graph=with_graph), ontology_snapshot=snapshot_from_ontology(_build_ontology()), ) result = await render_facts_module.render_facts(state, tools=_build_tools()) assert result is state assert calls["fresh"] == 1 - assert calls["update"] == 0 @pytest.mark.anyio @@ -214,44 +218,6 @@ async def fake_call_llm_with_retry(**kwargs): assert str(obj) == "10-15" -@pytest.mark.anyio -async def test_render_facts_update_coerces_invalid_literal_in_update_graph( - monkeypatch, -) -> None: - async def fake_call_llm_with_retry(**kwargs): - bad_graph = RDFGraph._from_jsonld_obj( - { - "@context": { - "ex": "https://example.com/ns#", - "xsd": "http://www.w3.org/2001/XMLSchema#", - }, - "@graph": [ - { - "@id": "ex:item", - "ex:value": {"@value": "10-15", "@type": "xsd:decimal"}, - } - ], - } - ) - return GraphUpdateRenderReport(insert_graph=bad_graph) - - monkeypatch.setattr( - render_facts_module, "call_llm_with_retry", fake_call_llm_with_retry - ) - - unit = _build_content_unit(with_graph=True) - state = UnitFactsState( - content_unit=unit, - ontology_snapshot=snapshot_from_ontology(_build_ontology()), - ) - initial_len = len(state.content_unit.graph) - result = await render_facts_module.render_facts_update(state, tools=_build_tools()) - - assert result.status == Status.SUCCESS - assert len(result.quarantined_literal_triples) == 0 - assert len(result.content_unit.graph) == initial_len + 1 - - @pytest.mark.anyio async def test_criticise_facts_prompt_includes_graph_format_instruction( monkeypatch, @@ -401,3 +367,50 @@ async def fake_call_llm_with_retry(**kwargs): await criticise_facts_module.criticise_facts(state, tools=_build_tools()) assert "TERM INDEX" not in str(captured.get("ontology_chapter", "")) + + +@pytest.mark.anyio +async def test_criticise_facts_records_an_unavailable_critic(monkeypatch) -> None: + """A critic call that fails is a billed call that produced no critique. + + It used to leave the unit as the render left it -- SUCCESS -- so a timed + out critic was indistinguishable from one that accepted. + """ + + async def failing_call(**kwargs): + raise TimeoutError("provider did not answer") + + monkeypatch.setattr(criticise_facts_module, "call_llm_with_retry", failing_call) + + state = UnitFactsState( + content_unit=_build_content_unit(with_graph=True), + ontology_snapshot=snapshot_from_ontology(_build_ontology()), + ) + result = await criticise_facts_module.criticise_facts(state, tools=_build_tools()) + + assert result.critic_outcome == "unavailable" + assert result.status == Status.FAILED + assert result.failure_stage == FailureStage.FACTS_CRITIQUE + attempt = result.attempt_log[-1] + assert attempt.kind == "critic" + assert attempt.success is False + assert attempt.accept_reason == "critic_unavailable" + assert "did not answer" in (attempt.failure_reason or "") + + +@pytest.mark.anyio +async def test_criticise_facts_marks_a_reviewed_unit(monkeypatch) -> None: + async def fake_call_llm_with_retry(**kwargs): + return FactsCritiqueReport(success=True, score=90) + + monkeypatch.setattr( + criticise_facts_module, "call_llm_with_retry", fake_call_llm_with_retry + ) + + state = UnitFactsState( + content_unit=_build_content_unit(with_graph=True), + ontology_snapshot=snapshot_from_ontology(_build_ontology()), + ) + result = await criticise_facts_module.criticise_facts(state, tools=_build_tools()) + + assert result.critic_outcome == "reviewed" diff --git a/test/test_aggregation_config.py b/test/test_aggregation_config.py index 4c23db90..81209b8e 100644 --- a/test/test_aggregation_config.py +++ b/test/test_aggregation_config.py @@ -1,5 +1,9 @@ +import pytest + from ontocast.config import Config +pytestmark = pytest.mark.unit + def test_aggregation_config_reads_env(monkeypatch) -> None: monkeypatch.setenv("AGG_EMBEDDING_MODEL", "all-MiniLM-L6-v2") diff --git a/test/test_api_tenancy_resolution.py b/test/test_api_tenancy_resolution.py index c0dd250a..2aaf564d 100644 --- a/test/test_api_tenancy_resolution.py +++ b/test/test_api_tenancy_resolution.py @@ -24,6 +24,8 @@ from ontocast.tool.triple_manager.in_memory import InMemoryTripleStoreManager from ontocast.toolbox import ToolBox +pytestmark = pytest.mark.unit + def _http_request(query_string: bytes) -> Request: scope: dict = { diff --git a/test/test_atomic_loop_bounds.py b/test/test_atomic_loop_bounds.py index 9aba300a..74841623 100644 --- a/test/test_atomic_loop_bounds.py +++ b/test/test_atomic_loop_bounds.py @@ -18,15 +18,18 @@ from rdflib import URIRef from ontocast.onto.content_unit import ContentUnit -from ontocast.onto.enum import FailureStage, RenderMode, Status +from ontocast.onto.enum import FailureStage, RenderMode, Status, WorkflowNode +from ontocast.onto.model import Suggestions, TripleFix from ontocast.onto.rdfgraph import RDFGraph +from ontocast.onto.retrieval_capabilities import EmptyOntologyContextError from ontocast.onto.state import AgentState -from ontocast.onto.unit_states import UnitFactsState +from ontocast.onto.unit_states import UnitFactsState, UnitOntologyState from ontocast.stategraph import atomic as atomic_module -from ontocast.stategraph.atomic import _resolve_critic_visits, facts_loop +from ontocast.stategraph.atomic import facts_loop, ontology_loop from ontocast.stategraph.context_resolver import UnitOntologyContext from ontocast.stategraph.unit_context import UnitLoopContext from ontocast.tool.atomic import AtomicToolBox +from ontocast.tool.facts_validation import CriticPatchPolicy from ontocast.toolbox import ToolBox from test.snapshot_helpers import empty_snapshot @@ -45,17 +48,29 @@ def _unit_state(**kwargs) -> UnitFactsState: ) -def _tools() -> ToolBox: +def _tools(critic_passes: int = 1, *, critic_min_triples: int = 0) -> ToolBox: return cast( ToolBox, SimpleNamespace( get_atomic_tools=lambda: cast( AtomicToolBox, SimpleNamespace( - facts_llm_repair_visits=1, + facts_critic_passes=critic_passes, + facts_patch_policy=CriticPatchPolicy(), + ontology_critic_passes=critic_passes, + ontology_patch_policy=CriticPatchPolicy(), additional_standard_namespaces=(), validation_policy=None, acceptance_policy=None, + ontology_acceptance_policy=None, + numeric_coverage_limit=30, + numeric_coverage_mandatory=False, + # The stub renders leave the graph empty, so the floor + # that skips empty renders must be off for the critic to + # be reached at all; the skip itself is tested below. + facts_critic_min_triples=critic_min_triples, + facts_completion_passes=0, + catalog_terms=lambda: set(), ), ), ), @@ -110,10 +125,8 @@ async def exploding_critic(state, tools): monkeypatch.setattr(atomic_module, "render_facts", ok_render) monkeypatch.setattr(atomic_module, "criticise_facts", exploding_critic) - # max_visits >= 2 so a render attempt exists that is not the final one, - # which is the only case where the critic runs at all. result = await facts_loop( - _unit_state(max_visits_per_node=2), + _unit_state(), _tools(), _context(), pre_resolved_context=_resolved_context(), @@ -123,31 +136,20 @@ async def exploding_critic(state, tools): assert result.failure_stage == FailureStage.FACTS_CRITIQUE -# -- 3c: the critic loop has its own bound --------------------------------- - - -def test_critic_visits_default_to_the_legacy_coupling() -> None: - """Unset keeps today's behaviour: the critic shares the render bound.""" - assert _resolve_critic_visits(_unit_state(max_visits_per_node=3)) == 3 - - -def test_critic_visits_can_be_decoupled_from_render_visits() -> None: - state = _unit_state(max_visits_per_node=3, max_critic_visits_per_node=1) - assert _resolve_critic_visits(state) == 1 +# -- 3c: the two budgets are independent ----------------------------------- @pytest.mark.anyio async def test_a_rejecting_critic_does_not_escalate_to_another_render( monkeypatch, ) -> None: - """The Step-4 ledger: rejection buys a repair, not a re-extraction. + """Rejection buys a patch, not a re-extraction. A rejecting critic used to fall through to the next ``render_attempt``, re-extracting the unit from scratch -- so a unit's worst-case cost grew with MAX_VISITS and the answer to "this term is wrong" was "write the whole - unit again". Its fixes now go through the same bounded rewrite-in-place - pass the deterministic findings use, so the render count is flat in - MAX_VISITS. + unit again". MAX_VISITS now bounds render *failures* only, so the render + count is flat in it. """ calls = {"render": 0, "critic": 0} @@ -167,41 +169,134 @@ async def failing_critic(state, tools): monkeypatch.setattr(atomic_module, "criticise_facts", failing_critic) await facts_loop( - _unit_state(max_visits_per_node=3, max_critic_visits_per_node=1), + _unit_state(max_visits_per_node=3), _tools(), _context(), pre_resolved_context=_resolved_context(), ) - # One render, one critique, then the repair pass -- whatever the bound. + # One render, one critique, then the patch pass -- whatever the bound. assert calls["render"] == 1 assert calls["critic"] == 1 @pytest.mark.anyio -async def test_critic_without_a_search_request_does_not_iterate(monkeypatch) -> None: - """Measures how reachable the inner critic loop actually is. - - A critic that fails *without* requesting external evidence breaks out of - the inner loop immediately and the unit goes to the repair pass, so with web - grounding off -- the default -- the critic runs exactly once per unit - whatever the bound is. The quadratic worst case the bound exists to cap is - reachable only through the evidence-request path, i.e. with - ``WEB_SEARCH_ENABLED=true``. +async def test_the_critic_runs_once_per_configured_pass(monkeypatch) -> None: + """The pass count is the critic's budget, and MAX_VISITS is not. + + The old loop reached the critic only when a render attempt was left over, + and could then re-run it within one attempt via the external-evidence path, + so its worst case was MAX_VISITS squared and its *best* case at the default + was zero. Both are gone: passes are counted directly. """ + calls = {"render": 0, "critic": 0} + + async def ok_render(state, tools, **kwargs): + calls["render"] += 1 + state.status = Status.SUCCESS + return state + + async def improving_critic(state, tools): + # Each pass must actually change something, or the loop converges: a + # second critique of an unchanged graph is a second identical answer at + # full price, which is exactly what convergence exists to avoid. + calls["critic"] += 1 + state.suggestions = Suggestions( + actionable_fixes=[ + TripleFix( + text_fragment="Alice", + action="ADD", + severity="important", + correct_value=( + f" " + f' "v" .' + ), + explanation="add one statement", + ) + ] + ) + return state + + monkeypatch.setattr(atomic_module, "render_facts", ok_render) + monkeypatch.setattr(atomic_module, "criticise_facts", improving_critic) + + await facts_loop( + _unit_state(max_visits_per_node=3), + _tools(critic_passes=2), + _context(), + pre_resolved_context=_resolved_context(), + ) + + assert calls == {"render": 1, "critic": 2} + + +@pytest.mark.anyio +async def test_a_pass_that_changes_nothing_ends_the_loop(monkeypatch) -> None: + """A second critique of an unchanged graph is the same answer, billed twice.""" calls = {"critic": 0} async def ok_render(state, tools, **kwargs): state.status = Status.SUCCESS return state - async def failing_critic(state, tools): + async def barren_critic(state, tools): calls["critic"] += 1 state.status = Status.FAILED return state monkeypatch.setattr(atomic_module, "render_facts", ok_render) - monkeypatch.setattr(atomic_module, "criticise_facts", failing_critic) + monkeypatch.setattr(atomic_module, "criticise_facts", barren_critic) + + await facts_loop( + _unit_state(), + _tools(critic_passes=5), + _context(), + pre_resolved_context=_resolved_context(), + ) + + assert calls["critic"] == 1 + + +@pytest.mark.anyio +async def test_zero_passes_costs_exactly_one_call(monkeypatch) -> None: + """Extraction only, with the deterministic findings still collected.""" + calls = {"render": 0, "critic": 0} + + async def ok_render(state, tools, **kwargs): + calls["render"] += 1 + state.status = Status.SUCCESS + return state + + async def critic(state, tools): + calls["critic"] += 1 + return state + + monkeypatch.setattr(atomic_module, "render_facts", ok_render) + monkeypatch.setattr(atomic_module, "criticise_facts", critic) + + result = await facts_loop( + _unit_state(max_visits_per_node=3), + _tools(critic_passes=0), + _context(), + pre_resolved_context=_resolved_context(), + ) + + assert calls == {"render": 1, "critic": 0} + # The residual metric sums this field across units, so a unit that skipped + # the critic must still report what the machine found. + assert result.deterministic_findings is not None + + +@pytest.mark.anyio +async def test_max_visits_retries_only_a_failed_render(monkeypatch) -> None: + calls = {"render": 0} + + async def failing_render(state, tools, **kwargs): + calls["render"] += 1 + state.status = Status.FAILED + return state + + monkeypatch.setattr(atomic_module, "render_facts", failing_render) await facts_loop( _unit_state(max_visits_per_node=3), @@ -210,5 +305,207 @@ async def failing_critic(state, tools): pre_resolved_context=_resolved_context(), ) - # One critique, then the repair pass -- the bound of 3 buys nothing. - assert calls["critic"] == 1 + assert calls["render"] == 3 + + +# -- when the critic is not spent, and when it does not answer --------------- + + +@pytest.mark.anyio +async def test_the_critic_is_skipped_on_an_empty_render(monkeypatch) -> None: + """A critic shown an empty graph scores it perfect and bills a call.""" + calls = {"critic": 0} + + async def ok_render(state, tools, **kwargs): + state.status = Status.SUCCESS + return state + + async def critic(state, tools): + calls["critic"] += 1 + return state + + monkeypatch.setattr(atomic_module, "render_facts", ok_render) + monkeypatch.setattr(atomic_module, "criticise_facts", critic) + + result = await facts_loop( + _unit_state(), + _tools(critic_min_triples=1), + _context(), + pre_resolved_context=_resolved_context(), + ) + + assert calls["critic"] == 0 + assert result.critic_outcome == "skipped" + skipped = [a for a in result.attempt_log if a.kind == "critic_skipped"] + assert [a.accept_reason for a in skipped] == ["empty_render"] + assert result.status == Status.SUCCESS + + +@pytest.mark.anyio +async def test_the_critic_is_skipped_on_citation_metadata(monkeypatch) -> None: + calls = {"critic": 0} + + async def ok_render(state, tools, **kwargs): + state.content_unit.graph.add( + ( + URIRef("https://example.com/w1"), + URIRef("https://schema.org/name"), + URIRef("https://example.com/n"), + ) + ) + state.status = Status.SUCCESS + return state + + async def critic(state, tools): + calls["critic"] += 1 + return state + + monkeypatch.setattr(atomic_module, "render_facts", ok_render) + monkeypatch.setattr(atomic_module, "criticise_facts", critic) + + unit = ContentUnit( + text="[1] A. Author, J. Stuff 12 (2019) 34.", + index=0, + doc_iri=URIRef("https://example.com/doc/d1"), + graph=RDFGraph(), + is_citation_metadata=True, + ) + result = await facts_loop( + UnitFactsState(content_unit=unit, ontology_snapshot=empty_snapshot()), + _tools(critic_min_triples=1), + _context(), + pre_resolved_context=_resolved_context(), + ) + + assert calls["critic"] == 0 + assert result.critic_outcome == "skipped" + assert result.attempt_log[-1].accept_reason == "citation_metadata" + + +@pytest.mark.anyio +async def test_an_unavailable_critic_leaves_the_render_unreviewed( + monkeypatch, +) -> None: + """A critic that did not answer has not accepted. + + The unit used to leave the loop SUCCESS with whatever suggestions the + state happened to hold; now it leaves FAILED at the critique stage with + its render intact, and nothing is compiled against a critique that never + arrived. + """ + triple = ( + URIRef("https://example.com/s"), + URIRef("https://example.com/p"), + URIRef("https://example.com/o"), + ) + + async def ok_render(state, tools, **kwargs): + state.content_unit.graph.add(triple) + state.status = Status.SUCCESS + return state + + async def unavailable_critic(state, tools): + # What the agent's failure branch records: an outcome, a billed + # attempt, and stale suggestions that must not be applied. + state.critic_outcome = "unavailable" + state.suggestions = Suggestions( + actionable_fixes=[ + TripleFix( + text_fragment="Alice", + action="ADD", + severity="critical", + correct_value=' "v" .', + explanation="stale", + ) + ] + ) + state.set_failure(FailureStage.FACTS_CRITIQUE, "request timed out") + return state + + monkeypatch.setattr(atomic_module, "render_facts", ok_render) + monkeypatch.setattr(atomic_module, "criticise_facts", unavailable_critic) + + result = await facts_loop( + _unit_state(), + _tools(critic_passes=2), + _context(), + pre_resolved_context=_resolved_context(), + ) + + assert result.status == Status.FAILED + assert result.failure_stage == FailureStage.FACTS_CRITIQUE + assert set(result.content_unit.graph) == {triple}, "the render is kept as is" + assert result.critic_fixes_applied == 0 + assert not [a for a in result.attempt_log if a.kind == "critic_patch"] + + +# -- a deployment fault is not a unit failure ------------------------------- + + +@pytest.mark.anyio +async def test_an_unresolvable_ontology_context_stops_the_run(monkeypatch) -> None: + """``ONTOLOGY_CONTEXT_REQUIRED`` promises the run stops; this is where. + + The blanket handler caught the error alongside genuine per-unit faults and + recorded it as a render failure. Since the cause is the deployment, every + sibling unit hit it too, so the fan-out finished, wrote a zero-triple + manifest and exited successfully -- the vacuous pass the setting exists to + prevent, with one traceback per unit burying the cause. + """ + + async def refuse(state, tools, **kwargs): # pragma: no cover - never reached + raise AssertionError("render must not run without an ontology context") + + async def empty_context(unit_state, context, tools): + raise EmptyOntologyContextError("catalog is empty") + + monkeypatch.setattr(atomic_module, "render_facts", refuse) + monkeypatch.setattr(atomic_module, "_apply_facts_ontology_context", empty_context) + + with pytest.raises(EmptyOntologyContextError): + await facts_loop(_unit_state(), _tools(), _context()) + + +@pytest.mark.anyio +async def test_the_ontology_loop_reaches_the_renderer_with_an_empty_catalog( + monkeypatch, +) -> None: + """The bootstrap journey, at the seam where it used to die. + + An ontology unit resolves its own context, and with no catalog that context + is empty -- which is the signal ``render_ontology`` reads to mint a fresh + ontology, not a fault. Asserting the resolver is asked for the exemption + pins the wiring: the resolver's own exemption is useless if the loop does + not request it. + """ + asked: list[bool] = [] + rendered: list[str] = [] + + async def resolve(context, tools, unit, *, can_create_vocabulary=False): + asked.append(can_create_vocabulary) + return UnitOntologyContext( + snapshot=empty_snapshot(), writable_iris=[], confidence=0.0 + ) + + async def render(state, tools, **kwargs): + rendered.append("ontology") + state.set_node_status(WorkflowNode.TEXT_TO_ONTOLOGY, Status.SUCCESS) + return state + + monkeypatch.setattr(atomic_module, "resolve_unit_ontology_context", resolve) + monkeypatch.setattr(atomic_module, "render_ontology", render) + + state = UnitOntologyState( + content_unit=ContentUnit( + text="Alice works for ACME.", + index=0, + doc_iri=URIRef("https://example.com/doc/d1"), + graph=RDFGraph(), + ), + ontology_snapshot=empty_snapshot(), + ) + result = await ontology_loop(state, _tools(critic_passes=0), _context()) + + assert asked == [True] + assert rendered == ["ontology"] + assert result.status != Status.FAILED diff --git a/test/test_budget_attribution.py b/test/test_budget_attribution.py index b1e507ef..faaf2de9 100644 --- a/test/test_budget_attribution.py +++ b/test/test_budget_attribution.py @@ -14,6 +14,8 @@ from ontocast.onto.state import BudgetTracker from ontocast.tool.llm import _active_budget_tracker, use_budget_tracker +pytestmark = pytest.mark.unit + def test_scope_restores_previous_tracker() -> None: outer = BudgetTracker() diff --git a/test/test_budget_tracker.py b/test/test_budget_tracker.py index 4bf5d495..45aa8e8d 100644 --- a/test/test_budget_tracker.py +++ b/test/test_budget_tracker.py @@ -1,8 +1,12 @@ """Tests for BudgetTracker usage and merge behavior.""" +import pytest + from ontocast.onto.state import UNIT_SUM_SUFFIX, BudgetTracker from ontocast.onto.token_usage import TokenUsage +pytestmark = pytest.mark.unit + def test_add_usage_records_chars_and_tokens() -> None: tracker = BudgetTracker() diff --git a/test/test_cache_lifecycle.py b/test/test_cache_lifecycle.py index 402e1801..0102baa6 100644 --- a/test/test_cache_lifecycle.py +++ b/test/test_cache_lifecycle.py @@ -12,6 +12,8 @@ from ontocast.config import PathConfig from ontocast.tool.cache import Cacher, _get_default_cache_dir, _running_under_pytest +pytestmark = pytest.mark.unit + @pytest.fixture def cacher(tmp_path) -> Cacher: diff --git a/test/test_chapter_determinism.py b/test/test_chapter_determinism.py new file mode 100644 index 00000000..7114344d --- /dev/null +++ b/test/test_chapter_determinism.py @@ -0,0 +1,131 @@ +"""The ontology chapter must be byte-identical for the same catalog, always. + +The chapter text is a cache key twice over: the LLM disk cache is keyed on the +prompt string, and a provider's prefix cache on its prefix. A chapter that +differs run to run for the same input is one neither cache can ever serve, and +the difference is invisible -- same triples, same length, different bytes. +""" + +from __future__ import annotations + +import subprocess +import sys +import textwrap + +import pytest +from rdflib import OWL, RDF, RDFS, BNode, Literal, Namespace + +from ontocast.onto.enum import LLMGraphFormat, OntologyChapterFormat +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.prompt.graph_format import get_graph_format_profile + +EX = Namespace("https://example.org/onto#") + + +def _graph_with_restrictions() -> RDFGraph: + """Blank nodes in structurally distinct positions, as an ontology has.""" + graph = RDFGraph() + graph.bind("ex", EX) + for index in range(6): + cls = EX[f"C{index}"] + graph.add((cls, RDF.type, OWL.Class)) + graph.add((cls, RDFS.label, Literal(f"class {index}"))) + restriction = BNode() + graph.add((cls, RDFS.subClassOf, restriction)) + graph.add((restriction, RDF.type, OWL.Restriction)) + graph.add((restriction, OWL.onProperty, EX[f"p{index}"])) + graph.add((restriction, OWL.someValuesFrom, EX[f"D{index}"])) + return graph + + +@pytest.mark.parametrize( + "chapter_format", + [OntologyChapterFormat.INHERIT, OntologyChapterFormat.TURTLE], +) +def test_relabelling_bnodes_does_not_change_the_chapter( + chapter_format: OntologyChapterFormat, +) -> None: + """Two parses of the same source differ only in random blank-node labels.""" + profile = get_graph_format_profile( + LLMGraphFormat.JSONLD, ontology_chapter_format=chapter_format + ) + first = _graph_with_restrictions() + turtle = first.serialize_canonical_turtle() + second = RDFGraph() + second.bind("ex", EX) + second.parse(data=turtle, format="turtle") + + assert profile.format_ontology_chapter( + first, max_triples=None + ) == profile.format_ontology_chapter(second, max_triples=None) + + +def test_literals_differing_only_in_language_tag_order_stably() -> None: + """The lexical form alone is not a sort key. + + Two literals can share it and differ only in language tag; sorting on the + shared value leaves their order to the iteration, which is the exact + nondeterminism this guards -- surviving where it is hardest to see. + """ + graph = RDFGraph() + graph.bind("ex", EX) + graph.add((EX.T, RDF.type, OWL.Class)) + graph.add((EX.T, RDFS.comment, Literal("same text", lang="en"))) + graph.add((EX.T, RDFS.comment, Literal("same text", lang="en-US"))) + graph.add((EX.T, RDFS.label, Literal("same text"))) + profile = get_graph_format_profile(LLMGraphFormat.JSONLD) + + rendered = { + profile.format_ontology_chapter(graph, max_triples=None) for _ in range(5) + } + + assert len(rendered) == 1 + + +def test_chapter_is_identical_across_processes() -> None: + """The property that matters: two runs, two interpreters, same bytes. + + Python randomises string hashing per process, so rdflib's iteration order -- + and every serialization that follows it -- differs between runs unless it is + made explicit. Only a subprocess can show that; within one process the seed + is fixed and the bug is invisible. + """ + script = textwrap.dedent( + """ + import hashlib + from rdflib import OWL, RDF, RDFS, BNode, Literal, Namespace + from ontocast.onto.enum import LLMGraphFormat + from ontocast.onto.rdfgraph import RDFGraph + from ontocast.prompt.graph_format import get_graph_format_profile + + EX = Namespace("https://example.org/onto#") + g = RDFGraph() + g.bind("ex", EX) + for i in range(6): + c = EX[f"C{i}"] + g.add((c, RDF.type, OWL.Class)) + g.add((c, RDFS.label, Literal(f"class {i}"))) + g.add((c, RDFS.comment, Literal("shared", lang="en"))) + g.add((c, RDFS.comment, Literal("shared", lang="en-US"))) + b = BNode() + g.add((c, RDFS.subClassOf, b)) + g.add((b, RDF.type, OWL.Restriction)) + g.add((b, OWL.onProperty, EX[f"p{i}"])) + g.add((b, OWL.someValuesFrom, EX[f"D{i}"])) + p = get_graph_format_profile(LLMGraphFormat.JSONLD) + print(hashlib.sha1(p.format_ontology_chapter(g, max_triples=None).encode()).hexdigest()) + """ + ) + digests = { + subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + check=True, + # A different hash seed per run is what makes this a real test. + env={"PYTHONHASHSEED": str(seed), "PATH": "/usr/bin:/bin"}, + ).stdout.strip() + for seed in (1, 2, 3) + } + + assert len(digests) == 1, f"chapter differs between processes: {digests}" diff --git a/test/test_cli_seed_directories.py b/test/test_cli_seed_directories.py new file mode 100644 index 00000000..a3177591 --- /dev/null +++ b/test/test_cli_seed_directories.py @@ -0,0 +1,145 @@ +"""Seed-directory resolution for ``ontocast serve`` and ``ontocast process``. + +A directory the operator named is an assertion, and the three ways it can fail +are not the same fault. These pin which of them stops a run. +""" + +from __future__ import annotations + +import logging +import pathlib +import tempfile + +import click +import pytest + +from ontocast.cli.server import _prepare_path_config, _resolve_seed_directory +from ontocast.config import Config, FactsValidationConfig, PathConfig, ToolConfig + +pytestmark = pytest.mark.unit + + +def _config(ontology_directory=None, shapes_dir=None) -> Config: + return Config( + tool_config=ToolConfig( + path_config=PathConfig(ontology_directory=ontology_directory), + facts_validation=FactsValidationConfig(shapes_dir=shapes_dir), + ) + ) + + +# --- _resolve_seed_directory ------------------------------------------------ + + +def test_an_unnamed_directory_is_not_a_fault() -> None: + assert ( + _resolve_seed_directory( + None, + source="ONTOCAST_ONTOLOGY_DIRECTORY", + kind="ontology", + missing_is_fatal=True, + ) + is None + ) + + +def test_a_named_directory_that_does_not_resolve_stops_a_batch_run() -> None: + """The failure that costs the most to diagnose anywhere else. + + Nothing downstream can tell a mistyped path from "deliberately none", so + the catalog just comes out empty and every symptom surfaces minutes later + inside retrieval, naming the vector index rather than the typo. + """ + with pytest.raises(click.UsageError) as excinfo: + _resolve_seed_directory( + "/nonexistent/ontologies", + source="--ontology-dir", + kind="ontology", + missing_is_fatal=True, + ) + + message = str(excinfo.value) + assert "--ontology-dir" in message, "the error must name what asserted the path" + assert "/nonexistent/ontologies" in message + + +def test_a_named_directory_that_does_not_resolve_only_warns_a_server(caplog) -> None: + """A long-lived server may have the directory appear under it later.""" + with caplog.at_level(logging.WARNING): + _resolve_seed_directory( + "/nonexistent/ontologies", + source="ONTOCAST_ONTOLOGY_DIRECTORY", + kind="ontology", + missing_is_fatal=False, + ) + + assert "/nonexistent/ontologies" in caplog.text + + +def test_a_resolvable_directory_is_expanded_and_logged(caplog) -> None: + with tempfile.TemporaryDirectory() as tmp: + with caplog.at_level(logging.INFO): + resolved = _resolve_seed_directory( + tmp, source="--ontology-dir", kind="ontology", missing_is_fatal=True + ) + + assert resolved == pathlib.Path(tmp) + assert str(pathlib.Path(tmp).absolute()) in caplog.text + + +# --- _prepare_path_config: the three states of each flag -------------------- + + +def test_an_omitted_flag_leaves_the_environment_in_force() -> None: + with tempfile.TemporaryDirectory() as tmp: + config = _config(ontology_directory=tmp, shapes_dir=tmp) + + _prepare_path_config(config) + + assert config.tool_config.path_config.ontology_directory == pathlib.Path(tmp) + assert config.tool_config.facts_validation.shapes_dir == tmp + + +def test_a_flag_overrides_the_environment() -> None: + with tempfile.TemporaryDirectory() as env_dir, tempfile.TemporaryDirectory() as cli: + config = _config(ontology_directory=env_dir, shapes_dir=env_dir) + + _prepare_path_config(config, ontology_dir=cli, shapes_dir=cli) + + assert config.tool_config.path_config.ontology_directory == pathlib.Path(cli) + assert config.tool_config.facts_validation.shapes_dir == cli + + +def test_an_empty_flag_clears_a_configured_directory() -> None: + """How a run declares "no seed ontologies, infer them". + + This is why the flags are plain strings rather than ``click.Path``: + ``pathlib.Path("")`` is ``.``, so an empty value would otherwise seed the + working directory. + """ + with tempfile.TemporaryDirectory() as tmp: + config = _config(ontology_directory=tmp, shapes_dir=tmp) + + _prepare_path_config(config, ontology_dir="", shapes_dir="") + + assert config.tool_config.path_config.ontology_directory is None + assert config.tool_config.facts_validation.shapes_dir is None + + +def test_clearing_a_directory_is_never_the_missing_path_error() -> None: + """An explicit "none" is an assertion that resolves, not one that fails.""" + with tempfile.TemporaryDirectory() as tmp: + config = _config(ontology_directory=tmp) + + _prepare_path_config(config, ontology_dir="", missing_is_fatal=True) + + assert config.tool_config.path_config.ontology_directory is None + + +def test_a_batch_run_rejects_a_mistyped_flag() -> None: + config = _config() + + with pytest.raises(click.UsageError): + _prepare_path_config( + config, ontology_dir="/nonexistent/ontologies", missing_is_fatal=True + ) diff --git a/test/test_cli_server.py b/test/test_cli_server.py index 5dcee508..00d0e4b0 100644 --- a/test/test_cli_server.py +++ b/test/test_cli_server.py @@ -3,6 +3,7 @@ from typing import cast import pytest +from langgraph.graph.state import CompiledStateGraph from rdflib import RDF, URIRef from starlette.testclient import TestClient @@ -14,6 +15,7 @@ resolve_ontology_context_mode, ) from ontocast.api.process_helpers import ( + GRAPH_RECURSION_LIMIT, calculate_recursion_limit, persist_unit_pipeline_outputs, select_unit_facts_ontology_graph, @@ -40,6 +42,8 @@ from ontocast.tool.converter import ConverterTool from ontocast.toolbox import ToolBox +pytestmark = pytest.mark.unit + def test_parse_ontology_context_mode_param_accepts_request_override() -> None: result = parse_ontology_context_mode_param( @@ -85,16 +89,26 @@ def test_parse_max_visits_param_rejects_non_numeric_values() -> None: parse_max_visits_param("abc", default=1) -def test_calculate_recursion_limit_uses_per_request_max_visits() -> None: - server_config = ServerConfig( - max_visits_per_node=1, - base_recursion_limit=10, - estimated_chunks=10, +def test_recursion_limit_does_not_vary_with_document_or_visit_budget() -> None: + """The document graph is a DAG, so its depth is a topology constant. + + Unit fan-out happens inside a node, not as graph edges, and retry budgets + are enforced in ``run_unit_loop`` -- so neither the chunk count nor the + visit budget can lengthen the graph. The formula that scaled the limit by + both resolved to the same floor in every shipped configuration. + """ + server_config = ServerConfig(max_visits_per_node=1) + + limits = { + calculate_recursion_limit(head_chunks, server_config, max_visits_per_node=v) + for head_chunks in (None, 1, 5, 500) + for v in (None, 1, 4) + } + assert limits == {GRAPH_RECURSION_LIMIT} + assert GRAPH_RECURSION_LIMIT > 12, ( + "must comfortably exceed the graph's longest path so a topology change " + "fails on its own merits rather than as a recursion error" ) - default_limit = calculate_recursion_limit(5, server_config) - override_limit = calculate_recursion_limit(5, server_config, max_visits_per_node=4) - assert default_limit == 50 - assert override_limit == 200 def test_build_agent_state_from_parsed_sets_max_visits() -> None: @@ -322,6 +336,8 @@ def postprocess_facts_units( SimpleNamespace( aggregator=_Aggregator(), shapes_catalog=SimpleNamespace(graph=lambda: None), + shapes_prompt_contract=lambda: ("", (), False), + ontology_manager=SimpleNamespace(catalog_terms=lambda: set()), config=Config(), ), ) @@ -660,6 +676,34 @@ def test_dump_facts_ttl_writes_the_graph(tmp_path) -> None: assert dump_facts_ttl(state, src, output_dir=out_dir) == out_dir / "paper.facts.ttl" +def test_dump_facts_ttl_can_keep_provenance(tmp_path) -> None: + """The batch dump must be able to emit a traceable graph. + + Stripping stays the default; without the option a batch output carries no + chunk references at all, so nothing in it can be traced back to a source + span or re-verified against the document. + """ + from rdflib import Literal + + from ontocast.api.process_helpers import dump_facts_ttl + from ontocast.onto.constants import PROV + + src = tmp_path / "paper.pdf" + src.write_bytes(b"x") + state = _batch_state() + state.aggregated_facts.add((state.doc_iri, PROV.wasDerivedFrom, Literal("chunk-3"))) + + stripped = dump_facts_ttl(state, src, output_dir=tmp_path / "stripped") + assert stripped is not None + assert "wasDerivedFrom" not in stripped.read_text(encoding="utf-8") + + kept = dump_facts_ttl( + state, src, output_dir=tmp_path / "kept", strip_provenance=False + ) + assert kept is not None + assert "wasDerivedFrom" in kept.read_text(encoding="utf-8") + + def test_dump_run_manifest_records_cost_and_configuration(tmp_path) -> None: import json @@ -699,6 +743,20 @@ def test_dump_run_manifest_records_cost_and_configuration(tmp_path) -> None: "model_name": "kimi-k3", "temperature": 0.0, "think": True, + "max_inflight": 16, + } + # The settings that move cost without moving any generation setting beside + # them. Two runs used to be able to differ several-fold in tokens with + # nothing in either manifest to say why. + assert payload["prompting"] == { + "llm_graph_format": "jsonld", + "ontology_chapter_format": "inherit", + "ontology_context_scope": "unit", + "fanout_warmup_units": 0, + "parallel_workers": 16, + "embedding_model_name": ( + "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" + ), } # Billed and replayed stay distinct all the way to disk -- the whole point # of persisting this is comparing runs, and a replay is not a cost. @@ -709,6 +767,61 @@ def test_dump_run_manifest_records_cost_and_configuration(tmp_path) -> None: assert payload["ontology_triples"] == len(state.reduced_ontology_artifacts[0].graph) +def test_dump_run_manifest_populates_completion_from_facts_loop_telemetry( + tmp_path, +) -> None: + """``RunManifest.completion`` reads the same telemetry ``critic`` does. + + All-zero when the completion pass never ran (the library default), and + reflecting the attempt log once it has -- ``summarize_completion``'s own + contract, wired into the manifest that ships beside the facts dump. + """ + import json + + from ontocast.api.process_helpers import dump_run_manifest + from ontocast.config import Config + from ontocast.onto.model import LoopAttempt + + src = tmp_path / "paper.pdf" + src.write_bytes(b"x") + + state = _batch_state() + out_off = dump_run_manifest( + state, src, config=Config(), output_dir=tmp_path / "off" + ) + assert out_off is not None + payload_off = json.loads(out_off.read_text(encoding="utf-8")) + assert payload_off["completion"] == { + "calls": 0, + "units": 0, + "subjects_inserted": 0, + "subjects_rolled_back": 0, + "triples_inserted": 0, + "measurements_recovered": 0, + } + + state.facts_loop_telemetry[0] = [ + LoopAttempt( + kind="completion", + n_fixes_applied=1, + n_fixes_rolled_back=1, + n_triples_inserted=3, + n_measurements_recovered=2, + ) + ] + out_on = dump_run_manifest(state, src, config=Config(), output_dir=tmp_path / "on") + assert out_on is not None + payload_on = json.loads(out_on.read_text(encoding="utf-8")) + assert payload_on["completion"] == { + "calls": 1, + "units": 1, + "subjects_inserted": 1, + "subjects_rolled_back": 1, + "triples_inserted": 3, + "measurements_recovered": 2, + } + + def test_dump_run_manifest_uses_the_line_number_for_jsonl_inputs(tmp_path) -> None: from ontocast.api.process_helpers import dump_run_manifest from ontocast.config import Config @@ -719,6 +832,60 @@ def test_dump_run_manifest_uses_the_line_number_for_jsonl_inputs(tmp_path) -> No assert out == tmp_path / "corpus.L3.run.json" +def test_dump_validation_report_carries_unit_repairs_and_failures(tmp_path) -> None: + """A predicate the machine substituted is invisible in the TTL; name it. + + ``gate_repairs`` covered the document-level gate only. The per-unit + passes (alias rewrites, literal coercions) were logged and dropped, so an + audit of what the machine changed in a render had to be mined from logs + -- and a unit that failed looked like a unit that found nothing. + """ + import json + + from ontocast.api.process_helpers import dump_validation_report + from ontocast.onto.model import ( + FactsUnitFindingKind, + GraphRepairRecord, + UnitFailure, + ) + + src = tmp_path / "paper.pdf" + src.write_bytes(b"x") + state = _batch_state() + state.facts_repairs_applied = { + 3: [ + GraphRepairRecord( + kind=FactsUnitFindingKind.PROPERTY_ALIAS, + source="ex:hasASiteComponent", + target="ex:hasBSiteComponent", + triple_count=2, + ) + ] + } + state.unit_failures = [UnitFailure(unit_index=5, phase="facts", stage="render")] + + out = dump_validation_report(state, src, output_dir=tmp_path / "out") + assert out is not None + assert out == tmp_path / "out" / "paper.facts.validation.json" + + payload = json.loads(out.read_text(encoding="utf-8")) + assert payload["unit_repairs"] == { + "3": [ + { + "kind": "property_alias", + "source": "ex:hasASiteComponent", + "target": "ex:hasBSiteComponent", + "triple_count": 2, + } + ] + } + assert payload["unit_failures"] == [ + {"unit_index": 5, "phase": "facts", "stage": "render", "reason": None} + ] + # Additive: readers of the previous shape find every key they knew. + assert set(payload) >= {"source", "conformance", "gate_repairs", "findings"} + + def test_dump_ontology_ttls_names_files_per_ontology(tmp_path) -> None: from ontocast.api.process_helpers import dump_ontology_ttls @@ -872,6 +1039,8 @@ def postprocess_facts_units(self, units, ontology_graph, **kwargs): SimpleNamespace( aggregator=_Aggregator(), shapes_catalog=SimpleNamespace(graph=lambda: stored_shapes), + shapes_prompt_contract=lambda: ("", (), False), + ontology_manager=SimpleNamespace(catalog_terms=lambda: set()), config=SimpleNamespace( get_tool_config=lambda: SimpleNamespace( facts_validation=FactsValidationConfig.model_construct() @@ -906,3 +1075,120 @@ def postprocess_facts_units(self, units, ontology_graph, **kwargs): str(XSD.decimal) in payload["data"]["facts"] or "xsd:decimal" in (payload["data"]["facts"]) ) + + +def _batch_toolbox() -> ToolBox: + """A ToolBox light enough for a batch-loop test: no external services.""" + from ontocast.config import LLMConfig, LLMProvider, PathConfig, ToolConfig + from ontocast.config.settings import OllamaModel + + return ToolBox( + Config( + tool_config=ToolConfig( + path_config=PathConfig(), + llm_config=LLMConfig( + provider=LLMProvider.OLLAMA, + model_name=OllamaModel.LLAMA3_1, + base_url="http://localhost:11434", + ), + ), + ) + ) + + +class _RejectingWorkflow: + """A workflow whose every call is refused by the provider.""" + + def astream(self, *args, **kwds): + from ontocast.tool.llm import LLMConfigurationError + + async def _stream(): + raise LLMConfigurationError("openai/gpt-x rejected the request") + yield # pragma: no cover - makes this an async generator + + return _stream() + + +def test_a_rejected_request_aborts_the_batch_and_writes_nothing(tmp_path) -> None: + """The failure this exists for: every call refused, run still "succeeded". + + Observed: the batch swallowed the rejection per unit, serialized an empty + graph, dumped a run manifest and a validation report next to no facts, and + exited 0 -- artifacts a downstream aggregator cannot tell from real ones. + Every remaining file would have paid for its own conversion to reach the + same rejection. + """ + from ontocast.api.process_helpers import process_files_input + from ontocast.tool.llm import LLMConfigurationError + + tools = _batch_toolbox() + first = tmp_path / "a.txt" + first.write_text("some text", encoding="utf-8") + second = tmp_path / "b.txt" + second.write_text("more text", encoding="utf-8") + out_dir = tmp_path / "out" + out_dir.mkdir() + + with pytest.raises(LLMConfigurationError): + asyncio.run( + process_files_input( + [first, second], + config=tools.config, + head_chunks=None, + use_unit_pipeline=False, + tools=tools, + workflow=cast(CompiledStateGraph, _RejectingWorkflow()), + ontology_context_mode_value=OntologyContextMode.SELECTED_SINGLE_ONTOLOGY, + tenant=None, + project=None, + output_dir=out_dir, + ) + ) + + assert list(out_dir.iterdir()) == [] + + +class _AllUnitsFailedWorkflow: + """A workflow that ran to the end with every unit dead.""" + + def astream(self, *args, **kwds): + from ontocast.onto.enum import Status + + async def _stream(): + yield {"status": Status.FAILED, "aggregated_facts": RDFGraph()} + + return _stream() + + +def test_a_document_whose_every_unit_failed_is_recorded_as_failed(tmp_path) -> None: + """The generic backstop, for causes the rejection classifier does not catch. + + The map nodes already computed FAILED for a document that produced nothing + and merge_facts preserved it -- the batch path just never read it, so the + run exited 0. cli/server.py turns a non-empty failed_files into a non-zero + exit, so nothing else is needed to make it scriptable. + """ + from ontocast.api.process_helpers import process_files_input + + tools = _batch_toolbox() + src = tmp_path / "a.txt" + src.write_text("some text", encoding="utf-8") + out_dir = tmp_path / "out" + out_dir.mkdir() + + failed = asyncio.run( + process_files_input( + [src], + config=tools.config, + head_chunks=None, + use_unit_pipeline=False, + tools=tools, + workflow=cast(CompiledStateGraph, _AllUnitsFailedWorkflow()), + ontology_context_mode_value=OntologyContextMode.SELECTED_SINGLE_ONTOLOGY, + tenant=None, + project=None, + output_dir=out_dir, + ) + ) + + assert failed == [src] diff --git a/test/test_complete_facts_prompt.py b/test/test_complete_facts_prompt.py new file mode 100644 index 00000000..b3027b29 --- /dev/null +++ b/test/test_complete_facts_prompt.py @@ -0,0 +1,103 @@ +"""Chapters the completion pass sends, and the empty cases that skip the call. + +The completion pass is a paid call per unit. Each of these builders returns +``""`` when it has nothing to say, and an empty MISSING MEASUREMENTS chapter is +what makes the pass skip a unit entirely -- so the empty cases are a cost +contract, not a formatting nicety. The module shipped untested. +""" + +import pytest +from rdflib import RDF, RDFS, Literal, URIRef + +from ontocast.onto.enum import LLMGraphFormat +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.prompt.complete_facts import ( + build_catalog_subjects_chapter, + build_missing_measurements_chapter, + output_instruction_for, +) +from ontocast.util.measurement_lexicon import Mention +from ontocast.util.numeric_inventory import NumericInventory + +pytestmark = pytest.mark.unit + +_SAMPLE = URIRef("https://growgraph.dev/facts/sample_1") +_CATALOG_CLASS = "https://growgraph.dev/ontologies/matsci#Sample" + + +def _mention(value: str, unit: str, context: str) -> Mention: + return Mention(value=value, unit=unit, start=0, end=0, context=context) + + +def test_an_empty_inventory_yields_no_chapter() -> None: + """No missing measurement means no chapter, which means no paid call.""" + assert build_missing_measurements_chapter(NumericInventory()) == "" + + +def test_unclassified_numbers_alone_do_not_open_the_chapter() -> None: + """A bare number is not a stated measurement, so it must not buy a call.""" + inventory = NumericInventory(measurements=[], unclassified=["3", "2015"]) + assert build_missing_measurements_chapter(inventory) == "" + + +def test_each_missing_measurement_carries_its_value_unit_and_context() -> None: + """Context is what lets the pass place a value; without it the ask is blind.""" + inventory = NumericInventory( + measurements=[ + _mention("96", "meV", "the shift was 96 meV overall"), + _mention("300", "K", "annealed at 300 K for an hour"), + ] + ) + chapter = build_missing_measurements_chapter(inventory) + + assert "# MISSING MEASUREMENTS" in chapter + assert '"96 meV"' in chapter + assert "the shift was 96 meV overall" in chapter + assert '"300 K"' in chapter + assert "annealed at 300 K for an hour" in chapter + + +def test_no_catalog_terms_means_no_subjects_chapter() -> None: + """With nothing to reuse, listing subjects would be prompt noise.""" + graph = RDFGraph() + graph.add((_SAMPLE, RDF.type, URIRef(_CATALOG_CLASS))) + assert build_catalog_subjects_chapter(graph, []) == "" + + +def test_a_subject_typed_outside_the_catalog_is_not_offered() -> None: + """Offering an off-catalog subject invites attaching a fact to the wrong node.""" + graph = RDFGraph() + graph.add((_SAMPLE, RDF.type, URIRef("https://example.org/other#Thing"))) + assert build_catalog_subjects_chapter(graph, [_CATALOG_CLASS]) == "" + + +def test_a_catalog_typed_subject_is_offered_for_reuse() -> None: + """The whole point of the chapter: attach, do not mint a duplicate.""" + graph = RDFGraph() + graph.add((_SAMPLE, RDF.type, URIRef(_CATALOG_CLASS))) + graph.add((_SAMPLE, RDFS.label, Literal("aged film A"))) + + chapter = build_catalog_subjects_chapter(graph, [_CATALOG_CLASS]) + + assert "# EXISTING SUBJECTS" in chapter + assert "sample_1" in chapter + assert "aged film A" in chapter + + +def test_the_subject_list_respects_its_cap() -> None: + """A large graph must not silently become the largest chapter in the prompt.""" + graph = RDFGraph() + for index in range(25): + subject = URIRef(f"https://growgraph.dev/facts/sample_{index}") + graph.add((subject, RDF.type, URIRef(_CATALOG_CLASS))) + + chapter = build_catalog_subjects_chapter(graph, [_CATALOG_CLASS], limit=5) + + listed = [line for line in chapter.splitlines() if "sample_" in line] + assert len(listed) == 5 + + +@pytest.mark.parametrize("fmt", list(LLMGraphFormat)) +def test_every_wire_format_has_an_output_instruction(fmt: LLMGraphFormat) -> None: + """The pass must not emit an empty output contract for a supported wire.""" + assert output_instruction_for(fmt).strip() diff --git a/test/test_config_cross_validators.py b/test/test_config_cross_validators.py new file mode 100644 index 00000000..2791b995 --- /dev/null +++ b/test/test_config_cross_validators.py @@ -0,0 +1,78 @@ +import logging + +from ontocast.config import Config, ServerConfig, ToolConfig +from ontocast.onto.enum import OntologyContextScope + + +def _cfg(scope, contract): + tc = ToolConfig() + tc.facts_validation.shapes_prompt_contract = contract + return Config(server=ServerConfig(ontology_context_scope=scope), tool_config=tc) + + +def test_context_contract_with_document_scope_warns(caplog): + with caplog.at_level(logging.WARNING, logger="ontocast.config.settings"): + _cfg(OntologyContextScope.DOCUMENT, "context") + assert "defeats" in caplog.text or "share a prefix" in caplog.text + + +def test_full_contract_with_document_scope_is_silent(caplog): + with caplog.at_level(logging.INFO, logger="ontocast.config.settings"): + _cfg(OntologyContextScope.DOCUMENT, "full") + assert caplog.text == "" + + +def test_context_contract_without_document_scope_is_silent(caplog): + with caplog.at_level(logging.INFO, logger="ontocast.config.settings"): + _cfg(OntologyContextScope.UNIT, "context") + assert caplog.text == "" + + +def test_unit_path_manifest_omits_fanout_only_settings(tmp_path): + """A single-unit run must not claim a fan-out prompt regime it never ran. + + ``ONTOLOGY_CONTEXT_SCOPE`` and ``FANOUT_WARMUP_UNITS`` are read only by the + document fan-out node, so on the ``/process_unit`` path they are inert -- + and echoing them into the manifest made a dump assert a prefix-sharing + setup that never applied. + """ + import pathlib + + from ontocast.api.process_helpers import dump_run_manifest + from ontocast.config import Config + from ontocast.onto.enum import OntologyContextScope + from test.test_cli_server import _batch_state as batch_state + + config = Config() + config.server.ontology_context_scope = OntologyContextScope.DOCUMENT + config.server.fanout_warmup_units = 1 + src = pathlib.Path("doc.txt") + + doc = dump_run_manifest( + batch_state(), src, config=config, output_dir=tmp_path / "doc" + ) + unit = dump_run_manifest( + batch_state(), + src, + config=config, + output_dir=tmp_path / "unit", + fanout_settings_apply=False, + ) + + import json + + assert doc is not None and unit is not None + doc_prompting = json.loads(doc.read_text())["prompting"] + unit_prompting = json.loads(unit.read_text())["prompting"] + + assert doc_prompting["ontology_context_scope"] == "document" + assert doc_prompting["fanout_warmup_units"] == 1 + # The dump excludes nulls, so the unit manifest simply does not carry + # the claim -- which is the point: absent, not asserted. + assert unit_prompting.get("ontology_context_scope") is None + assert unit_prompting.get("fanout_warmup_units") is None + # Settings that do apply on both paths must still be recorded. + assert ( + unit_prompting["ontology_chapter_format"] + == doc_prompting["ontology_chapter_format"] + ) diff --git a/test/test_convert_document_fixed_catalog.py b/test/test_convert_document_fixed_catalog.py index 2054e383..881e937e 100644 --- a/test/test_convert_document_fixed_catalog.py +++ b/test/test_convert_document_fixed_catalog.py @@ -1,11 +1,15 @@ """convert_document validates fixed-single-ontology catalog id when applicable.""" +import pytest + from ontocast.agent.convert_document import convert_document from ontocast.onto.enum import OntologyContextMode, Status from ontocast.onto.state import AgentState from ontocast.tool.converter import ConverterTool from ontocast.toolbox import ToolBox +pytestmark = pytest.mark.unit + def test_convert_document_fails_when_fixed_mode_missing_catalog_id() -> None: state = AgentState( diff --git a/test/test_convert_document_txt.py b/test/test_convert_document_txt.py index aa68b18c..9e1d6f30 100644 --- a/test/test_convert_document_txt.py +++ b/test/test_convert_document_txt.py @@ -1,11 +1,15 @@ """convert_document handles .txt uploads as plain text (regression for #67).""" +import pytest + from ontocast.agent.convert_document import convert_document from ontocast.onto.enum import Status from ontocast.onto.state import AgentState from ontocast.tool.converter import ConverterTool from ontocast.toolbox import ToolBox +pytestmark = pytest.mark.unit + def test_convert_document_txt_is_plain_text_not_json() -> None: # A plain-text .txt payload is not valid JSON; it must not be json.loads'd. diff --git a/test/test_converter_config.py b/test/test_converter_config.py index 7da6d0db..61e43dd9 100644 --- a/test/test_converter_config.py +++ b/test/test_converter_config.py @@ -5,16 +5,28 @@ import tempfile from pathlib import Path +import pytest + from ontocast.config import Config, ConverterConfig, PathConfig, ToolConfig from ontocast.onto.docling_helpers import ( apply_text_sanitizers, plain_text_to_docling_doc, + rejoin_flattened_exponents, repair_ligature_gaps, + repair_numeric_artifacts, + repair_single_sided_ligature_gaps, ) from ontocast.tool.cache import Cacher -from ontocast.tool.converter import ConverterTool, build_document_converter +from ontocast.tool.converter import ( + CONVERTER_CACHE_FORMAT_VERSION, + CONVERTER_REPAIR_RULES_VERSION, + ConverterTool, + build_document_converter, +) from ontocast.toolbox import ToolBox +pytestmark = pytest.mark.unit + def test_converter_config_born_digital_profile_applies_expected_defaults() -> None: config = ConverterConfig(profile="born_digital") @@ -155,3 +167,231 @@ def convert(self, _src): assert doc1 is not None and doc2 is not None assert len(builds) == 1 assert tool._converter is not None + + +# --- CONVERTER_REPAIR_NUMERIC_ARTIFACTS -------------------------------------- + + +def test_repair_numeric_artifacts_default_is_off() -> None: + assert ConverterConfig().repair_numeric_artifacts is False + assert ConverterConfig(profile="born_digital").repair_numeric_artifacts is False + + +def test_unescapes_only_named_entities_with_semicolons() -> None: + assert repair_numeric_artifacts("T < 300 K & p > 1 bar") == ( + "T < 300 K & p > 1 bar" + ) + assert repair_numeric_artifacts(""x" 'y'") == "\"x\" 'y'" + # No html.unescape: a semicolon-less entity in running text stays text. + assert repair_numeric_artifacts("see ¶ 3 and R&D") == "see ¶ 3 and R&D" + + +def test_carriage_return_wraps_collapse_but_paragraph_breaks_survive() -> None: + assert repair_numeric_artifacts("shift of\r \n20 meV") == "shift of\n20 meV" + assert repair_numeric_artifacts("one.\r \n\ntwo") == "one.\n\ntwo" + + +@pytest.mark.parametrize( + ("raw", "repaired"), + [ + ("2 × 10 6 cm-3", "2 × 10^6 cm-3"), + ("1.5 x 10 19 cm-3", "1.5 × 10^19 cm-3"), + ("3 × 10 −3 S/cm", "3 × 10^-3 S/cm"), + ("~10 6 cycles", "~10^6 cycles"), + ("≈ 10 5", "≈ 10^5"), + ("on the order of 10 4", "on the order of 10^4"), + ], +) +def test_flattened_exponents_are_rejoined(raw: str, repaired: str) -> None: + assert rejoin_flattened_exponents(raw) == repaired + + +@pytest.mark.parametrize( + "text", + [ + "10 6-membered rings", + "on the order of 10 6-membered rings", + "5 × 10 6-membered rings", + "10 6 samples", + "2 × 10 100", + "2 × 10^6 cm-3", + "10 cm × 10 cm", + # A dash between two numbers behind a bare cue is a range, not a + # negative exponent -- publishers typeset ranges with U+2212 too. + "∼10−15 meV", + "∼ 10−15 meV", + "~10–50 meV", + "on the order of 10-15 meV", + ], +) +def test_exponent_rejoin_leaves_other_number_pairs_alone(text: str) -> None: + """A bare '10 6' without a cue, or one starting a hyphenated word, is text.""" + assert rejoin_flattened_exponents(text) == text + + +@pytest.mark.parametrize( + ("raw", "repaired"), + [ + ("a ffected", "affected"), + ("di fferent", "different"), + ("e fficient", "efficient"), + ("switched o ff.", "switched off."), + ("signifi cant", "significant"), + ("confi ned", "confined"), + ("refl ected", "reflected"), + ("suffi cient", "sufficient"), + ], +) +def test_single_sided_ligature_gaps_with_one_reading_are_closed( + raw: str, repaired: str +) -> None: + assert repair_single_sided_ligature_gaps(raw) == repaired + + +@pytest.mark.parametrize( + "text", + [ + "the field", + "a flat film", + "of it", + "we find", + "fl oz", + "cliff face", + "if fed", + "of fish", + "NC SLs", + ], +) +def test_single_sided_ligature_rule_leaves_two_word_phrases_alone(text: str) -> None: + assert repair_single_sided_ligature_gaps(text) == text + + +def test_apply_text_sanitizers_numeric_flag_repairs_docling_document_texts() -> None: + doc = plain_text_to_docling_doc("T < 300 K at 2 × 10 6 cm-3, a ffected", "doc") + + sanitized = apply_text_sanitizers(doc, repair_numeric_artifacts_enabled=True) + + # The markdown export re-escapes "<" and "&"; the item text is what the + # chunker and the prompts see. + assert sanitized.texts[0].text == "T < 300 K at 2 × 10^6 cm-3, affected" + + +def test_apply_text_sanitizers_flags_compose() -> None: + doc = plain_text_to_docling_doc("the e ff ect was a ffected", "doc") + + sanitized = apply_text_sanitizers( + doc, repair_ligature_gaps_enabled=True, repair_numeric_artifacts_enabled=True + ) + + assert sanitized.export_to_markdown().strip() == "the effect was affected" + + +def test_apply_text_sanitizers_is_identity_when_both_flags_are_off() -> None: + doc = plain_text_to_docling_doc("a ffected <", "doc") + assert apply_text_sanitizers(doc).texts[0].text == "a ffected <" + + +def _fake_build(builds: list[ConverterConfig]): + def fake_build(config: ConverterConfig): + builds.append(config) + + class _Result: + document = plain_text_to_docling_doc("ok", "doc") + + class _Converter: + def convert(self, _src): + return _Result() + + return _Converter() + + return fake_build + + +def test_numeric_repair_flag_joins_the_converter_cache_key(monkeypatch) -> None: + builds: list[ConverterConfig] = [] + monkeypatch.setattr( + "ontocast.tool.converter.build_document_converter", _fake_build(builds) + ) + content = b"%PDF-numeric-key%" + with tempfile.TemporaryDirectory() as tmp: + shared_cache = Cacher(cache_dir=tmp) + + ConverterTool(cache=shared_cache, converter_config=ConverterConfig())(content) + assert len(builds) == 1 + + # Enabling the flag changes the text, so it must miss the cached entry. + ConverterTool( + cache=shared_cache, + converter_config=ConverterConfig(repair_numeric_artifacts=True), + )(content) + assert len(builds) == 2 + + # A fresh flag-off tool hits the first entry again. + ConverterTool(cache=shared_cache, converter_config=ConverterConfig())(content) + assert len(builds) == 2 + + +def test_repair_rules_version_joins_the_key_only_when_repair_is_on( + monkeypatch, +) -> None: + """A rule fix must invalidate repaired entries and leave the rest alone.""" + builds: list[ConverterConfig] = [] + monkeypatch.setattr( + "ontocast.tool.converter.build_document_converter", _fake_build(builds) + ) + content = b"%PDF-rules-version%" + with tempfile.TemporaryDirectory() as tmp: + shared_cache = Cacher(cache_dir=tmp) + repairing = ConverterConfig(repair_numeric_artifacts=True) + + ConverterTool(cache=shared_cache, converter_config=repairing)(content) + assert len(builds) == 1 + # Same rules version: the repaired conversion is still served. + ConverterTool(cache=shared_cache, converter_config=repairing)(content) + assert len(builds) == 1 + + # Bumping the rules version must re-convert rather than serve text + # repaired by the superseded rule. + monkeypatch.setattr( + "ontocast.tool.converter.CONVERTER_REPAIR_RULES_VERSION", + CONVERTER_REPAIR_RULES_VERSION + 1, + ) + ConverterTool(cache=shared_cache, converter_config=repairing)(content) + assert len(builds) == 2 + + # With repair off no rule ran, so the version must stay out of the key. + off = ConverterConfig() + ConverterTool(cache=shared_cache, converter_config=off)(content) + assert len(builds) == 3 + monkeypatch.setattr( + "ontocast.tool.converter.CONVERTER_REPAIR_RULES_VERSION", + CONVERTER_REPAIR_RULES_VERSION + 2, + ) + ConverterTool(cache=shared_cache, converter_config=off)(content) + assert len(builds) == 3 + + +def test_flag_off_keeps_pre_flag_cache_entries_valid(monkeypatch) -> None: + """The flag joins the key only when on, so old conversions stay cached.""" + builds: list[ConverterConfig] = [] + monkeypatch.setattr( + "ontocast.tool.converter.build_document_converter", _fake_build(builds) + ) + content = b"%PDF-legacy-key%" + with tempfile.TemporaryDirectory() as tmp: + shared_cache = Cacher(cache_dir=tmp) + tool = ConverterTool(cache=shared_cache, converter_config=ConverterConfig()) + + legacy_key = tool.converter_config.model_dump(mode="json") + legacy_key.pop("repair_numeric_artifacts") + legacy_key["cache_format_version"] = CONVERTER_CACHE_FORMAT_VERSION + tool.cache.set( + content, + plain_text_to_docling_doc("cached", "doc").model_dump_json(), + config=legacy_key, + ) + + doc = tool(content) + + assert builds == [] + assert doc.export_to_markdown().strip() == "cached" diff --git a/test/test_critique_report_coercion.py b/test/test_critique_report_coercion.py index 99e07232..040a3d36 100644 --- a/test/test_critique_report_coercion.py +++ b/test/test_critique_report_coercion.py @@ -16,6 +16,8 @@ TripleFix, ) +pytestmark = pytest.mark.unit + REPORT_CLASSES = [OntologyCritiqueReport, FactsCritiqueReport] diff --git a/test/test_entity_aligner.py b/test/test_entity_aligner.py index a81d8715..225eeee6 100644 --- a/test/test_entity_aligner.py +++ b/test/test_entity_aligner.py @@ -1,6 +1,7 @@ from __future__ import annotations import numpy as np +import pytest from rdflib import RDF, RDFS, Literal, URIRef from ontocast.onto.rdfgraph import RDFGraph @@ -8,6 +9,8 @@ from ontocast.tool.agg.match_models import MatchRegime, TaggedGraph from test.embedding_test_helpers import install_fake_encoder +pytestmark = pytest.mark.unit + def _graph(subject_ns: str, type_ns: str, predicate_ns: str) -> RDFGraph: graph = RDFGraph() diff --git a/test/test_env_example_coverage.py b/test/test_env_example_coverage.py index 2946c1f5..9f09993c 100644 --- a/test/test_env_example_coverage.py +++ b/test/test_env_example_coverage.py @@ -18,6 +18,8 @@ import ontocast.config.settings as settings_module +pytestmark = pytest.mark.unit + #: Declared but deliberately absent from .env.example, with the reason. DELIBERATELY_UNDOCUMENTED = { # Unprefixed alias of LLM_MAX_INFLIGHT, which is documented. Advertising diff --git a/test/test_failure_propagation.py b/test/test_failure_propagation.py index bd2305cd..06c229dc 100644 --- a/test/test_failure_propagation.py +++ b/test/test_failure_propagation.py @@ -27,6 +27,8 @@ ) from ontocast.toolbox import ToolBox +pytestmark = pytest.mark.unit + @pytest.mark.parametrize( ("failed", "total", "expected"), diff --git a/test/test_flush_vector_lifecycle.py b/test/test_flush_vector_lifecycle.py new file mode 100644 index 00000000..69030fde --- /dev/null +++ b/test/test_flush_vector_lifecycle.py @@ -0,0 +1,67 @@ +"""``POST /flush`` must leave the vector store in a state it can serve from. + +``clean_tenancy_data`` drops the collections and their embedding metadata. +Leaving the toolbox marked ready afterwards pointed every later search in the +process at a collection that no longer existed, and the failure surfaced as an +empty retrieval rather than an error. The startup path already wipes, recreates +and only then reports ready; the flush path now follows it. +""" + +import asyncio +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ontocast.config import Config +from ontocast.toolbox import ToolBox + +pytestmark = pytest.mark.unit + + +def _tools(*, ready: bool, recreate_fails: bool = False) -> Any: + tools = cast(Any, ToolBox.__new__(ToolBox)) + tools.config = Config() + tools.triple_store_manager = None + tools.vector_store = MagicMock() + tools.vector_store.supports_tenancy_partition = MagicMock(return_value=True) + tools.vector_store.clean_tenancy = AsyncMock() + tools.vector_store.initialize = AsyncMock( + side_effect=RuntimeError("collection gone") if recreate_fails else None + ) + tools.vector_store_ready = ready + tools.vector_store_last_error = None + tools._tenancy_lock = None + tools._tenancy_lock_loop = None + tools._active_tenancy = ("other", "scope") + tools.shapes_catalog = MagicMock() + return tools + + +def test_flush_recreates_the_collections_it_dropped() -> None: + tools = _tools(ready=True) + + asyncio.run(tools.clean_tenancy_data("acme", "p1")) + + tools.vector_store.clean_tenancy.assert_awaited_once_with("acme", "p1") + tools.vector_store.initialize.assert_awaited_once() + assert tools.vector_store_ready is True + + +def test_a_failed_recreate_leaves_the_store_marked_not_ready() -> None: + tools = _tools(ready=True, recreate_fails=True) + + asyncio.run(tools.clean_tenancy_data("acme", "p1")) + + assert tools.vector_store_ready is False + assert isinstance(tools.vector_store_last_error, RuntimeError) + + +def test_a_store_that_was_never_ready_is_not_brought_up_by_a_flush() -> None: + """Flush drops data; it is not the place a deployment gains retrieval.""" + tools = _tools(ready=False) + + asyncio.run(tools.clean_tenancy_data("acme", "p1")) + + tools.vector_store.initialize.assert_not_awaited() + assert tools.vector_store_ready is False diff --git a/test/test_fuseki_catalog.py b/test/test_fuseki_catalog.py index d4c4e2af..c7ff45a9 100644 --- a/test/test_fuseki_catalog.py +++ b/test/test_fuseki_catalog.py @@ -14,6 +14,8 @@ from ontocast.tool.triple_manager.core import TripleStoreUnavailableError from ontocast.tool.triple_manager.fuseki import FusekiTripleStoreManager +pytestmark = pytest.mark.unit + _ONTO_IRI = "https://example.org/catalog" _GRAPH_URI = f"{_ONTO_IRI}#abc123" diff --git a/test/test_fuseki_uri_normalization.py b/test/test_fuseki_uri_normalization.py index 5374715f..3714e149 100644 --- a/test/test_fuseki_uri_normalization.py +++ b/test/test_fuseki_uri_normalization.py @@ -1,7 +1,11 @@ """Tests for Fuseki base URI normalization.""" +import pytest + from ontocast.tool.triple_manager.fuseki import normalize_fuseki_server_uri +pytestmark = pytest.mark.unit + def test_normalize_strips_fragment_ui_style() -> None: assert ( diff --git a/test/test_graph_atomizer.py b/test/test_graph_atomizer.py index a8ffa45b..b8b36285 100644 --- a/test/test_graph_atomizer.py +++ b/test/test_graph_atomizer.py @@ -8,6 +8,7 @@ from __future__ import annotations +import pytest from rdflib import OWL, RDF, RDFS, SKOS, Literal, URIRef from ontocast.config import EmbeddingConfig, VectorStoreConfig @@ -23,6 +24,8 @@ sync_atomizer_from_store_config, ) +pytestmark = pytest.mark.unit + # ------------------------------------------------------------------------- # facts # ------------------------------------------------------------------------- diff --git a/test/test_graph_format_profile.py b/test/test_graph_format_profile.py index b90b8e14..5062d6f5 100644 --- a/test/test_graph_format_profile.py +++ b/test/test_graph_format_profile.py @@ -6,7 +6,7 @@ import pytest from pydantic import BaseModel -from ontocast.onto.enum import LLMGraphFormat +from ontocast.onto.enum import LLMGraphFormat, OntologyChapterFormat from ontocast.onto.llm_graph_payload import ( _coerce_jsonld_graph_payload, _coerce_turtle_graph_payload, @@ -27,6 +27,8 @@ from ontocast.prompt.llm_json_schema import schema_for_model from ontocast.prompt.web_grounding import WEB_SEARCH_REQUEST_FIELD +pytestmark = pytest.mark.unit + @pytest.mark.parametrize("fmt", list(LLMGraphFormat)) def test_fresh_output_instruction_single_format(fmt: LLMGraphFormat) -> None: @@ -269,6 +271,79 @@ def test_serialize_compact_jsonld_for_prompt() -> None: assert any(n.get("@id") == "ex:item" for n in data["@graph"]) +def _ontology_for_chapter() -> RDFGraph: + """A small schema with the shapes an ontology chapter carries.""" + from rdflib import OWL, RDF, RDFS, XSD, Literal, Namespace + + ex = Namespace("https://example.org/onto#") + graph = RDFGraph() + graph.bind("ex", ex) + for index in range(12): + cls = ex[f"Class{index}"] + graph.add((cls, RDF.type, OWL.Class)) + graph.add((cls, RDFS.label, Literal(f"class {index}", lang="en"))) + graph.add((cls, RDFS.comment, Literal(f"Things of kind {index}."))) + prop = ex[f"prop{index}"] + graph.add((prop, RDF.type, OWL.DatatypeProperty)) + graph.add((prop, RDFS.domain, cls)) + graph.add((prop, RDFS.range, XSD.decimal)) + return graph + + +def test_turtle_ontology_chapter_is_the_denser_context() -> None: + """The override swaps the chapter's syntax for the one that costs less.""" + graph = _ontology_for_chapter() + inherit = get_graph_format_profile(LLMGraphFormat.JSONLD) + overridden = get_graph_format_profile( + LLMGraphFormat.JSONLD, ontology_chapter_format=OntologyChapterFormat.TURTLE + ) + + jsonld = inherit.format_ontology_chapter(graph, suffix="\nINDEX") + turtle = overridden.format_ontology_chapter(graph, suffix="\nINDEX") + + assert jsonld.startswith("\n\n# ONTOLOGY\n\n```json\n") + assert turtle.startswith("\n\n# ONTOLOGY\n\n```ttl\n") + assert jsonld.endswith("\nINDEX") + assert turtle.endswith("\nINDEX") + assert len(turtle) < len(jsonld) + + # The Turtle body is real Turtle carrying the whole graph. + body = turtle.split("```ttl\n", 1)[1].split("\n```", 1)[0] + parsed = RDFGraph() + parsed.parse(data=body, format="turtle") + assert len(parsed) == len(graph) + + +def test_chapter_override_leaves_the_wire_format_alone() -> None: + """Only the read-only ontology context changes syntax. + + Output instructions, the facts chapter and the indexed ontology chapter + (the ontology critic's) stay in the wire format: the model still writes + JSON-LD, and the critic's patch still targets what it reads. + """ + graph = _ontology_for_chapter() + inherit = get_graph_format_profile(LLMGraphFormat.JSONLD) + overridden = get_graph_format_profile( + LLMGraphFormat.JSONLD, ontology_chapter_format=OntologyChapterFormat.TURTLE + ) + + assert inherit.ontology_chapter_wire is LLMGraphFormat.JSONLD + assert overridden.format is LLMGraphFormat.JSONLD + assert overridden.ontology_chapter_wire is LLMGraphFormat.TURTLE + assert ( + overridden.render_fresh_output_instruction() + == inherit.render_fresh_output_instruction() + ) + assert ( + overridden.critique_graph_instruction() == inherit.critique_graph_instruction() + ) + assert ( + overridden.format_facts_chapter_indexed(graph).text + == inherit.format_facts_chapter_indexed(graph).text + ) + assert "```json" in overridden.format_ontology_chapter_indexed(graph).text + + def test_parsed_graph_update_applies_via_unit_facts_state() -> None: from rdflib import URIRef diff --git a/test/test_graph_hash_stability.py b/test/test_graph_hash_stability.py index 1b2b3c37..d93785fd 100644 --- a/test/test_graph_hash_stability.py +++ b/test/test_graph_hash_stability.py @@ -22,6 +22,8 @@ _list_named_graph_uris, ) +pytestmark = pytest.mark.unit + SUBJECT = URIRef("urn:test:subject") PREDICATE = URIRef("urn:test:predicate") diff --git a/test/test_graph_update.py b/test/test_graph_update.py index e0a0d0d4..d70fba3e 100644 --- a/test/test_graph_update.py +++ b/test/test_graph_update.py @@ -4,6 +4,7 @@ queries that can be executed on RDFGraph instances using rdflib's update() method. """ +import pytest from rdflib import Literal, URIRef from ontocast.onto.rdfgraph import RDFGraph @@ -12,6 +13,8 @@ TripleOp, ) +pytestmark = pytest.mark.unit + def test_rdfgraph_recovers_dangling_semicolon_at_eof() -> None: """RDFGraph should recover from common LLM-truncated Turtle at EOF.""" diff --git a/test/test_graph_update_wire_regression.py b/test/test_graph_update_wire_regression.py index dbf86c8a..42c622a4 100644 --- a/test/test_graph_update_wire_regression.py +++ b/test/test_graph_update_wire_regression.py @@ -20,6 +20,8 @@ from ontocast.agent.common import parse_json_object, repair_bracket_kinds +pytestmark = pytest.mark.unit + FIXTURE = Path(__file__).parent / "data" / "llm_malformed_graph_updates.json" diff --git a/test/test_in_memory_manager.py b/test/test_in_memory_manager.py index 6ef7f788..34c5d2a5 100644 --- a/test/test_in_memory_manager.py +++ b/test/test_in_memory_manager.py @@ -4,15 +4,22 @@ import asyncio +import pyoxigraph as ox import pytest from rdflib import Literal, URIRef +from rdflib.graph import DATASET_DEFAULT_GRAPH_ID from rdflib.namespace import RDFS from ontocast.onto.ontology import Ontology from ontocast.onto.rdfgraph import RDFGraph -from ontocast.tool.triple_manager.in_memory import InMemoryTripleStoreManager +from ontocast.tool.triple_manager.in_memory import ( + InMemoryTripleStoreManager, + _rdflib_graph_to_quads, +) from ontocast.tool.triple_manager.util import dedupe_terminal_ontologies +pytestmark = pytest.mark.unit + def _sample_ontology() -> Ontology: graph = RDFGraph._from_turtle_str( @@ -335,3 +342,36 @@ async def main() -> None: assert len(await manager.aconstruct(query)) == 0 asyncio.run(main()) + + +def test_unserializable_object_names_term_position_and_graph() -> None: + """A bad term must be diagnosable, not a bare AssertionError. + + ``to_ox`` maps ``urn:x-rdflib:default`` to a DefaultGraph, which is not a + term, so a graph carrying it in a triple position used to trip an + unmessaged assert far from the code that built the graph. + """ + graph = RDFGraph() + graph.add((URIRef("urn:s"), URIRef("urn:p"), DATASET_DEFAULT_GRAPH_ID)) + with pytest.raises(TypeError) as excinfo: + _rdflib_graph_to_quads(graph, ox.NamedNode("urn:g")) + message = str(excinfo.value) + assert "object" in message + assert "urn:x-rdflib:default" in message + assert "urn:g" in message + + +def test_unserializable_subject_names_position() -> None: + graph = RDFGraph() + graph.add((Literal("a"), URIRef("urn:p"), Literal("b"))) + with pytest.raises(TypeError) as excinfo: + _rdflib_graph_to_quads(graph, ox.NamedNode("urn:g")) + assert "subject" in str(excinfo.value) + + +def test_valid_graph_still_converts() -> None: + graph = RDFGraph() + graph.add((URIRef("urn:s"), RDFS.label, Literal("x"))) + quads = _rdflib_graph_to_quads(graph, ox.NamedNode("urn:g")) + assert len(quads) == 1 + assert quads[0].graph_name == ox.NamedNode("urn:g") diff --git a/test/test_induced_subgraph_quality.py b/test/test_induced_subgraph_quality.py index 9cf1e13d..b079586a 100644 --- a/test/test_induced_subgraph_quality.py +++ b/test/test_induced_subgraph_quality.py @@ -1,5 +1,6 @@ """Tests for induced-subgraph and schema-centric snapshot assembly.""" +import pytest from rdflib import BNode, Literal, Namespace, URIRef from rdflib.namespace import DCTERMS, OWL, RDF, RDFS @@ -22,6 +23,8 @@ from ontocast.tool.vector_store.core import GraphAtom from ontocast.tool.vector_store.patch_retriever import _ranked_entity_weights +pytestmark = pytest.mark.unit + BASE = "https://growgraph.dev/ontologies/" QQVAL = Namespace(f"{BASE}qqval#") MATSCI = Namespace(f"{BASE}matsci#") diff --git a/test/test_input_path_crawl.py b/test/test_input_path_crawl.py index c9f69b92..d315a83d 100644 --- a/test/test_input_path_crawl.py +++ b/test/test_input_path_crawl.py @@ -11,6 +11,8 @@ from ontocast.util.files import crawl_directories +pytestmark = pytest.mark.unit + def test_single_file_is_accepted(tmp_path: pathlib.Path) -> None: paper = tmp_path / "paper.pdf" diff --git a/test/test_iri_policy.py b/test/test_iri_policy.py index 84f5db79..d5209c0c 100644 --- a/test/test_iri_policy.py +++ b/test/test_iri_policy.py @@ -1,3 +1,5 @@ +import pytest + from ontocast.onto.iri_policy import ( is_in_namespace, join_namespace_local, @@ -6,6 +8,8 @@ split_namespace_local, ) +pytestmark = pytest.mark.unit + def test_normalize_namespace_iri_uses_context_hints() -> None: assert normalize_namespace_iri( diff --git a/test/test_lancedb_vector_store.py b/test/test_lancedb_vector_store.py index f23f05b2..3424a038 100644 --- a/test/test_lancedb_vector_store.py +++ b/test/test_lancedb_vector_store.py @@ -140,3 +140,60 @@ def test_lancedb_default_tables_use_default_tenant_project(tmp_path: Path) -> No expected = tenant_project_ontologies_name(DEFAULT_TENANT, DEFAULT_PROJECT) assert store._ontology_table_name() == expected assert store._data_dir() == tmp_path.resolve() + + +def _ontology_without_optional_text(iri: str) -> Ontology: + """An ontology whose atoms populate none of the optional payload columns.""" + ttl = f""" + @prefix ex: <{iri}#> . + @prefix rdfs: . + ex:Plain a ex:Concept ; + rdfs:label "Plain concept" . + """ + return Ontology(iri=iri, graph=RDFGraph._from_turtle_str(ttl)) + + +def _ontology_with_symbols(iri: str) -> Ontology: + """An ontology whose atoms do populate them (symbols, notation, versions).""" + ttl = f""" + @prefix ex: <{iri}#> . + @prefix rdfs: . + @prefix owl: . + @prefix skos: . + @prefix qudt: . + <{iri}> a owl:Ontology ; owl:versionInfo "1.2.3" . + ex:Millivolt a ex:Unit ; + rdfs:label "millivolt" ; + skos:altLabel "mV" ; + skos:notation "mV" ; + qudt:symbol "mV" ; + rdfs:comment "A unit carrying symbol surfaces." . + """ + return Ontology(iri=iri, graph=RDFGraph._from_turtle_str(ttl)) + + +@pytest.mark.anyio +async def test_lancedb_indexes_a_second_ontology_that_fills_new_columns( + tmp_path: Path, +) -> None: + """Indexing order must not decide which ontologies a catalog can hold. + + The table schema used to be inferred from the first batch written, so a column + empty throughout the first ontology was typed ``null`` and every later ontology + that populated it failed to merge. A catalog silently capped at its first + ontology is indistinguishable from one where retrieval simply found nothing. + """ + store = _build_store(tmp_path) + await store.initialize() + + plain = _ontology_without_optional_text("https://example.org/plain") + symbols = _ontology_with_symbols("https://example.org/symbols") + + assert store.index_ontology(plain) > 0 + # Same table, and this one carries versions, notations and symbol surfaces. + assert store.index_ontology(symbols) > 0 + + # Retrievable, not merely written: a row whose optional columns were dropped + # on the way in would still count as indexed. + hits = store.search_patches(query="millivolt", top_k=5) + assert any(hit.ontology_iri == symbols.iri for hit in hits) diff --git a/test/test_llm_batch.py b/test/test_llm_batch.py index a6548aba..95d8a98b 100644 --- a/test/test_llm_batch.py +++ b/test/test_llm_batch.py @@ -4,6 +4,8 @@ import json from unittest.mock import AsyncMock, MagicMock, patch +import pytest + from ontocast.config import LLMConfig, LLMProvider, OpenAIModel from ontocast.onto.state import BudgetTracker from ontocast.tool.cache import Cacher @@ -13,6 +15,8 @@ write_openai_chat_batch_jsonl, ) +pytestmark = pytest.mark.unit + def test_batch_import_is_readable_by_the_llm_tool(tmp_path) -> None: """A prewarmed entry must actually be hit by the server that reads it. diff --git a/test/test_llm_cache.py b/test/test_llm_cache.py index 7695ee92..f6a8b5aa 100644 --- a/test/test_llm_cache.py +++ b/test/test_llm_cache.py @@ -14,6 +14,8 @@ from ontocast.tool.cache import Cacher from ontocast.tool.llm import LLMTool +pytestmark = pytest.mark.unit + @pytest.fixture def cache_dir(tmp_path): @@ -126,6 +128,48 @@ async def run() -> None: asyncio.run(run()) +def test_reasoning_knob_change_invalidates_cache(llm_config, cache_dir) -> None: + # A different reasoning budget is a different response: the same prompt at + # a lower effort must not replay the answer paid for at a higher one. + tracker = BudgetTracker() + + async def run() -> None: + tool = await _make_tool(llm_config, cache_dir, tracker) + await tool.complete("reasoned question") + assert tracker.calls_count == 1 + + tool.config.reasoning_effort = "low" + await tool.complete("reasoned question") + assert tracker.calls_count == 2 + + tool.config.thinking_budget = 0 + await tool.complete("reasoned question") + assert tracker.calls_count == 3 + + asyncio.run(run()) + + +def test_unset_reasoning_knobs_keep_the_pre_existing_cache_key() -> None: + """Entries written before the knobs existed must still be found. + + The key hashes the whole config mapping, so an unconditional ``None`` + entry would evict every existing entry -- each produced under the + provider default, which is exactly what ``None`` still means. + """ + from ontocast.tool.llm import llm_cache_config + + config = LLMConfig(provider=LLMProvider.OPENAI, model_name=OpenAIModel.GPT4_O_MINI) + key = llm_cache_config(config) + assert "reasoning_effort" not in key + assert "thinking_budget" not in key + + config.reasoning_effort = "high" + config.thinking_budget = 512 + key = llm_cache_config(config) + assert key["reasoning_effort"] == "high" + assert key["thinking_budget"] == 512 + + def test_get_cache_stats_includes_disk(llm_config, cache_dir) -> None: async def run() -> None: tool = await _make_tool(llm_config, cache_dir) diff --git a/test/test_llm_cache_correctness.py b/test/test_llm_cache_correctness.py index ec69e483..d2d787de 100644 --- a/test/test_llm_cache_correctness.py +++ b/test/test_llm_cache_correctness.py @@ -15,6 +15,8 @@ from ontocast.tool.cache import Cacher from ontocast.tool.llm import LLMTool +pytestmark = pytest.mark.unit + async def _tool(config: LLMConfig, cache_dir, response, tracker=None) -> LLMTool: """An LLMTool whose provider returns ``response`` (or raises, if given one).""" diff --git a/test/test_llm_config.py b/test/test_llm_config.py index 6c90e006..e012a279 100644 --- a/test/test_llm_config.py +++ b/test/test_llm_config.py @@ -1,8 +1,10 @@ """Tests for LLM provider/model configuration validation.""" import logging +from typing import Literal import pytest +from pydantic import ValidationError from ontocast.config import ( ClaudeModel, @@ -14,6 +16,8 @@ OpenAIModel, ) +pytestmark = pytest.mark.unit + @pytest.mark.parametrize( ("provider", "model_name"), @@ -117,3 +121,94 @@ def _default_model_for(provider: LLMProvider): if provider == LLMProvider.ANTHROPIC: return ClaudeModel.CLAUDE_SONNET_4 return GeminiModel.GEMINI_2_0_FLASH + + +@pytest.mark.parametrize( + "effort", ["none", "minimal", "low", "medium", "high", "xhigh"] +) +def test_llm_config_accepts_each_reasoning_effort( + effort: Literal["none", "minimal", "low", "medium", "high", "xhigh"], +) -> None: + # The union across providers and across model generations of one provider: + # the floor is spelled `minimal` on some models and `none` on others. Which + # of them a given model takes is the provider's business, not this type's. + config = LLMConfig(provider=LLMProvider.OPENAI, reasoning_effort=effort) + assert config.reasoning_effort == effort + + +def test_llm_config_rejects_an_unknown_reasoning_effort() -> None: + # A typo would otherwise reach the provider as a request error on the + # first call, after the ontology sync has already been paid for. + with pytest.raises(ValidationError): + LLMConfig.model_validate( + {"provider": LLMProvider.OPENAI, "reasoning_effort": "max"} + ) + + +def test_llm_config_thinking_budget_allows_off_and_model_chosen() -> None: + # 0 turns thinking off where the model allows it; -1 hands the choice to + # the model. Anything lower is not a value the provider defines. + assert LLMConfig(thinking_budget=0).thinking_budget == 0 + assert LLMConfig(thinking_budget=-1).thinking_budget == -1 + with pytest.raises(ValidationError): + LLMConfig(thinking_budget=-2) + + +def test_reasoning_knobs_are_read_from_the_environment(monkeypatch) -> None: + monkeypatch.setenv("LLM_REASONING_EFFORT", "low") + monkeypatch.setenv("LLM_THINKING_BUDGET", "1024") + config = LLMConfig() + assert config.reasoning_effort == "low" + assert config.thinking_budget == 1024 + + +def test_google_rejects_both_reasoning_spellings_at_once() -> None: + """The Gemini API treats the two as mutually exclusive. + + The client resolves the clash by dropping the budget with a warning, which + would leave the run billing one setting while the manifest recorded the + other -- so it is rejected here instead. + """ + with pytest.raises(ValidationError): + LLMConfig( + provider=LLMProvider.GOOGLE, + model_name=GeminiModel.GEMINI_3_5_FLASH, + reasoning_effort="minimal", + thinking_budget=0, + ) + + +def test_both_reasoning_spellings_are_allowed_off_google() -> None: + # Only Google reads both; elsewhere one of them is already an ignored + # no-op and warned about at setup, not a configuration error. + config = LLMConfig( + provider=LLMProvider.OPENAI, + model_name=OpenAIModel.GPT4_O_MINI, + reasoning_effort="minimal", + thinking_budget=0, + ) + assert config.reasoning_effort == "minimal" + + +def test_workers_above_inflight_warn_at_construction(caplog) -> None: + """PARALLEL_WORKERS past LLM_MAX_INFLIGHT only queue; say so up front. + + A unit worker never issues two calls at once, so the provider concurrency + a document reaches is min(workers, inflight). Each surplus worker holds a + unit slot and its memory while it waits -- a cost with no return that + nothing at runtime names beyond a growing llm/inflight_wait. + """ + from ontocast.toolbox import warn_if_workers_exceed_inflight + + config = Config() + config.server.parallel_workers = 16 + config.tool_config.llm_config.llm_max_inflight = 8 + with caplog.at_level(logging.WARNING, logger="ontocast.toolbox"): + assert warn_if_workers_exceed_inflight(config) is True + assert "PARALLEL_WORKERS=16 exceeds LLM_MAX_INFLIGHT=8" in caplog.text + + caplog.clear() + config.server.parallel_workers = 8 + with caplog.at_level(logging.WARNING, logger="ontocast.toolbox"): + assert warn_if_workers_exceed_inflight(config) is False + assert caplog.text == "" diff --git a/test/test_llm_graph_format_default.py b/test/test_llm_graph_format_default.py index 301cbccd..d7dfe103 100644 --- a/test/test_llm_graph_format_default.py +++ b/test/test_llm_graph_format_default.py @@ -14,6 +14,7 @@ shell. """ +import pytest from pydantic import BaseModel from ontocast.config.settings import ServerConfig @@ -22,6 +23,8 @@ from ontocast.onto.state import AgentState from ontocast.onto.unit_states import UnitState +pytestmark = pytest.mark.unit + def _declared_default(model: type[BaseModel], field: str) -> object: return model.model_fields[field].default diff --git a/test/test_llm_json_mode.py b/test/test_llm_json_mode.py new file mode 100644 index 00000000..28cb5cee --- /dev/null +++ b/test/test_llm_json_mode.py @@ -0,0 +1,46 @@ +"""LLM_JSON_MODE: the setting, and the prompt precondition it depends on.""" + +from __future__ import annotations + +import pytest + +from ontocast.config.settings import LLMConfig +from ontocast.onto.enum import LLMGraphFormat +from ontocast.onto.model import ( + FactsCritiqueReport, + FactsRenderReport, + GraphUpdateRenderReport, + OntologyCritiqueReport, + OntologySelectorReport, +) +from ontocast.prompt.llm_json_schema import format_instructions_for_model + +pytestmark = pytest.mark.unit + +_REPORT_MODELS = [ + FactsRenderReport, + GraphUpdateRenderReport, + FactsCritiqueReport, + OntologyCritiqueReport, + OntologySelectorReport, +] + + +def test_json_mode_is_off_by_default() -> None: + """Enabling it is a deployment decision, not a silent default change.""" + assert LLMConfig().json_mode is False + + +@pytest.mark.parametrize("model", _REPORT_MODELS, ids=lambda m: m.__name__) +@pytest.mark.parametrize("fmt", list(LLMGraphFormat), ids=lambda f: f.value) +def test_prompt_json_mode_precondition(model, fmt) -> None: + """Every format instruction must name JSON, in both graph formats. + + OpenAI rejects a ``response_format=json_object`` request unless the word + "JSON" appears somewhere in the prompt. That precondition is a property of + the prompt set, so it belongs in CI rather than being discovered by a 400 + from the provider. Turtle is included deliberately: it only changes the + graph *fields*, the envelope stays JSON. + """ + instructions = format_instructions_for_model(model, fmt) + assert "JSON" in instructions diff --git a/test/test_llm_rate_limit.py b/test/test_llm_rate_limit.py new file mode 100644 index 00000000..e864dfb9 --- /dev/null +++ b/test/test_llm_rate_limit.py @@ -0,0 +1,129 @@ +"""Provider pacing and throttle visibility (mocked, no live API). + +LLM_MAX_INFLIGHT caps concurrency; LLM_REQUESTS_PER_SECOND paces the +sustained rate underneath it; LLM_MAX_RETRIES tunes the provider SDK's own +backoff. A throttle that slips through anyway must be counted -- a 429 used +to surface as an ordinary failed render, indistinguishable from a model +failure in the telemetry. +""" + +import asyncio +from unittest.mock import MagicMock, patch + +import pytest +from langchain_core.rate_limiters import InMemoryRateLimiter + +from ontocast.config import LLMConfig, LLMProvider, OpenAIModel +from ontocast.tool.llm import LLMTool, _is_rate_limit_error + +pytestmark = pytest.mark.unit + + +def _openai_config(**overrides) -> LLMConfig: + return LLMConfig( + provider=LLMProvider.OPENAI, + model_name=OpenAIModel.GPT4_O_MINI, + api_key="test-key", + **overrides, + ) + + +def _setup_kwargs(config: LLMConfig, patch_target: str) -> dict: + with patch(patch_target) as mock_cls: + mock_cls.return_value = MagicMock() + tool = LLMTool(config=config) + asyncio.run(tool.setup()) + mock_cls.assert_called_once() + return dict(mock_cls.call_args.kwargs) + + +def test_pacing_kwargs_absent_by_default() -> None: + kwargs = _setup_kwargs(_openai_config(), "langchain_openai.ChatOpenAI") + assert "rate_limiter" not in kwargs + assert "max_retries" not in kwargs + + +def test_requests_per_second_builds_a_limiter() -> None: + kwargs = _setup_kwargs( + _openai_config(requests_per_second=2.0), "langchain_openai.ChatOpenAI" + ) + limiter = kwargs["rate_limiter"] + assert isinstance(limiter, InMemoryRateLimiter) + assert limiter.requests_per_second == 2.0 + + +def test_max_retries_is_forwarded() -> None: + kwargs = _setup_kwargs(_openai_config(max_retries=5), "langchain_openai.ChatOpenAI") + assert kwargs["max_retries"] == 5 + + +def test_ollama_gets_pacing_but_no_retry_budget() -> None: + config = LLMConfig( + provider=LLMProvider.OLLAMA, + model_name="qwen3.6:27b", + requests_per_second=1.0, + max_retries=5, + ) + kwargs = _setup_kwargs(config, "langchain_ollama.ChatOllama") + assert isinstance(kwargs["rate_limiter"], InMemoryRateLimiter) + # ChatOllama exposes no retry budget; the knob must not be forwarded. + assert "max_retries" not in kwargs + + +def test_google_gets_pacing_and_retries() -> None: + config = LLMConfig( + provider=LLMProvider.GOOGLE, + model_name="gemini-3.5-flash", + api_key="test-key", + requests_per_second=2.0, + max_retries=4, + ) + kwargs = _setup_kwargs(config, "langchain_google_genai.ChatGoogleGenerativeAI") + assert isinstance(kwargs["rate_limiter"], InMemoryRateLimiter) + assert kwargs["max_retries"] == 4 + + +class RateLimitError(Exception): + """Name-alike of the providers' throttle exceptions.""" + + +def test_rate_limit_classifier() -> None: + assert _is_rate_limit_error(RateLimitError("too many requests")) + # Google's name for the same condition. + exc = type("ResourceExhausted", (Exception,), {})("quota") + assert _is_rate_limit_error(exc) + # Message-shaped: a 429 mentioned alongside rate language. + assert _is_rate_limit_error( + RuntimeError("Error code: 429 - rate limit reached for gpt-5-mini") + ) + # Cause chain is walked. + wrapped = RuntimeError("provider call failed") + wrapped.__cause__ = RateLimitError("slow down") + assert _is_rate_limit_error(wrapped) + # Ordinary failures are not throttles. + assert not _is_rate_limit_error(ValueError("bad input")) + assert not _is_rate_limit_error(RuntimeError("HTTP 500")) + + +def test_throttle_is_counted_and_propagates() -> None: + """A rate-limit error increments llm/rate_limited and re-raises.""" + from ontocast.onto.state import BudgetTracker + from ontocast.tool.llm import use_budget_tracker + + tool = LLMTool(config=_openai_config(cache_enabled=False)) + failing = MagicMock() + + async def _boom(*args, **kwargs): + raise RateLimitError("Error code: 429") + + failing.ainvoke = _boom + tool._llm = failing + tracker = BudgetTracker() + + async def _call(): + with use_budget_tracker(tracker): + await tool._invoke_cached("prompt") + + with pytest.raises(RateLimitError): + asyncio.run(_call()) + assert tracker.counters.get("llm/rate_limited") == 1 diff --git a/test/test_llm_resilience.py b/test/test_llm_resilience.py index 0b8e3c89..b27bb7f4 100644 --- a/test/test_llm_resilience.py +++ b/test/test_llm_resilience.py @@ -21,7 +21,7 @@ from ontocast.onto.model import BasePydanticModel from ontocast.tool.llm import LLMTool -pytestmark = pytest.mark.anyio +pytestmark = [pytest.mark.anyio, pytest.mark.unit] @pytest.fixture @@ -218,6 +218,69 @@ async def ainvoke(self, *args, **kwds): await asyncio.wait_for(tool("hello again"), timeout=2.0) +async def test_timeout_is_charged_to_the_budget() -> None: + """A timed-out call must reach calls_count, not only llm/timeouts. + + The provider received and worked on the prompt; only the answer was + abandoned. Recorded as nothing, a run of timeouts read as a run of few, + cheap calls: the prompt characters it paid for were missing from + chars_sent and calls_count did not cover the attempt. + """ + from ontocast.config import LLMConfig + from ontocast.onto.state import BudgetTracker + from ontocast.tool.cache import Cacher + from ontocast.tool.llm import LLMRequestTimeoutError, LLMTool + + class _HangingModel: + async def ainvoke(self, *args, **kwds): + await asyncio.Event().wait() + + tracker = BudgetTracker() + tool = LLMTool( + config=LLMConfig(cache_enabled=False, request_timeout_seconds=0.05), + cache=Cacher(), + budget_tracker=tracker, + ) + tool._llm = _HangingModel() + + with pytest.raises(LLMRequestTimeoutError): + await tool("hello") + + assert tracker.calls_count == 1 + assert tracker.chars_sent == len("hello") + assert tracker.chars_received == 0 + assert tracker.counters["llm/timeouts"] == 1 + assert tracker.counters["llm/calls_failed"] == 1 + assert "llm/calls_timed" not in tracker.counters + + +async def test_a_provider_error_is_a_failed_call_but_not_usage() -> None: + """A rejected or dropped request cost nothing: counted, not charged.""" + from ontocast.config import LLMConfig + from ontocast.onto.state import BudgetTracker + from ontocast.tool.cache import Cacher + from ontocast.tool.llm import LLMTool + + class _FailingModel: + async def ainvoke(self, *args, **kwds): + raise RuntimeError("connection reset") + + tracker = BudgetTracker() + tool = LLMTool( + config=LLMConfig(cache_enabled=False), + cache=Cacher(), + budget_tracker=tracker, + ) + tool._llm = _FailingModel() + + with pytest.raises(RuntimeError): + await tool("hello") + + assert tracker.calls_count == 0 + assert tracker.counters["llm/calls_failed"] == 1 + assert "llm/timeouts" not in tracker.counters + + def test_unescape_json_delimiters_repairs_escaped_string_delimiters() -> None: """The model escapes the quotes that should *delimit* a JSON string. @@ -387,3 +450,152 @@ def test_parse_json_object_raises_typed_error_with_position() -> None: common.parse_json_object('{"a": 1 "b": 2}') assert excinfo.value.pos > 0 assert excinfo.value.msg + + +class _BadRequestError(Exception): + """Stand-in for the provider SDK's own 400, matched by class name.""" + + +def _rejection() -> _BadRequestError: + # The message verbatim from a run against a model that had dropped the + # level the configuration pinned. + return _BadRequestError( + "Error code: 400 - {'error': {'message': \"Unsupported value: " + "'reasoning_effort' does not support 'minimal' with this model. " + "Supported values are: 'none', 'low', 'medium', 'high', and 'xhigh'.\", " + "'type': 'invalid_request_error', 'param': 'reasoning_effort', " + "'code': 'unsupported_value'}}" + ) + + +def test_a_rejected_parameter_is_a_configuration_error() -> None: + from ontocast.tool.llm import _is_llm_configuration_error + + assert _is_llm_configuration_error(_rejection()) + + # Through a cause chain, the way langchain wraps provider errors. + wrapped = RuntimeError("call failed") + wrapped.__cause__ = _rejection() + assert _is_llm_configuration_error(wrapped) + + +def test_a_missing_key_or_unreachable_model_is_a_configuration_error() -> None: + for name in ("AuthenticationError", "PermissionDeniedError", "NotFoundError"): + from ontocast.tool.llm import _is_llm_configuration_error + + exc = type(name, (Exception,), {})("nope") + assert _is_llm_configuration_error(exc), name + + +def test_a_retryable_or_document_shaped_failure_is_not_a_configuration_error() -> None: + """The classification must stay narrow, or one long chunk aborts a run.""" + from ontocast.tool.llm import _is_llm_configuration_error + + class _RateLimitError(Exception): + pass + + assert not _is_llm_configuration_error(_RateLimitError("429 rate limit")) + assert not _is_llm_configuration_error(RuntimeError("500 internal server error")) + assert not _is_llm_configuration_error(ValueError("nothing to do with a provider")) + # A 400 that names the *input*: this chunk was too long, its siblings may + # be fine, and the unit loop must keep isolating it. + assert not _is_llm_configuration_error( + _BadRequestError( + "Error code: 400 - context_length_exceeded: this model's maximum " + "context length is 128000 tokens" + ) + ) + # Every OpenAI 400 carries the type `invalid_request_error`, so matching on + # that alone would classify any rejection this file has not thought to + # exclude as a run-ending fault. + assert not _is_llm_configuration_error( + _BadRequestError( + "Error code: 400 - {'error': {'message': \"Invalid " + "'messages[0].content': expected a string\", 'type': " + "'invalid_request_error', 'param': 'messages', 'code': None}}" + ) + ) + + +async def test_a_rejected_request_is_re_typed_and_not_cached() -> None: + """The re-type happens at the one funnel every provider call passes.""" + from ontocast.config import LLMConfig + from ontocast.onto.state import BudgetTracker + from ontocast.tool.cache import Cacher + from ontocast.tool.llm import LLMConfigurationError, LLMTool + + class _RejectingModel: + async def ainvoke(self, *args, **kwds): + raise _rejection() + + tracker = BudgetTracker() + tool = LLMTool( + config=LLMConfig(cache_enabled=False), + cache=Cacher(), + budget_tracker=tracker, + ) + tool._llm = _RejectingModel() + + with pytest.raises(LLMConfigurationError): + await tool("hello") + + assert tracker.calls_count == 0 + assert tracker.counters["llm/calls_failed"] == 1 + # The attributed subset, so a rejected arm does not read as a quality + # regression in the telemetry that survived it. + assert tracker.counters["llm/calls_rejected"] == 1 + + +async def test_a_rejected_request_is_not_retried(monkeypatch) -> None: + """One call, no parse feedback: the provider will refuse the next one too.""" + from ontocast.tool.llm import LLMConfigurationError + + monkeypatch.setattr(common, "_retry_backoff_seconds", lambda attempt: 0.0) + calls = 0 + + async def rejecting_llm(prompt): + nonlocal calls + calls += 1 + raise LLMConfigurationError("openai/gpt-x rejected the request") + + with pytest.raises(LLMConfigurationError): + await call_llm_with_retry( + cast(LLMTool, rejecting_llm), _prompt(), _parser(), _kwargs(), max_retries=3 + ) + assert calls == 1 + + +async def test_a_rejected_request_does_not_spend_the_timeout_reissue( + monkeypatch, +) -> None: + """A timeout followed by a rejection propagates the rejection immediately. + + An ``except (timeout, configuration)`` clause would re-issue a request the + provider has already said it will never accept. + """ + from ontocast.tool.llm import LLMConfigurationError, LLMRequestTimeoutError + + monkeypatch.setattr(common, "_retry_backoff_seconds", lambda attempt: 0.0) + calls = 0 + + async def timeout_then_reject(prompt): + nonlocal calls + calls += 1 + if calls == 1: + raise LLMRequestTimeoutError("LLM request exceeded 180.0s") + raise LLMConfigurationError("openai/gpt-x rejected the request") + + with pytest.raises(LLMConfigurationError): + await call_llm_with_retry( + cast(LLMTool, timeout_then_reject), _prompt(), _parser(), _kwargs() + ) + assert calls == 2 + + +def test_a_rejected_request_is_not_a_timeout() -> None: + """No shared base: the two must never be caught by one clause.""" + from ontocast.tool.llm import LLMConfigurationError, LLMRequestTimeoutError + + assert issubclass(LLMConfigurationError, Exception) + assert not issubclass(LLMConfigurationError, LLMRequestTimeoutError) + assert not issubclass(LLMRequestTimeoutError, LLMConfigurationError) diff --git a/test/test_llm_setup.py b/test/test_llm_setup.py index b90286f3..5b7dc610 100644 --- a/test/test_llm_setup.py +++ b/test/test_llm_setup.py @@ -1,8 +1,11 @@ """Tests for LLMTool provider setup (mocked, no live API).""" import asyncio +import logging from unittest.mock import MagicMock, patch +import pytest + from ontocast.config import ( ClaudeModel, GeminiModel, @@ -13,6 +16,8 @@ ) from ontocast.tool.llm import LLMTool +pytestmark = pytest.mark.unit + def test_setup_openai() -> None: config = LLMConfig( @@ -75,3 +80,185 @@ def test_setup_ollama() -> None: mock_cls.assert_called_once() kwargs = mock_cls.call_args.kwargs assert kwargs["model"] == OllamaModel.LLAMA3_1 + + +def test_setup_openai_passes_reasoning_effort_to_the_client() -> None: + config = LLMConfig( + provider=LLMProvider.OPENAI, + model_name=OpenAIModel.GPT4_O_MINI, + api_key="test-key", + reasoning_effort="low", + ) + with patch("langchain_openai.ChatOpenAI") as mock_cls: + mock_cls.return_value = MagicMock() + asyncio.run(LLMTool(config=config).setup()) + kwargs = mock_cls.call_args.kwargs + assert kwargs["reasoning_effort"] == "low" + assert "thinking_budget" not in kwargs + + +def test_setup_openai_leaves_reasoning_effort_to_the_client_when_unset() -> None: + # The client's own default is the provider's; passing None would override + # it with an explicit "no preference" the client may serialise. + config = LLMConfig( + provider=LLMProvider.OPENAI, model_name=OpenAIModel.GPT4_O_MINI, api_key="k" + ) + with patch("langchain_openai.ChatOpenAI") as mock_cls: + mock_cls.return_value = MagicMock() + asyncio.run(LLMTool(config=config).setup()) + assert "reasoning_effort" not in mock_cls.call_args.kwargs + + +def test_setup_google_passes_thinking_budget_to_the_client() -> None: + config = LLMConfig( + provider=LLMProvider.GOOGLE, + model_name=GeminiModel.GEMINI_2_0_FLASH, + api_key="test-key", + thinking_budget=0, + ) + with patch("langchain_google_genai.ChatGoogleGenerativeAI") as mock_cls: + mock_cls.return_value = MagicMock() + asyncio.run(LLMTool(config=config).setup()) + kwargs = mock_cls.call_args.kwargs + # 0 is a value (thinking off), not an absence. + assert kwargs["thinking_budget"] == 0 + assert "reasoning_effort" not in kwargs + + +@pytest.mark.parametrize( + ("provider", "model_name", "patched", "knob"), + [ + ( + LLMProvider.ANTHROPIC, + ClaudeModel.CLAUDE_SONNET_4, + "langchain_anthropic.ChatAnthropic", + {"reasoning_effort": "low"}, + ), + ( + LLMProvider.OLLAMA, + OllamaModel.LLAMA3_1, + "langchain_ollama.ChatOllama", + {"thinking_budget": 256}, + ), + ( + LLMProvider.OPENAI, + OpenAIModel.GPT4_O_MINI, + "langchain_openai.ChatOpenAI", + {"thinking_budget": 256}, + ), + ], +) +def test_setup_warns_and_ignores_a_knob_the_provider_does_not_read( + provider, model_name, patched, knob, caplog +) -> None: + """A knob the configured provider does not read is a no-op. + + Silently, the run would bill full reasoning while its manifest recorded a + budget that never applied. + """ + config = LLMConfig(provider=provider, model_name=model_name, api_key="k", **knob) + with patch(patched) as mock_cls: + mock_cls.return_value = MagicMock() + with caplog.at_level(logging.WARNING, logger="ontocast.tool.llm"): + asyncio.run(LLMTool(config=config).setup()) + assert not (mock_cls.call_args.kwargs.keys() & knob.keys()) + assert "is ignored:" in caplog.text + + +def test_setup_google_passes_reasoning_effort_as_the_thinking_level() -> None: + """Gemini 3+ reads the discrete level, which the client aliases. + + ``reasoning_effort`` on ChatGoogleGenerativeAI carries + ``alias="thinking_level"`` and is routed into ThinkingConfig, so this is + the same knob OpenAI reads under its own name -- not the integer budget. + """ + config = LLMConfig( + provider=LLMProvider.GOOGLE, + model_name=GeminiModel.GEMINI_3_5_FLASH, + api_key="test-key", + reasoning_effort="minimal", + ) + with patch("langchain_google_genai.ChatGoogleGenerativeAI") as mock_cls: + mock_cls.return_value = MagicMock() + asyncio.run(LLMTool(config=config).setup()) + kwargs = mock_cls.call_args.kwargs + assert kwargs["reasoning_effort"] == "minimal" + assert "thinking_budget" not in kwargs + + +def test_setup_drops_the_thinking_budget_on_a_gemini_3_model(caplog) -> None: + """The integer budget is superseded from Gemini 3 on. + + Forwarding it would hand the API a parameter of the wrong generation while + the warning claims it was ignored. + """ + config = LLMConfig( + provider=LLMProvider.GOOGLE, + model_name=GeminiModel.GEMINI_3_5_FLASH, + api_key="test-key", + thinking_budget=0, + ) + with patch("langchain_google_genai.ChatGoogleGenerativeAI") as mock_cls: + mock_cls.return_value = MagicMock() + with caplog.at_level(logging.WARNING, logger="ontocast.tool.llm"): + asyncio.run(LLMTool(config=config).setup()) + assert "thinking_budget" not in mock_cls.call_args.kwargs + assert "LLM_REASONING_EFFORT" in caplog.text + + +def test_setup_keeps_the_thinking_budget_on_a_gemini_2_5_model() -> None: + """The generation that still reads it is unaffected by the 3+ handling.""" + config = LLMConfig( + provider=LLMProvider.GOOGLE, + model_name=GeminiModel.GEMINI_2_5_FLASH, + api_key="test-key", + thinking_budget=0, + ) + with patch("langchain_google_genai.ChatGoogleGenerativeAI") as mock_cls: + mock_cls.return_value = MagicMock() + asyncio.run(LLMTool(config=config).setup()) + assert mock_cls.call_args.kwargs["thinking_budget"] == 0 + + +@pytest.mark.parametrize( + "model_name", + [OpenAIModel.GPT5, OpenAIModel.GPT5_MINI, OpenAIModel.GPT5_NANO], +) +def test_setup_pins_temperature_for_the_gpt_5_series(model_name) -> None: + """The series rejects any temperature but 1.0, so it is overridden.""" + config = LLMConfig( + provider=LLMProvider.OPENAI, + model_name=model_name, + api_key="k", + temperature=0.0, + ) + with patch("langchain_openai.ChatOpenAI") as mock_cls: + mock_cls.return_value = MagicMock() + asyncio.run(LLMTool(config=config).setup()) + assert mock_cls.call_args.kwargs["temperature"] == 1.0 + + +@pytest.mark.parametrize( + "model_name", + [OpenAIModel.GPT5_4, OpenAIModel.GPT5_4_MINI, OpenAIModel.GPT5_4_PRO], +) +def test_setup_leaves_temperature_alone_for_later_families(model_name) -> None: + """A name that merely starts like the pinned series is not the series. + + The override is anchored, so gpt-5.4 keeps the configured temperature. + Forcing it to 1.0 would silently unpin decoding on every arm that asks + for deterministic output -- and the mutation also reaches the cache key + and the run manifest, so the substitution would not be visible after + the fact. + """ + config = LLMConfig( + provider=LLMProvider.OPENAI, + model_name=model_name, + api_key="k", + temperature=0.0, + ) + with patch("langchain_openai.ChatOpenAI") as mock_cls: + mock_cls.return_value = MagicMock() + asyncio.run(LLMTool(config=config).setup()) + assert mock_cls.call_args.kwargs["temperature"] == 0.0 + assert config.temperature == 0.0 diff --git a/test/test_llm_usage.py b/test/test_llm_usage.py index 6e6d2b30..165e1eb2 100644 --- a/test/test_llm_usage.py +++ b/test/test_llm_usage.py @@ -1,14 +1,18 @@ """Tests for LLM token usage extraction from provider responses.""" +import pytest from langchain_core.messages.ai import AIMessage from ontocast.onto.token_usage import TokenUsage from ontocast.tool.llm import ( + _chars_received_from_result, _usage_from_llm_result, _usage_metadata_from, token_usage_from_openai_payload, ) +pytestmark = pytest.mark.unit + def test_usage_from_usage_metadata() -> None: message = AIMessage( @@ -114,3 +118,23 @@ def test_usage_metadata_round_trips() -> None: def test_usage_metadata_is_none_without_totals() -> None: assert _usage_metadata_from(TokenUsage(reasoning_tokens=5)) is None + + +def test_chars_received_counts_characters_of_block_content() -> None: + # Providers that answer in typed content blocks were measured in blocks: + # len() of a two-block reply is 2, so chars_received for such a run + # measured a different quantity from a string-content provider's, and + # every chars-per-call comparison across providers was on different units. + blocks = AIMessage( + content=[ + {"type": "text", "text": "abc"}, + {"type": "text", "text": "defgh"}, + ] + ) + assert _chars_received_from_result(blocks) == 8 + assert _chars_received_from_result(AIMessage(content="abcdefgh")) == 8 + + +def test_chars_received_is_zero_for_an_empty_reply() -> None: + assert _chars_received_from_result(AIMessage(content="")) == 0 + assert _chars_received_from_result(AIMessage(content=[])) == 0 diff --git a/test/test_loop_lag.py b/test/test_loop_lag.py index 5000f365..99e2461e 100644 --- a/test/test_loop_lag.py +++ b/test/test_loop_lag.py @@ -12,7 +12,7 @@ from ontocast.util.loop_lag import loop_lag -pytestmark = pytest.mark.anyio +pytestmark = [pytest.mark.anyio, pytest.mark.unit] @pytest.fixture diff --git a/test/test_match_derivation.py b/test/test_match_derivation.py index dca80679..940e4215 100644 --- a/test/test_match_derivation.py +++ b/test/test_match_derivation.py @@ -1,10 +1,13 @@ from __future__ import annotations +import pytest from rdflib import URIRef from ontocast.tool.agg.match_derivation import derive_pair_matches from ontocast.tool.agg.match_models import EntityCluster, GraphEntityMember +pytestmark = pytest.mark.unit + def test_derive_single_member_per_side() -> None: clusters = [ diff --git a/test/test_match_models.py b/test/test_match_models.py index 7aa803d1..c1bed920 100644 --- a/test/test_match_models.py +++ b/test/test_match_models.py @@ -1,5 +1,6 @@ from __future__ import annotations +import pytest from rdflib import URIRef from ontocast.tool.agg.match_models import ( @@ -9,6 +10,8 @@ coerce_uri_ref, ) +pytestmark = pytest.mark.unit + def test_coerce_uri_ref_accepts_string() -> None: uri = coerce_uri_ref("http://example.org/entity") diff --git a/test/test_max_visits_critic_propagation.py b/test/test_max_visits_critic_propagation.py index 2d7a3b77..bdc50a8c 100644 --- a/test/test_max_visits_critic_propagation.py +++ b/test/test_max_visits_critic_propagation.py @@ -1,12 +1,11 @@ -"""``--max-visits`` must observably reach the unit loops. - -A run comparing ``--max-visits 1`` against -``--max-visits 2``, but LLM-call accounting later showed the critic never ran -in the second arm — the two runs were an A/A comparison, and nothing recorded -the effective setting. These tests pin the two ends of the chain that make a -future arm auditable: the batch entry path writes the flag into -``AgentState``, and a unit loop at ``max_visits=2`` actually spends a critic -call. +"""The two per-unit budgets must observably reach the loop, and stay separate. + +An arm comparing ``--max-visits 1`` against ``--max-visits 2`` was later shown +by call accounting to be an A/A comparison: the critic had never run in either, +because it was gated behind a spare render slot and nothing recorded the +effective setting. The budgets are now independent -- ``max_visits`` retries a +*failed* render, ``FACTS_CRITIC_PASSES`` buys review-and-patch passes -- and +these tests pin both ends of the chain that makes an arm auditable. """ from __future__ import annotations @@ -22,6 +21,7 @@ from ontocast.config import Config from ontocast.onto.content_unit import ContentUnit from ontocast.onto.enum import OntologyContextMode, RenderMode, Status +from ontocast.onto.model import Suggestions, TripleFix from ontocast.onto.rdfgraph import RDFGraph from ontocast.onto.state import AgentState from ontocast.onto.unit_states import UnitFactsState @@ -30,6 +30,7 @@ from ontocast.stategraph.context_resolver import UnitOntologyContext from ontocast.stategraph.unit_context import UnitLoopContext from ontocast.tool.atomic import AtomicToolBox +from ontocast.tool.facts_validation import CriticPatchPolicy from ontocast.toolbox import ToolBox from test.snapshot_helpers import empty_snapshot @@ -65,160 +66,116 @@ def _unit_state(**kwargs) -> UnitFactsState: ) -def _tools() -> ToolBox: +def _tools(critic_passes: int = 1) -> ToolBox: return cast( ToolBox, SimpleNamespace( get_atomic_tools=lambda: cast( AtomicToolBox, SimpleNamespace( - facts_llm_repair_visits=0, + facts_critic_passes=critic_passes, + facts_patch_policy=CriticPatchPolicy(), additional_standard_namespaces=(), validation_policy=None, acceptance_policy=None, + numeric_coverage_limit=30, + numeric_coverage_mandatory=False, + facts_critic_min_triples=0, + facts_completion_passes=0, + catalog_terms=lambda: set(), ), ), ), ) -@pytest.mark.anyio -@pytest.mark.parametrize( - ("max_visits", "expected_critic_calls"), - [(1, 0), (2, 1)], -) -async def test_critic_spends_a_call_exactly_when_max_visits_allows( - monkeypatch, max_visits: int, expected_critic_calls: int -) -> None: - """At 1 the critic is skipped; at 2 a successful render is criticised. - - This is billing-visible in production (``criticise_facts`` is a provider - call), which is how the silent A/A run was eventually detected — so the - loop-level guarantee is asserted on call count, not on log output. - """ - critic_calls = 0 - - async def ok_render(state, tools, **kwargs): - state.status = Status.SUCCESS - return state - - async def converging_critic(state, tools): - nonlocal critic_calls - critic_calls += 1 - state.status = Status.SUCCESS - return state +def _context() -> UnitLoopContext: + return UnitLoopContext.from_agent_state(AgentState(render_mode=RenderMode.FACTS)) - monkeypatch.setattr(atomic_module, "render_facts", ok_render) - monkeypatch.setattr(atomic_module, "criticise_facts", converging_critic) - context = UnitLoopContext.from_agent_state(AgentState(render_mode=RenderMode.FACTS)) - resolved = UnitOntologyContext( +def _resolved_context() -> UnitOntologyContext: + return UnitOntologyContext( snapshot=empty_snapshot(), writable_iris=[], confidence=1.0 ) - result = await facts_loop( - _unit_state(max_visits_per_node=max_visits), - _tools(), - context, - pre_resolved_context=resolved, - ) - - assert result.status == Status.SUCCESS - assert critic_calls == expected_critic_calls @pytest.mark.anyio @pytest.mark.parametrize("max_visits", [1, 2, 3]) -async def test_a_converging_critic_costs_two_calls_at_any_bound( - monkeypatch, max_visits: int +@pytest.mark.parametrize("critic_passes", [0, 1, 2]) +async def test_the_two_budgets_do_not_trade_against_each_other( + monkeypatch, max_visits: int, critic_passes: int ) -> None: - """Raising ``MAX_VISITS`` buys nothing when the critic accepts the render. - - The loop returns as soon as a critique succeeds, so the *happy path* costs - one render plus one critique however high the bound is set — and exactly - one call at a bound of 1, where the critic is skipped outright. So the - production cost of ``MAX_VISITS=3`` is not a fixed multiple: it is driven - entirely by how often the critic rejects, which makes it a property of the - corpus rather than of the setting. + """One successful render, and exactly the passes that were paid for. + + The old loop entangled these: the critic ran only if a render attempt was + left over, so "enable the critic" meant "authorise a second full + extraction", and raising the render bound silently raised the critic bound + too. Neither is true now. """ - calls: list[str] = [] + calls = {"render": 0, "critic": 0} async def ok_render(state, tools, **kwargs): - calls.append("render") + calls["render"] += 1 state.status = Status.SUCCESS return state - async def converging_critic(state, tools): - calls.append("critic") - state.status = Status.SUCCESS + async def improving_critic(state, tools): + calls["critic"] += 1 + state.suggestions = Suggestions( + actionable_fixes=[ + TripleFix( + text_fragment="Alice", + action="ADD", + severity="important", + correct_value=( + f" " + f' "v" .' + ), + explanation="add one statement", + ) + ] + ) return state monkeypatch.setattr(atomic_module, "render_facts", ok_render) - monkeypatch.setattr(atomic_module, "criticise_facts", converging_critic) + monkeypatch.setattr(atomic_module, "criticise_facts", improving_critic) - context = UnitLoopContext.from_agent_state(AgentState(render_mode=RenderMode.FACTS)) - result = await facts_loop( + await facts_loop( _unit_state(max_visits_per_node=max_visits), - _tools(), - context, - pre_resolved_context=UnitOntologyContext( - snapshot=empty_snapshot(), writable_iris=[], confidence=1.0 - ), + _tools(critic_passes=critic_passes), + _context(), + pre_resolved_context=_resolved_context(), ) - assert result.status == Status.SUCCESS - expected = ["render"] if max_visits == 1 else ["render", "critic"] - assert calls == expected + assert calls == {"render": 1, "critic": critic_passes} @pytest.mark.anyio @pytest.mark.parametrize("max_visits", [1, 2, 3, 5]) -async def test_a_rejecting_critic_costs_the_same_at_any_bound( +async def test_a_rejecting_critic_costs_the_same_at_any_render_bound( monkeypatch, max_visits: int ) -> None: - """Worst-case per-unit calls no longer grow with MAX_VISITS. - - The ledger used to be ``2 * max_visits - 1``: a rejecting critic fell - through to the next ``render_attempt``, which re-extracted the whole unit. - So the answer to "this one term is wrong" was another full render, and - raising the bound bought more of them. Rejection now routes the critic's - fixes into the same bounded rewrite-in-place repair the deterministic - findings use, and the outer loop retries only on *render failure*. - - At ``max_visits=1`` the critic is skipped entirely (there is no second - render for it to inform), so that arm costs one render. Above 1 the cost is - one render plus one critique, flat. - - Web grounding is off here (the default): a critic that rejects *without* - requesting evidence breaks the inner loop immediately, so the nominal - ``max_visits ** 2`` worst case is unreachable on this path. The repair pass - is free in this fixture (``facts_llm_repair_visits=0``). - """ - calls: list[str] = [] + """A rejection buys a patch, never a re-extraction.""" + calls = {"render": 0, "critic": 0} async def ok_render(state, tools, **kwargs): - calls.append("render") + calls["render"] += 1 state.status = Status.SUCCESS return state async def rejecting_critic(state, tools): - calls.append("critic") + calls["critic"] += 1 state.status = Status.FAILED return state monkeypatch.setattr(atomic_module, "render_facts", ok_render) monkeypatch.setattr(atomic_module, "criticise_facts", rejecting_critic) - context = UnitLoopContext.from_agent_state(AgentState(render_mode=RenderMode.FACTS)) await facts_loop( _unit_state(max_visits_per_node=max_visits), - _tools(), - context, - pre_resolved_context=UnitOntologyContext( - snapshot=empty_snapshot(), writable_iris=[], confidence=1.0 - ), + _tools(critic_passes=1), + _context(), + pre_resolved_context=_resolved_context(), ) - expected_critics = 0 if max_visits == 1 else 1 - assert calls.count("render") == 1 - assert calls.count("critic") == expected_critics - assert len(calls) == 1 + expected_critics + assert calls == {"render": 1, "critic": 1} diff --git a/test/test_measurement_lexicon.py b/test/test_measurement_lexicon.py new file mode 100644 index 00000000..87c2828a --- /dev/null +++ b/test/test_measurement_lexicon.py @@ -0,0 +1,106 @@ +"""The lexicon behind numeric coverage and the completion pass. + +Every "missing measurement" finding, and every unit the completion pass is +asked to recover, starts as a :func:`unit_adjacent_numbers` mention. The module +shipped untested, so the two rules that decide whether a number is a +measurement -- compound-unit acceptance, and the figure-label/initial guards -- +were unpinned in the release whose headline feature depends on them. +""" + +import pytest + +from ontocast.util.measurement_lexicon import ( + is_unit_surface, + unit_adjacent_numbers, +) + +pytestmark = pytest.mark.unit + + +@pytest.mark.parametrize( + "token", + ["nm", "meV", "K", "ps", "°C", "%"], +) +def test_builtin_surfaces_are_recognized(token: str) -> None: + assert is_unit_surface(token) + + +@pytest.mark.parametrize( + "token", + ["mW/cm2", "g·mol⁻¹", "μJ·cm-2", "J/mol/K"], +) +def test_a_compound_is_accepted_when_every_factor_is_known(token: str) -> None: + """Compounds are decomposed rather than enumerated. + + Splitting on the factor separators and stripping an exponent is what lets + the lexicon stay small while covering the units a materials corpus actually + writes. + """ + assert is_unit_surface(token) + + +@pytest.mark.parametrize("token", ["widgets/cm2", "mW/frobs", "", " ", "the"]) +def test_an_unknown_factor_rejects_the_whole_compound(token: str) -> None: + assert not is_unit_surface(token) + + +def test_extra_surfaces_extend_the_lexicon_without_replacing_it() -> None: + """A caller's ontology units must add to the built-ins, not shadow them.""" + assert not is_unit_surface("furlong") + assert is_unit_surface("furlong", extra_surfaces={"furlong"}) + assert is_unit_surface("nm", extra_surfaces={"furlong"}) + + +def test_a_trailing_separator_does_not_hide_a_unit() -> None: + assert is_unit_surface("nm,") + assert is_unit_surface("K.") + + +def test_a_plain_number_next_to_a_unit_is_a_mention() -> None: + mentions = unit_adjacent_numbers("the shift was 96 meV overall") + assert [(m.value, m.unit) for m in mentions] == [("96", "meV")] + assert "96 meV" in mentions[0].context + + +def test_each_side_of_a_range_is_its_own_mention() -> None: + """A range holds two values the graph is expected to carry, not one. + + Emitting one mention per number is what makes the coverage inventory ask + for both endpoints rather than counting the range as satisfied by either. + """ + mentions = unit_adjacent_numbers("aged 10-15 meV above the peak") + assert [(m.value, m.unit) for m in mentions] == [("10", "meV"), ("15", "meV")] + + +def test_an_uncertainty_pair_shares_its_unit() -> None: + mentions = unit_adjacent_numbers("thickness 8.5 ± 0.5 nm") + assert [(m.value, m.unit) for m in mentions] == [("8.5", "nm"), ("0.5", "nm")] + + +@pytest.mark.parametrize( + "text", + ["see Figure 2 K for the trend", "Fig. 3 K shows the decay", "Table 2 K"], +) +def test_a_figure_or_table_label_is_not_a_measurement(text: str) -> None: + """ "Figure 2 K" is a panel name, and K is not measuring anything. + + Without this guard every figure cross-reference in a paper becomes a + missing-measurement finding, and the completion pass is sent to recover it. + """ + assert unit_adjacent_numbers(text) == [] + + +def test_a_number_with_no_unit_yields_nothing() -> None: + assert unit_adjacent_numbers("we repeated it 12 times") == [] + + +def test_mentions_come_back_in_text_order() -> None: + mentions = unit_adjacent_numbers("first 5 nm, then 300 K, then 2 ps") + assert [m.value for m in mentions] == ["5", "300", "2"] + assert [m.start for m in mentions] == sorted(m.start for m in mentions) + + +def test_offsets_bracket_the_number_and_its_unit() -> None: + text = "the shift was 96 meV overall" + (mention,) = unit_adjacent_numbers(text) + assert text[mention.start : mention.end] == "96 meV" diff --git a/test/test_numeric_inventory.py b/test/test_numeric_inventory.py index 4ea40638..f30eeee5 100644 --- a/test/test_numeric_inventory.py +++ b/test/test_numeric_inventory.py @@ -1,5 +1,6 @@ """Numeric-mention inventory tests.""" +import pytest from rdflib import Literal, URIRef from rdflib.namespace import XSD @@ -10,6 +11,8 @@ numeric_literals_in_graph, ) +pytestmark = pytest.mark.unit + def test_extract_numeric_tokens_basic_and_ranges() -> None: text = "a red shift of ~10-15 meV, up to 96 meV, edge length 8.5 ± 0.5 nm" @@ -28,7 +31,7 @@ def test_extract_numeric_tokens_canonicalizes() -> None: assert extract_numeric_tokens("value 230.0 and 230") == {"230"} -def test_numeric_literals_in_graph_covers_typed_and_label_numbers() -> None: +def _typed_and_labelled() -> RDFGraph: graph = RDFGraph() subject = URIRef("http://x/s") graph.add( @@ -45,11 +48,27 @@ def test_numeric_literals_in_graph_covers_typed_and_label_numbers() -> None: Literal("range 50 - 300 nm"), ) ) - values = numeric_literals_in_graph(graph) + return graph + + +def test_numeric_literals_in_graph_ignores_label_numbers_by_default() -> None: + """A number that exists only inside a label has not been extracted. + + Counting it let a placeholder labelled with the missing number silence + the coverage finding that asked for it. + """ + values = numeric_literals_in_graph(_typed_and_labelled()) + assert "12.5" in values + assert not {"50", "300"} & values + + +def test_numeric_literals_in_graph_can_include_annotations() -> None: + values = numeric_literals_in_graph(_typed_and_labelled(), include_annotations=True) assert {"12.5", "50", "300"} <= values def test_missing_numeric_mentions() -> None: + """A bare numeric literal does not clear a unit-adjacent mention.""" graph = RDFGraph() graph.add( ( @@ -59,4 +78,245 @@ def test_missing_numeric_mentions() -> None: ) ) missing = missing_numeric_mentions("shifts of 96 meV and 12.5 meV", graph) - assert missing == ["12.5"] + assert missing == ["96", "12.5"] + + +def test_missing_measurement_needs_matching_unit() -> None: + """``96 K`` in the graph does not cover a ``96 meV`` mention.""" + from rdflib import OWL, RDF, RDFS + + from ontocast.util.numeric_inventory import missing_numeric_inventory + + QUDT = "http://qudt.org/schema/qudt/" + UNIT = "http://qudt.org/vocab/unit/" + ontology = RDFGraph() + ontology.bind("qudt", QUDT) + ontology.add((URIRef(f"{QUDT}unit"), RDFS.range, URIRef(f"{QUDT}Unit"))) + ontology.add((URIRef(f"{QUDT}Unit"), RDF.type, OWL.Class)) + ontology.add((URIRef(f"{UNIT}K"), RDF.type, URIRef(f"{QUDT}Unit"))) + ontology.add((URIRef(f"{UNIT}K"), RDFS.label, Literal("K"))) + ontology.add((URIRef(f"{UNIT}MilliEV"), RDF.type, URIRef(f"{QUDT}Unit"))) + ontology.add((URIRef(f"{UNIT}MilliEV"), RDFS.label, Literal("meV"))) + + graph = RDFGraph() + graph.bind("qudt", QUDT) + graph.add( + ( + URIRef("http://x/s"), + URIRef(f"{QUDT}numericValue"), + Literal("96", datatype=XSD.decimal), + ) + ) + graph.add((URIRef("http://x/s"), URIRef(f"{QUDT}unit"), URIRef(f"{UNIT}K"))) + + inventory = missing_numeric_inventory( + "shifts of 96 meV and 96 K", + graph, + ontology_graph=ontology, + numeric_value_properties={f"{QUDT}numericValue"}, + unit_properties={f"{QUDT}unit"}, + ) + assert [(m.value, m.unit) for m in inventory.measurements] == [("96", "meV")] + + +# --------------------------------------------------------------------------- +# Identifier guard (FACTS_NUMERIC_IDENTIFIER_GUARD) +# --------------------------------------------------------------------------- + + +def test_identifier_fragments_kept_by_default() -> None: + """The guard is opt-in; without it the current inventory is unchanged.""" + assert "92" in extract_numeric_tokens("case 7 AZR 600/92 was decided") + + +def test_identifier_fragments_dropped_under_guard() -> None: + """A file number is one identifier, not three quantities missing from the graph.""" + guarded = extract_numeric_tokens( + "case 7 AZR 600/92 was decided", ignore_identifier_fragments=True + ) + assert "600" not in guarded + assert "92" not in guarded + + +def test_guard_keeps_a_value_with_its_unit() -> None: + """A magnitude followed by a unit is one digit group, not a code. + + ``5mg`` with no space matches nothing even without the guard -- the number + pattern's trailing ``(?![\\w])`` already excludes it -- so the case that + matters is the spaced form the pattern does read. + """ + guarded = extract_numeric_tokens( + "the sample held 5 mg", ignore_identifier_fragments=True + ) + assert "5" in guarded + + +def test_guard_keeps_plain_decimals_and_exponents() -> None: + guarded = extract_numeric_tokens( + "values 3.14 and 1.4e3 and 250", ignore_identifier_fragments=True + ) + assert "3.14" in guarded + assert "250" in guarded + assert any(token.startswith("1400") for token in guarded) + + +def test_guard_drops_doi_stems() -> None: + guarded = extract_numeric_tokens( + "recorded in 10.1234/example", ignore_identifier_fragments=True + ) + assert "10.1234" not in guarded + + +def test_guard_keeps_hyphenated_ranges() -> None: + """A hyphen joins two real values, so it is not an identifier separator. + + The module reads each side of a range separately by design; treating "-" + as a code separator would drop both sides of "10-15 meV". + """ + guarded = extract_numeric_tokens( + "a red shift of 10-15 meV", ignore_identifier_fragments=True + ) + assert {"10", "15"} <= guarded + + +def test_guard_does_not_reach_standalone_digit_groups() -> None: + """Stated limitation: a lone token carries no evidence either way. + + "7" in a file number is indistinguishable from a small quantity without + looking outside the token, and guessing there would cost real values. + """ + guarded = extract_numeric_tokens( + "case 7 AZR 600/92 was decided", ignore_identifier_fragments=True + ) + assert "7" in guarded + + +def test_missing_numeric_mentions_threads_the_guard() -> None: + graph = RDFGraph() + unguarded = missing_numeric_mentions("file 600/92 refers", graph) + guarded = missing_numeric_mentions( + "file 600/92 refers", graph, ignore_identifier_fragments=True + ) + assert "600" in unguarded + assert guarded == [] + + +# --------------------------------------------------------------------------- +# Unit-aware inventory: measurements vs bare numbers +# --------------------------------------------------------------------------- + + +def test_inventory_splits_measurements_from_bare_numbers() -> None: + from ontocast.util.numeric_inventory import inventory_numeric_mentions + + inventory = inventory_numeric_mentions( + "a red shift of 10-15 meV in sample 7, stored 30 days; see ref 42" + ) + + assert [(m.value, m.unit) for m in inventory.measurements] == [ + ("10", "meV"), + ("15", "meV"), + ("30", "days"), + ] + assert inventory.unclassified == ["7", "42"] + assert "a red shift of 10-15 meV" in inventory.measurements[0].context + + +def test_inventory_keeps_one_mention_per_value_unit_in_text_order() -> None: + from ontocast.util.numeric_inventory import inventory_numeric_mentions + + inventory = inventory_numeric_mentions("96 meV here, and 96 meV again, 5 nm") + + assert [m.value for m in inventory.measurements] == ["96", "5"] + + +def test_inventory_keeps_same_number_under_different_units() -> None: + from ontocast.util.numeric_inventory import inventory_numeric_mentions + + inventory = inventory_numeric_mentions("96 meV here and 96 K there") + + assert [(m.value, m.unit) for m in inventory.measurements] == [ + ("96", "meV"), + ("96", "K"), + ] + + +def test_snapshot_unit_surfaces_widen_the_measurement_lane() -> None: + from ontocast.util.numeric_inventory import inventory_numeric_mentions + + without = inventory_numeric_mentions("irradiated at 1 sun and 3 suns for 2 cycles") + with_units = inventory_numeric_mentions( + "aged for 12 pulses at 2 cycles", unit_surfaces={"pulse"} + ) + + assert [m.unit for m in without.measurements] == ["sun", "suns", "cycles"] + assert [(m.value, m.unit) for m in with_units.measurements] == [ + ("12", "pulses"), + ("2", "cycles"), + ] + + +def test_missing_inventory_lists_measurements_first_then_bare_numbers() -> None: + """Bare numeric literals leave unit-adjacent mentions missing.""" + from ontocast.util.numeric_inventory import missing_numeric_inventory + + graph = RDFGraph() + graph.add( + ( + URIRef("http://x/s"), + URIRef("http://qudt.org/schema/qudt/numericValue"), + Literal("96", datatype=XSD.decimal), + ) + ) + inventory = missing_numeric_inventory( + "shifts of 96 meV and 12.5 meV over 3 samples at 250 K", graph + ) + + assert [m.value for m in inventory.measurements] == ["96", "12.5", "250"] + assert inventory.unclassified == ["3"] + assert missing_numeric_mentions( + "shifts of 96 meV and 12.5 meV over 3 samples at 250 K", graph + ) == ["96", "12.5", "250", "3"] + + +def test_the_cap_drops_bare_numbers_before_measurements() -> None: + from ontocast.util.numeric_inventory import missing_numeric_inventory + + inventory = missing_numeric_inventory( + "1 and 2 and 3 and 4 then 5 nm and 6 nm", RDFGraph(), limit=3 + ) + + assert [m.value for m in inventory.measurements] == ["5", "6"] + assert inventory.unclassified == ["1"] + + +def test_unit_surface_index_reads_unit_individuals_and_is_memoised() -> None: + from rdflib import OWL, RDF, RDFS, SKOS + + from ontocast.util.numeric_inventory import ( + unit_surface_index, + unit_surfaces_in_ontology, + ) + + QUDT = "http://qudt.org/schema/qudt/" + UNIT = "http://qudt.org/vocab/unit/" + onto = RDFGraph() + onto.add((URIRef(f"{QUDT}unit"), RDFS.range, URIRef(f"{QUDT}Unit"))) + onto.add((URIRef(f"{UNIT}SUN"), RDF.type, URIRef(f"{QUDT}Unit"))) + onto.add((URIRef(f"{UNIT}SUN"), RDFS.label, Literal("sun equivalent"))) + onto.add((URIRef(f"{UNIT}SUN"), URIRef(f"{QUDT}symbol"), Literal("sun"))) + onto.add((URIRef(f"{UNIT}CYCLE"), RDF.type, URIRef(f"{QUDT}Unit"))) + onto.add((URIRef(f"{UNIT}CYCLE"), SKOS.altLabel, Literal("cycles"))) + onto.add((URIRef(f"{UNIT}CYCLE"), SKOS.notation, Literal("12"))) + onto.add((URIRef("http://x/Thing"), RDF.type, OWL.Class)) + onto.add((URIRef("http://x/t1"), RDF.type, URIRef("http://x/Thing"))) + onto.add((URIRef("http://x/t1"), RDFS.label, Literal("nope"))) + + index = unit_surface_index(onto, {f"{QUDT}unit"}) + + assert index == {"sun": (f"{UNIT}SUN",), "cycles": (f"{UNIT}CYCLE",)}, ( + "a spaced label, a numeric notation and a non-unit individual stay out" + ) + assert unit_surface_index(onto, {f"{QUDT}unit"}) is index, "memoised per graph" + assert unit_surfaces_in_ontology(onto, {f"{QUDT}unit"}) == {"sun", "cycles"} + assert unit_surface_index(None) == {} diff --git a/test/test_ontology_text_caps.py b/test/test_ontology_text_caps.py new file mode 100644 index 00000000..77abc52b --- /dev/null +++ b/test/test_ontology_text_caps.py @@ -0,0 +1,344 @@ +"""Bounding the text literals that reach an ontology chapter. + +The triple budget is a count and says nothing about how long one literal may +be, so a catalog well inside it can still ship an unbounded chapter -- and does, +on every call of every unit. These pin the bound: what it does, what it refuses +to do, and that it is inert when unset. +""" + +from __future__ import annotations + +import pytest +from rdflib import OWL, RDF, RDFS, SKOS, Literal, Namespace, URIRef + +from ontocast.onto.enum import LLMGraphFormat, OntologyChapterFormat +from ontocast.onto.ontology_condense import ( + CLIP_MARKER, + TextCaps, + clip_text, + condense_graph_for_prompt, +) +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.prompt.graph_format import get_graph_format_profile + +EX = Namespace("https://example.org/onto#") + + +def _catalog(*, terms: int = 12, prose: int = 400, note: int = 900) -> RDFGraph: + """A catalog whose prose volume is a parameter rather than a constant.""" + graph = RDFGraph() + graph.bind("ex", EX) + for index in range(terms): + term = EX[f"Term{index}"] + graph.add((term, RDF.type, OWL.Class)) + graph.add((term, RDFS.label, Literal(f"term {index}"))) + graph.add((term, RDFS.comment, Literal(" ".join(["prose"] * (prose // 6))))) + graph.add((term, SKOS.scopeNote, Literal(" ".join(["applies"] * (note // 8))))) + return graph + + +def test_clip_cuts_on_a_word_boundary_and_marks_the_cut() -> None: + clipped = clip_text("the quick brown fox jumps over the lazy dog", 20) + + assert clipped.endswith(CLIP_MARKER) + assert not clipped[: -len(CLIP_MARKER)].endswith(" ") + assert len(clipped) - len(CLIP_MARKER) <= 20 + assert "the quick brown fox" in clipped + + +def test_clip_stops_at_the_first_sentence_of_a_long_description() -> None: + """The opening sentence is the definition; the rest elaborates for a human. + + The cut is by content, so it may retain well under the cap rather than + filling it with elaboration an extractor never reads. + """ + text = ( + "A sample held under controlled conditions. " + "Historically this class was introduced to model storage inventories " + "and later generalised. " + "See the companion note for the full derivation." + ) + + clipped = clip_text(text, 120) + + assert clipped == "A sample held under controlled conditions." + CLIP_MARKER + + +def test_clip_keeps_the_clause_saying_when_the_term_applies() -> None: + """A term reduced to its definition can be applied where it must not be.""" + text = ( + "A measured quantity with a unit. " + "Use only when the source states the unit explicitly. " + "The class was renamed in a later revision of this catalog." + ) + + clipped = clip_text(text, 120) + + assert clipped == ( + "A measured quantity with a unit. " + "Use only when the source states the unit explicitly." + CLIP_MARKER + ) + + +def test_clip_cuts_on_a_sentence_boundary_not_mid_word() -> None: + text = ( + "An observation recorded during an experiment. " + "The remainder of this comment is elaboration that no extractor reads " + "and that a word-boundary clip would cut in the middle of a clause." + ) + + clipped = clip_text(text, 120) + + assert clipped.removesuffix(CLIP_MARKER).endswith(".") + + +def test_clip_ignores_a_period_inside_an_abbreviation() -> None: + """``e.g.`` is not a sentence boundary; cutting there loses the example.""" + text = ( + "A qualifier applied to a reported value, e.g. an approximation cue. " + "A second sentence of elaboration follows here to force the clip." + ) + + clipped = clip_text(text, 120) + + assert clipped == ( + "A qualifier applied to a reported value, e.g. an approximation cue." + + CLIP_MARKER + ) + + +def test_a_one_sentence_description_within_the_cap_is_untouched() -> None: + text = "A sample held under controlled conditions." + + assert clip_text(text, 200) == text + + +def test_a_single_sentence_over_the_cap_still_falls_back_to_a_word_boundary() -> None: + """There is no sentence boundary inside it to prefer.""" + text = "A sample held under controlled conditions for a prolonged period" + + clipped = clip_text(text, 20) + + assert clipped == "A sample held under" + CLIP_MARKER + + +def test_a_scope_note_still_wins_over_a_comment() -> None: + """The trim bounds the prose; it must not change which prose is chosen.""" + from ontocast.prompt.term_sheet import build_ontology_term_sheet + + graph = RDFGraph() + graph.bind("ex", EX) + graph.add((EX.Term, RDF.type, OWL.Class)) + graph.add((EX.Term, RDFS.label, Literal("term"))) + graph.add((EX.Term, RDFS.comment, Literal("A human-facing description."))) + graph.add((EX.Term, SKOS.scopeNote, Literal("Use only for measured values."))) + + condensed, _ = condense_graph_for_prompt(graph, None, TextCaps(contract=200)) + sheet = build_ontology_term_sheet(condensed) + + assert "note: Use only for measured values." in sheet + assert "A human-facing description." not in sheet + + +def test_clip_falls_back_when_there_is_no_word_boundary() -> None: + """A single long token still has to be bounded, marker included.""" + assert clip_text("x" * 40, 10) == "x" * 10 + CLIP_MARKER + + +def test_clip_leaves_short_text_byte_identical() -> None: + assert clip_text("short", 80) == "short" + assert clip_text("short", None) == "short" + + +def test_unset_caps_are_a_true_no_op() -> None: + """An untouched deployment must not see its prompts -- or cache keys -- move.""" + graph = _catalog() + condensed, report = condense_graph_for_prompt(graph, 4000, TextCaps()) + + assert condensed is graph, "inactive caps must not even copy the graph" + assert not report.changed + assert report.literals_clipped == 0 + + +def test_caps_clip_the_roles_they_govern_and_nothing_else() -> None: + graph = _catalog() + condensed, report = condense_graph_for_prompt( + graph, 4000, TextCaps(contract=120, prose=80) + ) + + labels = [str(o) for o in condensed.objects(None, RDFS.label)] + comments = [str(o) for o in condensed.objects(None, RDFS.comment)] + notes = [str(o) for o in condensed.objects(None, SKOS.scopeNote)] + + assert all(CLIP_MARKER not in label for label in labels), "naming was uncapped" + assert all(len(c) <= 80 + len(CLIP_MARKER) for c in comments) + assert all(len(n) <= 120 + len(CLIP_MARKER) for n in notes) + assert report.literals_clipped == len(comments) + len(notes) + assert len(condensed) == len(graph), "clipping must not remove statements" + + +def test_language_tag_survives_clipping() -> None: + graph = RDFGraph() + graph.add((EX.T, RDFS.comment, Literal("a " * 200, lang="en"))) + + condensed, _ = condense_graph_for_prompt(graph, 4000, TextCaps(prose=40)) + + (obj,) = list(condensed.objects(EX.T, RDFS.comment)) + assert isinstance(obj, Literal) + assert obj.language == "en" + + +@pytest.mark.parametrize("prose", [400, 4_000, 40_000]) +def test_total_budget_holds_however_verbose_the_catalog(prose: int) -> None: + """The point of the backstop: chapter size stops tracking prose volume. + + Without it the chapter costs whatever the catalog's authors chose to write. + This is the guarantee for a catalog nobody here has seen. + """ + budget = 3_000 + graph = _catalog(prose=prose, note=prose) + + condensed, report = condense_graph_for_prompt( + graph, 4000, TextCaps(total_budget=budget) + ) + + assert report.text_chars_after <= budget + assert not report.text_over_budget + assert report.text_chars_before > budget + + +def test_total_budget_never_removes_anything() -> None: + """The budget shortens text; it does not delete statements. + + A clipped definition still says the term has one and still carries the words + that say when it applies; a removed one says nothing. Shedding statements is + the triple budget's job, on the axis where statements are what is over + budget. + """ + graph = _catalog(terms=60, prose=400, note=400) + before = len(graph) + + condensed, report = condense_graph_for_prompt(graph, 4000, TextCaps(total_budget=1)) + + assert report.text_over_budget, "an impossible budget is reported, not met" + assert len(condensed) == before, "no statement may be removed to meet a budget" + assert len(list(condensed.objects(None, RDFS.label))) == 60 + assert all(str(o) for o in condensed.objects(None, RDFS.comment)), ( + "even prose keeps a stub rather than vanishing" + ) + + +def test_budget_is_fitted_rather_than_stepped_down_to() -> None: + """Every character under the budget is context it never asked to lose. + + A ladder of preset caps overshoots by however much the next rung happens to + cut; bisecting for the largest admissible cap lands near the target. + """ + budget = 4_000 + graph = _catalog(terms=40, prose=600, note=600) + + _, report = condense_graph_for_prompt(graph, 4000, TextCaps(total_budget=budget)) + + assert report.text_chars_after <= budget + assert budget - report.text_chars_after < budget * 0.1, ( + "fitted to the budget, not far under it" + ) + + +def test_caps_apply_to_the_turtle_chapter_too() -> None: + """Not a term-sheet feature: any chapter the pipeline builds is bounded.""" + graph = _catalog() + profile = get_graph_format_profile( + LLMGraphFormat.JSONLD, ontology_chapter_format=OntologyChapterFormat.TURTLE + ) + + uncapped = profile.format_ontology_chapter(graph, max_triples=4000) + capped = profile.format_ontology_chapter( + graph, max_triples=4000, text_caps=TextCaps(contract=100, prose=100) + ) + + assert len(capped) < len(uncapped) + assert CLIP_MARKER in capped + assert "ttl" in capped, "still a Turtle chapter" + + +def test_caps_join_the_memoised_chapter_key() -> None: + """Two cap settings are two chapters; the memo must not serve one as the other.""" + from ontocast.onto.ontology_snapshot import OntologySnapshot + + graph = _catalog() + snapshot = OntologySnapshot( + graph=graph, source_iris=[URIRef("https://example.org")] + ) + profile = get_graph_format_profile(LLMGraphFormat.TURTLE) + + loose = snapshot.prompt_chapter(profile, text_caps=TextCaps(prose=400)) + tight = snapshot.prompt_chapter(profile, text_caps=TextCaps(prose=40)) + + assert loose != tight + assert snapshot.prompt_chapter(profile, text_caps=TextCaps(prose=40)) == tight + + +def test_chapter_report_reaches_the_caller() -> None: + """A cap whose effect cannot be read back is a cap nobody can size.""" + from ontocast.onto.ontology_condense import CondenseReport + + graph = _catalog() + profile = get_graph_format_profile(LLMGraphFormat.TURTLE) + seen: list[CondenseReport] = [] + + profile.format_ontology_chapter( + graph, + max_triples=4000, + text_caps=TextCaps(prose=40), + on_report=seen.append, + ) + + (report,) = seen + assert report.text_chars_before > report.text_chars_after > 0 + assert report.literals_clipped > 0 + + +def test_chapter_report_fires_once_per_chapter_not_once_per_reader() -> None: + """The snapshot is shared across the fan-out; the chapter is built once. + + Counting it per reader would report the traffic, not the context. + """ + from ontocast.onto.ontology_condense import CondenseReport + from ontocast.onto.ontology_snapshot import OntologySnapshot + + snapshot = OntologySnapshot( + graph=_catalog(), source_iris=[URIRef("https://example.org")] + ) + profile = get_graph_format_profile(LLMGraphFormat.TURTLE) + caps = TextCaps(prose=40) + seen: list[CondenseReport] = [] + + for _ in range(4): + snapshot.prompt_chapter(profile, text_caps=caps, on_report=seen.append) + + assert len(seen) == 1 + + +def test_report_is_published_even_when_the_caps_are_inert() -> None: + """The inert case is the one a deployment most needs told. + + Sizing a cap is a property of the catalog, so "your caps did nothing here" + has to be readable rather than indistinguishable from "no caps set". + """ + from ontocast.onto.ontology_condense import CondenseReport + + graph = _catalog(prose=30, note=30) + profile = get_graph_format_profile(LLMGraphFormat.TURTLE) + seen: list[CondenseReport] = [] + + profile.format_ontology_chapter( + graph, + max_triples=4000, + text_caps=TextCaps(naming=80, contract=240, prose=160), + on_report=seen.append, + ) + + (report,) = seen + assert report.literals_clipped == 0 + assert report.text_chars_before == report.text_chars_after > 0 diff --git a/test/test_pipeline.py b/test/test_pipeline.py index 0d5850a1..e52d110b 100644 --- a/test/test_pipeline.py +++ b/test/test_pipeline.py @@ -52,10 +52,13 @@ from ontocast.stategraph.unit_context import UnitLoopContext from ontocast.tool import EmbeddingBasedAggregator from ontocast.tool.atomic import AtomicToolBox, SearchHit +from ontocast.tool.facts_validation import CriticPatchPolicy from ontocast.tool.ontology_manager import OntologyManager from ontocast.toolbox import ToolBox from test.snapshot_helpers import empty_snapshot, snapshot_from_ontology +pytestmark = pytest.mark.unit + render_ontology_module = importlib.import_module("ontocast.agent.render_ontology") criticise_ontology_module = importlib.import_module("ontocast.agent.criticise_ontology") unit_loops = importlib.import_module("ontocast.stategraph.atomic") @@ -106,7 +109,7 @@ async def fake_critic(state: UnitFactsState, tools) -> UnitFactsState: state.status = Status.SUCCESS return state - async def fake_resolve(_state, _tools, _unit): + async def fake_resolve(_state, _tools, _unit, **_kwargs): return UnitOntologyContext( snapshot=snapshot_from_ontology(_build_ontology()), writable_iris=["https://example.org/o"] @@ -129,10 +132,19 @@ async def fake_resolve(_state, _tools, _unit): get_atomic_tools=lambda: cast( AtomicToolBox, SimpleNamespace( - facts_llm_repair_visits=1, + facts_critic_passes=1, + ontology_critic_passes=1, + facts_patch_policy=CriticPatchPolicy(), + ontology_patch_policy=CriticPatchPolicy(), additional_standard_namespaces=(), validation_policy=None, acceptance_policy=None, + ontology_acceptance_policy=None, + numeric_coverage_limit=30, + numeric_coverage_mandatory=False, + facts_critic_min_triples=0, + facts_completion_passes=0, + catalog_terms=lambda: set(), ), ), ontology_manager=OntologyManager(), @@ -163,7 +175,7 @@ async def fake_critic(state: UnitOntologyState, tools) -> UnitOntologyState: state.status = Status.SUCCESS return state - async def fake_resolve(_state, _tools, _unit): + async def fake_resolve(_state, _tools, _unit, **_kwargs): return UnitOntologyContext( snapshot=empty_snapshot(), writable_iris=["https://example.com/onto"] @@ -184,7 +196,13 @@ async def fake_resolve(_state, _tools, _unit): ToolBox, SimpleNamespace( get_atomic_tools=lambda: cast( - AtomicToolBox, SimpleNamespace(validation_policy=None) + AtomicToolBox, + SimpleNamespace( + validation_policy=None, + ontology_critic_passes=1, + ontology_patch_policy=CriticPatchPolicy(), + ontology_acceptance_policy=None, + ), ), ontology_manager=OntologyManager(), ), @@ -669,7 +687,7 @@ async def fake_critic(state: UnitOntologyState, tools) -> UnitOntologyState: state.status = Status.SUCCESS return state - async def fake_resolve(_state, _tools, _unit): + async def fake_resolve(_state, _tools, _unit, **_kwargs): return UnitOntologyContext( snapshot=empty_snapshot(), writable_iris=["https://example.com/onto"] @@ -692,7 +710,13 @@ async def fake_resolve(_state, _tools, _unit): ToolBox, SimpleNamespace( get_atomic_tools=lambda: cast( - AtomicToolBox, SimpleNamespace(validation_policy=None) + AtomicToolBox, + SimpleNamespace( + validation_policy=None, + ontology_critic_passes=1, + ontology_patch_policy=CriticPatchPolicy(), + ontology_acceptance_policy=None, + ), ), ontology_manager=OntologyManager(), ), @@ -748,7 +772,7 @@ async def fake_critic(state: UnitOntologyState, tools) -> UnitOntologyState: state.status = Status.SUCCESS return state - async def fake_resolve(_state, _tools, _unit): + async def fake_resolve(_state, _tools, _unit, **_kwargs): return UnitOntologyContext( snapshot=empty_snapshot(), writable_iris=["https://example.com/onto"] @@ -773,7 +797,13 @@ async def fake_resolve(_state, _tools, _unit): ToolBox, SimpleNamespace( get_atomic_tools=lambda: cast( - AtomicToolBox, SimpleNamespace(validation_policy=None) + AtomicToolBox, + SimpleNamespace( + validation_policy=None, + ontology_critic_passes=1, + ontology_patch_policy=CriticPatchPolicy(), + ontology_acceptance_policy=None, + ), ), ontology_manager=OntologyManager(), ), @@ -1285,3 +1315,70 @@ async def fake_render(unit_state: UnitOntologyState, atomic_tools): # The regression: map-stage additions used to be dropped here. assert (map_stage_class, RDF.type, owl_class) in result_graph assert updated.ontology_updates_applied + + +def test_a_rejected_request_stops_the_document_graph(monkeypatch) -> None: + """End to end: the whole graph aborts rather than serializing nothing. + + The failure this guards: a reasoning level the model had dropped made every + provider call return 400. Each render caught it as its own unit's failure, + the fan-out reported `failed without usable output for N/N unit(s)`, and + the graph carried on to SERIALIZE — an empty graph uploaded, a manifest and + a validation report written beside no facts, exit 0. Every link in the + chain is pinned separately (test_llm_resilience, test_unit_fanout_failures, + test_cli_server); this pins the chain. + """ + # Classification matches class name + message markers, not the SDK type — + # same stand-in as test_llm_resilience, so this test stays offline without + # the openai extra. + from langchain_core.runnables import RunnableConfig + + from ontocast.tool.llm import LLMConfigurationError, LLMTool + + class BadRequestError(Exception): + """Stand-in for the provider SDK's own 400, matched by class name.""" + + class _RejectingModel: + async def ainvoke(self, *args, **kwds): + raise BadRequestError( + "Error code: 400 - {'error': {'message': \"Unsupported value: " + "'reasoning_effort' does not support 'minimal' with this model. " + "Supported values are: 'none', 'low', 'medium', 'high', and " + "'xhigh'.\", 'type': 'invalid_request_error', " + "'param': 'reasoning_effort', 'code': 'unsupported_value'}}" + ) + + # setup() rebuilds the client on first use, so the substitution has to + # survive it. + async def _install_rejecting_model(self) -> None: + self._llm = _RejectingModel() + + monkeypatch.setattr(LLMTool, "setup", _install_rejecting_model) + + config = Config( + tool_config=ToolConfig( + path_config=PathConfig(), + llm_config=LLMConfig( + provider=LLMProvider.OPENAI, + api_key="sk-not-used", + cache_enabled=False, + ), + ), + ) + app = create_agent_graph(ToolBox(config)) + text = "Perovskite films aged at 85 C for 500 hours lost 20% of their PCE. " * 20 + state = AgentState(raw_input={"doc.txt": text.encode("utf-8")}) + + seen: list[Any] = [] + + async def drive() -> None: + async for chunk in app.astream( + state, stream_mode="values", config=RunnableConfig(recursion_limit=40) + ): + seen.append(chunk) + + with pytest.raises(LLMConfigurationError, match="rejected the request"): + asyncio.run(drive()) + + # Nothing was serialized: the abort happens in the first fan-out. + assert all(not chunk.get("aggregated_facts") for chunk in seen) diff --git a/test/test_prefix_namespace_hygiene.py b/test/test_prefix_namespace_hygiene.py index 0478a94a..3f5c541b 100644 --- a/test/test_prefix_namespace_hygiene.py +++ b/test/test_prefix_namespace_hygiene.py @@ -9,6 +9,8 @@ from ontocast.prompt.facts_guidelines import format_facts_operational_guidelines from ontocast.prompt.ontology_context import extract_domain_prefix_pairs +pytestmark = pytest.mark.unit + def _matsci_turtle(*, prefix: str = "matsci") -> str: return f""" @@ -107,7 +109,15 @@ def test_facts_guidelines_domain_clause_appears_once() -> None: jsonld=False, ) assert guidelines.count(clause) == 1 - assert "domain ontology namespace(s) above" in guidelines + # The guidelines block is emitted *before* the ONTOLOGY chapter, so it must + # point the model forwards. It used to say "above", at nothing. + assert "ONTOLOGY section below" in guidelines + assert "above" not in guidelines + # Catalog terms outrank generic vocabulary; the two used to be offered as + # equals ("either ... or standard core vocabularies"), which is what an + # empty ontology chapter turned into a wholly generic extraction. + assert "VOCABULARY PRECEDENCE" in guidelines + assert "LAST RESORT" in guidelines # `ex:` is a MANDATORY repair finding, so the prompt must never advertise it: # every occurrence used to cost a repair render. This is the one behavioural # guard worth keeping from the old prompt-wording smoke tests; the rest diff --git a/test/test_prompt_jsonld_context.py b/test/test_prompt_jsonld_context.py index 7cc968ec..52cb313d 100644 --- a/test/test_prompt_jsonld_context.py +++ b/test/test_prompt_jsonld_context.py @@ -11,8 +11,12 @@ import json +import pytest + from ontocast.onto.rdfgraph import RDFGraph +pytestmark = pytest.mark.unit + _TURTLE = """ @prefix owl: . @prefix rdfs: . diff --git a/test/test_prompt_templates.py b/test/test_prompt_templates.py new file mode 100644 index 00000000..a4f037ca --- /dev/null +++ b/test/test_prompt_templates.py @@ -0,0 +1,328 @@ +"""Smoke coverage for every ``ontocast.prompt`` module. + +Seven of these modules had no test contact at all, so a template edit that +broke a ``.format()`` slot -- an unbalanced brace, a malformed conversion, a +stray single brace where the double-escaped ``{{search_guidelines}}`` form was +meant -- surfaced only on a live run, after paying for the call. + +The checks are structural: they assert a template can be parsed and filled, +never what it says. + +Two kinds of module-level string live here and only one is a template. A +template is rendered, by ``str.format`` or by ``PromptTemplate``. A *literal +block* (``facts_literal_rules_jsonld``, ``_OUTPUT_INSTRUCTION_JSONLD``) is +substituted **into** a template's slot and legitimately carries raw JSON braces +that ``format`` rejects -- ``{"@value": "2024-01-15"}`` is a JSON-LD example, +not a slot. Checking those would fail on correct code, so the two rendering +paths are discovered from the source instead of guessed from names. +""" + +from __future__ import annotations + +import importlib +import os +import pathlib +import pkgutil +import re +from string import Formatter + +import pytest +from langchain_core.prompts import ChatPromptTemplate, PromptTemplate + +import ontocast +import ontocast.prompt + +pytestmark = pytest.mark.unit + +_FORMATTER = Formatter() + +#: ``some_template.format(`` / ``prompt.some_template.format(`` call sites. +_FORMAT_CALL = re.compile(r"(?:^|[^\w.])(?:\w+\.)*(\w+)\.format\(") + +#: Constants handed to ``PromptTemplate(template=...)`` rather than formatted +#: directly. LangChain validates the f-string on construction. +_PROMPT_TEMPLATE_CONSTANT = "template_prompt" + + +def _prompt_modules() -> list[str]: + return sorted( + module.name + for module in pkgutil.iter_modules(ontocast.prompt.__path__) + if not module.ispkg + ) + + +def _formatted_names() -> frozenset[str]: + """Names the package calls ``.format()`` on, anywhere in its source.""" + root = pathlib.Path(ontocast.__file__).parent + names: set[str] = set() + for path in root.rglob("*.py"): + names.update(_FORMAT_CALL.findall(path.read_text(encoding="utf-8"))) + return frozenset(names) + + +def _string_constants(module) -> list[tuple[str, str]]: + return [ + (name, value) + for name, value in vars(module).items() + if isinstance(value, str) and not name.startswith("__") + ] + + +def _assert_renders(label: str, template: str) -> None: + """Parse the template, reject positional slots, and fill every named one.""" + try: + parsed = list(_FORMATTER.parse(template)) + except ValueError as exc: # pragma: no cover - the failure being guarded + pytest.fail(f"{label} is not parseable: {exc}") + + assert "" not in {field for _, field, _, _ in parsed if field is not None}, ( + f"{label} uses a positional slot, which makes it order-dependent" + ) + + fields = {field for _, field, _, _ in parsed if field} + try: + template.format(**dict.fromkeys(fields, "x")) + except (KeyError, IndexError, ValueError) as exc: # pragma: no cover + pytest.fail(f"{label} does not fill: {exc!r}") + + +MODULES = _prompt_modules() +FORMATTED_NAMES = _formatted_names() + + +def test_prompt_package_has_modules() -> None: + """Guard the guard: empty discovery would make everything below vacuous.""" + assert len(MODULES) >= 10 + + +def test_format_call_discovery_finds_a_known_template() -> None: + """Guard the guard: a broken regex would silently skip every fill check.""" + assert "facts_instruction_shared" in FORMATTED_NAMES + + +@pytest.mark.parametrize("module_name", MODULES) +def test_formatted_templates_render(module_name: str) -> None: + """Every template the package formats must fill from its declared slots.""" + module = importlib.import_module(f"ontocast.prompt.{module_name}") + for name, template in _string_constants(module): + if name in FORMATTED_NAMES: + _assert_renders(f"{module_name}.{name}", template) + + +@pytest.mark.parametrize("module_name", MODULES) +def test_prompt_template_constants_build(module_name: str) -> None: + """``template_prompt`` must survive PromptTemplate's f-string validation. + + This is the assembly path for the render and critic prompts, and the one an + edit is most likely to break: the agents declare ``input_variables`` + explicitly, so a slot added to the text and not to that list raises only + when the prompt is built for a real call. + """ + module = importlib.import_module(f"ontocast.prompt.{module_name}") + template = getattr(module, _PROMPT_TEMPLATE_CONSTANT, None) + if template is None: + pytest.skip(f"{module_name} declares no {_PROMPT_TEMPLATE_CONSTANT}") + + built = PromptTemplate.from_template(template) + assert built.input_variables, "a prompt with no slots is a constant, not a template" + rendered = built.format(**dict.fromkeys(built.input_variables, "x")) + assert rendered.strip() + + +@pytest.mark.parametrize("module_name", MODULES) +def test_chat_templates_render(module_name: str) -> None: + """A ChatPromptTemplate must render from its own declared input variables.""" + module = importlib.import_module(f"ontocast.prompt.{module_name}") + for name, template in vars(module).items(): + if not isinstance(template, ChatPromptTemplate): + continue + messages = template.format_messages( + **dict.fromkeys(template.input_variables, "x") + ) + assert messages, f"{module_name}.{name} rendered no messages" + + +def test_facts_prompts_carry_the_conformance_placeholder() -> None: + """Render and critic share one conformance contract slot.""" + from ontocast.prompt import criticise_facts, render_facts + + assert "{conformance_chapter}" in render_facts.template_prompt + assert "{conformance_chapter}" in criticise_facts.template_prompt + + +def _slot_order(template: str) -> list[str]: + """Named slots of a template, in the order they are filled.""" + return [field for _, field, _, _ in _FORMATTER.parse(template) if field] + + +def shared_prefix_length(left: str, right: str) -> int: + """Characters from the start that two prompts have in common. + + This is what a provider's prefix cache can serve the second call from the + first, so it is the number the chapter order is arranged to maximise. + """ + return len(os.path.commonprefix([left, right])) + + +def test_facts_prompt_chapter_order_puts_the_shared_prefix_first() -> None: + """Render and critic open identically: preamble, conformance, ontology. + + The ontology chapter is most of a facts prompt and identical between the + render and the critic call on a unit. Everything phase-specific -- the + operational or evaluation guidelines, the user instruction, the text -- + comes after it, so the two prompts are byte-identical up to the end of + the chapter and a provider's prefix cache can serve the second call what + the first one paid for. + """ + from ontocast.prompt import criticise_facts, render_facts + + shared_head = ["preamble", "conformance_chapter", "ontology_chapter"] + assert _slot_order(render_facts.template_prompt) == [ + *shared_head, + "facts_instruction", + "user_instruction", + "text_chapter", + "output_instruction", + "format_instructions", + ] + assert _slot_order(criticise_facts.template_prompt) == [ + *shared_head, + "evaluation_instruction", + "user_instruction", + "text_chapter", + "facts_chapter", + "graph_format_instruction", + "format_instructions", + ] + + +def test_facts_prompt_heads_are_shared_verbatim() -> None: + """The prefix starts at byte zero: preamble and separators must not differ.""" + from ontocast.prompt import criticise_facts, render_facts + + assert render_facts.preamble == criticise_facts.preamble + render_head = render_facts.template_prompt.split("{ontology_chapter}")[0] + critic_head = criticise_facts.template_prompt.split("{ontology_chapter}")[0] + assert render_head == critic_head + + +@pytest.mark.anyio +async def test_render_and_critic_prompts_share_the_prefix_through_the_ontology( + monkeypatch, +) -> None: + """The two calls on a unit agree byte-for-byte up to the ontology chapter. + + Drives the real agents rather than the templates, so a chapter builder, + profile lookup or separator that drifts between the two is caught here + and not on a bill. + """ + from ontocast.onto.model import FactsCritiqueReport, FactsRenderReport + from ontocast.onto.rdfgraph import RDFGraph + from ontocast.onto.unit_states import UnitFactsState + from test.snapshot_helpers import snapshot_from_ontology + from test.test_agent_facts import ( + _build_content_unit, + _build_ontology, + _build_tools, + ) + + # The package re-exports the agent *functions* under these names, so the + # modules have to be reached by path. + render_agent = importlib.import_module("ontocast.agent.render_facts") + critic_agent = importlib.import_module("ontocast.agent.criticise_facts") + + prompts: dict[str, str] = {} + chapters: dict[str, str] = {} + + def capturing(name: str, report): + async def fake(**kwargs): + prompts[name] = kwargs["prompt"].format(**kwargs["prompt_kwargs"]) + chapters[name] = kwargs["prompt_kwargs"]["ontology_chapter"] + return report + + return fake + + rendered = RDFGraph() + rendered.parse( + data="@prefix ex: .\nex:alice ex:worksFor ex:acme .", + format="turtle", + ) + monkeypatch.setattr( + render_agent, + "call_llm_with_retry", + capturing( + "render", + FactsRenderReport( + semantic_graph=rendered, + ontology_relevance_score=90, + triples_generation_score=90, + ), + ), + ) + monkeypatch.setattr( + critic_agent, + "call_llm_with_retry", + capturing( + "critic", + FactsCritiqueReport( + success=True, + score=95, + actionable_triple_fixes=[], + systemic_critique_summary="", + ), + ), + ) + + state = UnitFactsState( + content_unit=_build_content_unit(with_graph=False), + ontology_snapshot=snapshot_from_ontology(_build_ontology()), + conformance_chapter="# CONFORMANCE REQUIREMENTS\n- ex:Person:\n - needs ex:name", + facts_user_instruction="Prefer lowercase local names.", + ) + tools = _build_tools() + await render_agent.render_facts_fresh(state, tools=tools) + await critic_agent.criticise_facts(state, tools=tools) + + render_prompt, critic_prompt = prompts["render"], prompts["critic"] + assert chapters["render"] == chapters["critic"] + end_of_ontology = render_prompt.index(chapters["render"]) + len(chapters["render"]) + assert shared_prefix_length(render_prompt, critic_prompt) >= end_of_ontology + + shared = render_prompt[:end_of_ontology] + assert "# CONFORMANCE REQUIREMENTS" in shared + assert "# ONTOLOGY" in shared + # Everything phase-specific is after the shared prefix, in both prompts. + for prompt in (render_prompt, critic_prompt): + assert prompt.index("# TASK") >= end_of_ontology + assert prompt.index("# USER INSTRUCTION") >= end_of_ontology + assert prompt.index("# TEXT") >= end_of_ontology + + +def test_quantitative_completeness_rule_is_stated() -> None: + """Rule 3a counters the ontology-scoped selectivity of rule 3 without + weakening the anti-junk guard of rule 4.""" + from ontocast.prompt import facts_guidelines + + text = facts_guidelines.facts_instruction_shared + assert "QUANTITATIVE COMPLETENESS" in text + assert "EVERY quantitative statement" in text + # The anti-junk guard stays verbatim. + assert "Do NOT mint an entity for a bare number" in text + + +def test_critic_completeness_guideline_routes_misses_to_the_completion_pass() -> None: + """The critic lists what it judges missed; it does not mint nodes for it. + + Asking for an ADD per listed number produced placeholder subjects named + after ignored tokens, which the compiler now refuses; the guideline has to + say so in the same breath as it asks for completeness. + """ + from ontocast.prompt import criticise_facts + + text = criticise_facts.evaluation_instruction + assert "NUMERIC COVERAGE" in text + assert "propose an ADD fix per listed number" in text + assert "systemic_critique_summary" in text + assert "completion pass" in text + assert "NEVER mint an entity or placeholder node" in text diff --git a/test/test_proposition_windows.py b/test/test_proposition_windows.py new file mode 100644 index 00000000..e6111dc7 --- /dev/null +++ b/test/test_proposition_windows.py @@ -0,0 +1,339 @@ +"""The retrieval query window: what bounds it, and what an unset bound must not do. + +Every arm measured before a bound existed was windowed by sentence count. If +adding a bound moved those windows even slightly, each of those arms would +silently stop being comparable -- so the first group here asserts equality +across the knob space rather than at the defaults alone. + +The rest pin the *mechanism* of each opt-in bound: what it guarantees that the +sentence count cannot. None of them asserts a recall figure; recall is measured +outside the repository, against a corpus, and a number pinned here would be +true of one corpus on one day. +""" + +from __future__ import annotations + +import pytest + +from ontocast.tool.chunk.proposition import ( + FALLBACK_CHARS_PER_TOKEN, + split_proposition_windows, +) + +pytestmark = pytest.mark.unit + +#: Technical prose with the shapes the bounds exist for: a citation run that the +#: period-splitter shatters, measurements with units, and a range. +TEXT = ( + "Films were annealed at 100 C for 30 min. The PL peak shifted by 20 meV. " + "Samples aged for 4-15 days under ambient conditions. " + "Emission narrowed by 5 nm. Cite This: J. Phys. Chem. Lett. 2019, 10, 655. " + "See Fig. 3 and Ref. 12 for details. The shift saturated after two weeks." +) + + +def _counter(texts: list[str]) -> list[int]: + """A deterministic stand-in for an encoder tokenizer: words plus wrappers. + + The splitter takes the counter as an argument precisely so it can be + measured without loading a checkpoint; what is under test is the packing, + not any particular tokenization. Word-piece counts are additive across a + space -- concatenating two texts costs what they cost apart -- and this + reproduces that, which is the property the packer's arithmetic relies on. + """ + return [2 + len(text.split()) for text in texts] + + +# -------------------------------------------------------------------------- +# An unset bound changes nothing. +# -------------------------------------------------------------------------- + + +def test_unset_bounds_reproduce_sentence_windows_exactly() -> None: + """Each new bound is opt-in, and unset it must be byte-identical.""" + for sentences in (1, 2, 3, 5): + for windows in (1, 2, 16, 10**6): + for stride in (None, 1, 2): + reference = split_proposition_windows( + TEXT, + max_sentences=sentences, + max_windows=windows, + stride=stride, + ) + assert reference == split_proposition_windows( + TEXT, + max_sentences=sentences, + max_windows=windows, + stride=stride, + max_tokens=None, + token_counter=_counter, + abbreviation_aware=False, + measurement_aware=False, + overlap=0.0, + ) + + +def test_unset_bounds_leave_the_character_budget_untouched() -> None: + """The character budget predates these knobs and keeps its exact windows.""" + for budget in (60, 200, 800): + for stride in (None, 1, 2): + assert split_proposition_windows( + TEXT, max_windows=10**6, max_chars=budget, stride=stride + ) == split_proposition_windows( + TEXT, + max_windows=10**6, + max_chars=budget, + stride=stride, + max_tokens=None, + token_counter=_counter, + abbreviation_aware=False, + measurement_aware=False, + overlap=0.0, + ) + + +def test_every_new_bound_is_off_in_the_shipped_configuration() -> None: + from ontocast.config import VectorStoreConfig + + config = VectorStoreConfig() + assert config.proposition_window_max_tokens is None + assert config.proposition_window_overlap == 0.0 + assert config.proposition_abbreviation_aware is False + assert config.proposition_measurement_aware is False + + +# -------------------------------------------------------------------------- +# Abbreviation-aware boundaries. +# -------------------------------------------------------------------------- + + +def test_the_period_splitter_shatters_a_citation() -> None: + """The defect the abbreviation guard exists for, stated as a baseline.""" + windows = split_proposition_windows(TEXT, max_sentences=2, max_windows=10**6) + + assert any(len(window) < 20 for window in windows) + + +def test_abbreviation_awareness_keeps_a_citation_in_one_piece() -> None: + """An initial, a truncated title word and a volume number are one atom.""" + windows = split_proposition_windows( + TEXT, max_sentences=1, max_windows=10**6, abbreviation_aware=True + ) + + assert all(len(window) >= 20 for window in windows) + assert any("J. Phys. Chem. Lett." in window for window in windows) + + +def test_abbreviation_awareness_still_ends_an_ordinary_sentence() -> None: + """A sentence closing on a short unit token is not an abbreviation. + + The guard is shape-based, so "20 meV." looks exactly like a truncation. What + separates them is the fragment around it: prose with ordinary lowercase + words is a whole sentence, a run of capitalised stubs is not. + """ + text = "The peak shifted by 20 meV. The film then stabilised over two weeks." + windows = split_proposition_windows( + text, max_sentences=1, max_windows=10**6, abbreviation_aware=True + ) + + assert len(windows) == 2 + + +def test_abbreviation_awareness_merges_a_lowercase_continuation() -> None: + """A period followed by a lowercase word never ended a sentence.""" + text = "Values were averaged over 5 runs and, cf. the appendix, rounded up." + windows = split_proposition_windows( + text, max_sentences=1, max_windows=10**6, abbreviation_aware=True + ) + + assert windows == [text] + + +# -------------------------------------------------------------------------- +# The token budget. +# -------------------------------------------------------------------------- + + +def test_the_token_budget_bounds_every_window_by_the_encoder_s_own_unit() -> None: + """No window exceeds the budget -- including one built from a long sentence. + + This is what the character budget cannot promise: it emits an over-long + sentence whole, so a passage of long sentences is bounded in name only. + """ + for budget in (10, 16, 24): + windows = split_proposition_windows( + TEXT, max_windows=10**6, max_tokens=budget, token_counter=_counter + ) + assert windows + overhead = _counter([""])[0] + assert all(count - overhead <= budget for count in _counter(windows)) + + +def test_the_token_budget_cuts_a_sentence_the_encoder_would_truncate() -> None: + """A sentence over budget is cut at whitespace, not emitted whole. + + Emitted whole it would be truncated by the encoder and its tail would reach + no lane at all; cut here, the tail is a query of its own. + """ + sentence = "The film " + " ".join(f"stage{index}" for index in range(60)) + " ends." + windows = split_proposition_windows( + sentence, max_windows=10**6, max_tokens=16, token_counter=_counter + ) + + assert len(windows) > 1 + assert " ".join(windows) == sentence + + +def test_the_token_budget_replaces_the_character_budget() -> None: + """Two bounds honoured at once means the tighter one silently wins.""" + by_tokens = split_proposition_windows( + TEXT, max_windows=10**6, max_tokens=16, token_counter=_counter + ) + with_chars = split_proposition_windows( + TEXT, + max_windows=10**6, + max_tokens=16, + max_chars=40, + token_counter=_counter, + ) + + assert by_tokens == with_chars + + +def test_a_token_budget_without_a_tokenizer_approximates_and_says_so( + caplog: pytest.LogCaptureFixture, +) -> None: + """Degrading to characters is a documented fallback, not a silent revert. + + Silently reverting to the sentence bound would leave a deployment believing + truncation was ruled out when nothing bounds the window at all. + """ + import ontocast.tool.chunk.proposition as module + + module._warned_no_tokenizer = False + with caplog.at_level("WARNING"): + windows = split_proposition_windows(TEXT, max_windows=10**6, max_tokens=64) + + assert "tokenizer" in caplog.text + assert windows == split_proposition_windows( + TEXT, max_windows=10**6, max_chars=round(64 * FALLBACK_CHARS_PER_TOKEN) + ) + + +# -------------------------------------------------------------------------- +# Measurement-aware boundaries. +# -------------------------------------------------------------------------- + + +#: A single sentence long enough to be cut, whose natural cut lands between a +#: number and the unit it is written with. +MEASURED = ( + "The absorption edge of the films exhibits a red shift of 10 meV after " + "ageing for 15 days at 85 C in ambient air with 50 % relative humidity." +) + + +def test_a_break_is_never_left_between_a_number_and_its_unit() -> None: + """A window ending on "at 85" retrieves nothing that "85 C" would.""" + guarded = split_proposition_windows( + MEASURED, + max_windows=10**6, + max_tokens=10, + token_counter=_counter, + measurement_aware=True, + ) + + assert not any(window.rstrip().split()[-1].isdigit() for window in guarded) + + +def test_the_measurement_guard_only_moves_a_break_backwards() -> None: + """Moving a break later would push the window past the budget it was set to.""" + plain = split_proposition_windows( + MEASURED, max_windows=10**6, max_tokens=10, token_counter=_counter + ) + guarded = split_proposition_windows( + MEASURED, + max_windows=10**6, + max_tokens=10, + token_counter=_counter, + measurement_aware=True, + ) + + assert plain != guarded + overhead = _counter([""])[0] + assert all(count - overhead <= 10 for count in _counter(guarded)) + assert " ".join(guarded) == MEASURED + + +def test_the_measurement_guard_is_inert_where_no_sentence_is_cut() -> None: + """It guards a cut; a bound that never cuts inside a sentence has none.""" + assert split_proposition_windows( + TEXT, max_windows=10**6, max_chars=300, measurement_aware=True + ) == split_proposition_windows(TEXT, max_windows=10**6, max_chars=300) + + +# -------------------------------------------------------------------------- +# Fractional overlap. +# -------------------------------------------------------------------------- + + +def test_overlap_repeats_text_and_costs_queries() -> None: + """Overlap buys boundary-straddling statements at the price of more queries.""" + disjoint = split_proposition_windows( + TEXT, max_windows=10**6, max_tokens=16, token_counter=_counter + ) + overlapped = split_proposition_windows( + TEXT, max_windows=10**6, max_tokens=16, token_counter=_counter, overlap=0.5 + ) + + assert len(overlapped) > len(disjoint) + # Consecutive windows share text: that sharing is the whole mechanism, and + # it is what a statement straddling a boundary is bought with. + assert any( + set(first.split()) & set(second.split()) + for first, second in zip(overlapped, overlapped[1:], strict=False) + ) + + +def test_overlap_always_advances() -> None: + """A high overlap costs queries; it must not fail to terminate.""" + windows = split_proposition_windows( + TEXT, max_windows=10**6, max_tokens=8, token_counter=_counter, overlap=0.95 + ) + + assert windows + + +def test_overlap_applies_to_the_character_budget_too() -> None: + """Fractional overlap is a property of a budget, not of one particular unit.""" + disjoint = split_proposition_windows(TEXT, max_windows=10**6, max_chars=120) + overlapped = split_proposition_windows( + TEXT, max_windows=10**6, max_chars=120, overlap=0.5 + ) + + assert len(overlapped) > len(disjoint) + + +def test_a_nonsense_bound_is_rejected() -> None: + with pytest.raises(ValueError, match="max_tokens"): + split_proposition_windows(TEXT, max_tokens=0) + with pytest.raises(ValueError, match="overlap"): + split_proposition_windows(TEXT, max_chars=100, overlap=1.0) + + +# -------------------------------------------------------------------------- +# Domain independence. +# -------------------------------------------------------------------------- + + +def test_no_bound_carries_domain_vocabulary() -> None: + """Every rule here is typography, English or measurement *shape*. + + A domain word in the splitter would make retrieval quality a property of the + corpus it was written against, which is exactly the failure the equal- + information bounds are meant to remove. + """ + from ontocast.tool.chunk.proposition import ABBREVIATIONS + + assert all(word.replace(".", "").isalpha() for word in ABBREVIATIONS) + assert all(len(word) <= 6 for word in ABBREVIATIONS) diff --git a/test/test_qdrant_embedding_contract.py b/test/test_qdrant_embedding_contract.py index be017089..7a442ac7 100644 --- a/test/test_qdrant_embedding_contract.py +++ b/test/test_qdrant_embedding_contract.py @@ -18,6 +18,8 @@ ) from test.qdrant_util import DeterministicEmbeddingTool, qdrant_reachable +pytestmark = pytest.mark.unit + def test_embedding_model_fingerprint_includes_dense_and_bm25() -> None: emb = DeterministicEmbeddingTool( diff --git a/test/test_qdrant_point_to_atom.py b/test/test_qdrant_point_to_atom.py index c3019758..c2a7f57a 100644 --- a/test/test_qdrant_point_to_atom.py +++ b/test/test_qdrant_point_to_atom.py @@ -9,12 +9,15 @@ from __future__ import annotations +import pytest from qdrant_client.http import models as qdrant_models from ontocast.config import EmbeddingConfig, QdrantConfig from ontocast.tool.vector_store.qdrant import QdrantVectorStoreManager from test.qdrant_util import DeterministicEmbeddingTool +pytestmark = pytest.mark.unit + class _FakeScrollClient: """Stand-in for QdrantClient exposing only what fetch_atoms_by_ids touches.""" diff --git a/test/test_qdrant_smoke.py b/test/test_qdrant_smoke.py index 67685f52..36a531cb 100644 --- a/test/test_qdrant_smoke.py +++ b/test/test_qdrant_smoke.py @@ -19,6 +19,8 @@ from ontocast.toolbox import ToolBox from test.qdrant_util import DeterministicEmbeddingTool, QdrantSessionTestContext +pytestmark = pytest.mark.unit + def _build_smoke_ontology() -> Ontology: graph = RDFGraph._from_turtle_str( diff --git a/test/test_rdfgraph_turtle_parsing.py b/test/test_rdfgraph_turtle_parsing.py index 700cdbd5..096dc2c3 100644 --- a/test/test_rdfgraph_turtle_parsing.py +++ b/test/test_rdfgraph_turtle_parsing.py @@ -1,5 +1,6 @@ from copy import deepcopy +import pytest from rdflib import Graph, Literal, URIRef from ontocast.onto.enum import LLMGraphFormat @@ -9,6 +10,8 @@ format_quarantine_for_prompt, ) +pytestmark = pytest.mark.unit + def test_from_turtle_coerces_invalid_integer_typed_literal() -> None: ttl = """ diff --git a/test/test_repo_isolation.py b/test/test_repo_isolation.py index b3160250..6243b9ef 100644 --- a/test/test_repo_isolation.py +++ b/test/test_repo_isolation.py @@ -18,6 +18,10 @@ import re from pathlib import Path +import pytest + +pytestmark = pytest.mark.unit + TEST_ROOT = Path(__file__).resolve().parent #: ``parents[N]``, counted from the file's own directory: a file at diff --git a/test/test_retrieval_floor_and_signals.py b/test/test_retrieval_floor_and_signals.py index 3274cc06..df72c3b4 100644 --- a/test/test_retrieval_floor_and_signals.py +++ b/test/test_retrieval_floor_and_signals.py @@ -1,5 +1,7 @@ """Tests for the per-source atom floor, query unit signals, and module closure.""" +from types import SimpleNamespace + import pytest from rdflib import URIRef @@ -14,6 +16,8 @@ number_adjacent_tokens, ) +pytestmark = pytest.mark.unit + def _hit(iri: str, ontology_iri: str, score: float) -> OntologySearchHit: atom = GraphAtom( @@ -188,7 +192,7 @@ def test_catalog_surface_index_caches_per_hash() -> None: assert index.match({"meV"}, [ontology]) == first -def _closure_retriever(manager, closure_max: int): +def _closure_retriever(manager, closure_max: int, max_total: int | None = None): from ontocast.config import PatchRetrievalConfig from ontocast.tool.vector_store.patch_retriever import OntologyPatchRetriever @@ -197,7 +201,10 @@ def _closure_retriever(manager, closure_max: int): vector_store=None, sparql_tool=None, ontology_manager=manager, - patch=PatchRetrievalConfig(small_module_closure_max_triples=closure_max), + patch=PatchRetrievalConfig( + small_module_closure_max_triples=closure_max, + small_module_closure_max_total_triples=max_total, + ), ) return retriever @@ -259,3 +266,213 @@ def test_closure_floor_score_stays_below_weakest_seed() -> None: assert _closure_floor_score({"a": 0.8, "b": -0.4}) < -0.4 # No seeds at all: still finite and non-positive. assert _closure_floor_score({}) <= 0.0 + + +def _qqval_manager(): + """A small module with one class and one property, resolvable by IRI.""" + from types import SimpleNamespace + + module_graph = RDFGraph() + module_graph.parse( + data=""" + @prefix qqval: . + @prefix owl: . + @prefix rdfs: . + a owl:Ontology ; rdfs:label "qqval" . + qqval:QuantityRange a owl:Class ; rdfs:label "Quantity range" . + qqval:hasLowerBound a owl:ObjectProperty ; rdfs:label "has lower bound" . + """, + format="turtle", + ) + ontology = Ontology(graph=module_graph, iri="https://x.org/qqval") + return SimpleNamespace( + get_freshest_terminal_ontology_by_iri=lambda iri: ( + ontology if iri == "https://x.org/qqval" else None + ) + ) + + +def _module(iri: str, terms: int) -> "Ontology": + """A module of a given size, resolvable by IRI.""" + lines = [ + "@prefix owl: .", + "@prefix rdfs: .", + f'<{iri}> a owl:Ontology ; rdfs:label "module" .', + ] + for index in range(terms): + lines.append(f'<{iri}#T{index}> a owl:Class ; rdfs:label "term {index}" .') + graph = RDFGraph() + graph.parse(data="\n".join(lines), format="turtle") + return Ontology(graph=graph, iri=iri) + + +def _multi_module_manager(*modules): + from types import SimpleNamespace + + by_iri = {module.iri: module for module in modules} + return SimpleNamespace( + get_freshest_terminal_ontology_by_iri=lambda iri: by_iri.get(iri) + ) + + +BIG = "https://x.org/big" +SMALL = "https://x.org/small" + + +@pytest.mark.anyio +async def test_unlimited_budget_closes_every_candidate() -> None: + """The default. Nothing is excluded for being unpopular or small.""" + retriever = _closure_retriever( + _multi_module_manager(_module(BIG, 40), _module(SMALL, 4)), closure_max=200 + ) + snapshot = RDFGraph() + + await retriever._apply_small_module_closure(snapshot, [BIG, SMALL], {BIG: 0.9}) + + assert set(retriever.last_retrieval_metrics["module_closure_iris"]) == {BIG, SMALL} + assert "module_closure_declined_iris" not in retriever.last_retrieval_metrics + + +@pytest.mark.anyio +async def test_a_small_sharply_relevant_module_outranks_a_large_vague_one() -> None: + """The case that rules out ranking by seed count. + + A module can win at most as many seeds as it has terms, so counting them + ranks modules by size -- and would drop a tiny vocabulary that is precisely + the document's subject in favour of a large peripheral one. Scores do not + have that bias. + """ + retriever = _closure_retriever( + _multi_module_manager(_module(BIG, 40), _module(SMALL, 4)), + closure_max=200, + max_total=20, # room for one of them + ) + snapshot = RDFGraph() + + await retriever._apply_small_module_closure( + snapshot, [BIG, SMALL], {BIG: 0.20, SMALL: 0.95} + ) + + assert retriever.last_retrieval_metrics["module_closure_iris"] == [SMALL] + assert retriever.last_retrieval_metrics["module_closure_declined_iris"] == [BIG] + assert (URIRef(f"{SMALL}#T0"), None, None) in snapshot + + +@pytest.mark.anyio +async def test_a_module_too_large_for_the_remainder_is_skipped_not_terminal() -> None: + """The usual need is a combination of modules, not a single winner. + + Stopping at the first module that does not fit would spend the tail of the + budget on nothing and drop vocabularies that would have fitted. + """ + retriever = _closure_retriever( + _multi_module_manager(_module(BIG, 40), _module(SMALL, 4)), + closure_max=200, + max_total=30, + ) + snapshot = RDFGraph() + + # BIG ranks first on relevance but does not fit; SMALL must still get in. + await retriever._apply_small_module_closure( + snapshot, [BIG, SMALL], {BIG: 0.95, SMALL: 0.20} + ) + + assert retriever.last_retrieval_metrics["module_closure_iris"] == [SMALL] + assert retriever.last_retrieval_metrics["module_closure_declined_iris"] == [BIG] + + +@pytest.mark.anyio +async def test_closure_order_is_stable_for_equal_relevance() -> None: + """The snapshot is a prompt; an unstable prompt is uncacheable.""" + manager = _multi_module_manager(_module(BIG, 4), _module(SMALL, 4)) + runs = [] + for _ in range(3): + # Room for exactly one of the two, and nothing to separate them on + # relevance -- so only the tie-break decides, and it must not drift. + retriever = _closure_retriever(manager, closure_max=200, max_total=15) + await retriever._apply_small_module_closure(RDFGraph(), [BIG, SMALL], {}) + runs.append(retriever.last_retrieval_metrics["module_closure_iris"]) + + assert len(set(map(tuple, runs))) == 1 + assert len(runs[0]) == 1 + + +@pytest.mark.anyio +async def test_budget_spend_is_reported() -> None: + retriever = _closure_retriever( + _multi_module_manager(_module(SMALL, 4)), closure_max=200, max_total=100 + ) + snapshot = RDFGraph() + + await retriever._apply_small_module_closure(snapshot, [SMALL], {SMALL: 0.5}) + + assert retriever.last_retrieval_metrics["module_closure_triples"] > 0 + + +# ------------------------------------------------------- the signals flag + + +def _signal_retriever(manager, *, enabled: bool): + """A retriever wired for the query-signals lane only. + + `model_construct` bypasses validation because neither the vector store nor + the SPARQL tool is on this path; only the store config's flag is. + """ + from ontocast.config import PatchRetrievalConfig, VectorStoreConfig + from ontocast.tool.vector_store.patch_retriever import OntologyPatchRetriever + + store = SimpleNamespace( + store_config=VectorStoreConfig(query_unit_signals_enabled=enabled) + ) + return OntologyPatchRetriever.model_construct( + vector_store=store, + sparql_tool=None, + ontology_manager=manager, + patch=PatchRetrievalConfig(), + ) + + +def _units_manager(): + graph = RDFGraph() + graph.parse( + data=""" + @prefix unit: . + @prefix qudt: . + @prefix rdfs: . + @prefix owl: . + a owl:Ontology . + unit:DAY a qudt:Unit ; rdfs:label "Day"@en ; qudt:symbol "days" . + """, + format="turtle", + ) + ontology = Ontology(graph=graph, iri="https://x.org/units") + return SimpleNamespace(ontologies=[ontology]) + + +def test_query_unit_signals_flag_gates_the_lane() -> None: + """The flag itself, not just the matcher underneath it. + + The lane ships disabled, so nothing in a default deployment executes it; + without this the flag could be flipped -- or silently stop working -- with + no test noticing. + """ + manager = _units_manager() + text = "aged for 4-15 days under illumination" + + assert ( + _signal_retriever(manager, enabled=False)._match_query_unit_signals(text) == {} + ) + assert _signal_retriever(manager, enabled=True)._match_query_unit_signals(text) == { + "http://qudt.org/vocab/unit/DAY": "https://x.org/units" + } + + +def test_query_unit_signals_need_a_number_to_key_on() -> None: + """The lane keys on the token *after* a number; prose alone matches nothing.""" + retriever = _signal_retriever(_units_manager(), enabled=True) + assert retriever._match_query_unit_signals("aged for several days") == {} + + +def test_query_unit_signals_are_inert_without_a_catalog() -> None: + retriever = _signal_retriever(None, enabled=True) + assert retriever._match_query_unit_signals("4-15 days") == {} diff --git a/test/test_retrieval_knob_defaults.py b/test/test_retrieval_knob_defaults.py new file mode 100644 index 00000000..03393528 --- /dev/null +++ b/test/test_retrieval_knob_defaults.py @@ -0,0 +1,372 @@ +"""Every retrieval knob added for tuning must be inert at its default. + +A sweep is read against its baseline point. If adding a knob moved that baseline, +every comparison to previously measured behaviour would silently shift, and the +knob would be credited or blamed for a change it did not cause. So each of these +asserts the same thing from a different angle: the default reproduces what the +code did before the knob existed. +""" + +from __future__ import annotations + +import logging + +import pytest + +from ontocast.config import EmbeddingConfig, VectorStoreConfig +from ontocast.tool.chunk.proposition import split_proposition_windows +from ontocast.tool.vector_store.core import GraphAtom, OntologySearchHit +from ontocast.tool.vector_store.embedding import EmbeddingTool +from ontocast.tool.vector_store.util import ( + effective_bm25_top_k, + effective_top_k, + rank_fuse_channel_hits, +) + + +def _hit(atom_id: str, score: float) -> OntologySearchHit: + return OntologySearchHit( + atom=GraphAtom( + atom_id=atom_id, + ontology_iri="https://example.org/onto", + iri=f"https://example.org/onto#{atom_id}", + core_representation=atom_id, + neighborhood_representation=atom_id, + ), + score=score, + ) + + +def _fuse(hits: list[OntologySearchHit], **kwargs) -> list[str]: + fused = rank_fuse_channel_hits( + hits, + [], + list(reversed(hits)), + core_weight=0.5, + neighborhood_weight=0.1, + bm25_weight=0.4, + limit=10, + **kwargs, + ) + return [hit.atom.atom_id for hit in fused] + + +def test_bm25_top_k_unset_follows_the_dense_lanes() -> None: + """The knob is opt-in: unset, the sparse lane is as deep as the dense ones.""" + config = VectorStoreConfig(top_k=17) + assert config.bm25_top_k is None + assert effective_bm25_top_k(config, None) == effective_top_k(config, None) == 17 + # An explicit per-call top_k still reaches the sparse lane when unset. + assert effective_bm25_top_k(config, 5) == 5 + + +def test_bm25_top_k_when_set_overrides_even_an_explicit_top_k() -> None: + """A configured sparse depth is a deliberate statement about that lane.""" + config = VectorStoreConfig(top_k=20, bm25_top_k=3) + assert effective_bm25_top_k(config, None) == 3 + assert effective_bm25_top_k(config, 40) == 3 + assert effective_top_k(config, 40) == 40 + + +def test_fusion_rank_constant_defaults_to_the_unsmoothed_behaviour() -> None: + """At 0 the fused scores are exactly weight/rank, as before the knob existed.""" + assert VectorStoreConfig().fusion_rank_constant == 0.0 + hits = [_hit(f"a{i}", 1.0 - i / 10) for i in range(5)] + fused = rank_fuse_channel_hits( + hits, + [], + [], + core_weight=1.0, + neighborhood_weight=0.0, + bm25_weight=0.0, + limit=5, + ) + assert [round(hit.score, 6) for hit in fused] == [ + round(1.0 / rank, 6) for rank in range(1, 6) + ] + assert _fuse(hits) == _fuse(hits, rank_constant=0.0) + + +def test_fusion_rank_constant_flattens_the_decay() -> None: + """Raising it makes cross-lane agreement outweigh position within one lane.""" + hits = [_hit(f"a{i}", 1.0 - i / 10) for i in range(4)] + steep = rank_fuse_channel_hits( + hits, + [], + [], + core_weight=1.0, + neighborhood_weight=0.0, + bm25_weight=0.0, + limit=4, + ) + flat = rank_fuse_channel_hits( + hits, + [], + [], + core_weight=1.0, + neighborhood_weight=0.0, + bm25_weight=0.0, + limit=4, + rank_constant=60.0, + ) + steep_gap = steep[0].score - steep[1].score + flat_gap = flat[0].score - flat[1].score + assert flat_gap < steep_gap + # Order within a single lane is unchanged; only the spacing is. + assert [h.atom.atom_id for h in steep] == [h.atom.atom_id for h in flat] + + +TEXT = "One a. Two b. Three c. Four d. Five e." + + +def test_window_stride_defaults_to_contiguous_windows() -> None: + """Unset stride reproduces the disjoint windows the splitter always made.""" + assert VectorStoreConfig().proposition_window_stride is None + assert split_proposition_windows( + TEXT, max_sentences=2 + ) == split_proposition_windows(TEXT, max_sentences=2, stride=2) + + +def test_window_stride_below_the_window_size_overlaps() -> None: + """A statement straddling a boundary needs some window to contain both halves.""" + disjoint = split_proposition_windows(TEXT, max_sentences=2) + overlapped = split_proposition_windows(TEXT, max_sentences=2, stride=1) + assert "Two b. Three c." not in disjoint + assert "Two b. Three c." in overlapped + # Tail suffixes must not be emitted twice; an identical query would be paid for + # again and would skew the ranks it feeds. + assert len(overlapped) == len(set(overlapped)) + + +def test_window_stride_must_be_positive() -> None: + with pytest.raises(ValueError): + split_proposition_windows(TEXT, stride=0) + + +def test_window_sentences_is_no_longer_capped_at_four() -> None: + """The cap sat just short of where widening starts discarding query text. + + Which limit actually binds is the embedding model's sequence length, and that + is reported rather than guessed -- see the truncation telemetry. + """ + assert ( + VectorStoreConfig(proposition_window_sentences=8).proposition_window_sentences + == 8 + ) + + +def test_sequence_limit_is_unknown_for_providers_that_do_not_state_one() -> None: + """The base contract returns None rather than inventing a limit.""" + + class _Tool(EmbeddingTool): + def _embed_raw(self, texts: list[str]) -> list[list[float]]: + return [[0.0] for _ in texts] + + tool = _Tool(config=EmbeddingConfig()) + assert tool.sequence_limit is None + assert tool.count_over_limit(["anything"]) is None + + +# ------------------------------- configurations that cannot mean what they say + + +def test_mmr_with_atom_floors_is_rejected() -> None: + """Two selection policies for one budget: the config must not pick silently. + + MMR replaces the selection outright, so a non-zero floor stops being a + guarantee under it -- with no error and no metric that would show it. + """ + from pydantic import ValidationError + + from ontocast.config import PatchRetrievalConfig, ToolConfig + + with pytest.raises(ValidationError, match="MMR_LAMBDA"): + ToolConfig( + patch_retrieval=PatchRetrievalConfig( + mmr_lambda=0.5, per_ontology_atom_floor=2, per_role_atom_floor=12 + ) + ) + + +def test_mmr_is_configurable_once_the_floors_are_off() -> None: + from ontocast.config import PatchRetrievalConfig, ToolConfig + + config = ToolConfig( + patch_retrieval=PatchRetrievalConfig( + mmr_lambda=0.5, per_ontology_atom_floor=0, per_role_atom_floor=0 + ) + ) + assert config.patch_retrieval.mmr_lambda == 0.5 + + +def test_the_default_config_is_not_rejected() -> None: + """The floors ship non-zero and MMR ships off, so the default must construct.""" + from ontocast.config import ToolConfig + + assert ToolConfig().patch_retrieval.mmr_lambda == 1.0 + + +def test_unreachable_max_atoms_warns_naming_the_cap(caplog) -> None: + """`MAX_ATOMS` above the window-scaled ceiling is inert, and says so. + + A third of a 121-point sweep once measured nothing because every point + above the ceiling was the same run. + """ + from ontocast.config import PatchRetrievalConfig, ToolConfig, VectorStoreConfig + + with caplog.at_level(logging.WARNING): + ToolConfig( + patch_retrieval=PatchRetrievalConfig(max_atoms=192, max_atoms_base=96), + vector_store=VectorStoreConfig(proposition_max_windows=16), + ) + assert "ONTOLOGY_PATCH_MAX_ATOMS=192 cannot bind" in caplog.text + assert "PROPOSITION_MAX_WINDOWS" in caplog.text + + +def test_reachable_max_atoms_does_not_warn(caplog) -> None: + from ontocast.config import PatchRetrievalConfig, ToolConfig + + with caplog.at_level(logging.WARNING): + ToolConfig( + patch_retrieval=PatchRetrievalConfig(max_atoms=96, max_atoms_base=96) + ) + assert "cannot bind" not in caplog.text + + +@pytest.mark.anyio +@pytest.mark.parametrize("rank_constant", [0.0, 5.0, 60.0]) +async def test_the_relevance_gate_means_the_same_at_every_rank_constant( + rank_constant: float, +) -> None: + """The gate is a fraction of the attainable best, not a number on the scale. + + The rank constant divides every fused score by about ``1 + constant``. Read + as an absolute threshold, a gate calibrated without smoothing therefore + rejected *every* candidate once smoothing was on -- and the symptom, an + empty ontology context, reads as a retrieval failure rather than as a + miscalibration. Scaling the gate by the same factor is what lets smoothing + be tested on its own merits. + """ + hits = [_hit("keep", 0.9), _hit("also", 0.6)] + kept = await _retrieve_with( + hits, min_merged_max_score=0.18, rank_constant=rank_constant + ) + assert kept, f"gate emptied the snapshot at rank_constant={rank_constant}" + + +@pytest.mark.anyio +async def test_a_gate_above_the_attainable_best_still_empties_the_snapshot() -> None: + """The gate must still be able to say "nothing here is relevant".""" + hits = [_hit("weak", 0.9)] + assert not await _retrieve_with(hits, min_merged_max_score=1.5, rank_constant=0.0) + + +async def _retrieve_with( + hits: list[OntologySearchHit], *, min_merged_max_score: float, rank_constant: float +) -> list[str]: + """Entity IRIs surviving one retrieval at the given gate and smoothing.""" + from ontocast.config import ( + EmbeddingConfig, + PatchRetrievalConfig, + QdrantConfig, + VectorStoreConfig, + ) + from ontocast.tool.vector_store.patch_retriever import OntologyPatchRetriever + from test.test_vector_store_pipeline import ( + CountingEmbeddingTool, + StubSPARQLTool, + StubVectorStore, + _channel_hits, + ) + + vector_store = StubVectorStore( + store_config=VectorStoreConfig(fusion_rank_constant=rank_constant), + qdrant_config=QdrantConfig(), + embedding=CountingEmbeddingTool(config=EmbeddingConfig(dimension=8)), + ) + vector_store.set_hits_by_query([_channel_hits(core_hits=hits)]) + sparql_tool = StubSPARQLTool(triple_store_manager=None) + retriever = OntologyPatchRetriever( + vector_store=vector_store, + sparql_tool=sparql_tool, + patch=PatchRetrievalConfig(min_merged_max_score=min_merged_max_score), + ) + await retriever.aretrieve_ensemble(queries=["q1"], top_k=4, expand_sparql=True) + return list(sparql_tool.last_entity_uris) + + +def test_window_char_budget_is_unset_by_default() -> None: + """The budget is opt-in: unset, windows are bounded by sentence count.""" + from ontocast.config import VectorStoreConfig + + assert VectorStoreConfig().proposition_window_max_chars is None + + +def test_an_unset_char_budget_reproduces_sentence_windows_exactly() -> None: + """The budget must not perturb the path it did not replace. + + Every arm measured before this knob existed was windowed by sentence count. + If adding the budget moved those windows even slightly, each of those arms + would silently stop being comparable -- so this asserts equality across the + knob space rather than at the default alone. + """ + text = ( + "Films were annealed at 100 C for 30 min. The PL peak shifted by 20 meV. " + "Samples aged for 4-15 days under ambient conditions. " + "Emission narrowed by 5 nm. J. Phys. Chem. Lett. 2019, 10, 655. " + "The shift saturated after two weeks." + ) + for sentences in (1, 2, 3, 5): + for windows in (1, 2, 16, 10**6): + for stride in (None, 1, 2): + assert split_proposition_windows( + text, + max_sentences=sentences, + max_windows=windows, + stride=stride, + ) == split_proposition_windows( + text, + max_sentences=sentences, + max_windows=windows, + stride=stride, + max_chars=None, + ) + + +def test_the_char_budget_bounds_every_window_it_can() -> None: + """Only a single sentence longer than the budget may exceed it.""" + text = " ".join(f"Sentence number {index} of the passage." for index in range(40)) + for budget in (60, 120, 400): + windows = split_proposition_windows(text, max_windows=10**6, max_chars=budget) + assert windows + for window in windows: + # Over budget is permitted only where one sentence alone exceeds it, + # because cutting a sentence loses more than the encoder's own + # truncation would. + assert len(window) <= budget or " " not in window.rstrip(".") + + +def test_the_char_budget_coalesces_fragments_a_sentence_count_cannot() -> None: + """The splitter has no abbreviation handling, so citations shatter. + + A sentence bound takes those shards as whole windows; a character budget + keeps taking sentences until it has something worth embedding. + """ + text = "Cite This: J. Phys. Chem. Lett. 2019, 10, 655. See Fig. 3 and Ref. 12." + by_sentences = split_proposition_windows(text, max_sentences=2, max_windows=10**6) + by_chars = split_proposition_windows(text, max_windows=10**6, max_chars=200) + assert min(len(w) for w in by_sentences) < 20 + assert len(by_chars) == 1 + assert min(len(w) for w in by_chars) > 20 + + +def test_the_char_budget_never_splits_a_sentence() -> None: + """A sentence over the budget is emitted whole, not cut.""" + long_sentence = "The " + "very " * 60 + "long sentence." + windows = split_proposition_windows(long_sentence, max_windows=10**6, max_chars=50) + assert windows == [long_sentence] + + +def test_the_char_budget_rejects_a_nonsense_value() -> None: + with pytest.raises(ValueError, match="max_chars"): + split_proposition_windows("A sentence.", max_chars=0) diff --git a/test/test_retrieval_metric_keys.py b/test/test_retrieval_metric_keys.py index cc2400e2..8545b292 100644 --- a/test/test_retrieval_metric_keys.py +++ b/test/test_retrieval_metric_keys.py @@ -12,8 +12,12 @@ import re from pathlib import Path +import pytest + from ontocast.onto.enum import RetrievalMetric +pytestmark = pytest.mark.unit + # Every wire name, pinned. Add a line when a metric is added; change one only # in a release that documents the break. EXPECTED_WIRE_NAMES = { @@ -35,6 +39,24 @@ "FACTS_MANDATORY_RESIDUAL": "facts_mandatory_residual", "FACTS_CRITIC_CALLS": "facts_critic_calls", "FACTS_CRITIC_ACCEPTED": "facts_critic_accepted", + "FACTS_CRITIC_FIXES_APPLIED": "facts_critic_fixes_applied", + "FACTS_CRITIC_FIXES_RESIDUAL": "facts_critic_fixes_residual", + "FACTS_CRITIC_FIXES_NOOP": "facts_critic_fixes_noop", + "FACTS_CRITIC_PATCHES_ROLLED_BACK": "facts_critic_patches_rolled_back", + "ONTOLOGY_CRITIC_FIXES_APPLIED": "ontology_critic_fixes_applied", + "ONTOLOGY_CRITIC_FIXES_RESIDUAL": "ontology_critic_fixes_residual", + "ONTOLOGY_CRITIC_FIXES_NOOP": "ontology_critic_fixes_noop", + "ONTOLOGY_CRITIC_PATCHES_ROLLED_BACK": "ontology_critic_patches_rolled_back", + "FACTS_CRITIC_UNITS_UNREVIEWED": "facts_critic_units_unreviewed", + "FACTS_CRITIC_UNITS_SKIPPED": "facts_critic_units_skipped", + "FACTS_CRITIC_FIXES_ROLLED_BACK": "facts_critic_fixes_rolled_back", + "FACTS_CRITIC_FIXES_JUNK_REFUSED": "facts_critic_fixes_junk_refused", + "FACTS_CRITIC_FIXES_UNRESOLVED_PREFIX": "facts_critic_fixes_unresolved_prefix", + "FACTS_COMPLETION_CALLS": "facts_completion_calls", + "FACTS_COMPLETION_TRIPLES_INSERTED": "facts_completion_triples_inserted", + "FACTS_COMPLETION_MEASUREMENTS_RECOVERED": ( + "facts_completion_measurements_recovered" + ), "FACTS_FINDINGS_RESIDUAL": "facts_findings_residual", "FACTS_REJECTED_MERGES": "facts_rejected_merges", "FACTS_MERGE_REPAIR_PASSES": "facts_merge_repair_passes", diff --git a/test/test_retrieval_predicate_recall.py b/test/test_retrieval_predicate_recall.py index a00c2a1b..e294b962 100644 --- a/test/test_retrieval_predicate_recall.py +++ b/test/test_retrieval_predicate_recall.py @@ -14,6 +14,8 @@ from __future__ import annotations +import pytest + from ontocast.onto.rdfgraph import RDFGraph from ontocast.tool.representation_text import ROLE_PREDICATE, ROLE_RESOURCE from ontocast.tool.vector_store.core import GraphAtom, OntologySearchHit @@ -22,6 +24,8 @@ _select_hits_round_robin_by_ontology, ) +pytestmark = pytest.mark.unit + ONTO = "https://x.org/o" diff --git a/test/test_retrieval_recall.py b/test/test_retrieval_recall.py new file mode 100644 index 00000000..e2ee3335 --- /dev/null +++ b/test/test_retrieval_recall.py @@ -0,0 +1,297 @@ +"""Recall measurement for vector-mode ontology patch retrieval. + +Unlike the rest of the vector suite, this module uses **real** embeddings and a **real** +vector store. Hash-based fake vectors make recall numbers meaningless, and recall is +precisely what was never measured: the plumbing tests assert ordering, counts, and +parameter pass-through, but nothing asserts that a relevant catalog term survives to the +prompt snapshot. + +Two numbers are reported per run: + +* **seed recall** -- the expected term reached ``atoms_final`` (survived vector search, + cross-window merge, per-ontology round-robin, and the window-scaled cap). +* **snapshot recall** -- the expected term is *defined* in the returned graph (also + survived induced-subgraph expansion: BFS quotas, budget caps, component pruning). + +The gap between them attributes losses to the graph stage rather than the vector stage. +Both are printed as a funnel alongside the metrics the pipeline already emits, so a +regression can be localised without bisecting. + +**No LLM call is made**, so the whole harness runs with no provider credentials and no +generation spend. Wiring and scoring live in :mod:`test.retrieval_runner`, shared with +:mod:`test.retrieval_sweep`. + +Scale and corpus are environment-controlled so the same harness serves CI (small, fast) +and tuning sweeps / embedding bake-offs (large): + +* ``ONTOCAST_RECALL_CORPUS`` -- prebuilt corpus directory (``cases.jsonl`` + + ``ontologies/``), the domain-neutral tier +* ``ONTOCAST_RECALL_ROOT`` -- Text2KGBench corpus root +* ``ONTOCAST_RECALL_ONTOLOGIES`` -- ontologies loaded into the catalog (default 6); + Text2KGBench tier only +* ``ONTOCAST_RECALL_CASES`` -- cases per ontology (default 15); Text2KGBench tier + only +* ``ONTOCAST_RECALL_JSON`` -- write the funnel to this path as JSON + +Ablation controls, for asking whether indexing a large external vocabulary helps or +dilutes. Both are opt-in and inert when unset: + +* ``ONTOCAST_RECALL_EXTRA_ONTOLOGIES`` -- ``os.pathsep``-separated .ttl files/directories + appended to the corpus catalog. Keeps the *index* axis a one-variable flip while the + corpus on disk stays byte-identical across arms. +* ``ONTOCAST_RECALL_COLLECTION_SUFFIX`` -- pin the collection/table names and skip + teardown, so the index can be reused. With ``ONTOCAST_RECALL_SKIP_INDEX=1`` a later + arm scores against it without re-embedding. Everything on the *retrieval* axis + (seed quotas, merge mode, caps, closure budgets) is applied at merge time, so a whole + sweep needs one index. + +Case text is split into proposition windows exactly as production does, so a passage +spanning several sentences issues several queries rather than one. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import uuid +from collections.abc import Generator +from pathlib import Path +from typing import Any + +import pytest + +from ontocast.config import LanceDBConfig, QdrantConfig +from ontocast.onto.ontology import Ontology +from ontocast.toolbox import ToolBox +from test.qdrant_util import qdrant_reachable +from test.retrieval_gt import ( + RecallCase, + StageCounts, + corpus_root, + load_anchor_cases, + load_corpus, + load_text2kgbench, + text2kgbench_root, +) +from test.retrieval_runner import ( + build_toolbox, + index_catalog, + lancedb_store_config, + pinned_suffix, + score_cases, +) + +pytestmark = pytest.mark.integration + + +def _env_int(name: str, default: int) -> int: + raw = os.getenv(name) + if not raw: + return default + try: + return max(1, int(raw)) + except ValueError: + return default + + +@pytest.fixture +def recall_store_config( + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[dict[str, Any], Any, None]: + """Vector-store settings for one recall run, on whichever backend is configured. + + LanceDB is preferred when enabled: it is embedded, so a parameter sweep needs no + running service, and the index it builds is a directory that can be pinned and + reused across arms. Qdrant is used when ``QDRANT_URI`` names a reachable instance. + + Deliberately *not* the shared ``qdrant_session_test_context``: that fixture is used + by smoke tests that create 8-dimensional collections, and the embedding contract + would reject the real 384-dimensional model against those. + + Function-scoped so each tier indexes only its own ontologies; sharing a collection + let one tier's catalog leak into the other's ``seeds_by_ontology`` and made the + measured ontology count depend on test order. + + Yields: + dict[str, Any]: ``ToolConfig`` keyword overrides naming this run's store. + """ + pinned = pinned_suffix() + run_id = pinned or uuid.uuid4().hex[:8] + + if LanceDBConfig().enabled: + # A pinned run keeps its directory so a later arm can score against it with + # ONTOCAST_RECALL_SKIP_INDEX=1; an unpinned one gets a throwaway. + data_dir = ( + Path(LanceDBConfig().data_dir).expanduser() + if pinned + else tmp_path_factory.mktemp(f"recall_lancedb_{run_id}") + ) + yield lancedb_store_config(data_dir, run_id) + if not pinned: + shutil.rmtree(data_dir, ignore_errors=True) + return + + base = QdrantConfig() + if base.uri is None: + pytest.skip("no vector backend: set LANCEDB_ENABLED=true or QDRANT_URI") + if not qdrant_reachable(uri=base.uri, api_key=base.api_key): + pytest.skip(f"Qdrant not reachable at {base.uri}") + + config = base.model_copy( + update={ + "ontology_collection": f"ontocast_recall_{run_id}_ontologies", + "facts_collection": f"ontocast_recall_{run_id}_facts", + } + ) + + yield {"qdrant": config, "lancedb": LanceDBConfig(enabled=False)} + + if pinned: + return + + from qdrant_client import QdrantClient + + client = QdrantClient( + url=config.uri, + api_key=config.api_key, + grpc_port=config.grpc_port, + prefer_grpc=config.use_grpc, + ) + for name in (config.ontology_collection, config.facts_collection): + if name and client.collection_exists(collection_name=name): + client.delete_collection(collection_name=name) + + +def _run(tools: ToolBox, ontologies: list[Ontology], cases: list[RecallCase]): + async def _main() -> StageCounts: + await index_catalog(tools, ontologies) + return await score_cases(tools, ontologies, cases) + + return asyncio.run(_main()) + + +def _emit(counts: StageCounts, title: str) -> str: + """Print the funnel, write it as JSON when asked, and return the report. + + ``ONTOCAST_RECALL_JSON`` names a file the run's counters are written to. A + printed table that has to be re-parsed is how a measurement stops being + comparable across arms, so the numbers can also leave the process as data. + """ + report = counts.render(title) + print(f"\n{report}") + destination = os.getenv("ONTOCAST_RECALL_JSON", "").strip() + if destination: + payload = counts.as_dict() + payload["title"] = title + path = Path(destination) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True)) + return report + + +@pytest.mark.slow +def test_anchor_recall( + recall_store_config: dict[str, Any], + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """In-repo anchor fixtures: near-verbatim label matches across two ontologies. + + These labels appear almost literally in the source text, so this is close to a + best case. Failure here indicates a plumbing or lexical-lane defect rather than a + weak embedding model. + """ + cases, ontologies = load_anchor_cases() + if not cases: + pytest.skip("anchor fixtures unavailable") + + tools = build_toolbox( + recall_store_config, tmp_path_factory.mktemp("recall_ontologies") + ) + counts = _run(tools, ontologies, cases) + report = _emit(counts, "anchor recall") + + assert counts.cases > 0 + assert counts.seed_recall > 0.0, ( + "no anchor term reached the seed set; retrieval is not functioning at all\n" + + report + ) + + +@pytest.mark.slow +def test_corpus_recall( + recall_store_config: dict[str, Any], + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """A prebuilt domain corpus: multi-sentence passages against a linked catalog. + + The other two tiers score one sentence at a time against mutually disjoint + ontologies, which makes the cross-window merge a no-op and hides both the window + budget and cross-ontology expansion. A prebuilt corpus supplies passages long + enough to produce several proposition windows and a catalog whose ontologies + reference each other. + """ + root = corpus_root() + if root is None: + pytest.skip( + "no recall corpus; set ONTOCAST_RECALL_CORPUS to a directory holding " + "cases.jsonl and ontologies/" + ) + + cases, ontologies = load_corpus(root) + tools = build_toolbox( + recall_store_config, tmp_path_factory.mktemp("recall_ontologies") + ) + counts = _run(tools, ontologies, cases) + report = _emit( + counts, + f"corpus recall: {root.name} " + f"({len(ontologies)} ontologies, {len(cases)} cases)", + ) + + assert counts.cases > 0 + assert counts.seed_recall > 0.0, ( + "no expected term reached the seed set across the whole corpus\n" + report + ) + + +@pytest.mark.slow +def test_text2kgbench_recall( + recall_store_config: dict[str, Any], + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """Text2KGBench: real prose against a multi-ontology catalog. + + Ground truth is derived, not hand-labelled: each row's relation labels resolve to + ontology IRIs, so every case is an unambiguous retrieval target. Loading several + ontologies at once also stresses per-ontology seed allocation and multi-component + snapshot assembly, which two fixtures cannot reproduce. + """ + root = text2kgbench_root() + if root is None: + pytest.skip( + "Text2KGBench corpus not found; set ONTOCAST_RECALL_ROOT to its root" + ) + + cases, ontologies = load_text2kgbench( + root, + max_ontologies=_env_int("ONTOCAST_RECALL_ONTOLOGIES", 6), + max_cases_per_ontology=_env_int("ONTOCAST_RECALL_CASES", 15), + ) + if not cases: + pytest.skip("Text2KGBench corpus present but yielded no resolvable cases") + + tools = build_toolbox( + recall_store_config, tmp_path_factory.mktemp("recall_ontologies") + ) + counts = _run(tools, ontologies, cases) + report = _emit( + counts, + f"text2kgbench recall ({len(ontologies)} ontologies, {len(cases)} cases)", + ) + + assert counts.cases > 0 + assert counts.seed_recall > 0.0, ( + "no expected term reached the seed set across the whole corpus\n" + report + ) diff --git a/test/test_run_manifest_critic.py b/test/test_run_manifest_critic.py index 976a077f..d965db23 100644 --- a/test/test_run_manifest_critic.py +++ b/test/test_run_manifest_critic.py @@ -109,3 +109,156 @@ def test_batch_state_merge_carries_the_critic_telemetry() -> None: assert summarize_loop(state.facts_loop_telemetry).calls == 1 assert summarize_loop(state.ontology_loop_telemetry).calls == 1 assert state.ontology_reduce_metrics["minted_duplicates"] == 2 + + +def test_batch_state_merge_carries_the_selection_census_and_unit_ledgers() -> None: + """The selection census and the per-unit ledgers ride the same copy list. + + ``labeled_units`` / ``section_label_histogram`` read ``content_units``; + the validation dump reads ``facts_repairs_applied`` and ``unit_failures``. + None were copied, so every batch manifest reported an empty census -- a + section filter that never acted was unrecordable -- and every predicate + the machine rewrote in a render was logged and dropped. + """ + from rdflib import URIRef + + from ontocast.api.process_helpers import ( + _merge_workflow_state_into_agent_state, + _selection_manifest, + ) + from ontocast.config import Config + from ontocast.onto.content_unit import ContentUnit + from ontocast.onto.model import ( + FactsUnitFindingKind, + GraphRepairRecord, + UnitFailure, + ) + from ontocast.onto.state import AgentState + + doc = URIRef("https://example.com/doc") + chunk = { + "content_units": [ + ContentUnit(text="a", index=0, doc_iri=doc, section_label="methods"), + ContentUnit(text="b", index=1, doc_iri=doc, section_label="methods"), + ContentUnit(text="c", index=2, doc_iri=doc), + ], + "unit_failures": [UnitFailure(unit_index=2, phase="facts", stage="render")], + "facts_repairs_applied": { + 0: [ + GraphRepairRecord( + kind=FactsUnitFindingKind.PROPERTY_ALIAS, + source="ex:hasASite", + target="ex:hasBSite", + ) + ] + }, + "aggregation_clusters": {"ex:final": ["ex:a", "ex:b"]}, + "aggregation_key_clusters": ["ex:final"], + } + state = _merge_workflow_state_into_agent_state(AgentState(), chunk) + + selection = _selection_manifest(state, Config()) + assert selection.labeled_units == 2 + assert selection.unlabeled_units == 1 + assert selection.section_label_histogram == {"methods": 2, "(unlabeled)": 1} + assert [failure.unit_index for failure in state.unit_failures] == [2] + assert state.facts_repairs_applied[0][0].target == "ex:hasBSite" + assert state.aggregation_clusters == {"ex:final": ["ex:a", "ex:b"]} + assert state.aggregation_key_clusters == ["ex:final"] + + +def test_selection_manifest_records_the_summary_cap_only_when_summarizing() -> None: + """``summary_max_sentences`` has no effect without ``summarize_sections``. + + Writing its default beside an empty section list reads as a setting the + run used; the manifest must tell a knob that acted from one that merely + had a value. + """ + from ontocast.api.process_helpers import _selection_manifest + from ontocast.config import Config + from ontocast.onto.state import AgentState + + off = _selection_manifest(AgentState(), Config()) + assert off.summary_max_sentences is None + + on = _selection_manifest( + AgentState(summarize_sections=["introduction"], summary_max_sentences=3), + Config(), + ) + assert on.summary_max_sentences == 3 + + +def test_unreviewed_and_skipped_units_are_counted_apart_from_calls() -> None: + """A critic that did not answer is a billed call and an unreviewed unit; a + skipped pass is neither a call nor a review, and must not inflate either.""" + summary = summarize_loop( + { + 0: [ + LoopAttempt(kind="render", success=True), + LoopAttempt( + kind="critic", success=False, accept_reason="critic_unavailable" + ), + ], + 1: [ + LoopAttempt(kind="render", success=True), + LoopAttempt(kind="critic_skipped", accept_reason="empty_render"), + ], + 2: [_critic(90, success=True)], + } + ) + + assert summary.calls == 2 + assert summary.accepted == 1 + assert summary.units_unreviewed == 1 + assert summary.units_skipped == 1 + assert summary.accept_reason_histogram["critic_unavailable"] == 1 + + +def test_per_fix_outcomes_are_summed_over_patch_passes() -> None: + summary = summarize_loop( + { + 0: [ + LoopAttempt( + kind="critic_patch", + n_fixes_applied=2, + n_fixes_rolled_back=1, + patch_rolled_back=True, + n_fixes_junk_refused=3, + n_fixes_unresolved_prefix=1, + ), + LoopAttempt(kind="critic_patch", n_fixes_junk_refused=1), + ] + } + ) + + assert summary.patches_rolled_back == 1 + assert summary.fixes_rolled_back == 1 + assert summary.fixes_junk_refused == 4 + assert summary.fixes_unresolved_prefix == 1 + + +def test_completion_summary_reads_its_own_attempt_kind() -> None: + from ontocast.onto.run_manifest import summarize_completion + + summary = summarize_completion( + { + 0: [ + LoopAttempt(kind="critic"), + LoopAttempt( + kind="completion", + n_fixes_applied=2, + n_fixes_rolled_back=1, + n_triples_inserted=7, + n_measurements_recovered=2, + ), + ], + 1: [LoopAttempt(kind="render")], + } + ) + + assert summary.calls == 1 + assert summary.units == 1 + assert summary.subjects_inserted == 2 + assert summary.subjects_rolled_back == 1 + assert summary.triples_inserted == 7 + assert summary.measurements_recovered == 2 diff --git a/test/test_shared_encoder.py b/test/test_shared_encoder.py index 92da0060..9c3eb0e6 100644 --- a/test/test_shared_encoder.py +++ b/test/test_shared_encoder.py @@ -29,6 +29,8 @@ ) from ontocast.tool.vector_store.embedding import HuggingFaceEmbeddingTool +pytestmark = pytest.mark.unit + @pytest.fixture def clean_encoder_cache(): @@ -348,4 +350,27 @@ def _cache_config(tool: ChunkerTool) -> dict[str, Any]: "chunking_mode": tool.chunking_mode, "max_size": tool.config.max_size, "min_size": tool.config.min_size, + "cache_format_version": chunker_module.CHUNKER_CACHE_FORMAT_VERSION, + } + + +def test_chunk_cache_misses_entries_from_an_older_split_algorithm( + monkeypatch: pytest.MonkeyPatch, clean_encoder_cache, tmp_path +) -> None: + from ontocast.tool.cache import Cacher + + monkeypatch.setattr(chunker_module, "_embedding_model_available", lambda: False) + monkeypatch.setattr(chunker_module, "_semantic_chunking_available", lambda: False) + + cache = Cacher(cache_dir=tmp_path) + text = "One sentence. Another sentence." + tool = ChunkerTool(chunk_config=ChunkConfig(embedding_model="model-a"), cache=cache) + stale_key = { + k: v for k, v in _cache_config(tool).items() if k != "cache_format_version" } + # An entry written before the version joined the key holds boundaries from + # the old algorithm; it must not be served. + tool.cache.set(text, ["stale chunk"], config=stale_key) + + assert tool(text) != ["stale chunk"] + assert tool.cache.get(text, config=_cache_config(tool)) is not None diff --git a/test/test_snapshot_prompt_cache.py b/test/test_snapshot_prompt_cache.py index 656e9d12..81bf6641 100644 --- a/test/test_snapshot_prompt_cache.py +++ b/test/test_snapshot_prompt_cache.py @@ -8,12 +8,22 @@ from __future__ import annotations +import pytest from rdflib import OWL, RDF, RDFS, Literal, Namespace -from ontocast.onto.enum import LLMGraphFormat, OntologyAssemblyMode +from ontocast.onto.enum import ( + LLMGraphFormat, + OntologyAssemblyMode, + OntologyChapterFormat, +) from ontocast.onto.ontology_snapshot import OntologySnapshot from ontocast.onto.rdfgraph import RDFGraph -from ontocast.prompt.graph_format import get_graph_format_profile +from ontocast.prompt.graph_format import ( + GraphFormatProfile, + get_graph_format_profile, +) + +pytestmark = pytest.mark.unit EX = Namespace("https://example.org/onto#") @@ -36,15 +46,15 @@ def _snapshot(graph: RDFGraph) -> OntologySnapshot: ) -def _counting_profile(monkeypatch) -> tuple[object, list[int]]: - """Graph-format profile whose serialisation is counted.""" +def _counting_profile(monkeypatch) -> tuple[GraphFormatProfile, list[int]]: + """Graph-format profile whose serialisation is counted (on every profile).""" profile = get_graph_format_profile(LLMGraphFormat.TURTLE) calls: list[int] = [] original = type(profile).serialize_graph_for_prompt - def counted(self, graph): + def counted(self, graph, **kwargs): calls.append(1) - return original(self, graph) + return original(self, graph, **kwargs) monkeypatch.setattr(type(profile), "serialize_graph_for_prompt", counted) return profile, calls @@ -139,3 +149,30 @@ def test_memo_does_not_grow_without_bound() -> None: snapshot.prompt_chapter(profile) assert len(snapshot._prompt_cache) == 1 + + +def test_chapter_format_is_part_of_the_memo_key(monkeypatch) -> None: + """ONTOLOGY_CHAPTER_FORMAT changes the chapter text, so it must miss the memo. + + The key is the syntax the chapter is written in, not the profile that + asked: a JSON-LD profile carrying the Turtle override and a plain Turtle + profile produce the same chapter, and share the entry. + """ + _, calls = _counting_profile(monkeypatch) + snapshot = _snapshot(_graph()) + inherit = get_graph_format_profile(LLMGraphFormat.JSONLD) + overridden = get_graph_format_profile( + LLMGraphFormat.JSONLD, ontology_chapter_format=OntologyChapterFormat.TURTLE + ) + + jsonld = snapshot.prompt_chapter(inherit) + turtle = snapshot.prompt_chapter(overridden) + + assert len(calls) == 2, "a different chapter syntax must miss the memo" + assert jsonld != turtle + assert "```json" in jsonld + assert "```ttl" in turtle + + same_syntax = get_graph_format_profile(LLMGraphFormat.TURTLE) + assert snapshot.prompt_chapter(same_syntax) == turtle + assert len(calls) == 2, "the same chapter syntax must hit the memo" diff --git a/test/test_stategraph_context_resolver.py b/test/test_stategraph_context_resolver.py index f1ea4dfc..cc61eb06 100644 --- a/test/test_stategraph_context_resolver.py +++ b/test/test_stategraph_context_resolver.py @@ -10,10 +10,17 @@ from ontocast.config import VectorStoreConfig from ontocast.onto.content_unit import ContentUnit -from ontocast.onto.enum import OntologyAssemblyMode, OntologyContextMode +from ontocast.onto.enum import ( + OntologyAssemblyMode, + OntologyContextMode, + RetrievalMetric, +) from ontocast.onto.ontology import Ontology from ontocast.onto.rdfgraph import RDFGraph -from ontocast.onto.retrieval_capabilities import OntologyContextConfigError +from ontocast.onto.retrieval_capabilities import ( + EmptyOntologyContextError, + OntologyContextConfigError, +) from ontocast.onto.state import AgentState from ontocast.stategraph import context_resolver as cr from ontocast.stategraph.context_resolver import ( @@ -23,6 +30,8 @@ from ontocast.stategraph.unit_context import UnitLoopContext from ontocast.toolbox import ToolBox +pytestmark = pytest.mark.unit + class _StubPatchRetriever: def __init__(self, graph: RDFGraph, sources: list[str]) -> None: @@ -44,6 +53,7 @@ def _build_unit() -> ContentUnit: def _stub_ontology_manager(**kwargs: object) -> SimpleNamespace: + kwargs.setdefault("has_ontologies", True) return SimpleNamespace(preferred_namespace_prefixes={}, **kwargs) @@ -53,6 +63,7 @@ def _build_tools( vector_store: object | None, ontology_manager: object, llm: object | None = None, + ontology_context_required: bool = False, ) -> ToolBox: vcfg = VectorStoreConfig(top_k=3, proposition_retrieval_enabled=False) return cast( @@ -66,11 +77,230 @@ def _build_tools( vector_store_last_error=None, ontology_manager=ontology_manager, llm=llm, - config=SimpleNamespace(tool_config=SimpleNamespace(vector_store=vcfg)), + config=SimpleNamespace( + tool_config=SimpleNamespace(vector_store=vcfg), + server=SimpleNamespace( + ontology_context_required=ontology_context_required + ), + ), ), ) +class _StubVectorStore: + """Vector index that still lists ontologies the triple store has lost.""" + + def __init__(self, indexed: list[str] | None = None) -> None: + self._indexed = indexed if indexed is not None else ["https://example.org/o"] + + def list_indexed_ontology_iris(self) -> set[str]: + return set(self._indexed) + + +def _empty_context_tools(metrics: dict, **kwargs) -> ToolBox: + retriever = _StubPatchRetriever(graph=RDFGraph(), sources=[]) + retriever.last_retrieval_metrics = metrics + return _build_tools( + patch_retriever=retriever, + vector_store=_StubVectorStore(), + ontology_manager=_stub_ontology_manager(), + **kwargs, + ) + + +def _resolve_empty(metrics: dict, **kwargs): + tools = _empty_context_tools(metrics, **kwargs) + state = AgentState( + ontology_context_mode=OntologyContextMode.SELECTED_VECTOR_SEARCH_ONTOLOGY + ) + context = UnitLoopContext.from_agent_state(state) + return context, asyncio.run( + resolve_unit_ontology_context(context, tools, _build_unit()) + ) + + +def test_empty_ontology_context_stops_the_run_when_required() -> None: + """A finished run with no vocabulary is worse than no run. + + The renderer is told to extract "based on provided domain ontology"; handed + nothing it falls back on generic vocabulary, and the SHACL gate then finds + no node its shapes target, so the run reports a vacuous pass. This shipped + once and was invisible at every checkpoint. + + This is the *facts* reading of an empty context, which is the only one it + has: a facts unit cannot answer one. + """ + with pytest.raises(EmptyOntologyContextError): + _resolve_empty({"atoms_final": 99}, ontology_context_required=True) + + +def test_a_caller_that_can_create_vocabulary_is_handed_the_empty_context() -> None: + """The ontology loop answers an empty context by inventing vocabulary. + + ``render_ontology`` branches on an empty seed into + ``render_ontology_fresh``, which mints a catalog ontology from the text. + Raising ahead of it made that branch unreachable, so a corpus with no + ontology yet -- the starting point of every ontology-building run, and the + documented first run -- read as a deployment fault. It also stopped a + populated-catalog run whenever the selector honestly reported that no + catalog ontology fits the unit. + """ + tools = _empty_context_tools({"atoms_final": 99}, ontology_context_required=True) + state = AgentState( + ontology_context_mode=OntologyContextMode.SELECTED_VECTOR_SEARCH_ONTOLOGY + ) + context = UnitLoopContext.from_agent_state(state) + + result = asyncio.run( + resolve_unit_ontology_context( + context, tools, _build_unit(), can_create_vocabulary=True + ) + ) + + assert len(result.snapshot.graph) == 0 + + +def test_empty_ontology_context_is_survivable_when_opted_out() -> None: + context, result = _resolve_empty({"atoms_final": 99}) + + assert len(result.snapshot.graph) == 0 + assert RetrievalMetric.EMPTY_SNAPSHOT_REASON in context.retrieval_metrics + + +def test_an_empty_catalog_is_not_reported_as_a_threshold_problem() -> None: + """The diagnostic used to blame retrieval for a catalog fault. + + Atoms were selected and scored fine; the triple store simply listed no + ontology to expand them against. Reporting that as "scored below the + thresholds" sends an operator to tune numbers that were never involved. + """ + context, _ = _resolve_empty( + { + "atoms_final": 99, + "atoms_after_dedupe": 238, + "catalog_context_triples": 0, + "catalog_graph_cache_hits": 0, + "catalog_graph_cache_misses": 0, + } + ) + reason = context.retrieval_metrics[RetrievalMetric.EMPTY_SNAPSHOT_REASON] + + assert "catalog" in reason + assert "threshold" not in reason + + +def test_a_genuine_threshold_miss_still_reads_as_one() -> None: + """Signalled by the pre-gate counts, not by ``atoms_after_dedupe``. + + That one is measured *after* the score gate, so a threshold rejection + records zero of them -- the reading this test asserts was unreachable from + a real run, which is why a threshold miss used to report as "no candidate + atoms matched". + """ + context, _ = _resolve_empty( + { + "candidate_hits": 238, + "threshold_rejected": 238, + "atoms_after_dedupe": 0, + "catalog_context_triples": 1400, + "catalog_graph_cache_hits": 15, + } + ) + reason = context.retrieval_metrics[RetrievalMetric.EMPTY_SNAPSHOT_REASON] + + assert "threshold" in reason + + +def test_an_empty_catalog_is_found_even_when_retrieval_never_asked_it() -> None: + """The short-circuit on zero atoms leaves the catalog keys absent. + + ``metrics.get("catalog_context_triples") == 0`` is then ``None == 0``, so + both catalog branches were skipped on exactly the run where the catalog was + the cause and the empty index -- the symptom -- was reported instead. + """ + retriever = _StubPatchRetriever(graph=RDFGraph(), sources=[]) + retriever.last_retrieval_metrics = { + "query_count": 12, + "atoms_final": 0, + "atoms_after_dedupe": 0, + } + tools = _build_tools( + patch_retriever=retriever, + vector_store=_StubVectorStore(indexed=[]), + ontology_manager=_stub_ontology_manager(has_ontologies=False), + ) + state = AgentState( + ontology_context_mode=OntologyContextMode.SELECTED_VECTOR_SEARCH_ONTOLOGY + ) + context = UnitLoopContext.from_agent_state(state) + asyncio.run(resolve_unit_ontology_context(context, tools, _build_unit())) + reason = str(context.retrieval_metrics[RetrievalMetric.EMPTY_SNAPSHOT_REASON]) + + assert "catalog" in reason + + +def test_a_unit_with_no_text_may_have_an_empty_context() -> None: + """It has nothing to extract either way, so this is not a catalog fault.""" + tools = _empty_context_tools({"atoms_final": 0}, ontology_context_required=True) + state = AgentState( + ontology_context_mode=OntologyContextMode.SELECTED_VECTOR_SEARCH_ONTOLOGY + ) + blank = ContentUnit(text=" ", index=0, doc_iri=URIRef("https://example.org/d/1")) + + result = asyncio.run( + resolve_unit_ontology_context( + UnitLoopContext.from_agent_state(state), tools, blank + ) + ) + + assert len(result.snapshot.graph) == 0 + + +def test_the_empty_context_guard_covers_the_single_ontology_modes_too() -> None: + """Every mode can return an empty context, so the check is mode-agnostic. + + It lives on the dispatcher rather than in the vector branch for the same + reason the snapshot size is recorded there: the modes that bound nothing + were also the modes that reported nothing. + """ + manager = _stub_ontology_manager( + aget_ontologies=AsyncMock(return_value=[]), + aget_ontology_by_iri=AsyncMock(return_value=None), + ) + tools = _build_tools( + patch_retriever=None, + vector_store=None, + ontology_manager=manager, + ontology_context_required=True, + ) + state = AgentState( + ontology_context_mode=OntologyContextMode.FIXED_SINGLE_ONTOLOGY, + current_ontology_iri="https://example.org/missing", + ) + + with pytest.raises(EmptyOntologyContextError): + asyncio.run( + resolve_unit_ontology_context( + UnitLoopContext.from_agent_state(state), tools, _build_unit() + ) + ) + + # ... and the phase exemption is equally mode-agnostic. + assert ( + len( + asyncio.run( + resolve_unit_ontology_context( + UnitLoopContext.from_agent_state(state), + tools, + _build_unit(), + can_create_vocabulary=True, + ) + ).snapshot.graph + ) + == 0 + ) + + def test_resolver_vector_retrieval_prefers_ensemble() -> None: graph = RDFGraph._from_turtle_str( "@prefix ex: . ex:A ex:relatedTo ex:B ." diff --git a/test/test_tag_and_summarize_agents.py b/test/test_tag_and_summarize_agents.py index fd947db2..454bccfc 100644 --- a/test/test_tag_and_summarize_agents.py +++ b/test/test_tag_and_summarize_agents.py @@ -18,6 +18,8 @@ from ontocast.toolbox import ToolBox from test.docling_test_helpers import doc_from_markdown_lines +pytestmark = pytest.mark.unit + _SAMPLE_DOC = """# Introduction We survey prior work. @@ -133,6 +135,7 @@ async def llm(_prompt): assert llm_calls == 0 +@pytest.mark.slow @pytest.mark.anyio async def test_chunk_prepare_keyword_tier_labels_non_regex_chunk() -> None: """A heading the anchored patterns miss is still resolved without an LLM.""" diff --git a/test/test_tenancy.py b/test/test_tenancy.py index a6f2b1c1..72ef75bb 100644 --- a/test/test_tenancy.py +++ b/test/test_tenancy.py @@ -11,6 +11,8 @@ tenant_project_store_name, ) +pytestmark = pytest.mark.unit + def test_default_names_use_double_dash() -> None: assert tenant_project_facts_name(DEFAULT_TENANT, DEFAULT_PROJECT) == ( diff --git a/test/test_tenancy_scopes.py b/test/test_tenancy_scopes.py index 599abcb1..c8bc461d 100644 --- a/test/test_tenancy_scopes.py +++ b/test/test_tenancy_scopes.py @@ -14,7 +14,7 @@ from ontocast.config import Config, ToolConfig from ontocast.config.settings import PathConfig -from ontocast.onto.enum import VectorStoreBackend +from ontocast.onto.enum import OntologyContextMode, VectorStoreBackend from ontocast.onto.tenancy import TenancyScope from ontocast.registry import ToolBoxRegistry from ontocast.runtime import ToolBoxRuntime @@ -287,3 +287,58 @@ async def test_aclose_closes_scoped_toolboxes(tmp_path) -> None: await tools.aclose() assert tools._registry is None + + +@pytest.mark.anyio +async def test_a_cached_scope_gains_its_vector_store_when_a_later_request_needs_it( + tmp_path, +) -> None: + """One non-vector request must not decide the mode for a whole process. + + A scope is built for whichever request reached it first and then cached for + the process lifetime. Without an upgrade on reuse, the ontologies written + through that scope were indexed nowhere and every later vector request + retrieved nothing -- silently, because a store that was never initialized + reports no error. + """ + base = _config(tmp_path) + registry = ToolBoxRegistry(base, ToolBoxRuntime(base, llm=STUB_LLM)) + scope = TenancyScope.build("acme", "p1") + + tools = await registry.get(scope, ontology_context_mode=None) + assert not tools.is_vector_store_ready() + + prepared: list[Any] = [] + + async def initialize(**kwargs: Any) -> None: + prepared.append(kwargs) + + stub = cast(Any, tools) + stub.should_initialize_vector_store = lambda mode: mode is not None + stub.initialize = initialize + + again = await registry.get( + scope, ontology_context_mode=OntologyContextMode.SELECTED_VECTOR_SEARCH_ONTOLOGY + ) + + assert again is tools, "the scope is still cached, not rebuilt" + assert len(prepared) == 1 + assert prepared[0]["wipe_vector_store"] is False, "an upgrade never wipes" + + +@pytest.mark.anyio +async def test_a_ready_scope_is_not_reinitialized_on_every_request(tmp_path) -> None: + base = _config(tmp_path) + registry = ToolBoxRegistry(base, ToolBoxRuntime(base, llm=STUB_LLM)) + scope = TenancyScope.build("acme", "p1") + tools = await registry.get(scope) + + calls: list[Any] = [] + stub = cast(Any, tools) + stub.should_initialize_vector_store = lambda mode: True + stub.is_vector_store_ready = lambda: True + stub.initialize = lambda **kwargs: calls.append(kwargs) + + await registry.get(scope, ontology_context_mode="anything") + + assert calls == [] diff --git a/test/test_term_sheet.py b/test/test_term_sheet.py new file mode 100644 index 00000000..9fdf29ff --- /dev/null +++ b/test/test_term_sheet.py @@ -0,0 +1,369 @@ +"""The ontology chapter as a term sheet. + +The chapter is most of a facts prompt, and most of the chapter is RDF +scaffolding and prose no extractor reads. Rendering it as a listing is only +admissible if nothing the model needs to *use* a term is lost -- so these pin +what must survive, and what must refuse to happen on the ontology path. +""" + +from __future__ import annotations + +import pytest +from rdflib import OWL, RDF, RDFS, SKOS, Literal, Namespace, URIRef + +from ontocast.config.settings import RenderMode, ServerConfig +from ontocast.onto.enum import LLMGraphFormat, OntologyChapterFormat +from ontocast.onto.ontology_condense import TextCaps +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.prompt.graph_format import get_graph_format_profile +from ontocast.prompt.term_sheet import build_ontology_term_sheet + +EX = Namespace("https://example.org/onto#") + + +@pytest.fixture +def catalog() -> RDFGraph: + """One of each shape: a class hierarchy, a property, and a typed individual.""" + graph = RDFGraph() + graph.bind("ex", EX) + graph.add((EX.Sample, RDF.type, OWL.Class)) + graph.add((EX.Sample, RDFS.label, Literal("Sample"))) + graph.add((EX.Sample, RDFS.comment, Literal("A portion of material."))) + graph.add((EX.Powder, RDF.type, OWL.Class)) + graph.add((EX.Powder, RDFS.label, Literal("Powder"))) + graph.add((EX.Powder, RDFS.subClassOf, EX.Sample)) + graph.add((EX.Powder, SKOS.altLabel, Literal("powdered solid"))) + graph.add((EX.Powder, SKOS.scopeNote, Literal("Only for a milled sample."))) + graph.add((EX.hasMass, RDF.type, OWL.ObjectProperty)) + graph.add((EX.hasMass, RDFS.label, Literal("has mass"))) + graph.add((EX.hasMass, RDFS.domain, EX.Sample)) + graph.add((EX.hasMass, RDFS.range, EX.Mass)) + graph.add((EX.gram, RDF.type, EX.Unit)) + graph.add((EX.gram, RDF.type, OWL.NamedIndividual)) + graph.add((EX.gram, RDFS.label, Literal("gram"))) + graph.add((EX.gram, SKOS.altLabel, Literal("g"))) + return graph + + +def test_every_load_bearing_term_survives(catalog: RDFGraph) -> None: + """The one property that makes the representation admissible at all. + + A term absent from the sheet cannot be used, and the model is told to use + only what the sheet lists -- so an omission is not a smaller prompt, it is a + term silently withdrawn from the vocabulary. + """ + sheet = build_ontology_term_sheet(catalog) + + named = { + subject + for predicate in ( + RDFS.label, + RDF.type, + RDFS.subClassOf, + RDFS.domain, + RDFS.range, + RDFS.subPropertyOf, + ) + for subject in catalog.subjects(predicate, None) + if isinstance(subject, URIRef) + } + + missing = [ + catalog.namespace_manager.qname(term) + for term in named + if catalog.namespace_manager.qname(term) not in sheet + ] + assert not missing + + +def test_relations_and_signatures_are_stated(catalog: RDFGraph) -> None: + sheet = build_ontology_term_sheet(catalog) + + assert "ex:Powder" in sheet and "< ex:Sample" in sheet + assert "ex:hasMass" in sheet and "ex:Sample -> ex:Mass" in sheet + assert "ex:gram" in sheet and ": ex:Unit" in sheet + assert "owl:NamedIndividual" not in sheet, "a type saying nothing is noise" + + +def test_surface_forms_and_usage_contracts_survive(catalog: RDFGraph) -> None: + """Alternative labels are what a document match has to work with.""" + sheet = build_ontology_term_sheet(catalog) + + assert "powdered surface" not in sheet + assert "~ powdered solid" in sheet + assert "~ g" in sheet + assert "note: Only for a milled sample." in sheet + + +def test_one_prose_field_per_term_the_contract_winning(catalog: RDFGraph) -> None: + """Each term gets exactly one note, and a scope note outranks a comment. + + ``ex:Sample`` has only a comment, so the comment is its note -- dropping it + would leave the term a name and a parent. ``ex:Powder`` has both, and there + the comment restates for a human what the scope note states as a contract, + so only the contract is rendered. + """ + sheet = build_ontology_term_sheet(catalog) + assert "note: A portion of material." in sheet + assert "note: Only for a milled sample." in sheet + # The indented form: the legend in the header also spells "note:". + assert sheet.count(" note:") == 2 + + +def test_rendering_is_deterministic(catalog: RDFGraph) -> None: + """A stable chapter is what a provider's prefix cache can serve twice.""" + assert build_ontology_term_sheet(catalog) == build_ontology_term_sheet(catalog) + + +def test_blank_nodes_are_never_named(catalog: RDFGraph) -> None: + """rdflib mints bnode labels at random; a sheet that printed them would + differ between processes for the same catalog.""" + from rdflib import BNode + + restriction = RDFGraph() + restriction.bind("ex", EX) + for triple in catalog: + restriction.add(triple) + + bnode = BNode() + restriction.add((EX.Powder, RDFS.subClassOf, bnode)) + restriction.add((bnode, RDF.type, OWL.Restriction)) + restriction.add((bnode, OWL.onProperty, EX.hasMass)) + + sheet = build_ontology_term_sheet(restriction) + + assert str(bnode) not in sheet + assert "ex:Powder" in sheet and "< ex:Sample" in sheet + + +def test_empty_snapshot_renders_nothing() -> None: + assert build_ontology_term_sheet(RDFGraph()) == "" + + +def test_term_sheet_chapter_is_far_cheaper_than_the_graph(catalog: RDFGraph) -> None: + jsonld = get_graph_format_profile(LLMGraphFormat.JSONLD) + sheet = get_graph_format_profile( + LLMGraphFormat.JSONLD, + ontology_chapter_format=OntologyChapterFormat.TERM_SHEET, + ) + + graph_chapter = jsonld.format_ontology_chapter(catalog, max_triples=None) + sheet_chapter = sheet.format_ontology_chapter(catalog, max_triples=None) + + assert len(sheet_chapter) < len(graph_chapter) + assert "```" not in sheet_chapter, "a listing is not a fenced serialization" + assert sheet_chapter.lstrip().startswith("# ONTOLOGY") + + +def test_term_sheet_does_not_collide_with_turtle_in_the_memo( + catalog: RDFGraph, +) -> None: + """Both report a Turtle wire; only the discriminator tells them apart.""" + turtle = get_graph_format_profile( + LLMGraphFormat.JSONLD, ontology_chapter_format=OntologyChapterFormat.TURTLE + ) + sheet = get_graph_format_profile( + LLMGraphFormat.JSONLD, + ontology_chapter_format=OntologyChapterFormat.TERM_SHEET, + ) + + assert turtle.ontology_chapter_wire == sheet.ontology_chapter_wire + assert turtle.ontology_chapter_discriminator != sheet.ontology_chapter_discriminator + + +def test_text_caps_reach_the_term_sheet(catalog: RDFGraph) -> None: + profile = get_graph_format_profile( + LLMGraphFormat.JSONLD, + ontology_chapter_format=OntologyChapterFormat.TERM_SHEET, + ) + + chapter = profile.format_ontology_chapter( + catalog, max_triples=None, text_caps=TextCaps(contract=10) + ) + + assert "Only for a milled sample." not in chapter + assert "note: Only" in chapter + + +@pytest.mark.parametrize("mode", [RenderMode.ONTOLOGY, RenderMode.ONTOLOGY_AND_FACTS]) +def test_term_sheet_is_rejected_on_the_ontology_path(mode: RenderMode) -> None: + """The ontology loop patches the statements in its chapter. + + A listing has nothing to insert into or delete from, and a silent fallback + would spend an ontology pass producing patches nobody could apply while the + manifest recorded a setting that never took effect. + """ + with pytest.raises(ValueError, match="RENDER_MODE=facts"): + ServerConfig( + render_mode=mode, + ontology_chapter_format=OntologyChapterFormat.TERM_SHEET, + ) + + +def test_term_sheet_is_allowed_on_a_facts_run() -> None: + config = ServerConfig( + render_mode=RenderMode.FACTS, + ontology_chapter_format=OntologyChapterFormat.TERM_SHEET, + ) + assert config.ontology_chapter_format == OntologyChapterFormat.TERM_SHEET + + +def test_text_caps_property_is_inactive_by_default() -> None: + assert not ServerConfig(render_mode=RenderMode.FACTS).ontology_text_caps.active + + +def test_a_term_with_several_names_keeps_all_of_them() -> None: + """Catalogs spell terms more than one way; the sheet must not pick one. + + QUDT names its units in both British and American English, and the spelling + a source document used is exactly the one a match needs. Choosing a display + name and discarding the rest throws that away silently. + """ + graph = RDFGraph() + graph.bind("ex", EX) + graph.add((EX.Metre, RDF.type, EX.Unit)) + graph.add((EX.Metre, RDFS.label, Literal("Metre"))) + graph.add((EX.Metre, RDFS.label, Literal("Meter"))) + graph.add((EX.Metre, SKOS.altLabel, Literal("m"))) + + sheet = build_ontology_term_sheet(graph) + + assert "Metre" in sheet and "Meter" in sheet and "~ m" in sheet + + +def test_the_display_name_does_not_depend_on_iteration_order() -> None: + """rdflib yields objects in hash order, randomised per process. + + Reading "the first label" from that picks a different name for the same + term on a different run, drifting the chapter with no visible cause and + defeating every cache keyed on it. + """ + graph = RDFGraph() + graph.bind("ex", EX) + graph.add((EX.T, RDF.type, OWL.Class)) + for name in ("Zeta", "Alpha", "Mu", "Beta"): + graph.add((EX.T, RDFS.label, Literal(name))) + + line = next( + row for row in build_ontology_term_sheet(graph).splitlines() if "ex:T" in row + ) + + # Shortest, then alphabetical -- a total order, not an arrival order. + assert line.split('"')[1] == "Mu" + assert all(name in line for name in ("Zeta", "Alpha", "Beta")) + + +def test_the_display_name_is_never_repeated_as_a_surface_form() -> None: + graph = RDFGraph() + graph.bind("ex", EX) + graph.add((EX.T, RDF.type, OWL.Class)) + graph.add((EX.T, RDFS.label, Literal("Sample"))) + graph.add((EX.T, SKOS.altLabel, Literal("Sample"))) + + line = next( + row for row in build_ontology_term_sheet(graph).splitlines() if "ex:T" in row + ) + + assert line.count("Sample") == 1 + + +def test_comment_is_the_note_when_a_term_has_no_scope_note() -> None: + """A term's only description must survive the change of representation. + + Most catalog terms carry an ``rdfs:comment`` and no ``skos:scopeNote``. + Dropping the comment as "prose the extractor does not read" would leave + those terms as a name and a parent -- a loss of content, not a cheaper + encoding of it, and nothing downstream can recover it. + """ + graph = RDFGraph() + graph.parse( + data=""" + @prefix ex: . + @prefix owl: . + @prefix rdfs: . + ex:Anneal a owl:Class ; + rdfs:label "Anneal" ; + rdfs:comment "Heating held below the melting point." . + """, + format="turtle", + ) + sheet = build_ontology_term_sheet(graph) + assert "note: Heating held below the melting point." in sheet + + +def test_scope_note_outranks_comment() -> None: + """Where both exist the contract wins: the comment restates it for a human.""" + graph = RDFGraph() + graph.parse( + data=""" + @prefix ex: . + @prefix owl: . + @prefix rdfs: . + @prefix skos: . + ex:Anneal a owl:Class ; + rdfs:label "Anneal" ; + skos:scopeNote "Use for a stated hold; not for a ramp." ; + rdfs:comment "Heating held below the melting point." . + """, + format="turtle", + ) + sheet = build_ontology_term_sheet(graph) + assert "note: Use for a stated hold; not for a ramp." in sheet + assert "Heating held below the melting point" not in sheet + + +def test_auto_is_the_shipped_default() -> None: + """The default must be the mode-aware member, not a chapter. + + Pinning it here is what makes the two resolution tests below a statement + about what a deployment gets rather than about a value they set themselves. + """ + assert ( + ServerConfig.model_fields["ontology_chapter_format"].default + == OntologyChapterFormat.AUTO + ) + + +def test_auto_takes_the_term_sheet_on_a_facts_run() -> None: + """The cheapest chapter a facts run can legally read.""" + config = ServerConfig( + render_mode=RenderMode.FACTS, + ontology_chapter_format=OntologyChapterFormat.AUTO, + ) + assert config.ontology_chapter_format == OntologyChapterFormat.TERM_SHEET + + +@pytest.mark.parametrize("mode", [RenderMode.ONTOLOGY, RenderMode.ONTOLOGY_AND_FACTS]) +def test_auto_falls_back_to_a_graph_where_a_listing_is_illegal( + mode: RenderMode, +) -> None: + """`auto` never fails: where a listing cannot be patched, it yields a graph. + + That is the whole difference from an explicit `term_sheet`, which is + rejected on these modes -- asking for something impossible is an error, + while asking for the best available is not. + """ + config = ServerConfig( + render_mode=mode, ontology_chapter_format=OntologyChapterFormat.AUTO + ) + assert config.ontology_chapter_format == OntologyChapterFormat.INHERIT + + +def test_auto_is_resolved_before_anything_downstream_sees_it() -> None: + """Resolution happens once, in the config -- not at each point of use. + + Every consumer reads this field, so a value still meaning "decide later" + would reach the prompt profile, the LLM cache key and the run manifest as a + name for no chapter in particular. The profile lookup refuses it, which is + the backstop for that. + """ + for mode in RenderMode: + assert ( + ServerConfig(render_mode=mode).ontology_chapter_format + != OntologyChapterFormat.AUTO + ) + with pytest.raises(ValueError, match="resolved against the render mode"): + get_graph_format_profile( + LLMGraphFormat.JSONLD, + ontology_chapter_format=OntologyChapterFormat.AUTO, + ) diff --git a/test/test_toolbox_ontology_seed.py b/test/test_toolbox_ontology_seed.py index 2dc32c58..1e76d390 100644 --- a/test/test_toolbox_ontology_seed.py +++ b/test/test_toolbox_ontology_seed.py @@ -5,13 +5,21 @@ import asyncio import tempfile from pathlib import Path +from typing import cast from unittest.mock import AsyncMock +import pytest + from ontocast.config import Config, PathConfig, ToolConfig +from ontocast.onto.enum import OntologyContextMode, RenderMode from ontocast.onto.ontology import Ontology +from ontocast.onto.rdfgraph import RDFGraph from ontocast.tool.triple_manager.in_memory import InMemoryTripleStoreManager +from ontocast.tool.vector_store.core import VectorStoreManager from ontocast.toolbox import ToolBox +pytestmark = pytest.mark.unit + def _write_seed_ttl(directory: Path) -> Ontology: ttl = """ @@ -26,6 +34,23 @@ def _write_seed_ttl(directory: Path) -> Ontology: return Ontology.from_file(path) +def _write_seed_ttl_with_terms(directory: Path) -> Ontology: + """A seed that defines a class, not only its own ``owl:Ontology`` header.""" + ttl = """ + @prefix owl: . + @prefix rdfs: . + + a owl:Ontology ; + rdfs:label "Seed Ontology" . + + a owl:Class ; + rdfs:label "Thing" . + """ + path = directory / "seed.ttl" + path.write_text(ttl, encoding="utf-8") + return Ontology.from_file(path) + + def test_load_seed_ontologies_from_directory() -> None: with tempfile.TemporaryDirectory() as tmp: od = Path(tmp) / "ontologies" @@ -99,3 +124,206 @@ def test_ingest_ontology_ttl_without_a_seed_directory() -> None: """ ontology = asyncio.run(toolbox.ingest_ontology_ttl(ttl)) assert ontology.iri == "https://example.org/ingested" + + +def test_a_seed_repairs_an_iri_the_store_serves_no_graph_for() -> None: + """Listing an ontology is not serving it. + + A partition can name an ontology and return nothing for it -- a dataset + restored empty, a graph dropped from under a still-registered header. The + seed rule keyed on the IRI alone, so the one copy that could have repaired + it was skipped and the catalog stayed empty. With a wiped vector index there + is then nothing left to extract against at all. + """ + with tempfile.TemporaryDirectory() as tmp: + od = Path(tmp) / "ontologies" + od.mkdir() + seed = _write_seed_ttl(od) + empty_shell = Ontology( + graph=RDFGraph(), iri=seed.iri, ontology_id="seed", version="0.0.1" + ) + + toolbox = ToolBox( + Config( + tool_config=ToolConfig(path_config=PathConfig(ontology_directory=od)) + ) + ) + repairs = toolbox._seed_ontologies_missing_from([empty_shell], [seed]) + + assert [o.iri for o in repairs] == [seed.iri] + + +def test_a_served_ontology_is_never_overwritten_by_an_older_seed() -> None: + """Ontology-mode runs write evolved terminals back to the store. + + Re-materializing the on-disk seed over one of those would revert the + catalog to its bootstrap state without saying so. + """ + with tempfile.TemporaryDirectory() as tmp: + od = Path(tmp) / "ontologies" + od.mkdir() + seed = _write_seed_ttl_with_terms(od) + + toolbox = ToolBox( + Config( + tool_config=ToolConfig(path_config=PathConfig(ontology_directory=od)) + ) + ) + repairs = toolbox._seed_ontologies_missing_from([seed], [seed]) + + assert repairs == [] + + +def test_synchronize_prefers_the_seed_over_an_empty_store_entry() -> None: + with tempfile.TemporaryDirectory() as tmp: + od = Path(tmp) / "ontologies" + od.mkdir() + seed = _write_seed_ttl_with_terms(od) + empty_shell = Ontology( + graph=RDFGraph(), iri=seed.iri, ontology_id="seed", version="0.0.1" + ) + + class _Store: + async def afetch_ontologies(self) -> list[Ontology]: + return [empty_shell] + + toolbox = ToolBox( + Config( + tool_config=ToolConfig(path_config=PathConfig(ontology_directory=od)) + ) + ) + toolbox.triple_store_manager = cast(InMemoryTripleStoreManager, _Store()) + + synced = asyncio.run(toolbox._synchronize_ontologies()) + + assert [o.iri for o in synced] == [seed.iri] + assert ToolBox._defines_terms(synced[0]) + assert [o.iri for o in toolbox._last_seed_repairs] == [seed.iri] + + +def _empty_catalog_toolbox(tmp: str, render_mode: RenderMode) -> ToolBox: + empty_dir = Path(tmp) / "ontologies" + empty_dir.mkdir() + config = Config( + tool_config=ToolConfig(path_config=PathConfig(ontology_directory=empty_dir)) + ) + config.server.render_mode = render_mode + config.server.ontology_context_required = True + toolbox = ToolBox(config) + toolbox.triple_store_manager = InMemoryTripleStoreManager() + toolbox.vector_store = None + return toolbox + + +def test_a_facts_run_refuses_to_start_with_no_catalog_at_all() -> None: + """End to end through ``initialize``, which is where the flag is wired. + + The unit checks below it can pass while the call site still does not set + ``require_populated_catalog``, which is exactly the wiring that let a run + start and fail one unit at a time. + """ + from ontocast.onto.retrieval_capabilities import EmptyOntologyContextError + + with tempfile.TemporaryDirectory() as tmp: + toolbox = _empty_catalog_toolbox(tmp, RenderMode.FACTS) + + with pytest.raises(EmptyOntologyContextError): + asyncio.run(toolbox.initialize(require_populated_catalog=True)) + + +def test_an_ontology_run_starts_with_no_catalog_at_all() -> None: + """A corpus with no ontology is what this render mode is for. + + Refusing here is what made the documented first run -- ``process`` against + a document with no seed directory -- fail at startup. + """ + with tempfile.TemporaryDirectory() as tmp: + toolbox = _empty_catalog_toolbox(tmp, RenderMode.ONTOLOGY_AND_FACTS) + + asyncio.run(toolbox.initialize(require_populated_catalog=True)) + + +def test_a_server_may_start_with_no_catalog_and_be_filled_over_http() -> None: + with tempfile.TemporaryDirectory() as tmp: + empty_dir = Path(tmp) / "ontologies" + empty_dir.mkdir() + toolbox = ToolBox( + Config( + tool_config=ToolConfig( + path_config=PathConfig(ontology_directory=empty_dir) + ) + ) + ) + toolbox.triple_store_manager = InMemoryTripleStoreManager() + toolbox.vector_store = None + + asyncio.run(toolbox.initialize()) + + ontology = asyncio.run( + toolbox.ingest_ontology_ttl( + b""" + @prefix owl: . + a owl:Ontology . + """ + ) + ) + assert ontology.iri == "https://example.org/late" + + +def test_a_wipe_is_followed_by_a_reindex_from_the_seed_directory(monkeypatch) -> None: + """The property no test held: the refill actually happens. + + The wipe is unconditional and the refill is not, so the two have to be + asserted together -- a passing "wipe_store was awaited" says nothing about + whether anything was put back, which is the state the failure leaves behind. + """ + monkeypatch.setattr("ontocast.toolbox.update_ontology_manager", AsyncMock()) + + class _RecordingVectorStore: + def __init__(self) -> None: + self.indexed: dict[str, int] = {} + self.wiped = False + + async def wipe_store(self) -> None: + self.wiped = True + self.indexed.clear() + + async def initialize(self) -> None: + return None + + def reindex_ontology(self, ontology: Ontology) -> int: + self.indexed[ontology.iri] = len(ontology.graph) + return len(ontology.graph) + + def list_indexed_ontology_iris(self) -> set[str]: + return set(self.indexed) + + def prune_orphan_ontology_iris(self, keep_iris: set[str]) -> list[str]: + return [] + + with tempfile.TemporaryDirectory() as tmp: + od = Path(tmp) / "ontologies" + od.mkdir() + seed = _write_seed_ttl_with_terms(od) + + config = Config( + tool_config=ToolConfig(path_config=PathConfig(ontology_directory=od)) + ) + config.server.ontology_context_mode = ( + OntologyContextMode.SELECTED_VECTOR_SEARCH_ONTOLOGY + ) + toolbox = ToolBox(config) + toolbox.triple_store_manager = InMemoryTripleStoreManager() + store = _RecordingVectorStore() + toolbox.vector_store = cast(VectorStoreManager, store) + + asyncio.run( + toolbox.initialize( + ontology_context_mode=OntologyContextMode.SELECTED_VECTOR_SEARCH_ONTOLOGY, + wipe_vector_store=True, + require_populated_catalog=True, + ) + ) + + assert store.wiped + assert set(store.indexed) == {seed.iri} diff --git a/test/test_toolbox_sync.py b/test/test_toolbox_sync.py index b854b690..c64b1a64 100644 --- a/test/test_toolbox_sync.py +++ b/test/test_toolbox_sync.py @@ -1,6 +1,7 @@ """Tests for ToolBox ontology synchronization helpers.""" import asyncio +import logging import tempfile from pathlib import Path from typing import cast @@ -16,12 +17,14 @@ QdrantConfig, ToolConfig, ) -from ontocast.onto.enum import OntologyContextMode +from ontocast.onto.enum import OntologyContextMode, RenderMode from ontocast.onto.ontology import Ontology from ontocast.onto.rdfgraph import RDFGraph from ontocast.tool.ontology_manager import OntologyManager from ontocast.toolbox import ToolBox +pytestmark = pytest.mark.unit + def test_materialize_ontology_calls_vector_reindex(test_ontology): tb = MagicMock() @@ -88,6 +91,21 @@ def fake_add(ontology, *, skip_vector_index: bool = False): added.append((ontology, skip_vector_index)) class Stub: + # Bound to the real implementation so the catalog/index agreement + # check runs in these tests rather than being stubbed away. + async def _check_catalog_index_agreement(self, ontologies): + await ToolBox._check_catalog_index_agreement( + cast(ToolBox, self), ontologies + ) + + async def _check_catalog_ready(self, ontologies, *, required): + await ToolBox._check_catalog_ready( + cast(ToolBox, self), ontologies, required=required + ) + + def _catalog_sources_description(self): + return ToolBox._catalog_sources_description(cast(ToolBox, self)) + vector_store = None triple_store_manager = None llm = MagicMock() @@ -164,6 +182,21 @@ def test_initialize_skips_vector_store_in_full_ttl_mode(monkeypatch) -> None: synchronized: list = [] class Stub: + # Bound to the real implementation so the catalog/index agreement + # check runs in these tests rather than being stubbed away. + async def _check_catalog_index_agreement(self, ontologies): + await ToolBox._check_catalog_index_agreement( + cast(ToolBox, self), ontologies + ) + + async def _check_catalog_ready(self, ontologies, *, required): + await ToolBox._check_catalog_ready( + cast(ToolBox, self), ontologies, required=required + ) + + def _catalog_sources_description(self): + return ToolBox._catalog_sources_description(cast(ToolBox, self)) + triple_store_manager = None llm = MagicMock() ontology_manager: MagicMock @@ -212,6 +245,21 @@ def test_initialize_vector_store_failure_is_non_fatal_when_configured( ) class Stub: + # Bound to the real implementation so the catalog/index agreement + # check runs in these tests rather than being stubbed away. + async def _check_catalog_index_agreement(self, ontologies): + await ToolBox._check_catalog_index_agreement( + cast(ToolBox, self), ontologies + ) + + async def _check_catalog_ready(self, ontologies, *, required): + await ToolBox._check_catalog_ready( + cast(ToolBox, self), ontologies, required=required + ) + + def _catalog_sources_description(self): + return ToolBox._catalog_sources_description(cast(ToolBox, self)) + triple_store_manager = None llm = MagicMock() ontology_manager: MagicMock @@ -276,6 +324,21 @@ def test_ingest_ontology_ttl_rejects_identity_conflict_before_persisting() -> No ontology_manager.add_ontology(existing) class Stub: + # Bound to the real implementation so the catalog/index agreement + # check runs in these tests rather than being stubbed away. + async def _check_catalog_index_agreement(self, ontologies): + await ToolBox._check_catalog_index_agreement( + cast(ToolBox, self), ontologies + ) + + async def _check_catalog_ready(self, ontologies, *, required): + await ToolBox._check_catalog_ready( + cast(ToolBox, self), ontologies, required=required + ) + + def _catalog_sources_description(self): + return ToolBox._catalog_sources_description(cast(ToolBox, self)) + def __init__(self) -> None: self.config = config self.ontology_manager = ontology_manager @@ -327,6 +390,21 @@ async def fake_mat(self, o): active -= 1 class Stub: + # Bound to the real implementation so the catalog/index agreement + # check runs in these tests rather than being stubbed away. + async def _check_catalog_index_agreement(self, ontologies): + await ToolBox._check_catalog_index_agreement( + cast(ToolBox, self), ontologies + ) + + async def _check_catalog_ready(self, ontologies, *, required): + await ToolBox._check_catalog_ready( + cast(ToolBox, self), ontologies, required=required + ) + + def _catalog_sources_description(self): + return ToolBox._catalog_sources_description(cast(ToolBox, self)) + vector_store = None triple_store_manager = None llm = MagicMock() @@ -381,6 +459,21 @@ def test_initialize_wipe_and_prune_follow_their_flags( orphans = ["https://example.org/legacy"] if prune else [] class Stub: + # Bound to the real implementation so the catalog/index agreement + # check runs in these tests rather than being stubbed away. + async def _check_catalog_index_agreement(self, ontologies): + await ToolBox._check_catalog_index_agreement( + cast(ToolBox, self), ontologies + ) + + async def _check_catalog_ready(self, ontologies, *, required): + await ToolBox._check_catalog_ready( + cast(ToolBox, self), ontologies, required=required + ) + + def _catalog_sources_description(self): + return ToolBox._catalog_sources_description(cast(ToolBox, self)) + triple_store_manager = None llm = MagicMock() ontology_manager: MagicMock @@ -550,3 +643,261 @@ async def main() -> None: assert toolbox.ontology_manager.get_ontology_iris() == [onto.iri] asyncio.run(main()) + + +# --- Catalog/vector-index agreement at startup --------------------------- + + +def _agreement_toolbox(indexed: list[str], *, required: bool): + """A ToolBox stub whose only live parts are the two halves of retrieval.""" + config = Config() + config.server.ontology_context_required = required + + class Stub: + triple_store_manager = None + llm = MagicMock() + + def __init__(self) -> None: + self.config = config + self.vector_store = MagicMock() + self.vector_store.list_indexed_ontology_iris = MagicMock( + return_value=set(indexed) + ) + + def is_vector_store_ready(self): + return True + + return Stub() + + +def test_an_empty_catalog_beside_a_populated_index_stops_startup() -> None: + """The confirmed failure: ontologies never reached the triple store. + + Retrieval still selects atoms from the index and reports healthy metrics -- + the expected seeds, the expected atom count -- while the induced subgraph + built over the empty catalog comes back with nothing. Every unit then + renders against an empty ontology chapter and the conformance gate passes + over zero nodes, so nothing downstream can tell this from a good run. + """ + from ontocast.onto.retrieval_capabilities import EmptyOntologyContextError + + stub = _agreement_toolbox(["https://example.org/o1"], required=True) + + with pytest.raises(EmptyOntologyContextError) as excinfo: + asyncio.run(ToolBox._check_catalog_index_agreement(cast(ToolBox, stub), [])) + + assert "https://example.org/o1" in str(excinfo.value), ( + "the error must name what the index still holds, or the operator has " + "nothing to act on" + ) + + +def test_the_agreement_check_is_not_opted_out_of_by_wanting_no_catalog() -> None: + """``ONTOLOGY_CONTEXT_REQUIRED`` answers a different question. + + It says whether a run wants a catalog. This says the index and the triple + store disagree about which ontologies exist, which nobody configures on + purpose -- and a run that deliberately extracts without a catalog has not + thereby asked for a stale index to select atoms nothing can expand. + """ + from ontocast.onto.retrieval_capabilities import EmptyOntologyContextError + + stub = _agreement_toolbox(["https://example.org/o1"], required=False) + + with pytest.raises(EmptyOntologyContextError): + asyncio.run(ToolBox._check_catalog_index_agreement(cast(ToolBox, stub), [])) + + +def test_stale_index_entries_beside_a_real_catalog_only_warn(caplog) -> None: + """Ordinary staleness is what orphan pruning is for, not a startup failure.""" + stub = _agreement_toolbox( + ["https://example.org/o1", "https://example.org/gone"], required=True + ) + served = [Ontology(iri="https://example.org/o1", ontology_id="o1")] + + with caplog.at_level(logging.WARNING): + asyncio.run(ToolBox._check_catalog_index_agreement(cast(ToolBox, stub), served)) + + assert "https://example.org/gone" in caplog.text + + +def test_agreement_check_is_silent_when_both_sides_match() -> None: + stub = _agreement_toolbox(["https://example.org/o1"], required=True) + served = [Ontology(iri="https://example.org/o1", ontology_id="o1")] + + asyncio.run(ToolBox._check_catalog_index_agreement(cast(ToolBox, stub), served)) + + +# --- Catalog readiness after materialization ------------------------------ + + +def _readiness_toolbox( + indexed: list[str], + *, + ready: bool = True, + render_mode: RenderMode = RenderMode.FACTS, +): + """A ToolBox stub with a live vector index and nothing else.""" + config = Config() + config.server.ontology_context_required = True + config.server.render_mode = render_mode + + class Stub: + triple_store_manager = None + llm = MagicMock() + + def __init__(self) -> None: + self.config = config + self.vector_store = MagicMock() + self.vector_store.list_indexed_ontology_iris = MagicMock( + return_value=set(indexed) + ) + + def is_vector_store_ready(self): + return ready + + def _catalog_sources_description(self): + return ToolBox._catalog_sources_description(cast(ToolBox, self)) + + return Stub() + + +def test_a_wiped_index_with_no_catalog_to_refill_it_stops_a_facts_run() -> None: + """The case ``--wipe-vector-store`` hides from the agreement check. + + That check requires a populated index to compare against, and a wipe + guarantees an empty one, so it returns early on exactly the run where the + wipe destroyed the last copy of the vocabulary. The sync then found nothing + to reindex and startup succeeded, leaving every content unit to fail on its + own several minutes and several provider calls later. + """ + from ontocast.onto.retrieval_capabilities import EmptyOntologyContextError + + stub = _readiness_toolbox([], render_mode=RenderMode.FACTS) + + with pytest.raises(EmptyOntologyContextError) as excinfo: + asyncio.run( + ToolBox._check_catalog_ready(cast(ToolBox, stub), [], required=True) + ) + + assert "ontology_directory" in str(excinfo.value), ( + "the error must name where a catalog was looked for, or it says only " + "that something is missing" + ) + + +def test_an_empty_catalog_starts_when_the_run_will_create_ontologies(caplog) -> None: + """The corpus with no ontology yet is this render mode's starting point. + + Refusing here made ``render_ontology_fresh`` -- the branch written for an + empty seed -- unreachable, and turned the documented first run into a + startup error. + """ + stub = _readiness_toolbox([], render_mode=RenderMode.ONTOLOGY_AND_FACTS) + + with caplog.at_level(logging.INFO): + asyncio.run( + ToolBox._check_catalog_ready(cast(ToolBox, stub), [], required=True) + ) + + assert "create ontologies from the corpus" in caplog.text + + +def test_an_empty_catalog_only_warns_when_the_entry_point_allows_one() -> None: + """A server may legitimately start empty and be filled over HTTP.""" + stub = _readiness_toolbox([]) + + asyncio.run(ToolBox._check_catalog_ready(cast(ToolBox, stub), [], required=False)) + + +def test_a_catalog_that_indexed_to_nothing_always_stops(test_ontology) -> None: + """Retrieval has graphs it could expand and no atoms to select them with. + + Unlike an empty catalog, this is never what anyone meant: materialization + ran over a populated catalog and produced nothing. So it does not consult + ``required`` -- ``serve`` and an ontology-rendering run stop on it too. + """ + from ontocast.onto.retrieval_capabilities import EmptyOntologyContextError + + stub = _readiness_toolbox([], render_mode=RenderMode.ONTOLOGY_AND_FACTS) + + with pytest.raises(EmptyOntologyContextError) as excinfo: + asyncio.run( + ToolBox._check_catalog_ready( + cast(ToolBox, stub), [test_ontology], required=False + ) + ) + + assert "vector index is empty" in str(excinfo.value) + + +def test_a_populated_catalog_and_index_starts(test_ontology) -> None: + stub = _readiness_toolbox(["https://example.org/o1"]) + + asyncio.run( + ToolBox._check_catalog_ready( + cast(ToolBox, stub), [test_ontology], required=True + ) + ) + + +def test_wipe_is_honoured_outside_vector_search_mode( + monkeypatch, test_ontology +) -> None: + """A destructive flag must never be accepted and silently ignored. + + The wipe used to sit inside the branch gated on + ``SELECTED_VECTOR_SEARCH_ONTOLOGY``, so under any other context mode + ``--wipe-vector-store`` wiped nothing, initialized nothing, logged nothing + and exited 0 -- and a redeploy that meant to clear a stale partition before + reindexing left the old vectors in place. + """ + monkeypatch.setattr("ontocast.toolbox.update_ontology_manager", AsyncMock()) + + class Stub: + async def _check_catalog_index_agreement(self, ontologies): + return None + + async def _check_catalog_ready(self, ontologies, *, required): + return None + + triple_store_manager = None + llm = MagicMock() + config = Config() + + def __init__(self) -> None: + self.vector_store = MagicMock() + self.vector_store.initialize = AsyncMock() + self.vector_store.wipe_store = AsyncMock() + self.vector_store_ready = True + self.vector_store_last_error = None + self.ontology_manager = MagicMock() + self.shapes_catalog = MagicMock() + self.shapes_catalog.sync = AsyncMock() + + async def _synchronize_ontologies(self): + return [test_ontology] + + async def _materialize_ontology(self, _): + return None + + def should_initialize_vector_store(self, ontology_context_mode): + return ToolBox.should_initialize_vector_store( + cast(ToolBox, self), ontology_context_mode + ) + + def is_vector_store_ready(self): + return self.vector_store_ready + + st = Stub() + asyncio.run( + ToolBox.initialize( + cast(ToolBox, st), + ontology_context_mode=OntologyContextMode.SELECTED_SINGLE_ONTOLOGY, + wipe_vector_store=True, + ) + ) + + st.vector_store.wipe_store.assert_awaited_once() + st.vector_store.initialize.assert_not_awaited(), "the mode still governs init" + assert st.vector_store_ready is False, "a wiped store is not a ready store" diff --git a/test/test_triple_evaluator.py b/test/test_triple_evaluator.py index af342592..2676d226 100644 --- a/test/test_triple_evaluator.py +++ b/test/test_triple_evaluator.py @@ -1,11 +1,14 @@ from __future__ import annotations +import pytest from rdflib import RDF, RDFS, XSD, Literal, URIRef from ontocast.onto.rdfgraph import RDFGraph from ontocast.tool.agg.match_models import EntityMatch from ontocast.tool.agg.triple_evaluator import TripleSetEvaluator +pytestmark = pytest.mark.unit + def test_evaluate_perfect_alignment() -> None: graph = RDFGraph() diff --git a/test/test_triple_index.py b/test/test_triple_index.py new file mode 100644 index 00000000..7015f5df --- /dev/null +++ b/test/test_triple_index.py @@ -0,0 +1,167 @@ +"""The triple index is a reference mechanism, so its ids must be reproducible. + +The critic cites ids instead of requoting graph text. That only works if the id +a fix names is the id the resolver looks up -- which means the numbering must +not depend on store iteration order, and must be detectably stale once the loop +has patched the graph underneath it. +""" + +import pytest +from rdflib import RDF, RDFS, XSD, BNode, Literal, Namespace + +from ontocast.onto.enum import LLMGraphFormat +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.onto.triple_index import build_triple_index, fingerprint_graph +from ontocast.prompt.graph_format import GraphFormatProfile + +pytestmark = pytest.mark.unit + +CD = Namespace("https://growgraph.dev/facts/") +MS = Namespace("https://growgraph.dev/ontologies/matsci#") + + +def _triples() -> list[tuple]: + return [ + (CD.sample_1, RDF.type, MS.PerovskiteSample), + (CD.sample_1, MS.hasEdgeLength, CD.len_1), + ( + CD.len_1, + MS.numericValue, + Literal("0.30000000000000004", datatype=XSD.double), + ), + (CD.len_1, RDFS.label, Literal("edge length", lang="en")), + ] + + +def _graph(triples=None) -> RDFGraph: + graph = RDFGraph() + graph.bind("cd", CD) + graph.bind("ms", MS) + for triple in triples if triples is not None else _triples(): + graph.add(triple) + return graph + + +def test_ids_cover_every_triple_exactly_once() -> None: + graph = _graph() + index = build_triple_index(graph) + assert sorted(index.by_id) == list(range(1, len(graph) + 1)) + assert set(index.by_id.values()) == set(graph) + assert index.scope_size == len(graph) + + +def test_numbering_does_not_depend_on_insertion_order() -> None: + """Iteration order is store-dependent, so the sort has to impose it. + + If this fails, two prompts built from equal graphs hand out different ids and + a fix carried between them deletes the wrong statement. + """ + forward = build_triple_index(_graph(_triples())) + backward = build_triple_index(_graph(list(reversed(_triples())))) + assert forward.by_id == backward.by_id + assert forward.fingerprint == backward.fingerprint + + +def test_a_literal_and_an_iri_with_the_same_lexical_form_do_not_tie() -> None: + same = "https://growgraph.dev/facts/x" + graph = _graph([(CD.s, MS.p, CD.x), (CD.s, MS.p, Literal(same))]) + first = build_triple_index(graph) + second = build_triple_index( + _graph([(CD.s, MS.p, Literal(same)), (CD.s, MS.p, CD.x)]) + ) + assert first.by_id == second.by_id + + +def test_blank_node_statements_are_citable() -> None: + """A bnode restriction cannot be requoted as text, but it does have an id. + + This is a large part of why ids exist: the old fix format could not address + these statements at all. + """ + bnode = BNode("b7") + graph = _graph( + [(CD.sample_1, RDFS.subClassOf, bnode), (bnode, RDF.type, MS.Restriction)] + ) + index = build_triple_index(graph) + subjects = [subject for subject, _ in index.order] + assert bnode in subjects + assert any(triple[0] == bnode for triple in index.by_id.values()) + + +def test_the_listing_does_not_round_floating_point_literals() -> None: + """rdflib's Turtle writer renders doubles through %e and loses digits. + + A critic shown the rounded value reports a rounding artifact as a defect, so + the listing must use the lossless rendering. + """ + chapter = GraphFormatProfile( + format=LLMGraphFormat.TURTLE + ).format_facts_chapter_indexed(_graph()) + assert "0.30000000000000004" in chapter.text + + +def test_turtle_carries_ids_inline_and_jsonld_carries_a_table() -> None: + """JSON-LD's output contract demands strictly valid JSON, so no inline marker.""" + graph = _graph() + turtle = GraphFormatProfile( + format=LLMGraphFormat.TURTLE + ).format_facts_chapter_indexed(graph) + jsonld = GraphFormatProfile( + format=LLMGraphFormat.JSONLD + ).format_facts_chapter_indexed(graph) + + assert "[1]" in turtle.text + assert "TRIPLE INDEX" not in turtle.text + + assert "TRIPLE INDEX (id | subject | predicate | object)" in jsonld.text + body = jsonld.text.split("```json")[1].split("```")[0] + assert "[1]" not in body + assert turtle.index.by_id == jsonld.index.by_id + + +def test_rdf_type_is_written_as_the_turtle_shorthand() -> None: + chapter = GraphFormatProfile( + format=LLMGraphFormat.TURTLE + ).format_facts_chapter_indexed(_graph()) + assert "] a ms:PerovskiteSample" in chapter.text + + +def test_a_patched_graph_no_longer_matches_its_index() -> None: + """The loop mutates the graph between passes. + + A residual fix carried forward must not resolve against a later numbering, + so staleness has to be detectable rather than assumed away. + """ + graph = _graph() + index = build_triple_index(graph) + assert index.matches(graph) + + graph.add((CD.sample_1, MS.hasWidth, CD.width_1)) + assert not index.matches(graph) + assert fingerprint_graph(graph) != index.fingerprint + + +def test_the_ontology_chapter_numbers_what_it_shows_not_what_it_was_given() -> None: + """Condensing drops triples; an id on a dropped one is a blind delete.""" + graph = _graph() + profile = GraphFormatProfile(format=LLMGraphFormat.TURTLE) + chapter = profile.format_ontology_chapter_indexed(graph, max_triples=2) + assert len(chapter.index) <= len(graph) + for triple_id in chapter.index.by_id: + assert f"[{triple_id}]" in chapter.text + + +def test_an_empty_graph_yields_an_empty_index_and_no_table() -> None: + empty = RDFGraph() + index = build_triple_index(empty) + assert index.is_empty and len(index) == 0 + chapter = GraphFormatProfile( + format=LLMGraphFormat.JSONLD + ).format_facts_chapter_indexed(empty) + assert "TRIPLE INDEX" not in chapter.text + + +def test_resolve_returns_none_for_an_id_that_was_never_issued() -> None: + index = build_triple_index(_graph()) + assert index.resolve(len(index) + 1) is None + assert index.resolve(0) is None diff --git a/test/test_triple_term_guard.py b/test/test_triple_term_guard.py index de824ed4..affa3608 100644 --- a/test/test_triple_term_guard.py +++ b/test/test_triple_term_guard.py @@ -20,6 +20,8 @@ ) from ontocast.onto.sparql_models import GraphUpdate, TripleOp +pytestmark = pytest.mark.unit + EX = "http://example.org/" S = URIRef(f"{EX}subject") P = URIRef(f"{EX}predicate") diff --git a/test/test_unit_fanout_failures.py b/test/test_unit_fanout_failures.py new file mode 100644 index 00000000..65a10132 --- /dev/null +++ b/test/test_unit_fanout_failures.py @@ -0,0 +1,78 @@ +"""How the per-unit fan-out separates unit faults from deployment faults.""" + +from __future__ import annotations + +import pytest + +from ontocast.onto.enum import WorkflowNode +from ontocast.onto.retrieval_capabilities import EmptyOntologyContextError +from ontocast.onto.state import AgentState +from ontocast.stategraph.node_factories import _gather_units +from ontocast.tool.llm import LLMConfigurationError + +pytestmark = pytest.mark.unit + + +async def _ok(value: int) -> int: + return value + + +async def _boom(error: Exception) -> int: + raise error + + +@pytest.mark.anyio +async def test_a_unit_fault_is_isolated_to_its_own_unit() -> None: + state = AgentState() + + results, failures = await _gather_units( + WorkflowNode.RENDER_FACTS, + state, + [_ok(1), _boom(RuntimeError("provider exploded")), _ok(3)], + ) + + assert results == [1, 3] + assert failures == 1 + + +@pytest.mark.anyio +async def test_an_ontology_context_fault_is_not_isolated() -> None: + """It describes the deployment, so every sibling has it too. + + Isolating it turned one configuration fault into N unit failures and a + successful run over an empty graph. Re-raising after the gather has drained + orphans nothing: every sibling has already been awaited. + """ + state = AgentState() + + with pytest.raises(EmptyOntologyContextError): + await _gather_units( + WorkflowNode.RENDER_FACTS, + state, + [_ok(1), _boom(EmptyOntologyContextError("catalog is empty")), _ok(3)], + ) + + +@pytest.mark.anyio +async def test_a_rejected_request_is_not_isolated() -> None: + """The provider refuses the request as configured, not this unit's prompt. + + Isolating it is how a run whose every call was rejected still finished, + uploaded an empty graph, dumped a manifest and exited 0. + + Note that making the error a ``BaseException`` would not achieve this: + ``asyncio.gather(..., return_exceptions=True)`` captures those as values + too, so the re-raise has to be explicit. + """ + state = AgentState() + + with pytest.raises(LLMConfigurationError): + await _gather_units( + WorkflowNode.RENDER_FACTS, + state, + [ + _ok(1), + _boom(LLMConfigurationError("openai/gpt-x rejected the request")), + _ok(3), + ], + ) diff --git a/test/test_unit_loop_context.py b/test/test_unit_loop_context.py index 3b481b94..1d08aff4 100644 --- a/test/test_unit_loop_context.py +++ b/test/test_unit_loop_context.py @@ -1,5 +1,6 @@ """Tests for the slim per-unit loop context (replaces per-unit AgentState deep copies).""" +import pytest from rdflib import OWL, RDF, URIRef from ontocast.onto.enum import OntologyContextMode @@ -8,6 +9,8 @@ from ontocast.onto.state import AgentState, BudgetTracker from ontocast.stategraph.unit_context import UnitLoopContext +pytestmark = pytest.mark.unit + def _ontology(iri: str) -> Ontology: graph = RDFGraph() diff --git a/test/test_unit_pipeline.py b/test/test_unit_pipeline.py index f167f5d5..e93f246b 100644 --- a/test/test_unit_pipeline.py +++ b/test/test_unit_pipeline.py @@ -5,8 +5,15 @@ from rdflib import URIRef from ontocast.onto.docling_helpers import plain_text_to_docling_doc -from ontocast.onto.enum import OntologyAssemblyMode, RenderMode, Status +from ontocast.onto.enum import ( + OntologyAssemblyMode, + OntologyChapterFormat, + OntologyContextScope, + RenderMode, + Status, +) from ontocast.onto.ontology import Ontology +from ontocast.onto.ontology_condense import TextCaps from ontocast.onto.rdfgraph import RDFGraph from ontocast.onto.state import AgentState from ontocast.onto.unit_states import UnitFactsState, UnitOntologyState @@ -15,6 +22,8 @@ from ontocast.toolbox import ToolBox from test.snapshot_helpers import snapshot_from_ontology +pytestmark = pytest.mark.unit + def _build_ontology(iri: str = "https://example.com/onto") -> Ontology: graph = RDFGraph() @@ -86,11 +95,15 @@ async def fake_facts_loop( config=SimpleNamespace( server=SimpleNamespace( max_visits_per_node=3, - max_critic_visits_per_node=None, ontology_max_triples=50_000, ontology_context_max_triples=4000, + ontology_chapter_format=OntologyChapterFormat.INHERIT, + ontology_text_caps=TextCaps(), + ontology_context_scope=OntologyContextScope.UNIT, + fanout_warmup_units=0, ) - ) + ), + shapes_prompt_contract=lambda: ("", (), False), ), ) @@ -145,11 +158,15 @@ async def fake_facts_loop( config=SimpleNamespace( server=SimpleNamespace( max_visits_per_node=2, - max_critic_visits_per_node=None, ontology_max_triples=50_000, ontology_context_max_triples=4000, + ontology_chapter_format=OntologyChapterFormat.INHERIT, + ontology_text_caps=TextCaps(), + ontology_context_scope=OntologyContextScope.UNIT, + fanout_warmup_units=0, ) - ) + ), + shapes_prompt_contract=lambda: ("", (), False), ), ) diff --git a/test/test_update_agents_aligned.py b/test/test_update_agents_aligned.py index 7d36f741..b2065748 100644 --- a/test/test_update_agents_aligned.py +++ b/test/test_update_agents_aligned.py @@ -1,11 +1,16 @@ -"""The two graph-update agents must run the same post-parse hygiene. - -Facts updates and ontology updates consume the same -:class:`GraphUpdateRenderReport`, but everything after the parse had drifted -apart: the facts path quarantined invalid typed literals and cleared consumed -findings, and the ontology path did neither -- a malformed literal went from -the model straight into the working graph and on into a compiled SPARQL -UPDATE. These tests hold the two paths together. +"""Post-parse hygiene for the graph-update render agent. + +This began as an alignment suite: facts and ontology both had update renderers +consuming the same :class:`GraphUpdateRenderReport`, and everything after the +parse had drifted apart -- the facts path quarantined invalid typed literals and +cleared consumed findings, the ontology path did neither, so a malformed literal +went from the model straight into the working graph and on into a compiled +SPARQL UPDATE. + +The facts update renderer is gone: the loop no longer re-renders a unit that +rendered successfully, so its dispatch condition became unreachable. What +survives is the hygiene itself, which the remaining ontology renderer must keep +running, and the shared ``finalize_update_report`` both paths were built on. """ from __future__ import annotations @@ -26,7 +31,7 @@ from ontocast.tool.atomic import AtomicToolBox from test.snapshot_helpers import snapshot_from_ontology -pytestmark = pytest.mark.anyio +pytestmark = [pytest.mark.anyio, pytest.mark.unit] render_facts_module = importlib.import_module("ontocast.agent.render_facts") render_ontology_module = importlib.import_module("ontocast.agent.render_ontology") @@ -78,6 +83,7 @@ async def get_llm_tool(_budget_tracker): web_grounding_enabled_for_node=lambda _node: False, object_property_literal_check=True, property_alias_min_ratio=0.85, + catalog_terms=lambda: set(), code_predicates=(), citation_vocabulary={}, quantity_fallback_vocabulary=None, @@ -132,33 +138,22 @@ async def fake_call_llm_with_retry(**kwargs): monkeypatch.setattr(module, "call_llm_with_retry", fake_call_llm_with_retry) -async def test_both_update_agents_quarantine_an_invalid_typed_literal( +async def test_the_update_agent_quarantines_an_invalid_typed_literal( monkeypatch, ) -> None: - _stub_render(monkeypatch, render_facts_module, BAD_LITERAL_TTL) - facts = await render_facts_module.render_facts_update(_facts_state(), _tools()) - + """Without this a malformed literal reaches a compiled SPARQL UPDATE.""" _stub_render(monkeypatch, render_ontology_module, BAD_LITERAL_TTL) ontology = await render_ontology_module.render_ontology_update( _ontology_state(), _tools() ) - assert len(facts.quarantined_literal_triples) == 1 assert len(ontology.quarantined_literal_triples) == 1 - assert ( - facts.quarantined_literal_triples[0].datatype - == ontology.quarantined_literal_triples[0].datatype - ) -async def test_both_update_agents_consume_findings_and_suggestions( +async def test_the_update_agent_consumes_findings_and_suggestions( monkeypatch, ) -> None: - facts_state = _facts_state() - facts_state.suggestions = Suggestions(systemic_critique_summary="fix it") - _stub_render(monkeypatch, render_facts_module, GOOD_TTL) - facts = await render_facts_module.render_facts_update(facts_state, _tools()) - + """A render consumes what it was given, so the next pass collects fresh.""" ontology_state = _ontology_state() ontology_state.suggestions = Suggestions(systemic_critique_summary="fix it") _stub_render(monkeypatch, render_ontology_module, GOOD_TTL) @@ -166,9 +161,8 @@ async def test_both_update_agents_consume_findings_and_suggestions( ontology_state, _tools() ) - for result in (facts, ontology): - assert result.deterministic_findings == [] - assert result.suggestions.systemic_critique_summary == "" + assert ontology.deterministic_findings == [] + assert ontology.suggestions.systemic_critique_summary == "" def test_insert_hook_never_sees_the_delete_side() -> None: diff --git a/test/test_vector_store_pipeline.py b/test/test_vector_store_pipeline.py index 7e786d8b..036f4eda 100644 --- a/test/test_vector_store_pipeline.py +++ b/test/test_vector_store_pipeline.py @@ -43,8 +43,6 @@ _aexpand_ontology_iris, _aexpand_ontology_iris_by_reference, _expand_ontology_iris_by_reference, - _filter_hits_by_relative_floor, - _merge_hits_across_queries_hybrid, _merge_hits_across_queries_max_score, _merge_hits_across_queries_sum_score, _mmr_rerank, @@ -57,6 +55,8 @@ ) from ontocast.util.hash import render_text_hash +pytestmark = pytest.mark.unit + class CountingEmbeddingTool(EmbeddingTool): """Embedding test double with deterministic vectors and call tracking.""" @@ -801,60 +801,6 @@ def _channel_hits( ) -@pytest.mark.anyio -async def test_aretrieve_ensemble_per_query_ratio_keeps_weak_query_hits() -> None: - """Weak-query hits survive vs a strong query because the cutoff is per-query relative.""" - embedding = CountingEmbeddingTool(config=EmbeddingConfig(dimension=8)) - vector_store = StubVectorStore( - store_config=VectorStoreConfig(embedding_batch_size=2), - qdrant_config=QdrantConfig(upsert_batch_size=2), - embedding=embedding, - ) - vector_store.set_hits_by_query( - [ - _channel_hits( - core_hits=[ - _scored_atom("s1", "StrongTop", 0.95), - _scored_atom("s2", "StrongTail", 0.50), - ] - ), - _channel_hits( - core_hits=[ - _scored_atom("w1", "WeakTop", 0.40), - _scored_atom("w2", "WeakMid", 0.35), - ] - ), - ] - ) - - sparql_tool = StubSPARQLTool(triple_store_manager=None) - retriever = OntologyPatchRetriever( - vector_store=vector_store, - sparql_tool=sparql_tool, - patch=PatchRetrievalConfig( - per_query_core_score_ratio=0.85, - per_query_neighborhood_score_ratio=0.85, - min_merged_max_score=0.0, - merged_score_ratio=0.0, - min_core_query_best_score=0.0, - min_neighborhood_query_best_score=0.0, - ), - ) - await retriever.aretrieve_ensemble( - queries=["q1", "q2"], - top_k=4, - expand_sparql=True, - ) - - # 0.50 < 0.95 * 0.85 -> strong tail out; weak query keeps 0.40 and 0.35 (floor 0.34). - expected_iris = { - "https://example.org/smoke#StrongTop", - "https://example.org/smoke#WeakTop", - "https://example.org/smoke#WeakMid", - } - assert set(sparql_tool.last_entity_uris) == expected_iris - - @pytest.mark.anyio async def test_aretrieve_ensemble_empty_when_merged_scores_below_floor() -> None: embedding = CountingEmbeddingTool(config=EmbeddingConfig(dimension=8)) @@ -874,12 +820,8 @@ async def test_aretrieve_ensemble_empty_when_merged_scores_below_floor() -> None vector_store=vector_store, sparql_tool=sparql_tool, patch=PatchRetrievalConfig( - per_query_core_score_ratio=1.0, - per_query_neighborhood_score_ratio=1.0, min_merged_max_score=2.0, merged_score_ratio=0.0, - min_core_query_best_score=0.0, - min_neighborhood_query_best_score=0.0, ), ) graph, source_iris = await retriever.aretrieve_ensemble( @@ -893,42 +835,6 @@ async def test_aretrieve_ensemble_empty_when_merged_scores_below_floor() -> None @pytest.mark.anyio -async def test_aretrieve_ensemble_drops_subquery_when_top_below_min_query_best() -> ( - None -): - embedding = CountingEmbeddingTool(config=EmbeddingConfig(dimension=8)) - vector_store = StubVectorStore( - store_config=VectorStoreConfig(embedding_batch_size=2), - qdrant_config=QdrantConfig(upsert_batch_size=2), - embedding=embedding, - ) - vector_store.set_hits_by_query( - [ - _channel_hits(core_hits=[_scored_atom("low", "LowEnt", 0.05)]), - _channel_hits(core_hits=[_scored_atom("ok", "OkEnt", 0.80)]), - ] - ) - sparql_tool = StubSPARQLTool(triple_store_manager=None) - retriever = OntologyPatchRetriever( - vector_store=vector_store, - sparql_tool=sparql_tool, - patch=PatchRetrievalConfig( - per_query_core_score_ratio=1.0, - per_query_neighborhood_score_ratio=1.0, - min_merged_max_score=0.0, - merged_score_ratio=0.0, - min_core_query_best_score=0.1, - min_neighborhood_query_best_score=0.1, - ), - ) - await retriever.aretrieve_ensemble( - queries=["q1", "q2"], - top_k=2, - expand_sparql=True, - ) - assert sparql_tool.last_entity_uris == ["https://example.org/smoke#OkEnt"] - - def test_canonicalize_entity_role_maps_synonyms() -> None: assert canonicalize_entity_role("predicate") == "predicate" assert canonicalize_entity_role("property") == "predicate" @@ -1176,8 +1082,6 @@ async def test_aretrieve_ensemble_mmr_promotes_diverse_candidates() -> None: vector_store=vector_store, sparql_tool=sparql_tool, patch=PatchRetrievalConfig( - per_query_core_score_ratio=0.0, - per_query_neighborhood_score_ratio=0.0, min_merged_max_score=0.0, merged_score_ratio=0.0, mmr_lambda=0.5, @@ -1227,8 +1131,6 @@ async def test_aretrieve_ensemble_rank_fusion_uses_rank_not_score() -> None: vector_store=vector_store, sparql_tool=sparql_tool, patch=PatchRetrievalConfig( - per_query_core_score_ratio=0.0, - per_query_neighborhood_score_ratio=0.0, min_merged_max_score=0.0, merged_score_ratio=0.0, mmr_lambda=1.0, @@ -1269,8 +1171,6 @@ async def test_aretrieve_ensemble_lambda_one_skips_vector_fetch() -> None: vector_store=vector_store, sparql_tool=sparql_tool, patch=PatchRetrievalConfig( - per_query_core_score_ratio=0.0, - per_query_neighborhood_score_ratio=0.0, min_merged_max_score=0.0, merged_score_ratio=0.0, mmr_lambda=1.0, @@ -1312,8 +1212,6 @@ async def test_aretrieve_ensemble_merged_score_ratio_trims_below_floor() -> None vector_store=vector_store, sparql_tool=sparql_tool, patch=PatchRetrievalConfig( - per_query_core_score_ratio=0.0, - per_query_neighborhood_score_ratio=0.0, min_merged_max_score=0.0, merged_score_ratio=0.9, mmr_lambda=1.0, @@ -1350,8 +1248,6 @@ async def test_aretrieve_ensemble_forwards_ranking_and_budget_controls() -> None vector_store=vector_store, sparql_tool=sparql_tool, patch=PatchRetrievalConfig( - per_query_core_score_ratio=0.0, - per_query_neighborhood_score_ratio=0.0, min_merged_max_score=0.0, merged_score_ratio=0.0, mmr_lambda=1.0, @@ -1405,8 +1301,6 @@ async def test_aretrieve_ensemble_patch_max_atoms_caps_output() -> None: vector_store=vector_store, sparql_tool=sparql_tool, patch=PatchRetrievalConfig( - per_query_core_score_ratio=0.0, - per_query_neighborhood_score_ratio=0.0, min_merged_max_score=0.0, merged_score_ratio=0.0, mmr_lambda=1.0, @@ -1453,59 +1347,6 @@ def test_normalize_core_neighborhood_weights_renormalizes_dense_lanes() -> None: assert cw > nw -def test_hybrid_merge_favors_dominant_single_window_hit() -> None: - collected = [ - OntologySearchHit(atom=_scored_atom("dom", "Dominant", 1.2).atom, score=1.2), - OntologySearchHit(atom=_scored_atom("w1", "Weak", 0.34).atom, score=0.34), - OntologySearchHit(atom=_scored_atom("w2", "Weak", 0.33).atom, score=0.33), - OntologySearchHit(atom=_scored_atom("w3", "Weak", 0.32).atom, score=0.32), - ] - merged = _merge_hits_across_queries_hybrid( - collected, - max_atoms_tier1=2, - per_ontology_seed_quota=0, - min_entity_score=0.3, - max_atoms_total=2, - ) - assert merged[0].atom.iri.endswith("#Dominant") - assert merged[0].score == 1.2 - - -def test_hybrid_merge_tier2_adds_per_ontology_coverage() -> None: - matsci = "https://example.org/matsci" - perov = "https://example.org/perov" - - def _hit(entity: str, onto: str, score: float) -> OntologySearchHit: - atom = GraphAtom( - atom_id=entity, - ontology_iri=onto, - iri=f"{onto}#{entity}", - entity_role="resource", - core_representation=entity, - neighborhood_representation="", - score=score, - ) - return OntologySearchHit(atom=atom, score=score) - - collected = [ - _hit("M1", matsci, 0.95), - _hit("M2", matsci, 0.90), - _hit("M3", matsci, 0.85), - _hit("P1", perov, 0.40), - ] - merged = _merge_hits_across_queries_hybrid( - collected, - max_atoms_tier1=2, - per_ontology_seed_quota=1, - min_entity_score=0.35, - max_atoms_total=4, - ) - iris = {hit.atom.iri for hit in merged} - assert f"{matsci}#M1" in iris - assert f"{matsci}#M2" in iris - assert f"{perov}#P1" in iris - - def _weak_repeated(count: int) -> list[OntologySearchHit]: return [ OntologySearchHit( @@ -2039,41 +1880,6 @@ def test_embedding_prefixes_default_to_symmetric_encoding() -> None: assert embedding.seen_texts == [["abc"], ["abc"]] -def test_relative_floor_disabled_keeps_negative_scores() -> None: - """A ratio of 0 is documented as "disables"; it must not act as a floor of 0.0.""" - hits = [_scored_atom("a", "A", 0.4), _scored_atom("b", "B", -0.1)] - assert ( - _filter_hits_by_relative_floor(hits, score_ratio=0.0, min_query_best_score=0.0) - == hits - ) - - -def test_relative_floor_keeps_best_hit_when_scores_are_negative() -> None: - """Qdrant cosine can return negative scores; the best hit must always survive.""" - hits = [_scored_atom("a", "A", -0.2), _scored_atom("b", "B", -0.9)] - kept = _filter_hits_by_relative_floor( - hits, score_ratio=0.8, min_query_best_score=0.0 - ) - assert [hit.atom.atom_id for hit in kept] == ["a"] - - -def test_relative_floor_matches_multiplicative_form_when_positive() -> None: - """The sign-safe form must not change behaviour on the ordinary positive path. - - Scores stay off the ``best * ratio`` boundary: both forms land there within a float - ulp of each other, so a test pinned to it would assert rounding, not semantics. - """ - hits = [ - _scored_atom("a", "A", 0.8), # best - _scored_atom("b", "B", 0.70), # above the 0.64 floor - _scored_atom("c", "C", 0.50), # below it - ] - kept = _filter_hits_by_relative_floor( - hits, score_ratio=0.8, min_query_best_score=0.0 - ) - assert [hit.atom.atom_id for hit in kept] == ["a", "b"] - - @pytest.mark.anyio async def test_aretrieve_ensemble_round_robin_multi_ontology() -> None: matsci = "https://example.org/matsci" @@ -2117,7 +1923,6 @@ def _onto_atom( sparql_tool=sparql_tool, patch=PatchRetrievalConfig( cross_query_merge_mode=CrossQueryMergeMode.MAX_SCORE, - per_query_core_score_ratio=0.0, min_merged_max_score=0.0, merged_score_ratio=0.0, mmr_lambda=1.0, @@ -2156,7 +1961,6 @@ async def test_aretrieve_ensemble_window_scaled_cap() -> None: sparql_tool=sparql_tool, patch=PatchRetrievalConfig( cross_query_merge_mode=CrossQueryMergeMode.MAX_SCORE, - per_query_core_score_ratio=0.0, min_merged_max_score=0.0, merged_score_ratio=0.0, mmr_lambda=1.0, @@ -2172,3 +1976,96 @@ async def test_aretrieve_ensemble_window_scaled_cap() -> None: assert retriever.last_retrieval_metrics["effective_max_atoms"] == 28 assert retriever.last_retrieval_metrics["atoms_final"] == 7 assert len(sparql_tool.last_entity_uris) == 7 + + +def _scored_atom_in( + atom_id: str, iri_local: str, score: float, ontology_iri: str +) -> OntologySearchHit: + atom = GraphAtom( + atom_id=atom_id, + ontology_iri=ontology_iri, + ontology_id=ontology_iri.rsplit("/", 1)[-1], + ontology_hash="hash1", + ontology_version="1.0.0", + iri=f"{ontology_iri}#{iri_local}", + entity_role="resource", + core_representation=f"core {atom_id}", + neighborhood_representation="neighbor", + ) + return OntologySearchHit(atom=atom, score=score) + + +_BIG = "https://example.org/big" +_SMALL = "https://example.org/small" + + +async def _selection_under( + merge_mode: CrossQueryMergeMode, per_ontology_atom_floor: int +) -> set[str]: + """Run one retrieval in ``merge_mode`` and report which ontologies survived.""" + embedding = CountingEmbeddingTool(config=EmbeddingConfig(dimension=8)) + vector_store = StubVectorStore( + store_config=VectorStoreConfig(embedding_batch_size=2), + qdrant_config=QdrantConfig(upsert_batch_size=2), + embedding=embedding, + ) + vector_store.set_hits_by_query( + [ + _channel_hits( + core_hits=[ + _scored_atom_in("a", "A", 0.95, _BIG), + _scored_atom_in("b", "B", 0.93, _BIG), + _scored_atom_in("c", "C", 0.92, _BIG), + _scored_atom_in("d", "D", 0.50, _SMALL), + ] + ) + ] + ) + sparql_tool = StubSPARQLTool(triple_store_manager=None) + retriever = OntologyPatchRetriever( + vector_store=vector_store, + sparql_tool=sparql_tool, + patch=PatchRetrievalConfig( + cross_query_merge_mode=merge_mode, + min_merged_max_score=0.0, + merged_score_ratio=0.0, + mmr_lambda=1.0, + max_atoms=3, + per_ontology_atom_floor=per_ontology_atom_floor, + per_role_atom_floor=0, + ), + ) + await retriever.aretrieve_ensemble(queries=["q1"], top_k=4, expand_sparql=True) + return {uri.rsplit("#", 1)[0] for uri in sparql_tool.last_entity_uris} + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "merge_mode", [CrossQueryMergeMode.MAX_SCORE, CrossQueryMergeMode.SUM_SCORE] +) +async def test_every_merge_mode_honours_the_per_ontology_atom_floor( + merge_mode: CrossQueryMergeMode, +) -> None: + """The floor is a guarantee, so changing the cross-query mode must not drop it. + + A third mode once fell through to a bare `merged[:cap]` slice, which + silently skipped both atom floors -- non-zero by default, and there + precisely so one dominant ontology cannot crowd out a small module. That + mode is gone; the selection path it bypassed is the one every remaining + mode now shares, and this asserts it for each of them. + """ + assert await _selection_under(merge_mode, per_ontology_atom_floor=1) == { + _BIG, + _SMALL, + } + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "merge_mode", [CrossQueryMergeMode.MAX_SCORE, CrossQueryMergeMode.SUM_SCORE] +) +async def test_without_a_floor_selection_is_score_order( + merge_mode: CrossQueryMergeMode, +) -> None: + """With the floor off, the cap admits the dominant ontology alone.""" + assert await _selection_under(merge_mode, per_ontology_atom_floor=0) == {_BIG} diff --git a/test/test_web_grounding_prompts.py b/test/test_web_grounding_prompts.py index 07e2a804..cd50e3c6 100644 --- a/test/test_web_grounding_prompts.py +++ b/test/test_web_grounding_prompts.py @@ -22,6 +22,8 @@ from ontocast.tool.llm import LLMTool from test.snapshot_helpers import snapshot_from_ontology +pytestmark = pytest.mark.unit + render_ontology_module = importlib.import_module("ontocast.agent.render_ontology") render_facts_module = importlib.import_module("ontocast.agent.render_facts") criticise_ontology_module = importlib.import_module("ontocast.agent.criticise_ontology") diff --git a/test/window_stats.py b/test/window_stats.py new file mode 100644 index 00000000..5a719c45 --- /dev/null +++ b/test/window_stats.py @@ -0,0 +1,330 @@ +"""How long is a retrieval query window, really — in characters and in tokens? + +`VECTOR_STORE_PROPOSITION_WINDOW_SENTENCES` bounds a query by **sentence count**, +and nothing bounds its length. But length is what governs the two things that +decide whether a query works: + +* how many distinct terms one embedding vector has to carry, and therefore how + deep a ranked list must be to return them all; +* whether the encoder silently truncates the query — sentence-transformers cuts to + the checkpoint's ``max_seq_length`` before the model sees the text, and returns a + correctly shaped vector for the prefix, so nothing downstream can tell. + +Two sentences can be forty characters or four hundred. This measures that spread +before anything is tuned against it, because a knob whose unit does not match the +constraint it is meant to respect cannot be tuned, only guessed at. + +It also measures the **window budget**: `PROPOSITION_MAX_WINDOWS` caps how many +windows a unit yields, and over the cap windows are *subsampled* — that text +reaches no lane at all. Whether the cap binds is a property of unit length, so it +is measured here per unit scale rather than assumed from the harness's own. + +No LLM call, no vector store, no service. It loads the encoder to use its real +tokenizer, and nothing else. It lives here rather than in the measurement repo for +the same reason :mod:`test.retrieval_sweep` does: it needs the library's own +windower and the configured encoder, and that repo deliberately depends on +neither, so that it can score artifacts produced by any version. + +Usage:: + + uv run python -m test.window_stats --corpus + uv run python -m test.window_stats --source-dir --passage-chars 3172 +""" + +from __future__ import annotations + +import json +import pathlib +import statistics +from typing import Any + +import click + +_CLICK_DIR = click.Path(path_type=pathlib.Path, file_okay=False, exists=True) + +#: The sentence-count settings worth characterising: the shipped default is 2. +_SENTENCE_SETTINGS = (1, 2, 3, 5) + +#: Window *shapes* worth characterising beside the sentence bound, as keyword +#: arguments to the splitter. Each answers the same two questions the sentence +#: table does -- how long is a window, and how many does a unit yield -- which +#: is what makes windows-per-unit readable as the cost of a shape rather than +#: as an incidental of the corpus. Recall is measured elsewhere; this is the +#: cost axis alone. +_SHAPES: dict[str, dict[str, Any]] = { + "baseline": {}, + "abbrev": {"abbreviation_aware": True}, + "t48": {"max_tokens": 48, "abbreviation_aware": True}, + "t64": {"max_tokens": 64, "abbreviation_aware": True}, + "t96": {"max_tokens": 96, "abbreviation_aware": True}, + "t64-ov25": {"max_tokens": 64, "abbreviation_aware": True, "overlap": 0.25}, + "t64-ov50": {"max_tokens": 64, "abbreviation_aware": True, "overlap": 0.50}, + "s2-ov1": {"stride": 1}, + "meas": { + "max_tokens": 64, + "abbreviation_aware": True, + "measurement_aware": True, + }, +} + + +def _percentile(values: list[int], fraction: float) -> int: + """Nearest-rank percentile; plain and dependency-free.""" + if not values: + return 0 + ordered = sorted(values) + index = min(len(ordered) - 1, int(round(fraction * (len(ordered) - 1)))) + return ordered[index] + + +def _load_corpus_texts(corpus: pathlib.Path) -> list[str]: + """Case texts from a recall corpus.""" + lines = (corpus / "cases.jsonl").read_text().splitlines() + return [json.loads(line)["text"] for line in lines if line.strip()] + + +def _split_passages(text: str, size: int) -> list[str]: + """Pack paragraphs up to ``size`` characters, never splitting one. + + Mirrors the packing the recall-corpus builder uses, so a corpus and a re-cut + of its own sources are measured on comparable terms. Deliberately a *length* + probe: it does not reproduce the production chunker's semantics -- sections, + bibliography routing, the density split -- so it answers "what does a unit of + this length do to the window budget", not "what does a production unit + contain". + """ + paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()] + passages: list[str] = [] + current: list[str] = [] + length = 0 + for paragraph in paragraphs: + if current and length + len(paragraph) > size: + passages.append("\n\n".join(current)) + current, length = [], 0 + current.append(paragraph) + length += len(paragraph) + if current: + passages.append("\n\n".join(current)) + return passages + + +def _load_source_passages(source_dir: pathlib.Path, passage_chars: int) -> list[str]: + """Documents re-cut at a chosen passage length.""" + passages: list[str] = [] + for path in sorted(source_dir.glob("*.txt")): + passages.extend(_split_passages(path.read_text(), size=passage_chars)) + return passages + + +def _encoder() -> tuple[Any, int | None]: + """The configured embedding tool and its truncation ceiling. + + Built from the production config rather than by loading a checkpoint by name, + so the numbers describe the encoder a deployment actually uses -- including + its query prefix, which counts against the same budget as the text. + """ + from ontocast.config import EmbeddingConfig + from ontocast.tool.vector_store.embedding import EmbeddingTool + + tool = EmbeddingTool.create(EmbeddingConfig()) + return tool, tool.sequence_limit + + +def _measure( + texts: list[str], + max_windows: int, + encoder: Any, + limit: int | None, + label: str = "", + **shape: Any, +) -> dict[str, Any]: + """Window every text and describe the windows produced.""" + from ontocast.tool.chunk.proposition import split_proposition_windows + + if shape.get("max_tokens") is not None: + shape.setdefault("token_counter", encoder.token_lengths) + + chars: list[int] = [] + per_unit: list[int] = [] + capped_units = 0 + dropped_windows = 0 + + for text in texts: + windows = split_proposition_windows(text, max_windows=max_windows, **shape) + # What the unit *would* have produced without the cap, to see the loss. + uncapped = split_proposition_windows(text, max_windows=10**6, **shape) + if len(uncapped) > max_windows: + capped_units += 1 + dropped_windows += len(uncapped) - len(windows) + per_unit.append(len(uncapped)) + chars.extend(len(w) for w in windows) + + row: dict[str, Any] = { + "label": label, + "max_sentences": shape.get("max_sentences", 2), + "windows": len(chars), + "chars_min": min(chars) if chars else 0, + "chars_median": int(statistics.median(chars)) if chars else 0, + "chars_p90": _percentile(chars, 0.90), + "chars_max": max(chars) if chars else 0, + "windows_per_unit_median": ( + int(statistics.median(per_unit)) if per_unit else 0 + ), + "windows_per_unit_max": max(per_unit) if per_unit else 0, + "units_hitting_window_cap": capped_units, + "windows_dropped_by_cap": dropped_windows, + } + + if limit is not None: + # Re-window without the cap for the token view: truncation is a property of + # a window, not of whether the budget happened to keep it. + all_windows: list[str] = [] + for text in texts: + all_windows.extend( + split_proposition_windows(text, max_windows=10**6, **shape) + ) + counts = encoder.token_lengths(all_windows) + if counts is None: + return row + over = [n for n in counts if n > limit] + row.update( + { + "tokens_median": int(statistics.median(counts)) if counts else 0, + "tokens_p90": _percentile(counts, 0.90), + "tokens_max": max(counts) if counts else 0, + "over_limit": len(over), + "over_limit_pct": (100.0 * len(over) / len(counts)) if counts else 0.0, + "chars_per_token": ( + round(sum(len(w) for w in all_windows) / sum(counts), 2) + if sum(counts) + else 0.0 + ), + } + ) + return row + + +def _row_line(head: str, row: dict[str, Any]) -> str: + """One table line; the head column is the setting or shape being described.""" + return ( + f" {head}{row['windows']:>7}" + f"{row['chars_median']:>8}{row['chars_p90']:>8}{row['chars_max']:>8}" + f"{row.get('tokens_median', 0):>9}{row.get('tokens_p90', 0):>9}" + f"{row.get('over_limit_pct', 0.0):>7.0f}%" + f"{row.get('chars_per_token', 0.0):>8.2f}" + f"{row['windows_per_unit_median']:>8}" + f"{row['units_hitting_window_cap']:>8}" + f"{row['windows_dropped_by_cap']:>8}" + ) + + +@click.command() +@click.option("--corpus", "corpora", multiple=True, type=_CLICK_DIR) +@click.option( + "--source-dir", + type=_CLICK_DIR, + default=None, + help="Documents to re-cut, for measuring at a different unit scale.", +) +@click.option( + "--passage-chars", + multiple=True, + type=int, + help="Passage lengths to cut --source-dir at; repeat.", +) +@click.option( + "--max-windows", + default=16, + show_default=True, + help="The PROPOSITION_MAX_WINDOWS cap under test.", +) +@click.option( + "--shape", + "shapes", + multiple=True, + type=click.Choice(sorted(_SHAPES)), + help="Window shape to characterise beside the sentence table; repeat, or " + "pass --all-shapes.", +) +@click.option("--all-shapes", is_flag=True, help="Characterise every known shape.") +@click.option("--json-out", type=click.Path(path_type=pathlib.Path), default=None) +def main( + corpora: tuple[pathlib.Path, ...], + source_dir: pathlib.Path | None, + passage_chars: tuple[int, ...], + max_windows: int, + shapes: tuple[str, ...], + all_shapes: bool, + json_out: pathlib.Path | None, +) -> None: + """Report window length and window-budget pressure per sentence setting.""" + if not corpora and not source_dir: + raise click.UsageError("pass --corpus and/or --source-dir") + shapes = tuple(sorted(_SHAPES)) if all_shapes else shapes + + encoder, limit = _encoder() + click.echo( + f"encoder sequence limit: {limit if limit is not None else 'unreported'} " + f"word pieces" + + ( + "" + if encoder.token_lengths(["probe"]) is not None + else " (provider exposes no tokenizer; token columns omitted)" + ) + ) + + samples: list[tuple[str, list[str]]] = [ + (corpus.name, _load_corpus_texts(corpus)) for corpus in corpora + ] + if source_dir is not None: + for size in passage_chars or (1200,): + samples.append( + ( + f"{source_dir.name}@{size}c", + _load_source_passages(source_dir, size), + ) + ) + + results: dict[str, list[dict[str, Any]]] = {} + for name, texts in samples: + unit_chars = [len(t) for t in texts] + click.echo( + f"\n=== {name} === {len(texts)} units, " + f"median {int(statistics.median(unit_chars))} chars, " + f"max {max(unit_chars)}" + ) + click.echo( + f" {'sent':>4}{'wins':>7}{'ch_med':>8}{'ch_p90':>8}{'ch_max':>8}" + f"{'tok_med':>9}{'tok_p90':>9}{'>limit':>8}{'ch/tok':>8}" + f"{'w/unit':>8}{'capped':>8}{'lost_w':>8}" + ) + rows = [] + for setting in _SENTENCE_SETTINGS: + row = _measure( + texts, + max_windows, + encoder, + limit, + label=f"s{setting}", + max_sentences=setting, + ) + rows.append(row) + click.echo(_row_line(f"{row['max_sentences']:>4}", row)) + + if shapes: + click.echo(f"\n {'shape':>10} (windows/unit is the cost axis)") + for label in shapes: + row = _measure( + texts, max_windows, encoder, limit, label=label, **_SHAPES[label] + ) + rows.append(row) + click.echo(_row_line(f"{label:>10}", row)) + results[name] = rows + + if json_out is not None: + json_out.write_text(json.dumps(results, indent=2, sort_keys=True)) + click.echo(f"\n-> {json_out}") + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 5eaa1817..5d8ba964 100644 --- a/uv.lock +++ b/uv.lock @@ -2,18 +2,18 @@ version = 1 revision = 3 requires-python = ">=3.12, <4.0" resolution-markers = [ - "python_full_version < '3.13' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.13' and sys_platform == 'darwin'", - "python_full_version < '3.13' and sys_platform == 'emscripten'", - "python_full_version < '3.13' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'darwin'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.14' and sys_platform == 'darwin'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'darwin'", + "python_full_version < '3.13' and sys_platform == 'darwin'", "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version < '3.13' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] [[package]] @@ -36,11 +36,11 @@ wheels = [ [[package]] name = "annotated-doc" -version = "0.0.4" +version = "0.0.5" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, ] [[package]] @@ -54,21 +54,20 @@ wheels = [ [[package]] name = "anthropic" -version = "0.120.0" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "distro" }, { name = "docstring-parser" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "jiter" }, { name = "pydantic" }, { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/5c/3331da4fc009d448008a50c78d86cc929e8c937cd1442245ce3f80561c4e/anthropic-0.120.0.tar.gz", hash = "sha256:6ba6007dc9b00365b20f6101a6618f5196ac1ceef81512e4b5cc0e7436d4975d", size = 1008042, upload-time = "2026-07-24T16:32:52.384Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/1a/b5af41cc1fa14da277ec20ca5554dd2fcbc09b8523ac59b7a97fbb88e452/anthropic-1.2.0.tar.gz", hash = "sha256:12f8eedee7b7fb5685837b1371b7bfae1b281703f62355f4632598ec2fc53b34", size = 1137443, upload-time = "2026-08-27T20:29:12.68Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/8a/8522bdf809e1f95f0d9c936540987a3f6afba01d2921a2bf488dedf836a8/anthropic-0.120.0-py3-none-any.whl", hash = "sha256:591bd531563ec7b63a1e138f5c11f14cb94edda99623b349c2ce2ece8e08b8a5", size = 1022602, upload-time = "2026-07-24T16:32:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/ed/78/3f8b52708b03309e511990700bb8d0ec7a0c9db3d2a1e0d1c3ca417a4604/anthropic-1.2.0-py3-none-any.whl", hash = "sha256:b60642b3e3cd6b8e3e328a2d3f2863ad2b6e743f1037e42cc0143f7df99f63c6", size = 1289535, upload-time = "2026-08-27T20:29:11.01Z" }, ] [[package]] @@ -92,11 +91,11 @@ wheels = [ [[package]] name = "appnope" -version = "0.1.4" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/f7/a82489c2b6ebe32d3e2831895ae19c77861f0eadf3bb16034484d965dbb2/appnope-1.0.0.tar.gz", hash = "sha256:685db59cb6043c3c2e528adc0b3bce3a5f8d09bcf7492c6ea650d1b7421f3c49", size = 5454, upload-time = "2026-08-20T21:36:13.748Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, + { url = "https://files.pythonhosted.org/packages/46/c7/6b687cb0f83d2a51017d47953f2ee430ffad6fec2a8ebc964e0718a33eb1/appnope-1.0.0-py3-none-any.whl", hash = "sha256:6fe0c04218aab65c54c4ff81638cdbf848d89f5653b74d68638a137f200dd16e", size = 4158, upload-time = "2026-08-20T21:36:12.444Z" }, ] [[package]] @@ -171,11 +170,11 @@ css = [ [[package]] name = "cachetools" -version = "7.1.6" +version = "7.1.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/55/af/861ebc2e318a5c3300e3eb63bc4d30f3d70a46d13b360093728ac0705eed/cachetools-7.1.6.tar.gz", hash = "sha256:c7a79e7f30ba9943c1cefd08cc36f006aaae086e017af9166f1d59d6170c47e1", size = 40572, upload-time = "2026-07-23T22:47:53.737Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/d2/47e8bc06fe2a06d3f5bdf20f1126ab66c4e99dc48d940e7ba873f7ac7131/cachetools-7.1.7.tar.gz", hash = "sha256:a3e2a00b14d8f8a6b70c1dae7b4685e7ad3bc965c5b42124a2d6ce895da6cf50", size = 40680, upload-time = "2026-08-01T21:20:40.434Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/f2/2086ba18a925a73586c4d4e61d25f4a6058e56fd00d77ce8f1d361ab4c9b/cachetools-7.1.6-py3-none-any.whl", hash = "sha256:2c12e255780330af28b91bb7fb96cce4c766f04e38396b9a24510190a5827096", size = 16954, upload-time = "2026-07-23T22:47:52.397Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d8/767faeda872075724b95dd675466a645f1b92aadcdcf2d1429dcfd76c176/cachetools-7.1.7-py3-none-any.whl", hash = "sha256:ef98ef375ad188819ef2f9b3645e3987f4b8c5b7550e436ad998c2de78296df0", size = 16830, upload-time = "2026-08-01T21:20:38.977Z" }, ] [[package]] @@ -189,87 +188,87 @@ wheels = [ [[package]] name = "cffi" -version = "2.1.0" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, - { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, - { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, - { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, - { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, - { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, - { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, - { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, - { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, - { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, - { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, - { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, - { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, - { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, - { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, - { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, - { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, - { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, - { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, - { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, - { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, - { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, - { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, - { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, - { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, - { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, - { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, - { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, - { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, - { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, - { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, - { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, - { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, - { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, - { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, - { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, - { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, - { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, - { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, - { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, - { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, - { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, - { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, - { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, - { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, - { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, - { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, - { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, - { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, - { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, - { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, ] [[package]] @@ -283,75 +282,142 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, - { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, - { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, - { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, - { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, - { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, - { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, - { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, - { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, - { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, - { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, - { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, - { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, - { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, - { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, - { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, - { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, - { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, - { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, - { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, - { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, - { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, - { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, - { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, - { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, - { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, - { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, - { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, - { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, - { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, - { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, - { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, - { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, - { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, - { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, ] [[package]] name = "click" -version = "8.4.2" +version = "8.5.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, ] [[package]] @@ -386,75 +452,127 @@ wheels = [ [[package]] name = "cryptography" -version = "49.0.0" +version = "50.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -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" }, +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, ] [[package]] name = "cuda-bindings" -version = "12.9.4" +version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cuda-pathfinder", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fda147a344e8eaeca0c6ff113d2851ffca8f7dfc0a6c932374ee5c47caa649c8", size = 12151019, upload-time = "2025-10-21T14:51:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/56/e465c31dc9111be3441a9ba7df1941fe98f4aa6e71e8788a3fb4534ce24d/cuda_bindings-12.9.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:32bdc5a76906be4c61eb98f546a6786c5773a881f3b166486449b5d141e4a39f", size = 11906628, upload-time = "2025-10-21T14:51:49.905Z" }, - { url = "https://files.pythonhosted.org/packages/a3/84/1e6be415e37478070aeeee5884c2022713c1ecc735e6d82d744de0252eee/cuda_bindings-12.9.4-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56e0043c457a99ac473ddc926fe0dc4046694d99caef633e92601ab52cbe17eb", size = 11925991, upload-time = "2025-10-21T14:51:56.535Z" }, - { url = "https://files.pythonhosted.org/packages/d1/af/6dfd8f2ed90b1d4719bc053ff8940e494640fe4212dc3dd72f383e4992da/cuda_bindings-12.9.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b72ee72a9cc1b531db31eebaaee5c69a8ec3500e32c6933f2d3b15297b53686", size = 11922703, upload-time = "2025-10-21T14:52:03.585Z" }, - { url = "https://files.pythonhosted.org/packages/6c/19/90ac264acc00f6df8a49378eedec9fd2db3061bf9263bf9f39fd3d8377c3/cuda_bindings-12.9.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d80bffc357df9988dca279734bc9674c3934a654cab10cadeed27ce17d8635ee", size = 11924658, upload-time = "2025-10-21T14:52:10.411Z" }, + { 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.6.0" +version = "1.8.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/b4/d088047afe39827556df21118cac9ffd20cc3f968c99a7681494d1eb333c/cuda_pathfinder-1.6.0-py3-none-any.whl", hash = "sha256:1503af579d8379c24bdd65528379bc57039b0455be9f5f9686cf8e473a1fce51", size = 54591, upload-time = "2026-07-21T15:03:56.224Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b1/ef21259ec74fe0b265ed201379de1d0ef7c14178313ee03705952f1b7093/cuda_pathfinder-1.8.0-py3-none-any.whl", hash = "sha256:c44e574dc997fae2814721d1ae97d0fd6db76db82decbe9b753bf75de53f515e", size = 62539, upload-time = "2026-08-27T21:33:03.229Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.3.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c7/a79086a62c98befcdb8349656c6f114e2db3b8b2422f6e25c97a7f2a9a3c/cuda_toolkit-13.0.3.0-py2.py3-none-any.whl", hash = "sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f", size = 2512, upload-time = "2026-04-14T00:50:08.173Z" }, +] + +[package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +curand = [ + { name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +cusolver = [ + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] [[package]] @@ -490,15 +608,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, ] -[[package]] -name = "decorator" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, -] - [[package]] name = "defusedxml" version = "0.7.1" @@ -562,19 +671,19 @@ wheels = [ [[package]] name = "docling" -version = "2.115.0" +version = "2.123.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docling-slim", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/31/1901a67e35abd97868cff08c57d8cbebfeafb8045413340f3746a1a5f249/docling-2.115.0.tar.gz", hash = "sha256:99f6fb72293f3270f42514711808a766d4d7305152278171532fd59eb6bf55a3", size = 9015, upload-time = "2026-07-23T14:47:20.597Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/44/0b1c143a5156c170ee1247b60e7272d4b6e1b1ef07e6c7e14c6129338888/docling-2.123.1.tar.gz", hash = "sha256:6ded4775206b40a8959956d0e17c5e6f6fbe9425a6e4eb12e6032a40e5796853", size = 9068, upload-time = "2026-08-28T15:15:45.899Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/fc/c8110cbab5703e6b17b5daf287e646b5389533bbf44b2cc8830e00b9c3fe/docling-2.115.0-py3-none-any.whl", hash = "sha256:1a3d9bdf2f82610e97085a1a1b53cf259d1bd7aff97651ff2decc3b2b105123c", size = 5179, upload-time = "2026-07-23T14:47:19.379Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e2/21defca766bffa7c93c7dfee2a32a098121dbd734307b170a6592676f48c/docling-2.123.1-py3-none-any.whl", hash = "sha256:ec012ac0cd6bfc97b357f6aedd33aadbbc9a23ba08e5cee598081baff756030e", size = 5226, upload-time = "2026-08-28T15:15:44.521Z" }, ] [[package]] name = "docling-core" -version = "2.88.0" +version = "2.92.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "defusedxml" }, @@ -591,9 +700,9 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/e7/38b149398ef5d433689e2b3bc356a91e06d7c77bb89731f82f24ab3d3508/docling_core-2.88.0.tar.gz", hash = "sha256:ffc67cb863f6f93a875dfcc3dd3ac109fff68abcadcb4c951036fae777d82796", size = 341431, upload-time = "2026-07-27T11:54:31.624Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/9c/8ab960c09e501b57dda1b823dff3264bed70ab70ef60328767be38ca30f5/docling_core-2.92.0.tar.gz", hash = "sha256:33fd25e38c199336447a21925374400aca13a6b9316a032f111972c2dc0f085c", size = 360897, upload-time = "2026-08-19T10:55:21.431Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/81/35b1242c08593cfb6236de0cd826d0185be1f25f1916654490eba05c3de1/docling_core-2.88.0-py3-none-any.whl", hash = "sha256:b2872bc4cb337834bd6327774b7a88fd59e8a9af784bf95ac7bc02bf13cd536f", size = 284037, upload-time = "2026-07-27T11:54:30.137Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d1/7f6d9e737ea5c41c0fd4d2819732fc43416c0a60c5870342381db149d368/docling_core-2.92.0-py3-none-any.whl", hash = "sha256:726d89c23197e53be2f7192bb4c00108ff545830cf9ab7b981fb014fa92b5c02", size = 295507, upload-time = "2026-08-19T10:55:19.992Z" }, ] [package.optional-dependencies] @@ -609,31 +718,25 @@ chunking = [ [[package]] name = "docling-ibm-models" -version = "3.13.3" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "accelerate" }, - { name = "docling-core" }, { name = "huggingface-hub" }, - { name = "jsonlines" }, { name = "numpy" }, { name = "pillow" }, - { name = "pydantic" }, - { name = "rtree" }, { name = "safetensors", extra = ["torch"] }, { name = "torch" }, { name = "torchvision" }, - { name = "tqdm" }, { name = "transformers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/2b/c0ad433da1210672d6e8e774f1ba601824e0d23153e8cd963521dcf1c28c/docling_ibm_models-3.13.3.tar.gz", hash = "sha256:8c8b55e80b3d20fc74d85ca49a4d064578ef75b9f7251eec42fc9df6af426218", size = 98768, upload-time = "2026-06-04T08:23:08.088Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/c4/fbcc095e817ee3079235a7d43c270778e2f3bbdea42c3aae02ba64b050b4/docling_ibm_models-4.0.0.tar.gz", hash = "sha256:dcca49209816135a0d68bc8c6b5040f0608d921e67e87cc9075d600cb1b39816", size = 72098, upload-time = "2026-08-28T14:43:21.956Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/6e/a3acfc130967e44e7e3d185c7f59f5ae96df955647e87be533f20576ac41/docling_ibm_models-3.13.3-py3-none-any.whl", hash = "sha256:50fc83d244ce2dc43158e02155d1caa68374d7a169d0f34e67d7eb14f396ffe6", size = 94040, upload-time = "2026-06-04T08:23:06.893Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0c/724efe4bb0e2fbe1760f5a1a0ea8a490d15fbb13f64d51a4fcafdc704c4b/docling_ibm_models-4.0.0-py3-none-any.whl", hash = "sha256:b4dc321ca9203bfa7cda6092e6b996845fd592457dfe49dd69247fa75d3770aa", size = 67283, upload-time = "2026-08-28T14:43:20.409Z" }, ] [[package]] name = "docling-parse" -version = "7.8.1" +version = "7.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docling-core" }, @@ -641,28 +744,28 @@ dependencies = [ { name = "pydantic" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/94/33a91dfb849c1c2a645481cbd7b131c92ff6de17e0b01fe584177b1cb82b/docling_parse-7.8.1.tar.gz", hash = "sha256:5bb5ff8e003b9d0a88da9bf1d1f9927c81677dc9dc20815c87ba6e117d1af661", size = 6758311, upload-time = "2026-07-20T04:15:43.003Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/ee/83a4fb9926b0fb0185b30797a98027a8e433284a0bf16396c11fb85a09b6/docling_parse-7.16.0.tar.gz", hash = "sha256:870e781f7b7d1403d1168bf860c19fd2ab5ddb42335c60a1927673fa8fc615e2", size = 6963225, upload-time = "2026-08-25T15:49:04.615Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/c4/15af758b4d5ae2c3e6bf1c986558f6cd355340685c3fc0448c031adc96e9/docling_parse-7.8.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b7d046008ad37ca9072b8f908f3ae05d984cfe448f4219b342cfb3ca27ff772", size = 9580184, upload-time = "2026-07-20T04:15:13.014Z" }, - { url = "https://files.pythonhosted.org/packages/da/0c/66bb5f201103eeee54698bf45848dd154ebf2882664e4a020a3336b26cca/docling_parse-7.8.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2494aeb0ff130effa06c29d194dbb6ead9f4721a7c4b168c7200ba99ffe0812b", size = 10308032, upload-time = "2026-07-20T04:15:14.799Z" }, - { url = "https://files.pythonhosted.org/packages/29/7c/d056ed7e035891e44daa5aa454f5ff1bf2bac2d2b1909ddc666ae3d2068e/docling_parse-7.8.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:724d75d80d24fd41597f00dba38508438e20efafae4f465582332f743e6e7106", size = 10719107, upload-time = "2026-07-20T04:15:16.862Z" }, - { url = "https://files.pythonhosted.org/packages/1b/3b/e0e4ce34fd48974b8140c3970e12ebc52b86155e83556733f04e4b8ee9db/docling_parse-7.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:83b0d782b68c7122f1966bd6a590d7a4e06d33d40722e95fa63288d1f9a713e5", size = 11466873, upload-time = "2026-07-20T04:15:18.863Z" }, - { url = "https://files.pythonhosted.org/packages/4f/6e/ab7d1baca6793ef29039d03132939ed0738db70c978c17fb7cb16eedc31d/docling_parse-7.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:775c493f095fe4fa55e907c87912a875cdc74f4829d4078ead4b2ad0d3e76d7c", size = 8876011, upload-time = "2026-07-20T04:15:20.867Z" }, - { url = "https://files.pythonhosted.org/packages/c6/9d/35e9a445c1ad125f8890d7f6b67e41b1659cea2223e41137f49123d273dd/docling_parse-7.8.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:14efe7e430fc700f8e846fb3db156db5c61da50e8aab488c3d2225d68cabcc3a", size = 9580089, upload-time = "2026-07-20T04:15:22.601Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7f/ce2b358fa8d0b8fd60ef7097cc163fb8114ae6e03efbed56dabdbd3cc284/docling_parse-7.8.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b982f5446d87f852c701ce95e6c88834d282c6102fb283837407f5223e38b8", size = 10308281, upload-time = "2026-07-20T04:15:24.817Z" }, - { url = "https://files.pythonhosted.org/packages/65/b5/f8e64db9e39c1cb3cd6b605f61b100c17a60a49bb8a0d307f91f2cf805c4/docling_parse-7.8.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a9c5d032b8e975b46cbd7d4f3f1d4c67ba871f0f8e04d1f841f56906a962a9a", size = 10720162, upload-time = "2026-07-20T04:15:26.583Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c9/01a7797577f29ce88de064e1643c5bd9436c64cf12ce74dabeeb0720214c/docling_parse-7.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:fb1e0136ad7e4812cecf5ed0894d147e7ab9db40d58495cbbce3277f51da88fe", size = 11466865, upload-time = "2026-07-20T04:15:29.03Z" }, - { url = "https://files.pythonhosted.org/packages/61/fe/4d1ed910ccb0d68fd478be32892e88e58230e5da0cbe465c4be04a1b364b/docling_parse-7.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:daf8223320b51565a87ac0ee85af5b3a24be2a3b340dfe1934e096fe3f15a603", size = 8876079, upload-time = "2026-07-20T04:15:30.978Z" }, - { url = "https://files.pythonhosted.org/packages/18/2e/c590ec8f0ab08cf123a9836a7fc94b6f61dcb99f0d5037aec71e8cd3c98c/docling_parse-7.8.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:575a7ab6a2979b87ec89919a617f82dba4f8258a9e8053c88e2f610043e48c2c", size = 9580858, upload-time = "2026-07-20T04:15:32.896Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/a14e249b63b1392f1168123da98cbd1383cf1e72c749db908b9a61288726/docling_parse-7.8.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f88e22847d381d4ee045cc2ba8165a9012e52172470845f2062d0c7b54b6cfb8", size = 10308624, upload-time = "2026-07-20T04:15:34.691Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d0/068f9214f010c191b5d05a2eea6f0f70ee58e0d1c4dfe3230081c5fd6ee6/docling_parse-7.8.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:212918a3c7a47d358e1e3d1d19b4c28e7d85128a17dc72fb9e7957bc3e92786c", size = 10720304, upload-time = "2026-07-20T04:15:36.626Z" }, - { url = "https://files.pythonhosted.org/packages/bd/41/bbb2271f1a198d9b8cb4c48465a80fc3918765afaf082c26289a0b954096/docling_parse-7.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:5641c5cc7b33ba52fd65c8a90fdd1c8444bc450bc641792fadcc38eb55835475", size = 11893513, upload-time = "2026-07-20T04:15:38.896Z" }, - { url = "https://files.pythonhosted.org/packages/fe/71/3b453caf064a6e2ac0aec4da42132bd98b17854d5e308d534a301812b4f3/docling_parse-7.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8d318a6250828e42df3406f11153a2b449d6d791b08972ddfa08ef4528bcbb2", size = 9237521, upload-time = "2026-07-20T04:15:40.921Z" }, + { url = "https://files.pythonhosted.org/packages/08/e3/02e6e02eef860e276587c90896dd86d897e1150bf984c6135f4ab61f9352/docling_parse-7.16.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cb173b447806ff3a4af3c06936e9585dc201b3ecbb958aebde37a2725471675f", size = 9762630, upload-time = "2026-08-25T15:48:36.814Z" }, + { url = "https://files.pythonhosted.org/packages/25/70/3702dd16141ac8e45548d3f09d2e66c373a96ffc871086a0bb022caa81de/docling_parse-7.16.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c2bf844d567f729aa2a48f22b3c4f5d7e5a7db6b6b3a9ec07b45cf31d4ce3e", size = 10286523, upload-time = "2026-08-25T15:48:38.495Z" }, + { url = "https://files.pythonhosted.org/packages/c1/cb/cece74107282ce7cfe00d14ce5a33d2056aed689a34d20965be4779972da/docling_parse-7.16.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab71b6b9d1085b08ca2a3529356ae3122e56a8be693a32d37e996ae88c6fe866", size = 10686779, upload-time = "2026-08-25T15:48:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/51/b3/52068732b63eae2cbae779d057c311e0010e761e5853ad233080cf201a53/docling_parse-7.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:f1abfe050b670bcb25c7bcefc5d876bc3561949c1bff177e8a8a3d8d6f217314", size = 11746880, upload-time = "2026-08-25T15:48:42.089Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/2ceda924e1581c4b9fbec47d1343e0e64078ef89a314383dca2f604e6b48/docling_parse-7.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:da0483decd77d9a967c54e751107c36a3c3bae186e989f81339dff5037f0a7fe", size = 9051750, upload-time = "2026-08-25T15:48:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/68/b3/1a4b65a60e0cd11d4512c55e81c03a4f5f6c7c08ed6f78fb703e983aa4b0/docling_parse-7.16.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c3b4d67da75631a28252bf574023e4a45fdd422603ea07cf73a8e70074547436", size = 9762652, upload-time = "2026-08-25T15:48:45.716Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fa/0b1a0ce72403002080231708f753cbbfbef22385b336bfd5f2252a96fe54/docling_parse-7.16.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e2bebe14d3ce0b6e7f256f2e4bbc7f2405dc9ddc91bbc5031dcba07bf97d9b8", size = 10286719, upload-time = "2026-08-25T15:48:47.483Z" }, + { url = "https://files.pythonhosted.org/packages/c1/67/fe434aeb6d58bf3a69bdbcfee7b52633ec2cfad5e28ce7862d3d910f31c7/docling_parse-7.16.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ab9b9deefd347166d670f894d27f697bf2d213a51e526516dc9ff44e0c19b5a", size = 10686793, upload-time = "2026-08-25T15:48:49.073Z" }, + { url = "https://files.pythonhosted.org/packages/8d/79/97d07a4a056f540d7224720eb1cbdd606fe5f825d7954b5b574cf872efb8/docling_parse-7.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:b4dc06844b775014bbf9a8f2d830d6700a47b2ec13c62a4ee61a2c9382fe4dca", size = 11746783, upload-time = "2026-08-25T15:48:50.855Z" }, + { url = "https://files.pythonhosted.org/packages/42/eb/61492ee31917fdcc35117c0b5053626b81084b68cc1345e779e2428c6e08/docling_parse-7.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:4dad04e693ec3d120885249a097dc61b0823f01b0a99c52696828b9eb6649e0b", size = 9051766, upload-time = "2026-08-25T15:48:52.558Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ac/9ca727496edf2a1e5f2ecf7b2736173616d4e09ae65e4f7f858f9dc5d8b3/docling_parse-7.16.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:1ed3fd24a26a2b00357cc199001e2817d70163447b456ac0c66efe1823e38404", size = 9763549, upload-time = "2026-08-25T15:48:54.258Z" }, + { url = "https://files.pythonhosted.org/packages/13/86/84ab1b9bdc4ab0eb68bfef70b5abf871fde4f9c8f576b1e5e40ed0187c22/docling_parse-7.16.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12a1b9d145cc44d94a7b80e4ef62ea732b00cbd7803b1dac0d803893a82c100c", size = 10287706, upload-time = "2026-08-25T15:48:57.471Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ec/887fb066d848ed0c8ad9efb90d1fe0361ee869b0a95a55a0254e70f723f1/docling_parse-7.16.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e71584a38d8bfaaf7947557b51359a260266b348507b0682814fa39f33cb2f0b", size = 10687139, upload-time = "2026-08-25T15:48:59.394Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99699251e782eb8ffdbe2c49b7454d9bab8f0bed7cea30c56a8a1c564f0f/docling_parse-7.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:9c11e3c7cb509fa27de277795211bd80369a2e682ed0e3d7170e12df13ff6d05", size = 12180438, upload-time = "2026-08-25T15:49:01.006Z" }, + { url = "https://files.pythonhosted.org/packages/95/07/fba291257e7a40f9fddfca25fccb11f26404daabb83fddacd63cee72b307/docling_parse-7.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:c9d5994bdcb8b12d157871ca2fd1e399afeac4528a2343af7d82e87dfca35e82", size = 9419759, upload-time = "2026-08-25T15:49:02.827Z" }, ] [[package]] name = "docling-slim" -version = "2.115.0" +version = "2.123.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -674,9 +777,9 @@ dependencies = [ { name = "requests" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/b8/c78b8c9a07f20325176299d6a58ee8a0dcf088815ddc45590b29fc6c671a/docling_slim-2.115.0.tar.gz", hash = "sha256:4440eb2118e64e14df45eabf5f927a01eda2c37dcaa50435a708e31c19f8b7f3", size = 549486, upload-time = "2026-07-23T14:45:53.797Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/06/9345b77dad465bc8fd92e6a32075a5d9bb3ac31127121a6a9998f00bb81c/docling_slim-2.123.1.tar.gz", hash = "sha256:87966cabd0fd3c76333e238772c999f0f4e3a36e264df4242a48e1502616a52c", size = 621960, upload-time = "2026-08-28T15:14:27.269Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/a3/f8acf83bde1343728091d4362a9795a8ccf3bdd9737121833159ab2d1770/docling_slim-2.115.0-py3-none-any.whl", hash = "sha256:affa089efd112c88330af91b84a6a6d600731221c21e0971cd67a21a30448643", size = 684027, upload-time = "2026-07-23T14:45:51.9Z" }, + { url = "https://files.pythonhosted.org/packages/16/68/d0fd3a479fe63f1f78d5be21a97788308db73bb5199b8354b34eab859f3e/docling_slim-2.123.1-py3-none-any.whl", hash = "sha256:bdb99f2ed3bed1e1ad5b18b8e139cbe16ef729c93945f6b0a7f99f63a8fc9770", size = 778390, upload-time = "2026-08-28T15:14:25.341Z" }, ] [package.optional-dependencies] @@ -699,6 +802,7 @@ standard = [ { name = "pypdfium2" }, { name = "python-docx" }, { name = "python-dotenv" }, + { name = "python-oxmsg" }, { name = "python-pptx" }, { name = "rapidocr" }, { name = "rich" }, @@ -775,19 +879,19 @@ wheels = [ [[package]] name = "faker" -version = "40.36.0" +version = "40.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/d2/026af1e002bbc6df534d1f8262b18ec79a974f928e9290bfbfdfe7c7b2af/faker-40.36.0.tar.gz", hash = "sha256:754048c76c03afa7de83eee8f4bcee3cf668cbb7d995f54a4e9678db7f110308", size = 2025903, upload-time = "2026-07-24T21:11:33.088Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/fb/35acc76128d5ee8983d940da871cc8eba876e7b0e9e6bd402ecd7104dcdc/faker-40.37.0.tar.gz", hash = "sha256:a92dff7f310e61fb544c61720e15edb2e7448bc33d15a321a99e9ab7b94abf54", size = 2025920, upload-time = "2026-08-21T16:33:16.261Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/9a/b947ed175ce9a0dcb070ccf3607f0ce8720cfb5ed1a36166a150b2acd5af/faker-40.36.0-py3-none-any.whl", hash = "sha256:82b9497d9cfe017048075bcf969298a74b1b6e39f5e4dad1211085d1133f7b62", size = 2062829, upload-time = "2026-07-24T21:11:31.37Z" }, + { url = "https://files.pythonhosted.org/packages/3e/22/bf589b6b2c7527047f55450358fbe5961aeaadf168d6b51a1a1c67da497f/faker-40.37.0-py3-none-any.whl", hash = "sha256:ddbafa55c94d5b69c08ced3a7f202614204a02e07ba6548c729b8d18acc0b490", size = 2062853, upload-time = "2026-08-21T16:33:14.516Z" }, ] [[package]] name = "fastapi" -version = "0.140.7" +version = "0.141.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -796,9 +900,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/33/e0dfa29ccce4eb8c9a073f9e557b0d6bacbb3aa32e7ad595f678de4d036a/fastapi-0.140.7.tar.gz", hash = "sha256:09a640af2d29006345e1f28e4f031fa60f89b1a75d29f26070f3afa677d66cce", size = 422051, upload-time = "2026-07-27T17:34:45.908Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/0e/00cddd6b8668884e9c7588ab0eeb73becbd1efa3eaead34397f2e9a8de49/fastapi-0.140.7-py3-none-any.whl", hash = "sha256:960bb9696d8fd19dff488aa4f67f276364542cfcce9f7e68a82fe49dce126626", size = 131085, upload-time = "2026-07-27T17:34:47.036Z" }, + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, ] [[package]] @@ -824,20 +928,20 @@ wheels = [ [[package]] name = "fastjsonschema" -version = "2.22.1" +version = "2.22.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/98/474719c58eddaf77fa443b063693e76d49db32bbe851bcbaf58d2700119f/fastjsonschema-2.22.1.tar.gz", hash = "sha256:0b83d1ce8d7845b959dcb20e1a5c3c8883b6541d9c52ab02cce5166b75ec805f", size = 382291, upload-time = "2026-07-27T13:31:08.515Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/a4/9473c7c3b87009d9c1d74034e4a0f6a35ff0d42dd0f9866d0c3ec4e9217b/fastjsonschema-2.22.2.tar.gz", hash = "sha256:72064e12356a7d6ef02165be2946b9abadbdf238536e07eb587e3dbaa33099cf", size = 385171, upload-time = "2026-08-15T19:47:08.853Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/e1/62cc96341f01bdff2ba967441939178fcd1900d11ce7e6554d9954a5d7ec/fastjsonschema-2.22.1-py3-none-any.whl", hash = "sha256:cf377ff5c9a6f4f3125fb35f75a2c5767bd824ffbcf62c209a93cd48d1453999", size = 26239, upload-time = "2026-07-27T13:31:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/49/82/2755c7c982086f00d4dab85bc120ec35045a9fc2191893a6ce79afe94443/fastjsonschema-2.22.2-py3-none-any.whl", hash = "sha256:0fb3915616adac85ccfdd737d26be1089845d2019819505b42d39888458f74d4", size = 27413, upload-time = "2026-08-15T19:47:04.406Z" }, ] [[package]] name = "filelock" -version = "3.32.0" +version = "3.32.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/80/8232b582c4b318b817cf1274ba74976b07b34d35ef439b3eb948f98645a1/filelock-3.32.0.tar.gz", hash = "sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402", size = 213757, upload-time = "2026-07-21T13:17:42.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/30/03b03951873a1a0ffc7e8ca0e10c15597b59e8d0e39260704cd2ea087bc4/filelock-3.32.4.tar.gz", hash = "sha256:2bde2e4cf732e0153406d8a7bc80620ecf5e621fe0d25e41143c4e3b4733ff30", size = 222126, upload-time = "2026-08-23T17:37:55.363Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/01/a4/9b63d595d748e3aff8812b65eacc1a2c4bd90b7c2012e08e72373b4835eb/filelock-3.32.4-py3-none-any.whl", hash = "sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd", size = 99864, upload-time = "2026-08-23T17:37:53.913Z" }, ] [[package]] @@ -868,11 +972,11 @@ wheels = [ [[package]] name = "fsspec" -version = "2026.6.0" +version = "2026.7.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" } +sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, ] [[package]] @@ -889,15 +993,15 @@ wheels = [ [[package]] name = "google-auth" -version = "2.56.2" +version = "2.57.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/33/dbc946a407401b975f0719658f18e664ece2109f79ffd1ff3bf226c205f4/google_auth-2.56.2.tar.gz", hash = "sha256:e28f103ca8091fb7012b99c44243d7366c29863713b8e34a220c3322b7a07051", size = 365820, upload-time = "2026-07-21T21:53:28.188Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/64/55f316b729f92a552d26e00aa3b1542b2e149d0a5efe2842afff0cac7af7/google_auth-2.57.0.tar.gz", hash = "sha256:9b4f96d6a1feb5f7201231f47cfb3de08d8f176f8a61f9e461555116e95a8789", size = 370794, upload-time = "2026-08-25T19:18:26.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/63/50636aae68c9bf17c891c7eb18b49baa9bd6b31d2a97b8de4813a9fc8d1c/google_auth-2.56.2-py3-none-any.whl", hash = "sha256:c8270ea95b2697b74e3d8438ae9c5b898e38b623b915c7b5c5635921e7de68a6", size = 258588, upload-time = "2026-07-21T21:53:26.399Z" }, + { url = "https://files.pythonhosted.org/packages/00/f3/8508a702c094af5f6e89773f4dfdeee74913df0f41a02c21b5e7dc3d75cd/google_auth-2.57.0-py3-none-any.whl", hash = "sha256:180dafe015cfb62193bea26b677500fab5b9fd51a1e825ebf3ad9b182047ae59", size = 259728, upload-time = "2026-08-24T21:55:08.449Z" }, ] [package.optional-dependencies] @@ -907,7 +1011,7 @@ requests = [ [[package]] name = "google-genai" -version = "2.14.0" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -921,9 +1025,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/df/4f820054c99f29f2fe3de4a8a7c9534dd795302e4a07483a0cb07c3a29b6/google_genai-2.14.0.tar.gz", hash = "sha256:a9d1f4f362d76280f1be1340fcb3c86e63dbca128f6a4ae09d86ab47ff7148e8", size = 641055, upload-time = "2026-07-22T21:35:44.717Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/dd/eacd43097318ea6b3e648862713a964d5de261a2eabcc7826db9b9de9758/google_genai-2.20.0.tar.gz", hash = "sha256:d382186f024e9050a7a4b25af6eacba9aa16c6e09594f5d1b530f22ff7f9d76f", size = 664965, upload-time = "2026-08-25T21:28:27.136Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/86/5ac5fb53e44cca4a6607fb917eb331fa237c65a103b9ec2e8e8acc8a42db/google_genai-2.14.0-py3-none-any.whl", hash = "sha256:ae7172cdd35695189b516b33a878e4132e5daa2dbc03a5b44cddfa8a82fad664", size = 1030738, upload-time = "2026-07-22T21:35:42.785Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a7/a979230234c9df019e008085c923726dc4d92c14a5701ad698e369c9ab2a/google_genai-2.20.0-py3-none-any.whl", hash = "sha256:49bddeccd29a4e6bf1706c5de67735f7115f537f08b6c36a70b8023c99399095", size = 1064276, upload-time = "2026-08-25T21:28:25.287Z" }, ] [[package]] @@ -940,52 +1044,52 @@ wheels = [ [[package]] name = "griffelib" -version = "2.1.0" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/b4/a767e91c606deefc447a96eaf59edd77397960b1d677dffd833ee8449831/griffelib-2.2.0.tar.gz", hash = "sha256:e1bc36fe9cd21d4b6b659b456346755e4cfdc5676c0a5214083126ee12612b3c", size = 227048, upload-time = "2026-08-16T14:04:58.383Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/f65ac785d4ac90dcf7c831ac6256f5dd4a19780f4e1575b2c0d6eeebe319/griffelib-2.2.0-py3-none-any.whl", hash = "sha256:d71c3bc2bbed9f958488634fe788b843a9f705d6d2838ca32cd6c25eeb64dfc4", size = 166779, upload-time = "2026-08-16T14:04:54.365Z" }, ] [[package]] name = "grpcio" -version = "1.83.0" +version = "1.83.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/15/2b/51e32514a4e9b715375c99721aadff0f24164cc2049b8269eda4de82a814/grpcio-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930", size = 6303167, upload-time = "2026-07-23T15:19:33.78Z" }, - { url = "https://files.pythonhosted.org/packages/39/33/b5b50fc2c6fbe350e04814047bb2d409feec7b36ef8b170254c050e06bc0/grpcio-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da", size = 12160538, upload-time = "2026-07-23T15:19:35.958Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5f/734e72e7b9f79bcf0b2c270b8d3bca0e4ebb97a27a50d06240b145f6d41e/grpcio-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16", size = 6869310, upload-time = "2026-07-23T15:19:38.607Z" }, - { url = "https://files.pythonhosted.org/packages/a4/17/a1735f215b2a5cd43c38b79eac072ad197e61be9829905b6b29550abd0db/grpcio-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf", size = 7613472, upload-time = "2026-07-23T15:19:40.645Z" }, - { url = "https://files.pythonhosted.org/packages/b2/78/c9e81f806ac704b6b145cb01628db398985b1f8dfdc10e23b55fb0902b3d/grpcio-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd", size = 7040616, upload-time = "2026-07-23T15:19:42.349Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ba/94cd5af859876049d340480acbb61a959096c84b567f215534faa78d0424/grpcio-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c", size = 7570491, upload-time = "2026-07-23T15:19:44.357Z" }, - { url = "https://files.pythonhosted.org/packages/3e/15/108d30d5a5c964312ae8b9cb0e8cc5b3c1cc68d8f757cca52b3565534d26/grpcio-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5", size = 8605036, upload-time = "2026-07-23T15:19:46.454Z" }, - { url = "https://files.pythonhosted.org/packages/ea/23/3828ae13c3db8233d123ad612747665817b952d8a954f32390230b582336/grpcio-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5", size = 7981587, upload-time = "2026-07-23T15:19:48.913Z" }, - { url = "https://files.pythonhosted.org/packages/17/5b/77af31228f55f55a2a5112bb0077ad0a1c4d23dbb0c2853a62475bbdcc14/grpcio-1.83.0-cp312-cp312-win32.whl", hash = "sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc", size = 4394004, upload-time = "2026-07-23T15:19:50.618Z" }, - { url = "https://files.pythonhosted.org/packages/c0/da/f706e39550e7a3732ce2b9c5926107a93d74a802775b19b642a6df27dc96/grpcio-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df", size = 5158525, upload-time = "2026-07-23T15:19:52.246Z" }, - { url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" }, - { url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" }, - { url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" }, - { url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" }, - { url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" }, - { url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" }, - { url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" }, - { url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" }, - { url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" }, - { url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" }, - { url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" }, - { url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" }, - { url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" }, - { url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" }, - { url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" }, - { url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" }, - { url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" }, - { url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/e2/b1/46539f5050d7c316a13396d185451f95084a74ddc68b12d818595bef0377/grpcio-1.83.1.tar.gz", hash = "sha256:9cee6fcbf2eb57c4b49451787bfa87be8efc1ca02a0b327dd4b54d44502e362b", size = 13445033, upload-time = "2026-08-28T07:09:11.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/9e/a3ba13e08bbee5bf6e57597dfe4823961fd7e94c0b8afe3a4bb7dca639f3/grpcio-1.83.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:5acd14c6ddf047de62cbf8745b11103ea91abbf57d1b8edd5395ccd9fcd13abb", size = 6303170, upload-time = "2026-08-28T07:08:08.188Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ae/65ce56a2527faa17d02cba4c2231c74047ad898be339486ba87f093bfb66/grpcio-1.83.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:16138031a47b771860a16a975b53087f4fd5bbdbb2c03a188c5d90ad65d2bdae", size = 12165806, upload-time = "2026-08-28T07:08:10.309Z" }, + { url = "https://files.pythonhosted.org/packages/4e/91/40432480088a2243d360864de072ed5b78c4ebbaabd29c28918f1e1b1454/grpcio-1.83.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5ccc26715fd4defca5e129e280dd883b1737b65045ec50ffe22ce42104089519", size = 6872490, upload-time = "2026-08-28T07:08:12.355Z" }, + { url = "https://files.pythonhosted.org/packages/c8/62/3da2300c8c79fd20a78a8a4bb6251e5068d9af33bc8fd389b98fec35e8a3/grpcio-1.83.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b74f2a1d9ab1dfa3e263ef33d581613679b78d0884babf11671af26e45570ead", size = 7618367, upload-time = "2026-08-28T07:08:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/bc/19/9fc702e31a631262d7a752fa699f6022821e707fefc8bff49b1550a57729/grpcio-1.83.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72578aa07a4008f17521ef52debcc3acfd1e2c5426243bc3ffb56a38bfe610b7", size = 7040936, upload-time = "2026-08-28T07:08:15.963Z" }, + { url = "https://files.pythonhosted.org/packages/ec/56/95933cc44cba2429765fa065c951dd529e5771b119d9d2481b4646f1d6a5/grpcio-1.83.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c12e1fc59c6dc26d10d9144453ddc6cbfe4cd4c31e874ed2d0132f88e685eb8b", size = 7573096, upload-time = "2026-08-28T07:08:17.729Z" }, + { url = "https://files.pythonhosted.org/packages/ac/80/af63359da06b016de48cb111f144703a10043850dafa43ae0a038907b9e8/grpcio-1.83.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4910b62f7d12197160bfb7de06d876d64dd12d43483e8292f98f49ca09b628d9", size = 8609442, upload-time = "2026-08-28T07:08:19.777Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fa/f0586c56bdfb8a7a2adda01e0ac2413447cde3141ab09411a5d5afdcffd3/grpcio-1.83.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9e703effe3ae779925c82ac24fdb82cf4105e1096810151ed9501c5f34546b9c", size = 7984321, upload-time = "2026-08-28T07:08:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/25/8a/14ec05669f9eb295801e26c2ea8c561a1b786b0e3557c2c22131165ab010/grpcio-1.83.1-cp312-cp312-win32.whl", hash = "sha256:a2aea8bd6e0a34f12cbaddb7bb70bec836818789fa5c7ab7572c6b745396a2d4", size = 4395604, upload-time = "2026-08-28T07:08:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/e9/37/8c2f7cc16089e36a3fbacaacc7a3d043912aa0d2dfae5556f6450414ea6e/grpcio-1.83.1-cp312-cp312-win_amd64.whl", hash = "sha256:583bf2e8255040a4a312f9572dfe62a05271437b149550e1a536d5c47d2d1e8a", size = 5161512, upload-time = "2026-08-28T07:08:25.81Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fd/d1fc58933bf88c9209f89dc570c810f1aa57cb04b3459cf2b26f61e32112/grpcio-1.83.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:8d228e253b77865efcbdd7b5894ca882c9e0ea98c02b7d20582e61ded8dfd4b5", size = 6305628, upload-time = "2026-08-28T07:08:27.872Z" }, + { url = "https://files.pythonhosted.org/packages/c4/49/0b40bae059c619505c9b751cee6caa208e4904e290aaefa1728c4c2c67a5/grpcio-1.83.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:0468b627f2987c9a77f7580030207cbd85457ffe52998beff4f0b5c38c58a72c", size = 12156839, upload-time = "2026-08-28T07:08:30.191Z" }, + { url = "https://files.pythonhosted.org/packages/61/4b/e8c0d635da0ee5ddd9950c8d540f5dcdd0ef1854a382cc55496a487a8d31/grpcio-1.83.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6a282e81530cead60bbd752cc04950a57f224379e9821495d6a35bd5ce9b1f4", size = 6877036, upload-time = "2026-08-28T07:08:32.285Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d4/760a33f339a7dd3d5f4b3e0e9bec5472d95592a80f887b2e9dab4e41cfbc/grpcio-1.83.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:947d945f52e8ecf3cafd2bb7113502a16ccfda3e12c854443094de32d83ad432", size = 7624404, upload-time = "2026-08-28T07:08:34.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/bd798654b06fb42a92b57d1dc1b530084fa89ed442806fcd0a833a36f9b3/grpcio-1.83.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:55656318d5dd387077396dffb929171ca3966e24bfead9a6c5dba9f889062cb4", size = 7042942, upload-time = "2026-08-28T07:08:36.208Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/c00f86614566dd0961825cf0f43d4f96a74371d9d95f952bcbc4b86d9a27/grpcio-1.83.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9daf5acf4fc9d5f5627229969c2580a91e511779d76e4ccdeb9f4770f05d8bc2", size = 7576937, upload-time = "2026-08-28T07:08:38.041Z" }, + { url = "https://files.pythonhosted.org/packages/b1/38/85eff43a5c89dc666a252b5c9f8e9ab03f89e11c95b6263d2933f08fdbe7/grpcio-1.83.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7b94174cbca93316888f805efbeb08f1c020f7b7493d2d50cc4f6b64ebb7e8bd", size = 8608391, upload-time = "2026-08-28T07:08:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/35/4e/82835483e2f812494be865e7965c0d626cb9e71ab0d83a420d75aea4ad67/grpcio-1.83.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:65c5a7210911ffe0f67b1cdc5308f9854b6d1f1b345e3e49ab7cac1ba50fa346", size = 7980060, upload-time = "2026-08-28T07:08:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/68a98bef733fef704fbcfb3957c8dba67e3e38ca7a7fea851195bc97c648/grpcio-1.83.1-cp313-cp313-win32.whl", hash = "sha256:179368d9361854616ce6f397d4716e07480129652752fcbcfc5a7260455ad6f2", size = 4395226, upload-time = "2026-08-28T07:08:44.463Z" }, + { url = "https://files.pythonhosted.org/packages/85/a0/df4de3b51d37ac8fb0320bb9668381ce2bd3b7aa990880bfc56a8a26f665/grpcio-1.83.1-cp313-cp313-win_amd64.whl", hash = "sha256:2e57af456385491a76e13c4aada8c8f43a8e47051e06ea97a9dbe2a49654e6db", size = 5160273, upload-time = "2026-08-28T07:08:46.216Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/484d981d8b90c4e6abf3030bd2ed747e84d1eb192b3ec9cbb41e0b73e4bf/grpcio-1.83.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:8b3c87ca908296bf125f841d3e1a2225a2b39aaa8ed7a57e7ccde465ee519bab", size = 6306089, upload-time = "2026-08-28T07:08:48.379Z" }, + { url = "https://files.pythonhosted.org/packages/84/01/0afec1c92e4f292f74a44ecf75eabbf40903125b8c4df103c9868d6338da/grpcio-1.83.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c0f3f20c90e72a171917ae65706500b096a1c3eb5f162c3ce702a2e25635f132", size = 12170381, upload-time = "2026-08-28T07:08:50.653Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5a/e9a2383804433a0a61d6d93777ad321c7f36ac1cfdaa4c6d1a7c9ac846b7/grpcio-1.83.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81bbf35a46bf8cad2dfbb2eccc19c711befb58b288acb534bbcd0d74283202a6", size = 6883286, upload-time = "2026-08-28T07:08:53.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/f8ca8f76994e14c70b9a0052e82f10de497a23db450c36379c9716ebfc4d/grpcio-1.83.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:215cec07d11176507387bda4bf2751816e880f9bff8dc1ca524bfbb8ed8f2fad", size = 7624293, upload-time = "2026-08-28T07:08:55.709Z" }, + { url = "https://files.pythonhosted.org/packages/bb/7a/4b672814b0cd0fe63bdd735379d88b165759f3144ab023ad8ec5fc4d53ac/grpcio-1.83.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:abce7d43ec29cd39230fa8339de1a07643b55adc412a454850fbd875349950ff", size = 7044346, upload-time = "2026-08-28T07:08:57.802Z" }, + { url = "https://files.pythonhosted.org/packages/50/b8/d89fe60e4239ad51be333dd9cc703741d449a35064e51f8a0b5bfa755432/grpcio-1.83.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e256f95a40e3b0183a98556fb7164d24b97eeb353123ccabfcba94712b35ee2a", size = 7584187, upload-time = "2026-08-28T07:08:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b2/b290d7402633d9166e4dd47e6f5f74a24ce10a8340b84455896ebc349f85/grpcio-1.83.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2110059146fb0ea216e1ffddb29377b5cc2fd412a5b0a92e102616bd5edf18c2", size = 8608730, upload-time = "2026-08-28T07:09:02.592Z" }, + { url = "https://files.pythonhosted.org/packages/f5/44/fa89e44d1b5cf5b9fa71b2fd7abf506f182fd43917231a92fbf1ea326b02/grpcio-1.83.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20d944d967843f8183f9f23d5916388362e5f8eeeae855bbe4354d906dc9f31b", size = 7983283, upload-time = "2026-08-28T07:09:05.087Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b8/9db73ed1f35ffa76124ac574bf296d06a359798dfd6b50d382f2b8a060a1/grpcio-1.83.1-cp314-cp314-win32.whl", hash = "sha256:623c87c6d4a1cb30d82c4e896f95477050f2e01b4a1f8cf91ff2b1abdf89c457", size = 4474327, upload-time = "2026-08-28T07:09:07.179Z" }, + { url = "https://files.pythonhosted.org/packages/65/22/fc9a622d885a7a37ff972a12faaef443d74e47407181da70d0ab62ab41f0/grpcio-1.83.1-cp314-cp314-win_amd64.whl", hash = "sha256:47e6934ad38779271e2e7cc5f78a63a407cf3d98114c65c1fdbcd3f5a716f29b", size = 5302032, upload-time = "2026-08-28T07:09:09.285Z" }, ] [[package]] @@ -999,15 +1103,15 @@ wheels = [ [[package]] name = "h2" -version = "4.4.0" +version = "4.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hpack" }, { name = "hyperframe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/d4/a7d6fb3f58be99d65cbf2d3f766896217a2921d0f3ab10711c45dc1519ee/h2-4.4.0.tar.gz", hash = "sha256:46b551bdcdc7e83cf5c04d0bf93badb8a939bd2287d9fee1abb23a445b9e0580", size = 2156691, upload-time = "2026-07-23T19:14:19.442Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/df/5b14a118322d6097cb9bb30ec6bacad268e546a8ecfcb1f6d0de618dac2f/h2-4.4.0-py3-none-any.whl", hash = "sha256:6acffe1aeab79098d7eb0f8385c1add11f2c7a94815f6fa2b7060eeddee3d87c", size = 62368, upload-time = "2026-07-23T19:14:16.143Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" }, ] [[package]] @@ -1035,26 +1139,26 @@ wheels = [ [[package]] name = "hf-xet" -version = "1.5.2" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/39/67be8d71f900d9a55761b6022821d6679fb56c64f1b6063d5af2c2606727/hf_xet-1.5.2.tar.gz", hash = "sha256:73044bd31bae33c984af832d19c752a0dffb67518fee9ddbd91d616e1101cf47", size = 903674, upload-time = "2026-07-16T17:29:56.833Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload-time = "2026-08-03T22:33:13.243Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/be/525eabac5d1736b679c39e342ecd4292534012546a2d18f0043c8e3b6021/hf_xet-1.5.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4a5ecb9cda8512ba2aa8ee5d37c87a1422992165892d653098c7b90247481c3b", size = 4064284, upload-time = "2026-07-16T17:29:29.907Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3f/699749dd78442480eda4e4fca494284b0e3542e4063cc37654d5fdc929e6/hf_xet-1.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8764488197c1d7b1378c8438c18d2eea902e150dbca0b0f0d2d32603fb9b5576", size = 3828537, upload-time = "2026-07-16T17:29:31.549Z" }, - { url = "https://files.pythonhosted.org/packages/22/d7/2658ac0a5b9f4664ca27ce31bd015044fe9dea50ed455fb5197aba819c11/hf_xet-1.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d7446f72abbf7e01ca5ff131786bc2e74a56393462c17a6bf1e303fbab81db4", size = 4417133, upload-time = "2026-07-16T17:29:33.391Z" }, - { url = "https://files.pythonhosted.org/packages/d9/58/8343f3cb63c8fa058d576136df3871550f7d5214a8f048a7ea2eab6ac906/hf_xet-1.5.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:580e59e29bf37aece1f2b68537de1e3fb04f43a23d910dcf6f128280b5bfbba4", size = 4212613, upload-time = "2026-07-16T17:29:34.989Z" }, - { url = "https://files.pythonhosted.org/packages/0c/33/a968f4e4535037b36941ec00714625fb60e026302407e7e26ca9f3e65f4e/hf_xet-1.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bee28c619622d36968056532fd49cf2b35ca75099b1d616c31a618a893491380", size = 4412710, upload-time = "2026-07-16T17:29:36.646Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/9e33981173dbaf194ba0015202b02d467b624d44d4eba89e1bf06c0d2995/hf_xet-1.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e396ab0faf6298199ad7a95305c3ca8498cb825978a6485be6d00587ee4ec577", size = 4628455, upload-time = "2026-07-16T17:29:38.352Z" }, - { url = "https://files.pythonhosted.org/packages/e9/4b/cc682832de4264a03880a2d1b5ec3e1fab3bf307f508817250baafdb9996/hf_xet-1.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fd3add255549e8ef58fa35b2e42dc016961c050600444e7d77d030ba6b57120e", size = 3979044, upload-time = "2026-07-16T17:29:40.329Z" }, - { url = "https://files.pythonhosted.org/packages/ea/09/b2cdf2a0fb39a08af3222b96092a36bd3b40c54123eef07de4422e870971/hf_xet-1.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d6f9c58549407b84b9a5383afd68db0acc42345326a3159990b36a5ca8a20e4e", size = 3808037, upload-time = "2026-07-16T17:29:42.357Z" }, - { url = "https://files.pythonhosted.org/packages/de/ba/2b70603c7552db82baeb2623e2336898304a17328845151be4fe1f48d420/hf_xet-1.5.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f922b8f5fb84f1dd3d7ab7a1316354a1bca9b1c73ecfc19c76e51a2a49d29799", size = 4033760, upload-time = "2026-07-16T17:29:43.884Z" }, - { url = "https://files.pythonhosted.org/packages/60/ac/b097a86a1e4a6098f3a79382643ab09d5733d87ccc864877ad1e12b49b70/hf_xet-1.5.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:045f84440c55cdeb659cf1a1dd48c77bcd0d2e93632e2fea8f2c3bdee79f38ed", size = 3841438, upload-time = "2026-07-16T17:29:45.539Z" }, - { url = "https://files.pythonhosted.org/packages/d3/35/db860aa3a0780660324a506ad4b3d322ddc6ecbba4b9340aed0942cbf21c/hf_xet-1.5.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db78c39c83d6279daddc98e2238f373ab8980685556d42472b4ec51abcf03e8c", size = 4428006, upload-time = "2026-07-16T17:29:46.996Z" }, - { url = "https://files.pythonhosted.org/packages/af/6b/832dd980af4b0c3ae0660e309285f2ffcdff2faa38129390dbb47aa4a3f9/hf_xet-1.5.2-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7db73c810500c54c6760be8c39d4b2e476974de85424c50063efc22fdda13025", size = 4221099, upload-time = "2026-07-16T17:29:48.525Z" }, - { url = "https://files.pythonhosted.org/packages/9e/05/ae50f0d34e3254e6c3e208beb2519f6b8673016fc4b3643badaf6450d186/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6395cfe3c9cbead4f16b31808b0e67eac428b66c656f856e99636adaddea878f", size = 4420766, upload-time = "2026-07-16T17:29:50.092Z" }, - { url = "https://files.pythonhosted.org/packages/07/a9/c050bc2743a2bcd68928bfee157b08681667a164a24ec95fbfcfcd717e08/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cde8cd167126bb6109b2ceb19b844433a4988643e8f3e01dd9dd0e4a34535097", size = 4636716, upload-time = "2026-07-16T17:29:51.62Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f8/68b01c5c2edb56ac9a67b3d076ffddcb90867abaee923923eb34e7a14e76/hf_xet-1.5.2-cp38-abi3-win_amd64.whl", hash = "sha256:ecf63d1cb69a9a7319910f8f83fcf9b46e7a32dfcf4b8f8eeddb55f647306e65", size = 3988373, upload-time = "2026-07-16T17:29:53.395Z" }, - { url = "https://files.pythonhosted.org/packages/39/c6/988383e9dc17294d536fcbcd6fd16eed882e411ad16c954984a53e47b09c/hf_xet-1.5.2-cp38-abi3-win_arm64.whl", hash = "sha256:1da28519496eb7c8094c11e4d25509b4a468457a0302d58136099db2fd9a671d", size = 3816957, upload-time = "2026-07-16T17:29:54.991Z" }, + { url = "https://files.pythonhosted.org/packages/41/62/3c062f593bd92ef4e77a0ef39541e3d82a0a1d3947c8a777a02a13a27828/hf_xet-1.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d", size = 4074584, upload-time = "2026-08-03T22:32:47.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1e/c0ad437dd267a8e435bef594acf781bbc3874ff0b6435b4962d03ecf7cc4/hf_xet-1.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675", size = 3867381, upload-time = "2026-08-03T22:32:49.049Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ee/7c0d7b6ab336167531b1c30af2af003f054af4c749becbd7209ae33a77c3/hf_xet-1.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b", size = 4453982, upload-time = "2026-08-03T22:32:50.568Z" }, + { url = "https://files.pythonhosted.org/packages/63/06/ad8eab1c9525246650cbaa821caa3cdbaca734ab1a5b8c91bea09cbd8d69/hf_xet-1.6.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522", size = 4249445, upload-time = "2026-08-03T22:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/d8/26/1eee8aedb0dafc1ab9717dc9ac602cde33361b232dc06803f1f6ed18b58c/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e", size = 4451099, upload-time = "2026-08-03T22:32:54.114Z" }, + { url = "https://files.pythonhosted.org/packages/67/57/0b88af1f194ab6c9c650547d9cc06bfeaab836ae4dcdb331676bfb8be95a/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9", size = 4664712, upload-time = "2026-08-03T22:32:55.547Z" }, + { url = "https://files.pythonhosted.org/packages/53/a0/26b717a9d1840e8abf48dcec64b5ed8fbe472671d38ad28d30e147132b33/hf_xet-1.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338", size = 4025906, upload-time = "2026-08-03T22:32:57.391Z" }, + { url = "https://files.pythonhosted.org/packages/49/f6/4a9966633c6fef83af997e2cff68ec1963676d412bdfd096df2a93b8e185/hf_xet-1.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765", size = 3849221, upload-time = "2026-08-03T22:32:59.123Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload-time = "2026-08-03T22:33:00.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload-time = "2026-08-03T22:33:02.251Z" }, + { url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload-time = "2026-08-03T22:33:03.802Z" }, + { url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538, upload-time = "2026-08-03T22:33:05.287Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520, upload-time = "2026-08-03T22:33:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937, upload-time = "2026-08-03T22:33:08.616Z" }, + { url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128, upload-time = "2026-08-03T22:33:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" }, ] [[package]] @@ -1088,6 +1192,19 @@ 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 = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11", marker = "sys_platform != 'emscripten'" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + [[package]] name = "httptools" version = "0.8.0" @@ -1144,9 +1261,35 @@ http2 = [ { name = "h2" }, ] +[[package]] +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "huggingface-hub" -version = "1.25.1" +version = "1.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1159,9 +1302,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/50/db3771a6e4fad4bd28fb055d4363b51cb0ae98c1aa504b79d41fdcab5483/huggingface_hub-1.25.1.tar.gz", hash = "sha256:21129595ca7a753be479b319913e22cc8808361ac118bd76cc413db831b28a99", size = 928426, upload-time = "2026-07-27T09:24:10.117Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/35/42316e8f6908b6d21bc8df017cc6efba94fb5edbf99b64e28dd142325e20/huggingface_hub-1.29.0.tar.gz", hash = "sha256:6ebb385a581435325cf6d5c5b233d5d4bc91175834d99fd65dae14379b36e9ad", size = 963121, upload-time = "2026-08-27T12:18:37.432Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/3f/21e816831c6d16f88a6c784974413fa0421ce8a5d04380c2666ed5b503e5/huggingface_hub-1.25.1-py3-none-any.whl", hash = "sha256:004d4e70350517e24c68a7dbb7dc5e40b2b6aefef8f94bf7a85f6f9835102ea5", size = 774909, upload-time = "2026-07-27T09:24:08.079Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a5/47c2ea9b228ccbcba8467e9a64823146e8ebbad29855e591d8f5eedcc9c7/huggingface_hub-1.29.0-py3-none-any.whl", hash = "sha256:b00f7782afc14db4bc6572763810a635bdfbab8623d957bfb553bd18e03852cd", size = 795768, upload-time = "2026-08-27T12:18:35.431Z" }, ] [[package]] @@ -1184,11 +1327,11 @@ wheels = [ [[package]] name = "idna" -version = "3.18" +version = "3.19" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] [[package]] @@ -1239,11 +1382,10 @@ wheels = [ [[package]] name = "ipython" -version = "9.15.0" +version = "9.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, { name = "ipython-pygments-lexers" }, { name = "jedi" }, { name = "matplotlib-inline" }, @@ -1254,9 +1396,9 @@ dependencies = [ { name = "stack-data" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/bc/e05ae123712ce4e1fde4408eedca1791fc1ff832684565132ea1dc646092/ipython-9.17.0.tar.gz", hash = "sha256:1dc69e6966b270fb259f676c71a21450e63607729b14a672b942914a54e8b730", size = 4538547, upload-time = "2026-08-28T09:00:58.233Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/5f/f992b57e8deb8fa6c2e614b422d80698adb5107902bbc89832e203665bd1/ipython-9.17.0-py3-none-any.whl", hash = "sha256:ce647713be8fef3fab2418c515a0def4d45d6705dd102be2c6d1f3015d7368b0", size = 638698, upload-time = "2026-08-28T09:00:56.174Z" }, ] [[package]] @@ -1372,18 +1514,6 @@ 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 = "jsonlines" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/35/87/bcda8e46c88d0e34cad2f09ee2d0c7f5957bccdb9791b0b934ec84d84be4/jsonlines-4.0.0.tar.gz", hash = "sha256:0c6d2c09117550c089995247f605ae4cf77dd1533041d366351f6f298822ea74", size = 11359, upload-time = "2023-09-01T12:34:44.187Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/62/d9ba6323b9202dd2fe166beab8a86d29465c41a0288cbe229fac60c1ab8d/jsonlines-4.0.0-py3-none-any.whl", hash = "sha256:185b334ff2ca5a91362993f42e83588a360cf95ce4b71a73548502bda52a7c55", size = 8701, upload-time = "2023-09-01T12:34:42.563Z" }, -] - [[package]] name = "jsonpatch" version = "1.33" @@ -1443,7 +1573,7 @@ wheels = [ [[package]] name = "jupyter-client" -version = "8.9.1" +version = "8.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jupyter-core" }, @@ -1453,9 +1583,9 @@ dependencies = [ { name = "traitlets" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/2a/906772148a06e48885039e0250c340b770a55bbf37b08d6ee0449df369c3/jupyter_client-8.10.0.tar.gz", hash = "sha256:9f7116294dca55f1785be880057d44544db9b1567718d92cb33c58886afb9497", size = 360653, upload-time = "2026-08-28T12:17:10.854Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" }, + { url = "https://files.pythonhosted.org/packages/0f/88/7c548de1f6c2ade7c931a3282da73f9274fa6a1531091be682f89c85efb9/jupyter_client-8.10.0-py3-none-any.whl", hash = "sha256:5f73f24f22fa25192cfff6b23c051932a2473a797b05734aff495b392103e14e", size = 110184, upload-time = "2026-08-28T12:17:09.028Z" }, ] [[package]] @@ -1498,19 +1628,19 @@ wheels = [ [[package]] name = "lance-namespace" -version = "0.9.0" +version = "0.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lance-namespace-urllib3-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/81/4cf8d0412e1f37b2bfa70d0aeb9c7ae4ab73607534e44d60b55efb485306/lance_namespace-0.9.0.tar.gz", hash = "sha256:f738b641cc615b17323baa4eb47900f184688739ee3d2ea9fe39396b9588e53d", size = 11637, upload-time = "2026-07-01T07:42:41.78Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/93/da5f7fcac690db9b282a3439ed9e34960c147619a0d6e1f4eb8cd240e7a5/lance_namespace-0.11.1.tar.gz", hash = "sha256:f67cfbbe0647b7cb42f23b673e7edf8a75b7d8a047265a916492f8d247ee1bc2", size = 11631, upload-time = "2026-08-18T17:40:06.294Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/fe/f38747c9610ade83dd9a99a0470b9432b6f21ce4e2bb5524edbe66f626fd/lance_namespace-0.9.0-py3-none-any.whl", hash = "sha256:f785ff10927e4ce0db69986576670fedd37f8a33521e8a4630c6be22db8061b2", size = 13501, upload-time = "2026-07-01T07:42:39.372Z" }, + { url = "https://files.pythonhosted.org/packages/fa/bc/601f2b3cc4cfa0070d858a33223bc823fffdd7981a25c45984a5216ca952/lance_namespace-0.11.1-py3-none-any.whl", hash = "sha256:07643fce9a42ad4d58cc8bf91e3f592bc7f4cbd8d0ad5233223506debf67551c", size = 13507, upload-time = "2026-08-18T17:40:03.561Z" }, ] [[package]] name = "lance-namespace-urllib3-client" -version = "0.9.0" +version = "0.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, @@ -1518,14 +1648,14 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/c3/32d0e2618549ace857c80a457e5915ef3e1145661baff876c8a5ec27be5b/lance_namespace_urllib3_client-0.9.0.tar.gz", hash = "sha256:cf796fa5307fa4dde91fe4bec2af28b90ba79191852d4394e8fe44276538e40f", size = 235805, upload-time = "2026-07-01T07:42:42.563Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/c5/2bdd0ff98b469894c8a73be809d26ffdad5402517b0e5f9e758026cba29e/lance_namespace_urllib3_client-0.11.1.tar.gz", hash = "sha256:145a9e9424d7597487249b5b95ee274423bf2910e1a9160b6a07b676b61ea46a", size = 237345, upload-time = "2026-08-18T17:40:07.308Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/ab/c8754da0a1efc817f8480100cfe12b7e04034df834759de8ecf02beff3cc/lance_namespace_urllib3_client-0.9.0-py3-none-any.whl", hash = "sha256:be819c8cffb1e460a3a504dbf52d1ca009560a48e7202b8c4279998e4adf9fe4", size = 405586, upload-time = "2026-07-01T07:42:40.503Z" }, + { url = "https://files.pythonhosted.org/packages/2a/2a/eaaefd55d1190291207049fedc6b3eb22b506e57d6de91bae46bbaaa9c60/lance_namespace_urllib3_client-0.11.1-py3-none-any.whl", hash = "sha256:36537f529294da6d884ba0fe783704483f0a75463497c7705fd083a4d0257990", size = 406311, upload-time = "2026-08-18T17:40:04.842Z" }, ] [[package]] name = "lancedb" -version = "0.34.0" +version = "0.37.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation" }, @@ -1537,31 +1667,32 @@ dependencies = [ { name = "tqdm" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/df/f7/5262b9aa593f790757163c0165ab0da1dda054758901bea7e4f02c9cb633/lancedb-0.34.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:c462f2e6f933cad659fd0179394eaab578acbc9151fe2ef41bc29b36ecca5058", size = 52654213, upload-time = "2026-07-02T17:13:31.102Z" }, - { url = "https://files.pythonhosted.org/packages/69/99/05ea0d32229ebea695193ff20c15d6ecae25785ad82a9d4723d98832a284/lancedb-0.34.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:48829e88e708947d0520454ab9e4f8efa35f3e3626469eadd3a6e061b89cb223", size = 55434501, upload-time = "2026-07-02T17:13:34.81Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4e/4325c13d5afa93c466428a5a0f168ad4d96f5eb4a77bbe7c5100d39c9897/lancedb-0.34.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:05ba8a5b58e064edfbe5be71b1abf2e411b4eaf295d1a173dcb1a55c5bfb5285", size = 58659359, upload-time = "2026-07-02T17:13:38.424Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5d/8ca165f1386caf6c4d1c515afd52f345b66432264eecfdfb7fd33eefd9af/lancedb-0.34.0-cp39-abi3-win_amd64.whl", hash = "sha256:51cbc11808f9e3332819b9367c975b3a888541447a8e7bea09c57c852a279153", size = 63530726, upload-time = "2026-07-02T17:13:41.612Z" }, + { url = "https://files.pythonhosted.org/packages/23/2f/4ddcab82bb618c6c8de00725f3cf59585dcb9040964dde13cb0cae6ed3cd/lancedb-0.37.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c15c46f23cf6959c79fb93cdba2c76536cf784d3134386662da03dc6ccac3c26", size = 58474767, upload-time = "2026-08-10T10:41:45.675Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5e/dac0fd9478a21685444f23e6ec937babf4ff48b1616b68e8137778d6ecb9/lancedb-0.37.1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:35d872d920cbfdc3771fbcd33f2c63bff6ff7203d6d2ba8fe4330e98eb859d12", size = 61666580, upload-time = "2026-08-10T10:41:49.535Z" }, + { url = "https://files.pythonhosted.org/packages/1f/fa/ee1cdb1e904872d75fa0ff44ad35cd23494e810299c33e68de0687de7a71/lancedb-0.37.1-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:86597f4dbc51a33a07341550dc77d21a1ddd1f7539266eda5bb64c4f3bd11cca", size = 64802395, upload-time = "2026-08-10T10:41:52.969Z" }, + { url = "https://files.pythonhosted.org/packages/42/55/65c69307373b7f05dea38465b7d3836c86390f0ebc79b5c56d2c0919f229/lancedb-0.37.1-cp310-abi3-win_amd64.whl", hash = "sha256:488eca15361dfc34439500c9e2607c4fb2b8bf190fa1003bd54b1d6eb40e0316", size = 70994296, upload-time = "2026-08-10T10:41:56.609Z" }, ] [[package]] name = "langchain-anthropic" -version = "1.5.2" +version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic" }, { name = "langchain-core" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/be/07f180f98cee1826c31fd194bd676a43c0e99cdc746966eb61c0e30044a6/langchain_anthropic-1.5.2.tar.gz", hash = "sha256:11cda4fac1129f229022a5c7a9be47156dea02ecf6e666acbb050943eb2df613", size = 712706, upload-time = "2026-07-24T19:08:09.637Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/fc/52f6d1d6069bafb08626e204c89c49c8dd4a536eedbb94f0b7e78668594d/langchain_anthropic-1.7.0.tar.gz", hash = "sha256:d48e3c118ff8d3eea83f17b50234a2d2ff491a2375d565f212eb990e7e3856cb", size = 750068, upload-time = "2026-08-27T15:23:59.261Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/1f/554cdfcacec55ea00bbefa5d25c80bd3870f0509b532c89e26fffd25f56b/langchain_anthropic-1.5.2-py3-none-any.whl", hash = "sha256:49f94c4daccde31fd06bee70a1a0d00a0b9a91dda10fe1115495e63c9c2f90da", size = 53898, upload-time = "2026-07-24T19:08:08.517Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ce/e4367713080bc750e1dba845409c058f196543d1a0dc99683e2ef062f581/langchain_anthropic-1.7.0-py3-none-any.whl", hash = "sha256:68b34369aa01dad0c67bc690b8c47e09d06bd30fb28f320ad19ddc71c4445dc0", size = 60475, upload-time = "2026-08-27T15:23:57.989Z" }, ] [[package]] name = "langchain-core" -version = "1.5.1" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "httpx" }, { name = "jsonpatch" }, { name = "langchain-protocol" }, { name = "langsmith" }, @@ -1572,14 +1703,14 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/fc/84e23e8adff5adcd7792273ad610c4972ec534658a52698fb8db6defa87a/langchain_core-1.5.1.tar.gz", hash = "sha256:b0df382704c6403c1e0c9603415bce09290455d4aeeb38f350d15f78ca597483", size = 972219, upload-time = "2026-07-23T20:13:22.282Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/12/aff76ca89c219ebe6f9dd3c5dbc4e3b1cf5450e9fc7037dccad23d45cd7a/langchain_core-1.6.1.tar.gz", hash = "sha256:1b156cb395aac4f009a8a1b38a574c7d948fe2d5f74c96e0d8a5017b4149e04f", size = 1003359, upload-time = "2026-08-27T19:31:14.956Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/1e/1eb8833dc59b9c1f49da89a4a1ad7510440c9eb57fb539ea2203294a1acd/langchain_core-1.5.1-py3-none-any.whl", hash = "sha256:c5ec8f51dd05124f950c9afd0fd8bb3f7be4e405eeb868d481b2f8ff652cb9d2", size = 561634, upload-time = "2026-07-23T20:13:20.717Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/f50dd65673c819aa33d3c34df58c115dbb6ec627d19f93e6e401dd0fc8d7/langchain_core-1.6.1-py3-none-any.whl", hash = "sha256:954a84132a5cb0435d27b910e336347b6744ecc18fbeef1e2de7029a0959841a", size = 571478, upload-time = "2026-08-27T19:31:13.34Z" }, ] [[package]] name = "langchain-google-genai" -version = "4.3.2" +version = "4.3.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filetype" }, @@ -1587,9 +1718,9 @@ dependencies = [ { name = "langchain-core" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/2f/e03b63ad3a61fd1aa479bbc0f3df5d27abb8f9159d111cba96629df844ef/langchain_google_genai-4.3.2.tar.gz", hash = "sha256:6471769a4463fedb10d2d19a9b56c31de1cde505edf7fffd8cdbf98af8c1d7da", size = 286018, upload-time = "2026-07-27T16:27:39.214Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/17/dcf759ad83ab3566db1b02c8b21211f43085c66f3b5964f6fe573ffe8664/langchain_google_genai-4.3.7.tar.gz", hash = "sha256:8a57f936b1fbde52776fdde6673fdabd99cfa5cbbb122a926f1cff0ba00f6bc6", size = 373696, upload-time = "2026-08-27T20:44:05.203Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/cb/4a2eb187b108a240d57cf8dcf67e818ca75365f769444bf5716e2823cd98/langchain_google_genai-4.3.2-py3-none-any.whl", hash = "sha256:f3b1c09b264612fd1735a9590987bfa0cccca0bc0111691543decb5a03b8667d", size = 72770, upload-time = "2026-07-27T16:27:38.052Z" }, + { url = "https://files.pythonhosted.org/packages/47/2f/ade5d1a7ecfbc88f7de65c6610ef89d80905b29fa3dd28a764f7028f3d42/langchain_google_genai-4.3.7-py3-none-any.whl", hash = "sha256:8d4b1aa8f2c2e17b8e790d34a7e9a7b8e7d13e9a00175679d17647c235104bf9", size = 79611, upload-time = "2026-08-27T20:44:03.884Z" }, ] [[package]] @@ -1607,33 +1738,34 @@ wheels = [ [[package]] name = "langchain-openai" -version = "1.4.1" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "certifi" }, { name = "langchain-core" }, { name = "openai" }, { name = "tiktoken" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bf/1b/a83bf6cae4632363cef0b6f2ee1b4f62c8a5ebcf22cd8ef24430a736c2a8/langchain_openai-1.4.1.tar.gz", hash = "sha256:6d16be615d997db80294731b8e768783f1fb8e0313668e64acd50cd68acbad20", size = 3262416, upload-time = "2026-07-23T20:31:13.053Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/aa/5b4dbe8f90c44729fb7800bfde113ce1bc7c9687115d2e0d20fb377671e9/langchain_openai-1.6.0.tar.gz", hash = "sha256:7c545cc1f2bee048a7e219f375b78cd820b32c876ccf825320e25e1e94132577", size = 3290712, upload-time = "2026-08-19T21:44:45.334Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/1c/8b604dc8be2735c8ae5c655e520066231057d1301958c20c776e62bd00fb/langchain_openai-1.4.1-py3-none-any.whl", hash = "sha256:8528bb34cc78fdfd2d895573c7917f9441cbb82db5f18ae0e6b3b75d95bdefb3", size = 122067, upload-time = "2026-07-23T20:31:11.809Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fd/e22f2ffa2e2cf6043eb56f6a6bc5e18ac24da9b48b92cd458ba54af24b9e/langchain_openai-1.6.0-py3-none-any.whl", hash = "sha256:648112bbd135aa51d60d4aa2fd4ad353628a8d92a2f8e779244a0d9869a16f18", size = 125078, upload-time = "2026-08-19T21:44:44.242Z" }, ] [[package]] name = "langchain-protocol" -version = "0.0.18" +version = "0.0.19" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/56/913599f2f9cec8524868929f12d72b2ede377a6056ca8a40a32bdadfa535/langchain_protocol-0.0.19.tar.gz", hash = "sha256:79d90a1425122ac87e8052e2ec054fbd09c3edbf341bdfb6397112a495c7bf8c", size = 6265, upload-time = "2026-08-26T21:12:00.703Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, + { url = "https://files.pythonhosted.org/packages/80/c9/f6cbf357d48ccbd18bb394433b1fd7ad9be004eed9377ad08bb85777e5e6/langchain_protocol-0.0.19-py3-none-any.whl", hash = "sha256:4cdf879a492a35980fd859ae792d3c65458ccaae504e183c9a10d7eac1f0720f", size = 7327, upload-time = "2026-08-26T21:11:59.781Z" }, ] [[package]] name = "langgraph" -version = "1.2.9" +version = "1.2.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, @@ -1643,22 +1775,22 @@ dependencies = [ { name = "pydantic" }, { name = "xxhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/4b/0d1130e26b41a99dcc88353bbe7162a1f255c4db746bd94024268e6af27b/langgraph-1.2.9.tar.gz", hash = "sha256:385f87bc1802c35af7e0aa479278ecba8582d103515eb48256cb2ddcd42d0bd4", size = 722869, upload-time = "2026-07-10T01:30:14.985Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/0d/c8e7ee98896659e1b6555db0ab115a9ca899844744645d5d894032bab1d7/langgraph-1.2.11.tar.gz", hash = "sha256:9ecfe11e50d338b34b15cf4d8a442642de103e8ae6971320efba84e4542eb363", size = 725753, upload-time = "2026-08-11T14:00:36.945Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/16/0b8dc48823f1326f3e0c8012a3c07a40da6f194299e2ec080df236287baf/langgraph-1.2.9-py3-none-any.whl", hash = "sha256:c2d98ad94333937922ba04148641c1da2bfe45b5b8e55d7b6dcb0bb2df809e76", size = 247473, upload-time = "2026-07-10T01:30:13.733Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7f/c5c30e4be99ff821029c7ac872a480676bb179c9f3df85ea3f38d13f86d4/langgraph-1.2.11-py3-none-any.whl", hash = "sha256:8bab70de7b2d00b5300fb289bcf38d8b241400f3184c1e95e8ce706fb0e8686b", size = 248854, upload-time = "2026-08-11T14:00:35.494Z" }, ] [[package]] name = "langgraph-checkpoint" -version = "4.1.1" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "ormsgpack" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/e1/089c4c9e0a2fec7f883f82ae8e6a727138d50074cfeb6644bc2d13b1019b/langgraph_checkpoint-4.2.0.tar.gz", hash = "sha256:51a593b6bee684b0818e5d6e58e28ab340c6db7794575056ce7bd1b746a84ed7", size = 180239, upload-time = "2026-08-07T20:05:03.756Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/05/71/3b475f09bd57d3a5649792c66353312b4432afd843f301739dfcebd157f0/langgraph_checkpoint-4.2.0-py3-none-any.whl", hash = "sha256:0547fd228935a0b758865de3a3d6d7a2537c308895d0f9ab092ce9151b5da942", size = 56833, upload-time = "2026-08-07T20:05:02.655Z" }, ] [[package]] @@ -1676,7 +1808,7 @@ wheels = [ [[package]] name = "langgraph-sdk" -version = "0.4.2" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -1685,14 +1817,14 @@ dependencies = [ { name = "orjson" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/ae/91446c1fffa04a2dc1f81afbfb5bfff3590452891a72bd9098a656ab2657/langgraph_sdk-0.4.4.tar.gz", hash = "sha256:4e651ffa09de695681579396375377bdde23bedbc8e35b070e615cbd5af7da8b", size = 345789, upload-time = "2026-08-27T21:25:22.593Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, + { url = "https://files.pythonhosted.org/packages/43/d5/2ad7f3a835c6fcebf58b6669552536ecd52d2dfe4cf49c6c36648fd83572/langgraph_sdk-0.4.4-py3-none-any.whl", hash = "sha256:39afe416c91742925e6f8a93715f566d499b36e1b636b804a4ffe3190e4f4e64", size = 162270, upload-time = "2026-08-27T21:25:21.072Z" }, ] [[package]] name = "langsmith" -version = "0.10.10" +version = "0.11.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1710,9 +1842,9 @@ dependencies = [ { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/38/c709ff119172e490a8e8bccd10deeda8da239f15f11521009a58c7b904ac/langsmith-0.10.10.tar.gz", hash = "sha256:e0e175e9dd8de6d96dbb55244482e20590a2691ec7c5e087afc1f302e33d98fe", size = 4739726, upload-time = "2026-07-23T04:18:56.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/0a/1acb2a3ffbccbe8f8dc358778967c9d2979e8a59b67ceba6eb54474324ab/langsmith-0.11.2.tar.gz", hash = "sha256:927694c939c9fb44187e0126cf718413c45ffce2324d480438e70eb0526e1380", size = 4841592, upload-time = "2026-08-27T22:31:34.004Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/ec/0b2348bea502e07cb3c2a6e5109423debd82aef9fcef6f807c159ceb5297/langsmith-0.10.10-py3-none-any.whl", hash = "sha256:eb5e9da635f5853fc657cddd1c27f40a8e8b2f00c38051555d608c8e57eb605d", size = 675232, upload-time = "2026-07-23T04:18:54.399Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/cec0c1d24dda8c67a23ad204d0167e50aee202460e9388488661d94513f5/langsmith-0.11.2-py3-none-any.whl", hash = "sha256:75258142d27dffcc5df331479704b23fc3fd812cfca0469119bb9055a842882f", size = 753928, upload-time = "2026-08-27T22:31:31.644Z" }, ] [[package]] @@ -1738,26 +1870,27 @@ wheels = [ [[package]] name = "llvmlite" -version = "0.48.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/a0/acc8ffcd5bdc63df0097e22c719bfcd61b604358343089313a8aebbb24ab/llvmlite-0.48.0.tar.gz", hash = "sha256:543b19f9ef8f3c7c60d1468191e4ee1b1537bf9f8a3d56f64c0ddd98de92edd2", size = 184016, upload-time = "2026-07-02T20:20:05.308Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/a2/28696a9e61e245d1a79816d29d106692a90a2b6e7d78c98b326db70827af/llvmlite-0.48.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:d66c3beb4209087ddd4cf4ed2a0856b6887e6a913bdcf1aacfec9851cf2cba4e", size = 40480651, upload-time = "2026-07-01T18:41:35.694Z" }, - { url = "https://files.pythonhosted.org/packages/80/f2/72409351db66d0a317ec5087e076f31fb7b773a640db8a90ce6b5cac9edd/llvmlite-0.48.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:416fa4c2c66c2c6dc6d0a402648c19206e548efa0aa1eff01ad5cdad0af8217d", size = 59890118, upload-time = "2026-07-01T18:41:44.886Z" }, - { url = "https://files.pythonhosted.org/packages/3a/27/5ae2f3722606360480707adb47f001ad89df8251d06b14ee80336e660b66/llvmlite-0.48.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5e5a5131045b72345c71062ea1a91910dde913792b6c9b28ebb2c1c0a712e98", size = 58343459, upload-time = "2026-07-01T18:41:40.306Z" }, - { url = "https://files.pythonhosted.org/packages/16/78/d824ffff7521cd140dc2006e44ce2bc82e64b48d1b32e90e956308c85a74/llvmlite-0.48.0-cp312-cp312-win_amd64.whl", hash = "sha256:d45c7541a80934ec6d8ab0defe67439494ecd2193cbf852a44ba827808976ac1", size = 41865022, upload-time = "2026-07-01T18:41:48.663Z" }, - { url = "https://files.pythonhosted.org/packages/9c/23/fe9316d14626b42c73ef0b502e724705a6ee9450afe53759c0a99c37c2d7/llvmlite-0.48.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a83a99ef0c05b4ccddf9b6218ed9fe84b653a0caf7c1d9dbe148d6d16c67f518", size = 40480652, upload-time = "2026-07-01T18:41:52.216Z" }, - { url = "https://files.pythonhosted.org/packages/1b/4a/90715fa12006d681270b08d881195b6fab3ec39572e048764a1f7f59fed7/llvmlite-0.48.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8761b9e522f55207e24424fcd98370289eec2710bf8e915c82d1053f642450dc", size = 59890120, upload-time = "2026-07-01T18:42:00.748Z" }, - { url = "https://files.pythonhosted.org/packages/70/5e/7b3e20d64650ca3c80af0cdb664ec4b575ec83d9d4dd05bea8bd31f9bbb6/llvmlite-0.48.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fe5cb59b2063bfa039dcb8ca6481c0181bf552f340d10dcf61d7996a665556e", size = 58343457, upload-time = "2026-07-01T18:41:56.41Z" }, - { url = "https://files.pythonhosted.org/packages/17/97/5a430055d1838cf1fb7a01cfa943300f5e4c026fc6333a522c5e4a03b0c1/llvmlite-0.48.0-cp313-cp313-win_amd64.whl", hash = "sha256:91c7e24e74cde3f02b88aa5acca678373f9e069f3b98531b3dbb3a142d9d10bb", size = 41865022, upload-time = "2026-07-01T18:42:04.57Z" }, - { url = "https://files.pythonhosted.org/packages/8d/8e/8170f2e0c217f88069c333d85bb976e536b332aecfcce606ddbdb249385f/llvmlite-0.48.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:321f1ac39b462603f0b589751aecf2d237d056f6d005749c1752b6f23ec3f074", size = 40480650, upload-time = "2026-07-01T18:42:07.935Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e1/05b50692b647cac3c18200ac485b04f342f00ed173c9cc46767274469a15/llvmlite-0.48.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:05f0103c8f2f96a37441337e3643863c01b8e83e530aff38960dcb383c54a065", size = 59890115, upload-time = "2026-07-01T18:42:17.805Z" }, - { url = "https://files.pythonhosted.org/packages/f7/c3/470b8c4ff9ae2db2f9cf5c3e73de76ed908a32788ae9eb5602d43e6a476b/llvmlite-0.48.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37d66fae72802175b0bfe1ea06e624b51e2d7aee6c3c34bbd09739b8f88e8e0b", size = 58343457, upload-time = "2026-07-01T18:42:13.217Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2d/6a5171fb7236ac0895e1a02ccba3735bf291e8597239aa6421894d3c0ba8/llvmlite-0.48.0-cp314-cp314-win_amd64.whl", hash = "sha256:966dcab0a598e2bd8fb5f2cc082cf7b07bae564fc485a3a8692393caf986facf", size = 42986372, upload-time = "2026-07-01T18:42:21.483Z" }, - { url = "https://files.pythonhosted.org/packages/94/e3/7a93e09c9f94e637ca90209ceef0334a9a1d45b0bdb7c92ff922d25d6187/llvmlite-0.48.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7a5c413317050a1d67c34708bde97707f9b2257ef1017f7532d21fe7d9a9ff30", size = 40480654, upload-time = "2026-07-01T18:42:25.076Z" }, - { url = "https://files.pythonhosted.org/packages/27/98/a29133b4728671a175f7d616fab8b1c6e1d8c269d1523581d3160697bfb1/llvmlite-0.48.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d9f952dff6c350c529997423d4fa43abae9722a884ac7ffafc37a3af676e7db", size = 59890119, upload-time = "2026-07-01T18:42:33.88Z" }, - { url = "https://files.pythonhosted.org/packages/1a/cf/7aac11a1f1c7ec54b60c7f6814e87561fb6b55b2f290455d7941eb113420/llvmlite-0.48.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:054aa7d46595935565f276cf0c1659b4f10929c996dd4a606875fae26fba2a23", size = 58343460, upload-time = "2026-07-01T18:42:29.545Z" }, - { url = "https://files.pythonhosted.org/packages/db/41/b96f440c7df5ebba07872cad4e30fbc3560387755b1ea0b629adb76d5ca8/llvmlite-0.48.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d0b3c61aac83b42fb48cc96bffbf57c81b82b2aa92276b7ed6420c814629a99a", size = 42986383, upload-time = "2026-07-01T18:42:37.544Z" }, +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/27/72ae94ea5c8f7349ec1c229d4cd058feb799cbd0833ad6d1b47c919b37b7/llvmlite-0.49.0.tar.gz", hash = "sha256:00f16db782f4a13c78c5804aedc434e46794a77e89999a168f9401106270e50a", size = 194467, upload-time = "2026-08-11T16:26:00.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/ae/3f699ebe3590e15e023a6372dd147526fd8ec398aacf9ceb844e854964a8/llvmlite-0.49.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:b541c8fac3450db7574d1f53cf9dff83f285bfed9d69bf81fe71fc2a7d4f97fe", size = 40479231, upload-time = "2026-08-11T16:23:56.773Z" }, + { url = "https://files.pythonhosted.org/packages/be/3c/e97f69c62a2d972066d9a2612ce1f3de313035ac897a5b9f787cad8b55f7/llvmlite-0.49.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6acba646d88abbc87d5c113a3d62c1fbf8b8fee11c6493f516803e30f21ae870", size = 59890658, upload-time = "2026-08-11T16:24:05.451Z" }, + { url = "https://files.pythonhosted.org/packages/69/e6/e942ee08605fc0526ff3854260c384d8315a5830e16c4c2a5aebc14dc9bf/llvmlite-0.49.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec8ad805e7515cb8440a690eb3cef4d34acb29eef80b705ec4e1c1ad3c43c68", size = 58344481, upload-time = "2026-08-11T16:24:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/49/2a44871cac6b5a2fd4aabd68cfdaf6de9a5c7edb36dee5d47b77bda4b50f/llvmlite-0.49.0-cp312-cp312-win_amd64.whl", hash = "sha256:3a9c9e3af4e214acfefa4f73ebe7bc3fb35854a62b654edb3953f5ae33c08ba3", size = 41865543, upload-time = "2026-08-11T16:24:20.41Z" }, + { url = "https://files.pythonhosted.org/packages/7d/85/0b536a3c59f2636d9dd51dda832b6c1d0ffec37608429dedf128664918f1/llvmlite-0.49.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:039fa4054a06f537fb39248d4472284ca96be311a142ec09e69f95630ab469cc", size = 40479230, upload-time = "2026-08-11T16:24:27.295Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/ca8ba47b057b793099784475499771780ec46839f2782f753a7079d23520/llvmlite-0.49.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddc7aecd4f56397ed6e8f120ec5dcd5a1a8f0e6032ca4af413462792d4dca2e3", size = 59890659, upload-time = "2026-08-11T16:24:35.595Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/9526dfdd33a923f33e29a18b8f9801ee7ee4b7397e88d28192c1024c4a75/llvmlite-0.49.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3dee64784201b64c13a8df62c48a4f4218858faaa65889866bb29bdc243c038", size = 58344482, upload-time = "2026-08-11T16:24:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/96/7f/9f5afcf6476b228d6b170408f377a0c4f91477fc1fc91f8141088b45bf46/llvmlite-0.49.0-cp313-cp313-win_amd64.whl", hash = "sha256:a1b414dc6b164738ec39dd8987cea73829057b7dd92fc6d91b52838385fc1dd2", size = 41865544, upload-time = "2026-08-11T16:24:53.962Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/16599b8c9f21802448059482eab48a9e74086dc56b901a677ba355565e64/llvmlite-0.49.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:80a84683d04516bb51da1bbeebddaf2c2f558809c93078a8f91807909ae331f8", size = 40479230, upload-time = "2026-08-11T16:25:01.513Z" }, + { url = "https://files.pythonhosted.org/packages/3a/61/0b23849141a4c4e7091fcd158ebb45195896974bebca3e58fee7cad4b4f4/llvmlite-0.49.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4281a0171d66d2098adce4ba706b8c550b1b10718650f682d64cde16e84e4de5", size = 59890659, upload-time = "2026-08-11T16:25:08.733Z" }, + { url = "https://files.pythonhosted.org/packages/c4/92/628692b74b31e27af9ba7e8ba651941ee4956959d5478123c453f59aad4a/llvmlite-0.49.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b095f15fb12c4d90495df5b1a3772b4732cc408398b204a787dbedd370e09c69", size = 58344479, upload-time = "2026-08-11T16:25:15.731Z" }, + { url = "https://files.pythonhosted.org/packages/96/8a/412fc273521b02cbfe0b5f8ad56cc696385f6eaeecdb9e9ae6a90111d98d/llvmlite-0.49.0-cp314-cp314-win_amd64.whl", hash = "sha256:294e2f0b70aef8f92d0ae7b203e2609f08beb39437eee73de59a21669331aae9", size = 42986588, upload-time = "2026-08-11T16:25:22.534Z" }, + { url = "https://files.pythonhosted.org/packages/fc/15/f47cf45c00c8b165ac3d268502dcb21d900e86f27fd338268a66ce922ab0/llvmlite-0.49.0-cp314-cp314-win_arm64.whl", hash = "sha256:95d1071023ed858b79f6971954fd7cc1f5dbcbab987718a4ccbe1411e47d0b81", size = 37441881, upload-time = "2026-08-11T16:25:28.324Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2e/eafd488766d1c02413cba24f7b22acb9b3ccdfd8407e98d30eb16bac4e2a/llvmlite-0.49.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:f3f2ff0aeb17d34fcce9f79b99baac441cfd3efa41b83e233ca4530a72381f72", size = 40479230, upload-time = "2026-08-11T16:25:35.125Z" }, + { url = "https://files.pythonhosted.org/packages/98/07/a2c4f04e2111ccc274b4d5e3331398a9dcf6d6e5e55d6444b1ad9d6381cf/llvmlite-0.49.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d5555ea1d63928481cbf7fcb1d67452b216c7e5b393a4eb7aa1401e67f2a4fc4", size = 59890658, upload-time = "2026-08-11T16:25:43.294Z" }, + { url = "https://files.pythonhosted.org/packages/80/f9/7b7b50f80b4585bcd78675ff3110c256877b11df32a8cde284f851762f57/llvmlite-0.49.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32adb84fdaae28aeb86fdb6253084ee707ee157289a2e98fe3caf48a62bee82", size = 58344482, upload-time = "2026-08-11T16:25:51.527Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c6/32d68bfbf1d0c36888530ef6fd72864861af23dc546302b41033471a8c3a/llvmlite-0.49.0-cp314-cp314t-win_amd64.whl", hash = "sha256:be637e465010bc9c50f070468f7f1cf5385e92fee364d192dd5e6cea790ecba9", size = 42986602, upload-time = "2026-08-11T16:25:57.69Z" }, ] [[package]] @@ -1775,100 +1908,130 @@ wheels = [ [[package]] name = "lxml" -version = "6.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" }, - { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" }, - { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" }, - { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" }, - { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" }, - { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" }, - { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" }, - { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" }, - { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" }, - { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" }, - { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" }, - { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" }, - { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" }, - { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" }, - { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" }, - { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" }, - { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" }, - { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" }, - { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" }, - { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" }, - { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" }, - { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" }, - { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" }, - { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" }, - { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" }, - { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" }, - { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" }, - { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" }, - { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" }, - { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" }, - { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" }, - { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, - { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, - { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, - { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, - { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, - { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, - { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, - { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, - { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, - { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, - { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, - { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, - { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, - { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, - { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, - { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, - { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, - { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, - { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, - { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, - { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, - { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, - { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, - { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, - { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, - { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, - { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, - { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, - { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/a9/970b8fa0ecc4fbf1dfaed0d89bbc1fc1421b25ec26a2038c91e872dc6c8e/lxml-6.1.2.tar.gz", hash = "sha256:1055241852f2b02068af4a625a5d32c087db193c12251928af2562ecd2239f18", size = 4210626, upload-time = "2026-08-19T04:58:15.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/a4/55eb54507073089ab27743c5da2113c84f0d0b1715b33175fdd943c9652d/lxml-6.1.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7d506bdba580ecb1a6ad2e2b5c49445e66d3e1f95894885739094393a1aad237", size = 8602111, upload-time = "2026-08-19T04:58:28.017Z" }, + { url = "https://files.pythonhosted.org/packages/bc/bf/6332f45d78da385bb01d5cac3fe4acda19f025d1307cbc7ad538355fecbb/lxml-6.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12acd337d2821cb8b9247dfe4b7aa2f2769a3df5ae8511b7e550df42b8f4d3c3", size = 4638376, upload-time = "2026-08-19T04:58:41.181Z" }, + { url = "https://files.pythonhosted.org/packages/68/e0/21fba0fe74d417fbe976903ae6bc77e92cdce01aae7b636abd87756f4588/lxml-6.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5078ff51e6316c0f75ea8127c2cd24374747fb351f62fb93d1761f8ae5a04a40", size = 4939689, upload-time = "2026-08-19T04:58:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/de/e5/ce3e885264fdd0bdcb6b49c1ea1842f94281b39e4ff956099e8d57532c60/lxml-6.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9477e14217c212e6023c994a71a1a349db19b0e10fd5bf189666b281ae63b1fd", size = 5105185, upload-time = "2026-08-19T04:59:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b6/990a8446c488c70fa25681e150de94b7bf2eaaf387e374d195ab3c8faafb/lxml-6.1.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:261d98065326676d7253882db0198d0aa06748d7ee0443367acf10b148273f99", size = 5011863, upload-time = "2026-08-19T04:59:50.58Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6a/f70f41363dae27e3bfd6224b128f5ba150874bd32ca4938552930ffa33b0/lxml-6.1.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0666943ee1576fa890a6dc6316ef42e8241b5dd56f67bc5475acb2ac298c6ca9", size = 5638234, upload-time = "2026-08-19T05:00:00.802Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/a65b64f34d556925faef2c4f14167d58c571bc15a3e1f2bba71138830562/lxml-6.1.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04cf9e3f4ee9cab9d9ba05401bef8668840fa9620fcd4d8e85a2d2fd0b0fa960", size = 5244532, upload-time = "2026-08-19T05:00:07.516Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a9/471552e015e954fc9d960aa27c3d67ebf489683d03f033399a790417c67c/lxml-6.1.2-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:9429d2371d406344ed1da5b5686d9412e74137c07b0171278368ff704f470ed5", size = 5358194, upload-time = "2026-08-19T05:00:22.747Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0f/bc6248fbec2cc416f102b1267f1567e07510f6fa909bbe8cd2a22d6fb78e/lxml-6.1.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:eff128ffdc093cc6317955934ad9751105d37ed8dbca3ff4ccd751af6be37185", size = 4704432, upload-time = "2026-08-19T05:00:51.115Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3f/cec859f50e63f1fa338fab43d2362d7543e1237f2475960d8ab0769de0eb/lxml-6.1.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ba58574d710b82ead7cbedea01cac3e110bc3ef82d4731519b74a2c11f7cf5e9", size = 5255038, upload-time = "2026-08-19T05:00:58.895Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d9/2ced0cf2967115f92a1b8b3ae6bd18763abc3ebef88c98cf25145fda396c/lxml-6.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:52f6d4dff133c9778a24e9a2cfc1608930b15869866171aacc5131b5a418a003", size = 5054481, upload-time = "2026-08-19T05:01:10.096Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f5/4f07386d3c88673daeec3b8cc09a2a4d39fa01c1fc49009791b0746d97fa/lxml-6.1.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8807998c1023d1e9d60e02500f90e85a0752dbc0b670989806bba87b82dd5b42", size = 4785535, upload-time = "2026-08-19T05:01:18.909Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5a/f4fe3ecbc189f48fba2547c5db5c940a10151d3e86b856a60a533a77e816/lxml-6.1.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2170d0a280c877b6e2dc6738217db947be35dd8cf09ca458b355aa1bab2a9e70", size = 5655337, upload-time = "2026-08-19T05:01:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/f586aa1bf27bfbace2dfdbb704da5c52f0bdece8ee440c8fb4946c940b2e/lxml-6.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c67f3c1278f942e97d8665c2a690324aaea5137de16f056583a21f0ac706177f", size = 5245778, upload-time = "2026-08-19T05:01:45.227Z" }, + { url = "https://files.pythonhosted.org/packages/18/a1/677494bbaef4d6db5e4633af817414f478865850b55c03ae4bf70fa7b8ca/lxml-6.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:093fbf547d0f3ca02705381f795a050fbb58988be4aac7f79f99f280c4082313", size = 5267274, upload-time = "2026-08-19T05:01:57.687Z" }, + { url = "https://files.pythonhosted.org/packages/5a/71/b71425b8764d4cb7c92eb970483be7d5610dce2a6316242b5aaae7d260be/lxml-6.1.2-cp312-cp312-win32.whl", hash = "sha256:be365ce8d2d411cf2fb573747684b4fd470fa6224e0094d9d5a21155acc369d3", size = 3602563, upload-time = "2026-08-19T05:02:01.837Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fb/909584e16d2148c1a252cc2c32dd99fe0e2682459c586d3d7a192e74a0ae/lxml-6.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:b97153ca609b434b712ddfb92cd6af101a7045a7724c542258bd4727a344472f", size = 4005965, upload-time = "2026-08-19T05:02:07.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8d/41207c9212caad0b52749e34739fb9bfab67486729f52a8fe9bd9266fee6/lxml-6.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:7feb72424f19a893ae4f3373c7aae821b1aacb6076b708915c651f0683a97c49", size = 3666641, upload-time = "2026-08-19T05:02:11.3Z" }, + { url = "https://files.pythonhosted.org/packages/61/2a/e9651f47a31a60b5cae031abc23391ed9aa30c8fc07571d1a38f58d6d770/lxml-6.1.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:351318f5c0eb7fcab5b4fdb507c6f88fb2c4b5e67784c7e5911448c91fffb5d4", size = 8590165, upload-time = "2026-08-19T04:58:40.489Z" }, + { url = "https://files.pythonhosted.org/packages/61/87/a8098abaf35118767d1703b84c98940a5d833064e0eca39a00ecfe9840ab/lxml-6.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0edde95e4b4278dcc0175eda06dc8aa2631ad9f83ae5dbdbc4f0925e200b0b0", size = 4632474, upload-time = "2026-08-19T04:58:47.465Z" }, + { url = "https://files.pythonhosted.org/packages/93/cc/fe74d1def7f4fb967c4a825608a074d4dbdbb871b0d6bd59c6ed07d67868/lxml-6.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8326e24ae6c3a6bfb03fa8b4793f9a5d804c125228aa067f652b0428e31b87c", size = 4936196, upload-time = "2026-08-19T04:59:03.477Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ad/b96e6ca926e26726a99aa643602aac7411ecc1731ddb1b25af8cc57edfcd/lxml-6.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7c534ed898413f439b048130011e99a4245ee13d62d431f6b4f7f2484d02a93a", size = 5093290, upload-time = "2026-08-19T04:59:17.498Z" }, + { url = "https://files.pythonhosted.org/packages/d2/84/616f5d3b7cd086fcfba3e5add6fccda67f976c1c753ae9ed7bbd317cb9be/lxml-6.1.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e37fe49fe2d5aa40a2cb1cc8176673ad7de0d124e6f4a509d9318f5979c7871", size = 4998767, upload-time = "2026-08-19T04:59:28.385Z" }, + { url = "https://files.pythonhosted.org/packages/80/88/d5b453a8d083483c9442ad7f5ac5c560796022eb5c80d60b65d75e449236/lxml-6.1.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9b52ea73a37fc64aa3357ff8607801d46dd170506d3cf8253a91a1d91639d4f9", size = 5626717, upload-time = "2026-08-19T04:59:40.045Z" }, + { url = "https://files.pythonhosted.org/packages/71/45/31e5aa4d4bae024908ba1d03480c7425cf027a28b7e5c88d1b7202bd80cc/lxml-6.1.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8b9a92652e75e7731309ea51db5dee892eef414ce70a6ec3441e5d36bf5189f", size = 5232330, upload-time = "2026-08-19T04:59:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5a/2627912420df8b2d31ba3014da5539f15ec85add01d42048864ffefda516/lxml-6.1.2-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:9088da25ecd609965f838d89fda0465a905b48f4dd90331db9845518f2177372", size = 5347054, upload-time = "2026-08-19T04:59:52.762Z" }, + { url = "https://files.pythonhosted.org/packages/16/86/54ac0f529b22a8f12313726dd49e12961bb46471d9028cc28d2a29408f0b/lxml-6.1.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:0349321a0537d4fdbebb2af06dd1b64676132c72e2ae250de8cdb58f8c43019c", size = 4707275, upload-time = "2026-08-19T05:00:04.836Z" }, + { url = "https://files.pythonhosted.org/packages/3a/42/ffcdc6e4519be90df907cdae7e88409efb25d823ae4de8846f737dae1884/lxml-6.1.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b20440e578d269c5e8a722ab602ddd0f0cedb8b080006b3f936da9991a593d3b", size = 5240071, upload-time = "2026-08-19T05:00:19.604Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/5b1d7ab35f013f1127ec48f3108319f58b65b00d5cb26f215adbe86eadfb/lxml-6.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7766e525282dd38fd89567311323e441996eb958e8e816d16b38f782e3aecd2a", size = 5050356, upload-time = "2026-08-19T05:00:27.968Z" }, + { url = "https://files.pythonhosted.org/packages/b0/57/1cf049d054189b55c8fe8012269234f6602256949b69cd3ba80608a88219/lxml-6.1.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9221442682c27417f10fe11184ea4cce174b25ab52465570b1f3ee3f85f320fa", size = 4780394, upload-time = "2026-08-19T05:00:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ad/064488a8fa60e639fd773e421a18bf17541d02a95fbf36238ad7c65f69d4/lxml-6.1.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75530642d8471327e691ab9b0513a5f9c77f38871014ceda40f51bb51765c0a1", size = 5645854, upload-time = "2026-08-19T05:03:42.697Z" }, + { url = "https://files.pythonhosted.org/packages/85/bb/120e56f3cf1c149bb3b014278fb86d0a6dd552403981081f0ee0a0a57be7/lxml-6.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:678e35f1cbca98f55107511ee21a60568535c950f3c2371819bd64504c980d20", size = 5231132, upload-time = "2026-08-19T05:03:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/7d49aab893c128671a3276580074cce4c002896145b8dd2893da79633bca/lxml-6.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c2bae42b3a09f977330a08f4a8fe72aec58c4bdb89069d3fe7272a71d885881", size = 5256076, upload-time = "2026-08-19T05:03:48.092Z" }, + { url = "https://files.pythonhosted.org/packages/72/28/ddea3aa1fa9acfd384fe34d4a2a93eecc07541dd2d922fa9b140c60d8014/lxml-6.1.2-cp313-cp313-win32.whl", hash = "sha256:5848f3de6a8de8a93cff9f068134393ff5fa69ac2a04399f7d49cd67c61c348c", size = 3602177, upload-time = "2026-08-19T05:03:50.571Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7a/96bac167538748cae2544335855f812fa33e49a9a67bc8b8520dcbd592bd/lxml-6.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:6cb0c87421946030b92b558be416852780a912454e3dcba0998e4497c9c588d5", size = 4004117, upload-time = "2026-08-19T05:03:53.074Z" }, + { url = "https://files.pythonhosted.org/packages/0a/24/9498fa3c84135956e5ef55ea4d8bd11e999e381f7f210fb6f8c6a980ef03/lxml-6.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:648861c19b775b89ebefa14586f85090b10163367476d77f242c4131c835ce73", size = 3665412, upload-time = "2026-08-19T05:03:55.621Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/728b0578791b397ace8d1b101c8b3fe10f36043542f7bb85f82d8bdc3f50/lxml-6.1.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d50a44113fe6800dcc8a859332b823a4735b1e6ae1b0063882e4cca569ec3e29", size = 8609651, upload-time = "2026-08-19T04:58:42.42Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6b/49209fa6225c15c48a30061f03d3aba75e3c19634813b88bf83b88c525ed/lxml-6.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fa813b0247d0543a563b993ac3dba6168eef59e3a61448432cf5453300c2412b", size = 4639588, upload-time = "2026-08-19T04:59:01.501Z" }, + { url = "https://files.pythonhosted.org/packages/20/86/80bae4e8bc2eed9d6f017701a3d86fdea56936218efa738911d0b76aa7f4/lxml-6.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d858e718b94033ab4b67e4a58fe3114c65bae01ae2314a62fb39ae8897ed4324", size = 4964846, upload-time = "2026-08-19T04:59:08.59Z" }, + { url = "https://files.pythonhosted.org/packages/70/ce/4782caee7a22959c1ac67cb46495e03912c22a4ba7d20c163496a519e815/lxml-6.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e3b666f57a5d81562f38c766c762416b0f6eb58a00590546911514b48412abd", size = 5099288, upload-time = "2026-08-19T04:59:18.649Z" }, + { url = "https://files.pythonhosted.org/packages/32/21/f120967cc43b54e05512dff0c39726b832c836195d30f41f88733ef36ac8/lxml-6.1.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26ff164c6629e5c4d11c9e55d5ea3d6eed0be2a420eee1f55cbce6e2c23e231a", size = 5036837, upload-time = "2026-08-19T04:59:47.217Z" }, + { url = "https://files.pythonhosted.org/packages/61/ba/8005e9f47598e3ec5c18312c77f94e889580027616678848405c6aeba5de/lxml-6.1.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:962c12b51d0b164f12569af225dea57568477e24a845b96eaccbef6c07e4cc03", size = 5658569, upload-time = "2026-08-19T04:59:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ba/add33b3c7ce51462cf7a4637bcfec2eaa258364d6015b989dd7d1216e6a6/lxml-6.1.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47e367dfe341521426692819803e260d0673899c0ff611f14af978d725e2c999", size = 5246003, upload-time = "2026-08-19T04:59:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/05/b3/a43012748fb861c914c5eac1c1a3bad44282e767499cd02280d4d1edf092/lxml-6.1.2-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:92c2b366028ac01e90399e6d17734ce6e4f4aeddd8ba75fbaf80ea11d6c6d645", size = 5354047, upload-time = "2026-08-19T05:00:21.657Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cb/813021d9a445713b8d758b9e5eae2ed392cd598d9f119d9b053b37c2ab93/lxml-6.1.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:7e81fc065ede5d58dd0bf0912025aee1bd04c52c2affd61fdb93226a97ce2fc6", size = 4704382, upload-time = "2026-08-19T05:00:47.067Z" }, + { url = "https://files.pythonhosted.org/packages/17/c9/1155299f4577bebf3c280497534a73e4b8ad8cab3b96074731ad10949d4e/lxml-6.1.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:633ac039cb32366dd5935868e041e385875c017b8cd54ea56aeee3fe29ca5935", size = 5258530, upload-time = "2026-08-19T05:01:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/25/6e/d76e58384b378b877e140e25b9a9835da00035f81ff70cbe943a3749bf27/lxml-6.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f3194777c0d05945ac91d8594be25d2679d1d826e01e1fc90bae568ff3a547b", size = 5089919, upload-time = "2026-08-19T05:01:33.602Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b7/898013c0f8891481d0624ab3bd5dd8c8ff827232dfee2a5d1f8bf970a7cc/lxml-6.1.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1133bd969f2bfcc6b0c0cf7cdf5f2631e62b23fa2471ee8bd44f6ab73554ee9a", size = 4741972, upload-time = "2026-08-19T05:01:38.18Z" }, + { url = "https://files.pythonhosted.org/packages/c3/47/efb53c4d7b655831c03317a450d9da439b0829c61f34d9d4fe7c863445d6/lxml-6.1.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1edca8f4a92b94e873093df959f141d388f2141fcad0c47598442fb4730ef57a", size = 5683241, upload-time = "2026-08-19T05:02:00.731Z" }, + { url = "https://files.pythonhosted.org/packages/da/0a/0ff36a584cbba14a71326ee8a5300694400f0b97927d1f90a87d95b17d4a/lxml-6.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8512b3775d68994dd1d6d533161e0a214f2ad9c634659d34a99c98e86c6c3d68", size = 5245892, upload-time = "2026-08-19T05:02:06.108Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9e/303717a1aa56d4bd775c91936717d3c9e8d999a8e8b68b00979c4c1f93d0/lxml-6.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5005c0c9e4d749a76a2ff8bd5918a8bb248df8e08e73a55654b9f79c9cd1e2b", size = 5269528, upload-time = "2026-08-19T05:02:09.883Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c2/2ae7cb97089eb86bf0689516db3cf280a007b6145853d2a0235a1f01683d/lxml-6.1.2-cp314-cp314-win32.whl", hash = "sha256:e17e2c30e27f56da5551e7a425888b45f013e940b99ab07d125a1c33f77a4605", size = 3662743, upload-time = "2026-08-19T05:03:02.513Z" }, + { url = "https://files.pythonhosted.org/packages/77/13/a3d483230a09201e211ceb1aa208b1374d27d23b8b180d74dba14b30f6b3/lxml-6.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:87e9673cd8a3445024fe38e7f91b55fa3428437eec9b7a7ff7d81979520c0d2d", size = 4073942, upload-time = "2026-08-19T05:03:04.864Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f1/c1445d4b6ad7c51e39d4e2ebbf015a4880f5b297a4ab0e77e4d0e5b70110/lxml-6.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:878e7c8ada8f92c52f13f35a2ab98ef0adf7fd0211d164fc2af589e4c3cfed63", size = 3749235, upload-time = "2026-08-19T05:03:07.239Z" }, + { url = "https://files.pythonhosted.org/packages/9d/eb/598c76f4ce19a67c635e86a46d880cc854f308f39a6f1fdf13bbb01813ec/lxml-6.1.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:94162456ed0a64fb1c06915df5bd06af4675ae3966d6048fcb73b0906e0e0222", size = 8860315, upload-time = "2026-08-19T05:02:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/da/c7/1f9fac7b566a86ad0da13dcc0259164266469c0ad86744c740ccd5c2a081/lxml-6.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4b0fa7109b1d0bc1747d8241a0853e135eefb1c978685241b544c46937383efd", size = 4755176, upload-time = "2026-08-19T05:02:18.705Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1b/cfda9307388d496e7eeb7493d9455896b8137ed95f51f3d6ae6ddcc14a47/lxml-6.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:604f4778632588d7c000e7e19430639dc12fca58b5b6e99edffba7631725ef0e", size = 4979444, upload-time = "2026-08-19T05:02:21.262Z" }, + { url = "https://files.pythonhosted.org/packages/e5/71/f732c8919c45b7f29acf443288c6e90036877a67bfeeb1acceb0fffa011b/lxml-6.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a096d6a5f96b776a5b020cb45c17c545effd2a3b6639e6fa97bc95537600923", size = 5115887, upload-time = "2026-08-19T05:02:23.62Z" }, + { url = "https://files.pythonhosted.org/packages/30/00/121d52b944f41e33ea86c62875f902d24982842dc7231ab154ac5a6c6593/lxml-6.1.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6454d184d556eaf4cb3d6f69e405d21602d6fdcf08b8d57796824275986c6595", size = 5032418, upload-time = "2026-08-19T05:02:26.114Z" }, + { url = "https://files.pythonhosted.org/packages/70/19/cadb73c7fe48c7563dc8ab62ea53d5b920c8911bfb808507a6daa82e78d2/lxml-6.1.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b68f2548259bb04e0b3d5df0c397abe8b0080f5e1ffe4019fb7a8bf01a9339e", size = 5603304, upload-time = "2026-08-19T05:02:28.694Z" }, + { url = "https://files.pythonhosted.org/packages/13/32/9de126a14d5a5db8c371c5ec869178417db226707b62a47273a95ae6df7f/lxml-6.1.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c9cc4b6532abe154dbdebb42aaba8d52c852919591e45067f5b7d46a0405e88", size = 5228938, upload-time = "2026-08-19T05:02:30.99Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9b/22dd9e843629ed04652591fb220eb2bf2394d97be3be377d60d8083405d7/lxml-6.1.2-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:57188e441ab24f906bd5a5c14eb55363ab51aa6c0de549f3dd320043721cc118", size = 5317790, upload-time = "2026-08-19T05:02:33.301Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2c/b12a1dc121f81c280635c721c7bcaa341441fcbe37397f60b8915048aece/lxml-6.1.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d0bfd719c254bbe60ea022cff0e6ffb799a6fa7d4d72852cebe0257957b32d68", size = 4646468, upload-time = "2026-08-19T05:02:35.504Z" }, + { url = "https://files.pythonhosted.org/packages/57/41/fd87a41edc531e7969c25ab1d6b52b5b041eb108b88f6394d6afb4374396/lxml-6.1.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:be6f87cd224254a8f81324e34cc655508b83f1d70458a1a39857ad2aa9925852", size = 5240607, upload-time = "2026-08-19T05:02:37.805Z" }, + { url = "https://files.pythonhosted.org/packages/6e/30/713ba813b6e6673c6dc34733746516017efcd17949b767b154cc50bccf20/lxml-6.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:074a88f70a7360a4a0c5be5d898062cd26f898c25b459efb1bdd43ae700c5a1a", size = 5086495, upload-time = "2026-08-19T05:02:40.099Z" }, + { url = "https://files.pythonhosted.org/packages/33/f8/6532ce0fecd9c326d06b08274ee075cc28dbc9f5e9285355db8504689114/lxml-6.1.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:9031f5f01452681abf39fdd65f84a70cb01a7572a1bbf570042e826b1232d07b", size = 4758801, upload-time = "2026-08-19T05:02:45.434Z" }, + { url = "https://files.pythonhosted.org/packages/74/b6/5a1f7833ebaa0dd33c28f6f9755ec6ff3891bf63f097634b44e6da1bb65e/lxml-6.1.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:cfeac14425fc7a6fca7864b774d4ee63547926158f4a18c67d77b2c9a948acf1", size = 5626977, upload-time = "2026-08-19T05:02:48.092Z" }, + { url = "https://files.pythonhosted.org/packages/e6/20/6ae0fc1b45e20877cdcfb1168ceeaf9abb0fba5ed36bd639a260e7b2101e/lxml-6.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8ec111ff8067325f85c08aa9c2b26179ec0537bb89c003fde31127139f85f82d", size = 5235036, upload-time = "2026-08-19T05:02:50.726Z" }, + { url = "https://files.pythonhosted.org/packages/47/b4/2bc7b37fbb990ccfb7d30393660741592177224a94e07d842c8da70638e8/lxml-6.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48e912f37c99a297175ba955f55a47c0e1c834b506ef162e52a6e4fe276e6e45", size = 5252270, upload-time = "2026-08-19T05:02:53.454Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0b/07fb8e1dee29a78e2c5fa5c6c914218be76a6406baff27907429566e90ec/lxml-6.1.2-cp314-cp314t-win32.whl", hash = "sha256:7c444c3a6e8e75334879980eed96568f0e12064c8b1913424eac1805e976736b", size = 3902666, upload-time = "2026-08-19T05:02:55.607Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/3371527bd9820aae6f511697c93032ed197b0d8dab0f17818f18d3099637/lxml-6.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7f35ba7667004ecdafebbe08da7c9fa06ee6195275bb7ef7a29ee1901e69519c", size = 4401011, upload-time = "2026-08-19T05:02:57.899Z" }, + { url = "https://files.pythonhosted.org/packages/e6/bb/e6de9b2546a4e6df4fb52fb18921906a8b7a041aba06570995759a4d6d8b/lxml-6.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d117f39b28ab8a330a74abdbe61c2255b51973b238db25fd6c2448de1eb2a02d", size = 3823384, upload-time = "2026-08-19T05:03:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/0e/83/7ff98683e14a148191278728d11ba782c3d5137886d49fd95ab4036efa1b/lxml-6.1.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:1e3c67b817867c484794d7fe0d73045d7d0c67460c78a0a1249a9e92266e6a0e", size = 8609183, upload-time = "2026-08-19T04:58:32.19Z" }, + { url = "https://files.pythonhosted.org/packages/24/39/c39f05e8240e98009dd3d4ceb248319d0f36467babc5f90a909ed0c5b68a/lxml-6.1.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:d3e97ac4353cca3fbbfa829bc0c6a913771573d1c6d46932d4335c46f2b7796a", size = 4639898, upload-time = "2026-08-19T04:58:39.017Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bf/25e26b089510940a0777ab334357874569255e50930224c8159cd649e754/lxml-6.1.2-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:827438bf6c8292d22a409bb7990d7cffce410f33e7664e46ca74d2ecc26975ef", size = 5037527, upload-time = "2026-08-19T04:58:46.224Z" }, + { url = "https://files.pythonhosted.org/packages/65/6d/aed3a58a3d662f7367a537fabe8c549f1446dbd043719e0ae8cd53f47819/lxml-6.1.2-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c470d192e27f97842a068cf12a1c1296b20ca716c56a9249715c6654bc192d19", size = 5661918, upload-time = "2026-08-19T04:59:02.534Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ca/706d32b6957c0c2e005a9833e8fc528449196b38d5cfcf9e0fd86a96fb00/lxml-6.1.2-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef0b8ba6e13597f681b2b4924ca9c4e8c88420bf0e21d9a9006c757f2fc39d1f", size = 5249359, upload-time = "2026-08-19T05:04:01.956Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e9/445ff43f56fcffa06f6f3a7189920c216f3eacef68ef834d4111cdbd86ba/lxml-6.1.2-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:65c32ddc5d0750129c7b119fb57d48192b76d334c21e6b690d19dfb06b34af79", size = 4704548, upload-time = "2026-08-19T05:04:04.57Z" }, + { url = "https://files.pythonhosted.org/packages/69/78/20b8b7e79a1b1d9cd4465c332d62962858562b446692f16a27068fa54b85/lxml-6.1.2-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0aa07065497f191ad26c4b587ce5dbb5a7105285a3789aafd0661750e8bac537", size = 5261170, upload-time = "2026-08-19T05:04:07.336Z" }, + { url = "https://files.pythonhosted.org/packages/54/ca/84a0e1148bf511e12e0d99732a4e136a3bf1b91622f0a1b197796e2ff984/lxml-6.1.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cde6b8db7d2e5135129eb5e74b7b44dd2053aa767cd5023541fccedddc262453", size = 5090576, upload-time = "2026-08-19T05:04:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f8/1ef6fc7070bed8753315f2e4ea66bc0d37620e1444d014db7f0267b8faaf/lxml-6.1.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b28842b30c4bc2e6afe137d98a5d2071a62589471e76d053bea55b0e53298af9", size = 4744614, upload-time = "2026-08-19T05:04:12.717Z" }, + { url = "https://files.pythonhosted.org/packages/87/f6/3a4824cd1c1b81d996d2d75bbd176ba13fbe9b5d89489290d93ff9558486/lxml-6.1.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:11f529062255209a421ae4de5b1bb36b2f0a2e1a700745e675a4bf4084d13c00", size = 5685792, upload-time = "2026-08-19T05:04:15.367Z" }, + { url = "https://files.pythonhosted.org/packages/64/9a/f133bf16a67149e00ca5d8a8f1ae662c30a86c303aa242693b67f8e19856/lxml-6.1.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:f8b89b3be75a37509602b03f9cfa1a28298d4eed4625748148307aeb907901b7", size = 5248972, upload-time = "2026-08-19T05:04:18.491Z" }, + { url = "https://files.pythonhosted.org/packages/50/63/273e7e8a73a5d183d8552dfdaa131dfda0292ddab7bcddc5a66a0ae525d8/lxml-6.1.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1a2331da06dd55a8184985306eb2afd72d708283ce7e85d67bba77317b785060", size = 5271809, upload-time = "2026-08-19T05:04:21.448Z" }, + { url = "https://files.pythonhosted.org/packages/49/eb/614117c36a28909e79ff7cdec87008f0bd996478f35cf72309189cf398b1/lxml-6.1.2-cp315-cp315-win32.whl", hash = "sha256:442766b326d9892585a64e8c6c4b5ab81d0e6c0538c9f0fc11a84dc101a5d97f", size = 3662854, upload-time = "2026-08-19T05:05:07.141Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/06aee6107cf8e7b870f10f82539f366cba10dc6053144cca80e838caf8c8/lxml-6.1.2-cp315-cp315-win_amd64.whl", hash = "sha256:a7fd1dd6faa3df9dcd8f1765237362cd885ca62cdf77a7c5f5ea383ae5b6048b", size = 4074590, upload-time = "2026-08-19T05:05:09.697Z" }, + { url = "https://files.pythonhosted.org/packages/84/bf/dad9b6baf9b26d79584834e15cef2a5dd0a13c7b1df08831e8f18244b494/lxml-6.1.2-cp315-cp315-win_arm64.whl", hash = "sha256:054175250531a5fb102d485743ff16412279c93add12385b3b1c3d7b16d8deaa", size = 3749336, upload-time = "2026-08-19T05:05:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9d/cd0c43d45e2eb52df7735c6558f24054ca633499191899b0cb9040fbbc3c/lxml-6.1.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:84a2a46b93b789d8acb44cfcb3d967ce9dbe29884ddb93fbb1a33f0e0c8fcd86", size = 8857688, upload-time = "2026-08-19T05:04:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/0b/26/27093dc1a9edbdd8a54652f237a387f7e63ec0192efe708bc2576d8a1383/lxml-6.1.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4aced3284e0353c798b060fe2c175eb81410e99b9a7e2ae6951be5333732b111", size = 4754422, upload-time = "2026-08-19T05:04:27.645Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ee/502f7c93507f57eb496744a64da8f4ca86855cf88e48d14584342f1bfd92/lxml-6.1.2-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47c92dc5167de16e27ace8332454f12ba172dcab04f7a78a9eae14e2e41b6a41", size = 5033396, upload-time = "2026-08-19T05:04:30.054Z" }, + { url = "https://files.pythonhosted.org/packages/bf/72/c4cbbe72f951650f2afe43a70e51687e111d82b9bec46e3310ea76419d46/lxml-6.1.2-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40366c23a938008a3bedfcfd80709b3a857c188b4d710b083e978ef5d2c1c715", size = 5615298, upload-time = "2026-08-19T05:04:32.752Z" }, + { url = "https://files.pythonhosted.org/packages/14/83/a3df966d6d7b6513e9dfb6fbfb041c0619642170359c1b36ab20a83e59eb/lxml-6.1.2-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c4c6dc1b2485aaa4adfb6ed754f90dddcb2b96a66bbebc9e1ac242b5ce5e818", size = 5236282, upload-time = "2026-08-19T05:04:35.762Z" }, + { url = "https://files.pythonhosted.org/packages/4f/85/8692ec8173c9f8d295735b9bf410d202317e7b3ed11141e80a30f421f409/lxml-6.1.2-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:3a698fad6f122a9b3e2dc2fb598c1de7329c74a67c7a334c9109a440de2508e5", size = 4650647, upload-time = "2026-08-19T05:04:38.396Z" }, + { url = "https://files.pythonhosted.org/packages/11/e7/dbe3cece28a5bf82997a091d9dbb0fc49e725a5fa86550897ee2cf6412e6/lxml-6.1.2-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14879fa5eb2b793c040bbfcb62011aa3015c65d6c9875e063ea98ce2029d51fb", size = 5243387, upload-time = "2026-08-19T05:04:41.247Z" }, + { url = "https://files.pythonhosted.org/packages/99/a9/81a2d27640db0d27200b2f32339a54e74c36d58feb5ad528b87d52a59ecc/lxml-6.1.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b631174cd2e4d9f8a94ef17f911c6ded10ede93b5e7860dee7bbf85961d321e9", size = 5092624, upload-time = "2026-08-19T05:04:43.919Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f4/0b0304c70c087f618d95b0306738b070bd556afd09c2c92589b78dbe5eb0/lxml-6.1.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:ceafa5e0536c62a5cd9f65327fa0b57d6f0b0e3435daf2c98a78d0dde7ecbae1", size = 4758742, upload-time = "2026-08-19T05:04:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/f9fc45f1d01b632b673e11880e75292dff9953db9f426d1a38201b8eb5f5/lxml-6.1.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:7c482e87cc86bed78a50462560675bc2c348ef72c47596f9b933346d5a8e920e", size = 5649540, upload-time = "2026-08-19T05:04:49.777Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0b/d65e0458c2bcce0df68d5cc29ad0006e76446f02d9e50caf188fd1fb8bae/lxml-6.1.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c0d2dde8a50520efc51644587f0fc4810e3af7d3e029d7af0be93bf39e2b5c", size = 5234869, upload-time = "2026-08-19T05:04:52.972Z" }, + { url = "https://files.pythonhosted.org/packages/ee/62/1fee828238badd3bfe9544f5cc9ce6ded421ef38e9634030445dedd78b36/lxml-6.1.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dd7ea3fa47154b9fff90591b961e41b3718bd7fcd5bc2d9bb47e9845c8ace088", size = 5259992, upload-time = "2026-08-19T05:04:56.028Z" }, + { url = "https://files.pythonhosted.org/packages/20/18/35fb14dd6baccbffa6daeb2369802f04a94e3f73db3c7bb405dbab009729/lxml-6.1.2-cp315-cp315t-win32.whl", hash = "sha256:87534cec6ea325435e4adf2326b0cf3110eee9a47abf73652eb155db639c08c6", size = 3901151, upload-time = "2026-08-19T05:04:58.671Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b6/07530896ca062bc3d2f09d5cb8a48e799c05b12c496205db03159ba13b6c/lxml-6.1.2-cp315-cp315t-win_amd64.whl", hash = "sha256:4e220a9c297e5d36895d489a08c9a3f1f6193b6414e702c5fb751e4a3767f8d0", size = 4395355, upload-time = "2026-08-19T05:05:01.651Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/237d8de1d77085cfd41d0c6049a044d8d01886f3afb7f1eda2f43d900a96/lxml-6.1.2-cp315-cp315t-win_arm64.whl", hash = "sha256:f16a407766bac51c65d605b06d900821751a79aa20e12185f273f14a17180e7b", size = 3822823, upload-time = "2026-08-19T05:05:04.63Z" }, ] [[package]] name = "mail-parser" -version = "4.4.0" +version = "4.6.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/6e/2cee08e0d6fbce02a301d5ab72ef5ffe8f47dd59ac3d8ce61324d8a9143a/mail_parser-4.4.0.tar.gz", hash = "sha256:82d2c0fa98a138620feaa87a378af2a2643b2941b2e125d7abfc65dd21094b33", size = 2852896, upload-time = "2026-06-11T06:56:49.789Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/0f/b6fc717f7a7d9188e451e9d30e98b3f61866313958f866f9da7ff5ed3c84/mail_parser-4.6.4.tar.gz", hash = "sha256:1929109b7934ee061c1533a897b18c57fd0adb3548b79b5e28d901819bade872", size = 2898988, upload-time = "2026-08-21T21:47:13.58Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/a7/99dfed167ba728384521acd54fadbb7649ce49333018f42e1f190ed25911/mail_parser-4.4.0-py3-none-any.whl", hash = "sha256:46f7931817660fec4f7723e6f1722e66a9cce44c2daeacbeb9e705078379561f", size = 35504, upload-time = "2026-06-11T06:56:48.348Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f9/b35a02c12346111e997ebfaa46ebeb759828373a3eb9f0e82c1e826b3530/mail_parser-4.6.4-py3-none-any.whl", hash = "sha256:bc6e437b3afe38091893e7b6ea49c7f2188ad7616abdd4fa3059e85f1b69efce", size = 50073, upload-time = "2026-08-21T21:47:12.07Z" }, ] [[package]] name = "markdown" -version = "3.10.2" +version = "3.10.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, ] [[package]] @@ -1885,11 +2048,11 @@ wheels = [ [[package]] name = "marko" -version = "2.2.3" +version = "2.2.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/cc/01b80dc58e4d44fe039403ef1ac0008bcb9375364ccd246a4b8bfec29b46/marko-2.2.3.tar.gz", hash = "sha256:e31ec2875383bc62f9093d16babed5a2c2cde601c00d834ea935a2222120ec19", size = 144531, upload-time = "2026-05-28T02:07:39.479Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/6d/671a18bb386adca6311bca6c2fe6bec873947ee501cc5f8f509ec06bdde0/marko-2.2.4.tar.gz", hash = "sha256:c042c66f835425673123d7536b39b4660de3b68e30078c70fd26245b31170683", size = 151013, upload-time = "2026-08-12T03:20:11.772Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/50/0a8fab45fa374820c27cc4c3178c4914c60902ba9d6404a692a979e20dbc/marko-2.2.3-py3-none-any.whl", hash = "sha256:8e1d7a0387281e59dfbc52a381b58c570156970e36b2bbe047f8a3a2f368cacc", size = 42951, upload-time = "2026-05-28T02:07:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/d3/78/751d49c8bdfa0b30147c99235516433af6b63eca367ff28593c7346a0494/marko-2.2.4-py3-none-any.whl", hash = "sha256:d80510506edba096ec49d4720a09645fa0bb78e7b7b88697f20032fc19730aa9", size = 46753, upload-time = "2026-08-12T03:20:10.811Z" }, ] [[package]] @@ -2168,98 +2331,135 @@ python = [ [[package]] name = "mkdocstrings-python" -version = "2.0.5" +version = "2.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffelib" }, { name = "mkdocs-autorefs" }, { name = "mkdocstrings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/b6/e858701499d57eee8b3fd8e78168083956c6683ddbe727b46758b19e1119/mkdocstrings_python-2.0.5.tar.gz", hash = "sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594", size = 199578, upload-time = "2026-06-19T10:41:08.868Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/5d/1be1c7a49d8fa13dc80f66a85f53333d52cf5206911412006ffdff8fb9a0/mkdocstrings_python-2.0.7.tar.gz", hash = "sha256:8c49faf66d243072d7590a1b5dea028d9d7425fac191f54f096123a4a9c1a783", size = 201598, upload-time = "2026-08-17T16:56:18.239Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d", size = 104951, upload-time = "2026-06-19T10:41:07.378Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6d/77546d8c26f038fce314a507106954f76270f6c182488bcf9ac9721175df/mkdocstrings_python-2.0.7-py3-none-any.whl", hash = "sha256:1fce5fbfe4ffa6e8136a35351cdc97c3bf55219c7efbd3f92a82260f93235d60", size = 105387, upload-time = "2026-08-17T16:56:16.813Z" }, ] [[package]] name = "mmh3" -version = "5.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775, upload-time = "2026-03-05T15:55:57.716Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/94/bc5c3b573b40a328c4d141c20e399039ada95e5e2a661df3425c5165fd84/mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1", size = 56087, upload-time = "2026-03-05T15:54:21.92Z" }, - { url = "https://files.pythonhosted.org/packages/f6/80/64a02cc3e95c3af0aaa2590849d9ed24a9f14bb93537addde688e039b7c3/mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00", size = 40500, upload-time = "2026-03-05T15:54:22.953Z" }, - { url = "https://files.pythonhosted.org/packages/8b/72/e6d6602ce18adf4ddcd0e48f2e13590cc92a536199e52109f46f259d3c46/mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7", size = 40034, upload-time = "2026-03-05T15:54:23.943Z" }, - { url = "https://files.pythonhosted.org/packages/59/c2/bf4537a8e58e21886ef16477041238cab5095c836496e19fafc34b7445d2/mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b", size = 97292, upload-time = "2026-03-05T15:54:25.335Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e2/51ed62063b44d10b06d975ac87af287729eeb5e3ed9772f7584a17983e90/mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006", size = 103274, upload-time = "2026-03-05T15:54:26.44Z" }, - { url = "https://files.pythonhosted.org/packages/75/ce/12a7524dca59eec92e5b31fdb13ede1e98eda277cf2b786cf73bfbc24e81/mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825", size = 106158, upload-time = "2026-03-05T15:54:28.578Z" }, - { url = "https://files.pythonhosted.org/packages/86/1f/d3ba6dd322d01ab5d44c46c8f0c38ab6bbbf9b5e20e666dfc05bf4a23604/mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a", size = 113005, upload-time = "2026-03-05T15:54:29.767Z" }, - { url = "https://files.pythonhosted.org/packages/b6/a9/15d6b6f913294ea41b44d901741298e3718e1cb89ee626b3694625826a43/mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b", size = 120744, upload-time = "2026-03-05T15:54:30.931Z" }, - { url = "https://files.pythonhosted.org/packages/76/b3/70b73923fd0284c439860ff5c871b20210dfdbe9a6b9dd0ee6496d77f174/mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166", size = 99111, upload-time = "2026-03-05T15:54:32.353Z" }, - { url = "https://files.pythonhosted.org/packages/dd/38/99f7f75cd27d10d8b899a1caafb9d531f3903e4d54d572220e3d8ac35e89/mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16", size = 98623, upload-time = "2026-03-05T15:54:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/fd/68/6e292c0853e204c44d2f03ea5f090be3317a0e2d9417ecb62c9eb27687df/mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211", size = 106437, upload-time = "2026-03-05T15:54:35.177Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c6/fedd7284c459cfb58721d461fcf5607a4c1f5d9ab195d113d51d10164d16/mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000", size = 110002, upload-time = "2026-03-05T15:54:36.673Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ac/ca8e0c19a34f5b71390171d2ff0b9f7f187550d66801a731bb68925126a4/mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5", size = 97507, upload-time = "2026-03-05T15:54:37.804Z" }, - { url = "https://files.pythonhosted.org/packages/df/94/6ebb9094cfc7ac5e7950776b9d13a66bb4a34f83814f32ba2abc9494fc68/mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025", size = 40773, upload-time = "2026-03-05T15:54:40.077Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/cd3527198cf159495966551c84a5f36805a10ac17b294f41f67b83f6a4d6/mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00", size = 41560, upload-time = "2026-03-05T15:54:41.148Z" }, - { url = "https://files.pythonhosted.org/packages/15/96/6fe5ebd0f970a076e3ed5512871ce7569447b962e96c125528a2f9724470/mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc", size = 39313, upload-time = "2026-03-05T15:54:42.171Z" }, - { url = "https://files.pythonhosted.org/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e", size = 40832, upload-time = "2026-03-05T15:54:43.212Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d", size = 41964, upload-time = "2026-03-05T15:54:44.204Z" }, - { url = "https://files.pythonhosted.org/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6", size = 39114, upload-time = "2026-03-05T15:54:45.205Z" }, - { url = "https://files.pythonhosted.org/packages/95/c2/ab7901f87af438468b496728d11264cb397b3574d41506e71b92128e0373/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f", size = 39819, upload-time = "2026-03-05T15:54:46.509Z" }, - { url = "https://files.pythonhosted.org/packages/2f/ed/6f88dda0df67de1612f2e130ffea34cf84aaee5bff5b0aff4dbff2babe34/mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8", size = 40330, upload-time = "2026-03-05T15:54:47.864Z" }, - { url = "https://files.pythonhosted.org/packages/3d/66/7516d23f53cdf90f43fce24ab80c28f45e6851d78b46bef8c02084edf583/mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6", size = 56078, upload-time = "2026-03-05T15:54:48.9Z" }, - { url = "https://files.pythonhosted.org/packages/bc/34/4d152fdf4a91a132cb226b671f11c6b796eada9ab78080fb5ce1e95adaab/mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9", size = 40498, upload-time = "2026-03-05T15:54:49.942Z" }, - { url = "https://files.pythonhosted.org/packages/d4/4c/8e3af1b6d85a299767ec97bd923f12b06267089c1472c27c1696870d1175/mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03", size = 40033, upload-time = "2026-03-05T15:54:50.994Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f2/966ea560e32578d453c9e9db53d602cbb1d0da27317e232afa7c38ceba11/mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b", size = 97320, upload-time = "2026-03-05T15:54:52.072Z" }, - { url = "https://files.pythonhosted.org/packages/bb/0d/2c5f9893b38aeb6b034d1a44ecd55a010148054f6a516abe53b5e4057297/mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5", size = 103299, upload-time = "2026-03-05T15:54:53.569Z" }, - { url = "https://files.pythonhosted.org/packages/1c/fc/2ebaef4a4d4376f89761274dc274035ffd96006ab496b4ee5af9b08f21a9/mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593", size = 106222, upload-time = "2026-03-05T15:54:55.092Z" }, - { url = "https://files.pythonhosted.org/packages/57/09/ea7ffe126d0ba0406622602a2d05e1e1a6841cc92fc322eb576c95b27fad/mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4", size = 113048, upload-time = "2026-03-05T15:54:56.305Z" }, - { url = "https://files.pythonhosted.org/packages/85/57/9447032edf93a64aa9bef4d9aa596400b1756f40411890f77a284f6293ca/mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1", size = 120742, upload-time = "2026-03-05T15:54:57.453Z" }, - { url = "https://files.pythonhosted.org/packages/53/82/a86cc87cc88c92e9e1a598fee509f0409435b57879a6129bf3b3e40513c7/mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104", size = 99132, upload-time = "2026-03-05T15:54:58.583Z" }, - { url = "https://files.pythonhosted.org/packages/54/f7/6b16eb1b40ee89bb740698735574536bc20d6cdafc65ae702ea235578e05/mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d", size = 98686, upload-time = "2026-03-05T15:55:00.078Z" }, - { url = "https://files.pythonhosted.org/packages/e8/88/a601e9f32ad1410f438a6d0544298ea621f989bd34a0731a7190f7dec799/mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f", size = 106479, upload-time = "2026-03-05T15:55:01.532Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5c/ce29ae3dfc4feec4007a437a1b7435fb9507532a25147602cd5b52be86db/mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2", size = 110030, upload-time = "2026-03-05T15:55:02.934Z" }, - { url = "https://files.pythonhosted.org/packages/13/30/ae444ef2ff87c805d525da4fa63d27cda4fe8a48e77003a036b8461cfd5c/mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a", size = 97536, upload-time = "2026-03-05T15:55:04.135Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b", size = 40769, upload-time = "2026-03-05T15:55:05.277Z" }, - { url = "https://files.pythonhosted.org/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229", size = 41563, upload-time = "2026-03-05T15:55:06.283Z" }, - { url = "https://files.pythonhosted.org/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d", size = 39310, upload-time = "2026-03-05T15:55:07.323Z" }, - { url = "https://files.pythonhosted.org/packages/63/b4/65bc1fb2bb7f83e91c30865023b1847cf89a5f237165575e8c83aa536584/mmh3-5.2.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d771f085fcdf4035786adfb1d8db026df1eb4b41dac1c3d070d1e49512843227", size = 40794, upload-time = "2026-03-05T15:55:09.773Z" }, - { url = "https://files.pythonhosted.org/packages/c4/86/7168b3d83be8eb553897b1fac9da8bbb06568e5cfe555ffc329ebb46f59d/mmh3-5.2.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:7f196cd7910d71e9d9860da0ff7a77f64d22c1ad931f1dd18559a06e03109fc0", size = 41923, upload-time = "2026-03-05T15:55:10.924Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9b/b653ab611c9060ce8ff0ba25c0226757755725e789292f3ca138a58082cd/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b1f12bd684887a0a5d55e6363ca87056f361e45451105012d329b86ec19dbe0b", size = 39131, upload-time = "2026-03-05T15:55:11.961Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b4/5a2e0d34ab4d33543f01121e832395ea510132ea8e52cdf63926d9d81754/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d106493a60dcb4aef35a0fac85105e150a11cf8bc2b0d388f5a33272d756c966", size = 39825, upload-time = "2026-03-05T15:55:13.013Z" }, - { url = "https://files.pythonhosted.org/packages/bd/69/81699a8f39a3f8d368bec6443435c0c392df0d200ad915bf0d222b588e03/mmh3-5.2.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44983e45310ee5b9f73397350251cdf6e63a466406a105f1d16cb5baa659270b", size = 40344, upload-time = "2026-03-05T15:55:14.026Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b3/71c8c775807606e8fd8acc5c69016e1caf3200d50b50b6dd4b40ce10b76c/mmh3-5.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:368625fb01666655985391dbad3860dc0ba7c0d6b9125819f3121ee7292b4ac8", size = 56291, upload-time = "2026-03-05T15:55:15.137Z" }, - { url = "https://files.pythonhosted.org/packages/6f/75/2c24517d4b2ce9e4917362d24f274d3d541346af764430249ddcc4cb3a08/mmh3-5.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:72d1cc63bcc91e14933f77d51b3df899d6a07d184ec515ea7f56bff659e124d7", size = 40575, upload-time = "2026-03-05T15:55:16.518Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b9/e4a360164365ac9f07a25f0f7928e3a66eb9ecc989384060747aa170e6aa/mmh3-5.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e8b4b5580280b9265af3e0409974fb79c64cf7523632d03fbf11df18f8b0181e", size = 40052, upload-time = "2026-03-05T15:55:17.735Z" }, - { url = "https://files.pythonhosted.org/packages/97/ca/120d92223a7546131bbbc31c9174168ee7a73b1366f5463ffe69d9e691fe/mmh3-5.2.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4cbbde66f1183db040daede83dd86c06d663c5bb2af6de1142b7c8c37923dd74", size = 97311, upload-time = "2026-03-05T15:55:18.959Z" }, - { url = "https://files.pythonhosted.org/packages/b6/71/c1a60c1652b8813ef9de6d289784847355417ee0f2980bca002fe87f4ae5/mmh3-5.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ff038d52ef6aa0f309feeba00c5095c9118d0abf787e8e8454d6048db2037fc", size = 103279, upload-time = "2026-03-05T15:55:20.448Z" }, - { url = "https://files.pythonhosted.org/packages/48/29/ad97f4be1509cdcb28ae32c15593ce7c415db47ace37f8fad35b493faa9a/mmh3-5.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4130d0b9ce5fad6af07421b1aecc7e079519f70d6c05729ab871794eded8617", size = 106290, upload-time = "2026-03-05T15:55:21.6Z" }, - { url = "https://files.pythonhosted.org/packages/77/29/1f86d22e281bd8827ba373600a4a8b0c0eae5ca6aa55b9a8c26d2a34decc/mmh3-5.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e0bfe77d238308839699944164b96a2eeccaf55f2af400f54dc20669d8d5f2", size = 113116, upload-time = "2026-03-05T15:55:22.826Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7c/339971ea7ed4c12d98f421f13db3ea576a9114082ccb59d2d1a0f00ccac1/mmh3-5.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f963eafc0a77a6c0562397da004f5876a9bcf7265a7bcc3205e29636bc4a1312", size = 120740, upload-time = "2026-03-05T15:55:24.3Z" }, - { url = "https://files.pythonhosted.org/packages/e4/92/3c7c4bdb8e926bb3c972d1e2907d77960c1c4b250b41e8366cf20c6e4373/mmh3-5.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92883836caf50d5255be03d988d75bc93e3f86ba247b7ca137347c323f731deb", size = 99143, upload-time = "2026-03-05T15:55:25.456Z" }, - { url = "https://files.pythonhosted.org/packages/df/0a/33dd8706e732458c8375eae63c981292de07a406bad4ec03e5269654aa2c/mmh3-5.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57b52603e89355ff318025dd55158f6e71396c0f1f609d548e9ea9c94cc6ce0a", size = 98703, upload-time = "2026-03-05T15:55:26.723Z" }, - { url = "https://files.pythonhosted.org/packages/51/04/76bbce05df76cbc3d396f13b2ea5b1578ef02b6a5187e132c6c33f99d596/mmh3-5.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f40a95186a72fa0b67d15fef0f157bfcda00b4f59c8a07cbe5530d41ac35d105", size = 106484, upload-time = "2026-03-05T15:55:28.214Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8f/c6e204a2c70b719c1f62ffd9da27aef2dddcba875ea9c31ca0e87b975a46/mmh3-5.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:58370d05d033ee97224c81263af123dea3d931025030fd34b61227a768a8858a", size = 110012, upload-time = "2026-03-05T15:55:29.532Z" }, - { url = "https://files.pythonhosted.org/packages/e3/37/7181efd8e39db386c1ebc3e6b7d1f702a09d7c1197a6f2742ed6b5c16597/mmh3-5.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7be6dfb49e48fd0a7d91ff758a2b51336f1cd21f9d44b20f6801f072bd080cdd", size = 97508, upload-time = "2026-03-05T15:55:31.01Z" }, - { url = "https://files.pythonhosted.org/packages/42/0f/afa7ca2615fd85e1469474bb860e381443d0b868c083b62b41cb1d7ca32f/mmh3-5.2.1-cp314-cp314-win32.whl", hash = "sha256:54fe8518abe06a4c3852754bfd498b30cc58e667f376c513eac89a244ce781a4", size = 41387, upload-time = "2026-03-05T15:55:32.403Z" }, - { url = "https://files.pythonhosted.org/packages/71/0d/46d42a260ee1357db3d486e6c7a692e303c017968e14865e00efa10d09fc/mmh3-5.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3f796b535008708846044c43302719c6956f39ca2d93f2edda5319e79a29efbb", size = 42101, upload-time = "2026-03-05T15:55:33.646Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7b/848a8378059d96501a41159fca90d6a99e89736b0afbe8e8edffeac8c74b/mmh3-5.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:cd471ede0d802dd936b6fab28188302b2d497f68436025857ca72cd3810423fe", size = 39836, upload-time = "2026-03-05T15:55:35.026Z" }, - { url = "https://files.pythonhosted.org/packages/27/61/1dabea76c011ba8547c25d30c91c0ec22544487a8750997a27a0c9e1180b/mmh3-5.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5174a697ce042fa77c407e05efe41e03aa56dae9ec67388055820fb48cf4c3ba", size = 57727, upload-time = "2026-03-05T15:55:36.162Z" }, - { url = "https://files.pythonhosted.org/packages/b7/32/731185950d1cf2d5e28979cc8593016ba1619a295faba10dda664a4931b5/mmh3-5.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a3984146e414684a6be2862d84fcb1035f4984851cb81b26d933bab6119bf00", size = 41308, upload-time = "2026-03-05T15:55:37.254Z" }, - { url = "https://files.pythonhosted.org/packages/76/aa/66c76801c24b8c9418b4edde9b5e57c75e72c94e29c48f707e3962534f18/mmh3-5.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bd6e7d363aa93bd3421b30b6af97064daf47bc96005bddba67c5ffbc6df426b8", size = 40758, upload-time = "2026-03-05T15:55:38.61Z" }, - { url = "https://files.pythonhosted.org/packages/9e/bb/79a1f638a02f0ae389f706d13891e2fbf7d8c0a22ecde67ba828951bb60a/mmh3-5.2.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:113f78e7463a36dbbcea05bfe688efd7fa759d0f0c56e73c974d60dcfec3dfcc", size = 109670, upload-time = "2026-03-05T15:55:40.13Z" }, - { url = "https://files.pythonhosted.org/packages/26/94/8cd0e187a288985bcfc79bf5144d1d712df9dee74365f59d26e3a1865be6/mmh3-5.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e8ec5f606e0809426d2440e0683509fb605a8820a21ebd120dcdba61b74ef7f", size = 117399, upload-time = "2026-03-05T15:55:42.076Z" }, - { url = "https://files.pythonhosted.org/packages/42/94/dfea6059bd5c5beda565f58a4096e43f4858fb6d2862806b8bbd12cbb284/mmh3-5.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22b0f9971ec4e07e8223f2beebe96a6cfc779d940b6f27d26604040dd74d3a44", size = 120386, upload-time = "2026-03-05T15:55:43.481Z" }, - { url = "https://files.pythonhosted.org/packages/47/cb/f9c45e62aaa67220179f487772461d891bb582bb2f9783c944832c60efd9/mmh3-5.2.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85ffc9920ffc39c5eee1e3ac9100c913a0973996fbad5111f939bbda49204bb7", size = 125924, upload-time = "2026-03-05T15:55:44.638Z" }, - { url = "https://files.pythonhosted.org/packages/a5/83/fe54a4a7c11bc9f623dfc1707decd034245602b076dfc1dcc771a4163170/mmh3-5.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7aec798c2b01aaa65a55f1124f3405804184373abb318a3091325aece235f67c", size = 135280, upload-time = "2026-03-05T15:55:45.866Z" }, - { url = "https://files.pythonhosted.org/packages/97/67/fe7e9e9c143daddd210cd22aef89cbc425d58ecf238d2b7d9eb0da974105/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55dbbd8ffbc40d1697d5e2d0375b08599dae8746b0b08dea05eee4ce81648fac", size = 110050, upload-time = "2026-03-05T15:55:47.074Z" }, - { url = "https://files.pythonhosted.org/packages/43/c4/6d4b09fcbef80794de447c9378e39eefc047156b290fa3dd2d5257ca8227/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c85c38a279ca9295a69b9b088a2e48aa49737bb1b34e6a9dc6297c110e8d912", size = 111158, upload-time = "2026-03-05T15:55:48.239Z" }, - { url = "https://files.pythonhosted.org/packages/81/a6/ca51c864bdb30524beb055a6d8826db3906af0834ec8c41d097a6e8573d5/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6290289fa5fb4c70fd7f72016e03633d60388185483ff3b162912c81205ae2cf", size = 116890, upload-time = "2026-03-05T15:55:49.405Z" }, - { url = "https://files.pythonhosted.org/packages/cc/04/5a1fe2e2ad843d03e89af25238cbc4f6840a8bb6c4329a98ab694c71deda/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4fc6cd65dc4d2fdb2625e288939a3566e36127a84811a4913f02f3d5931da52d", size = 123121, upload-time = "2026-03-05T15:55:50.61Z" }, - { url = "https://files.pythonhosted.org/packages/af/4d/3c820c6f4897afd25905270a9f2330a23f77a207ea7356f7aadace7273c0/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:623f938f6a039536cc02b7582a07a080f13fdfd48f87e63201d92d7e34d09a18", size = 110187, upload-time = "2026-03-05T15:55:52.143Z" }, - { url = "https://files.pythonhosted.org/packages/21/54/1d71cd143752361c0aebef16ad3f55926a6faf7b112d355745c1f8a25f7f/mmh3-5.2.1-cp314-cp314t-win32.whl", hash = "sha256:29bc3973676ae334412efdd367fcd11d036b7be3efc1ce2407ef8676dabfeb82", size = 41934, upload-time = "2026-03-05T15:55:53.564Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e4/63a2a88f31d93dea03947cccc2a076946857e799ea4f7acdecbf43b324aa/mmh3-5.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:28cfab66577000b9505a0d068c731aee7ca85cd26d4d63881fab17857e0fe1fb", size = 43036, upload-time = "2026-03-05T15:55:55.252Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0f/59204bf136d1201f8d7884cfbaf7498c5b4674e87a4c693f9bde63741ce1/mmh3-5.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dfd51b4c56b673dfbc43d7d27ef857dd91124801e2806c69bb45585ce0fa019b", size = 40391, upload-time = "2026-03-05T15:55:56.697Z" }, +version = "5.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/69/d00269fee7a3102fcc0f04f0a312e41c6b237762bdcad4c19426f18e697c/mmh3-5.3.0.tar.gz", hash = "sha256:95832419b87b882bec9dcd7d041d74887ba7745b3659c14be1ae1db5cfa35cad", size = 33607, upload-time = "2026-08-26T04:58:20.042Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/97/28c3905a7e27e100ef50322798659e0af514eff600aabffbab00e3cd27ad/mmh3-5.3.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5e7373e6834e4bdf2c24dbb1a0c6dd834bb5a189efb65723ebb58a8f3e76204b", size = 55484, upload-time = "2026-08-26T04:56:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/44/13/629beb4d3e92ffcf1486c81cbb0605d9e6685b2721eb6753946a9d217359/mmh3-5.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:430ed4de594d0084d9b7956b05075a9054d290a3a0d7b370553a9096a4fd429f", size = 40091, upload-time = "2026-08-26T04:56:13.289Z" }, + { url = "https://files.pythonhosted.org/packages/c7/64/0a5832cd45207c507ee83bb7286dfeccf51c438aa8b6217f44f286f354f4/mmh3-5.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bee76669a5b588cd806aa619ea9eb8f0c8a00e6991001d830e07cc69258962a9", size = 39694, upload-time = "2026-08-26T04:56:14.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/ac/e9a157fbeebf44da5e39e49ef3901cbd68651d966658b58303074f349422/mmh3-5.3.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6fbc4e3017fb99e639abdb58a6a31e14bcbd270562805a0b80a102f8a4f3024", size = 97220, upload-time = "2026-08-26T04:56:15.312Z" }, + { url = "https://files.pythonhosted.org/packages/af/a6/5012e363699c598166fb955f7267975284dd51eadc0383771a1a593d4ac2/mmh3-5.3.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98db40c6ef8bbeb028e0424736a6bef3b1d8d0a02399236eb00db0dd0b7ca957", size = 103238, upload-time = "2026-08-26T04:56:16.425Z" }, + { url = "https://files.pythonhosted.org/packages/47/53/5710edf5edd969bbc42985577ddc73758a1c90a1613ffc4356087b5e330f/mmh3-5.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa216ac716e7c99e4dc4b039c6219a31cd381cc0588ca45cf66f36011613f3ed", size = 106138, upload-time = "2026-08-26T04:56:17.54Z" }, + { url = "https://files.pythonhosted.org/packages/c5/dc/e7737d142ecb5847aad04fc92d63bd455587f5dc33ebbfddeb7936f98713/mmh3-5.3.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef9fe783b932927da8070f5b2913ce412e42c80bf17fd523042325ee3a44f756", size = 112973, upload-time = "2026-08-26T04:56:18.644Z" }, + { url = "https://files.pythonhosted.org/packages/cd/04/34e1242a23e14c78a39dd76ffdf860e76d01c917e4103b9729c27efc22f7/mmh3-5.3.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1d5de36640293374673ec6813b7a23d8a9621bbd87f079c6ec4c5e8585cb1f64", size = 120621, upload-time = "2026-08-26T04:56:19.837Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/665f1bd97666095f8a92b4191e22c21038c3e5f6bccad2a09218962e0541/mmh3-5.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0a6bd95c410ec9500d9515a4fe522e24452f71df38de47395f99aebc085a5d5a", size = 99020, upload-time = "2026-08-26T04:56:21.07Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a8/0ce084753cc8a82f8cbadcb6723f2e17c341b17683eafc49729a55db4930/mmh3-5.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f0c7a36ccb66bfc8fcfa7a9722614b959231e325f0e08862c6ea70a7283a6520", size = 98497, upload-time = "2026-08-26T04:56:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/01/1e/501564ca0687e4138c01c12d5b1bc049511753f5a5db2538ad662bf16d97/mmh3-5.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:767c6c0cf3f67c3e4e246ae7e1cc9ce7755f174f994aa3111c8357f16a587161", size = 106369, upload-time = "2026-08-26T04:56:23.375Z" }, + { url = "https://files.pythonhosted.org/packages/f7/af/7e072e63c10f81b0d4f2abff29363029144c62006baa1fbe437302d24f05/mmh3-5.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:408d37be08e12a154b482dcb300781d3dd154abab8e002ab32ecde6aa6a325bc", size = 109882, upload-time = "2026-08-26T04:56:24.495Z" }, + { url = "https://files.pythonhosted.org/packages/a6/89/90e3f0f59eb13b362cb0c091a1ce43b94325fae8c6c20a991dc9ee6bd0b7/mmh3-5.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2b77b3e6a9d817822407b32c514205b44ceeb8ab197bee09de19e5f1e04ce18", size = 97421, upload-time = "2026-08-26T04:56:25.675Z" }, + { url = "https://files.pythonhosted.org/packages/f4/be/eaa4b95e3fdb2617ec1c1502ab15f5941408c02a3c80a5df63ab4c412abd/mmh3-5.3.0-cp312-cp312-win32.whl", hash = "sha256:d4cc2cb5f117da6460c14c65cbc0d1cf0976af3e56de6cd627cc36019f323e15", size = 40467, upload-time = "2026-08-26T04:56:26.804Z" }, + { url = "https://files.pythonhosted.org/packages/39/86/695592b763d2c0a5739ff50e62be102a1790bd5817a5f7f9ed2ffcbe198f/mmh3-5.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:ad5e6b56000d4b1b82a380c664982371939dc8f728fcbb73d017edc035247dcb", size = 41864, upload-time = "2026-08-26T04:56:27.779Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ea/b534107b184454994865f5c1695df2f666672f16636b814da182b3ab0f6f/mmh3-5.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:4b660543eee66d5f07408fc1cdd0d017416f0f5cd1725def314cca3f67b0cac5", size = 39206, upload-time = "2026-08-26T04:56:28.789Z" }, + { url = "https://files.pythonhosted.org/packages/8b/fa/e07f6b9e2fba550fe539b6c66ee7fc28e44f5bd445a7203ae4c169d4aa72/mmh3-5.3.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:6576400e7a748ec5c7ea72f38d626939876dd1756f4a0ccf552b8646dcf6f3e9", size = 41040, upload-time = "2026-08-26T04:56:29.766Z" }, + { url = "https://files.pythonhosted.org/packages/62/9c/cb0f23e71bddcc519331c9787ec029e0d2fef64ed1bc490ad84b00a43950/mmh3-5.3.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:2b4cd2fcf1b517872530d9ef1a2de2ef9b86e5a0f8927539ea0b68337618244e", size = 42153, upload-time = "2026-08-26T04:56:30.954Z" }, + { url = "https://files.pythonhosted.org/packages/27/1c/99f2ff480922046a496c2e53728a13a467b68f01a0f48370577c0825a763/mmh3-5.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f401a82d80c53d88605b82a80623edd95d922732d2c513c1c5f8e4b5e10c2913", size = 38796, upload-time = "2026-08-26T04:56:32.207Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/e137452583b33a56d053ad48643a7c56e4cf466efc8a145da6a736913ae3/mmh3-5.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:86238bb78ff65c9fc1e6b371b78f271e23c5d61898222c201122209dc8eadc76", size = 39474, upload-time = "2026-08-26T04:56:33.196Z" }, + { url = "https://files.pythonhosted.org/packages/74/e8/a9031fe6ed0eb06ab1c98eb76182eb01dc484dc46873a8e5ef3097d9bdf4/mmh3-5.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:222ea0a485e23bdcb29e28d15b8b01ebe34e8720bad4b5f92b645ed86e3fc715", size = 39983, upload-time = "2026-08-26T04:56:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ae/11459b39af5341de2a048998a717f57b3a1c4e6a9edf8fe09e314b2c263c/mmh3-5.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d95ee6696aa5b7283f4a27b67eb7db1c4fb5bb7a9117205d29ebaaa7f6294d7b", size = 55464, upload-time = "2026-08-26T04:56:35.23Z" }, + { url = "https://files.pythonhosted.org/packages/49/b7/841b580415a614a3ad836db1cb8d57c425bbeb717c80263a9d979b1a4eac/mmh3-5.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e2f439ffd4fd7d64b77f6a287d4605700bad26fe12bb1b63b4ee45211344e2fc", size = 40084, upload-time = "2026-08-26T04:56:36.44Z" }, + { url = "https://files.pythonhosted.org/packages/02/f6/f5cb0e2f7bb7df876847dab63ee984c3f6173569d5b892edc04bc797c1b7/mmh3-5.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f9cb34c661454f73432112a81ac522ebe69500feeb8d77f744f6bd3e8b2f2ba", size = 39693, upload-time = "2026-08-26T04:56:37.428Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/9506d0b30d7388846cc3b884ec63b8d67f3ad2d521e61775315111e70ec3/mmh3-5.3.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b6b7397804e9299bd0c01ea426245fa3d730d3e9c31f583f51aa87bed399c481", size = 97247, upload-time = "2026-08-26T04:56:38.439Z" }, + { url = "https://files.pythonhosted.org/packages/da/6f/c6a4acf4715fede1e0acb17d6080ebe9b88290d113bbf9513a8728c65b4d/mmh3-5.3.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4a19b00097fcc8e3008bb006cd6bfaf0544e9fa2abc4cc77fbad57971a37dcc0", size = 103256, upload-time = "2026-08-26T04:56:39.52Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2a/f4fe4ebf2e44f49953ed1ee6b90d329d6843ebb949e60e881dbfde84e17d/mmh3-5.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83d93abf6a68d54b4e2c4c041ffcffeb94b1c9ab3171443fda3f5f19024be517", size = 106172, upload-time = "2026-08-26T04:56:40.67Z" }, + { url = "https://files.pythonhosted.org/packages/1b/61/23168dc6fa92e1d9b57905b89c694a521a00570f0fe325bc3f6422fe6119/mmh3-5.3.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:598350a6adefb5799c800fd00bbeacbc115ee560e2fd7b35f703608c1037a2ed", size = 113020, upload-time = "2026-08-26T04:56:42.035Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/68dbb5461727bb14fbca2342a7bba4521400d679d64e00dd98a992eb0be4/mmh3-5.3.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c0772d6bba19a5601d24b3c6ce6627484fd5a3fd1d402913e1578b1447d51a0", size = 120648, upload-time = "2026-08-26T04:56:43.339Z" }, + { url = "https://files.pythonhosted.org/packages/92/18/a879ce4c26e8c1741b575e6c854b2323fd7e116000965c8904428e66fba6/mmh3-5.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00105934e7d52f80b4364282918c37e2cc3cf9868ef4052016cbc39d8711c3f4", size = 99034, upload-time = "2026-08-26T04:56:44.771Z" }, + { url = "https://files.pythonhosted.org/packages/1b/28/b0548d78133f8e79bb16d6487f2df504ff4e16ea1330954f26be83b645ca/mmh3-5.3.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:99a5b0a908beb01b0e134b7b085d0ea6bfb7ed28ba3ed0737365aa2ce9bda0e4", size = 98587, upload-time = "2026-08-26T04:56:46.089Z" }, + { url = "https://files.pythonhosted.org/packages/97/10/abfa952c6a443d07efb286b53b439d4418f7cfbac49fca8974cd78c17427/mmh3-5.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:da4ad7a0d4c589069c46101dcb55ee304616293bcf614f4c445b3ecc961fa836", size = 106407, upload-time = "2026-08-26T04:56:47.16Z" }, + { url = "https://files.pythonhosted.org/packages/a6/61/3b974a1cfe683c6cb01d2d3f2d5eddfca7e0a175d976d2307269143f4e0e/mmh3-5.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8962a67c314f1da82957aee5b940698aaffff13e41b3298baa59d30cbddb23e2", size = 109922, upload-time = "2026-08-26T04:56:48.451Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d2/ac7e18d6fcee3b26e06b3d33ecab4459b7aa6aff9859b13eb13e353a69ab/mmh3-5.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f61f2850b318c043961662f6cdd08e69b05f1d25d0e321782a3995d39f811548", size = 97456, upload-time = "2026-08-26T04:56:49.559Z" }, + { url = "https://files.pythonhosted.org/packages/ee/80/7911629b930d46ef8cb3165bce44f021d31361a2d7c56e35efe7b0d90493/mmh3-5.3.0-cp313-cp313-win32.whl", hash = "sha256:d7eec1b09bde3a9b6e2102717a587b9c9a96c360a1ef478b5668414619cac606", size = 40458, upload-time = "2026-08-26T04:56:50.785Z" }, + { url = "https://files.pythonhosted.org/packages/45/c7/138d77c740f9d33ceffa968786fe23abe24ce442c210772b5b7cb6e0c198/mmh3-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:b1829bfe98d1f6e7bd79646b78e73dcef92c5aa32aaa622b9e07bf39df98c9b5", size = 41879, upload-time = "2026-08-26T04:56:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e1/4312e5c18c8ef4060c9b50f1a645c9af7c3b79fad012c18fbe64a1b17103/mmh3-5.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:3fb0d4918b7d827ac804069849fde03d516628cbbf7bffe0b957ba6f1440cca6", size = 39203, upload-time = "2026-08-26T04:56:52.746Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5d/838c2260e490baa075d86900ea5179cc99a3f85b6b4377b0d3671ebc3c7e/mmh3-5.3.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:60d00bbddaec09e8d3259a0d8805e16b96b2709f14d9c486e29568ffe6f55993", size = 41038, upload-time = "2026-08-26T04:56:53.798Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/a2ff105cd6bc7f18c19c9e5e288fa741821788834d5ddd637b5f33d879ee/mmh3-5.3.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:811f0ef99cc178becef108563505b9e5aa69a406028136874edf6513d3568f8b", size = 42168, upload-time = "2026-08-26T04:56:54.871Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d9/c61e52eddb1e02a25367ef1121a14b69696f17067e3395411e0a86d69ce5/mmh3-5.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9a1841be8752f46cbf58a4825f3789ea66ed9c1190be5d81327509cf757b6f5a", size = 38810, upload-time = "2026-08-26T04:56:55.878Z" }, + { url = "https://files.pythonhosted.org/packages/7f/e3/2a1bfa294ef9df2f48d298e26d1bb05b44ae5ccfab3531a66fcf16eee361/mmh3-5.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:24eb191910181f4f41ffa2f323d63ebe725ddb0ac461baaa2274658cbe6b4c7e", size = 39489, upload-time = "2026-08-26T04:56:56.978Z" }, + { url = "https://files.pythonhosted.org/packages/60/b8/5dc5a1c593655b8ded88a88c27532d59ba7c94dac1c51665dddf6457cfca/mmh3-5.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a404419c65611d015eb03b204d3558635d97fcef149d0e8ed0ef818f7262f76b", size = 39999, upload-time = "2026-08-26T04:56:58.137Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ed/cf1a8e5d5d2a74918891cd790e22779d11e3ceb0bcabacfac7d47be2ccc3/mmh3-5.3.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b77364386164808efb1d7ea1305b14bb04fc6907aab242df3f6c04f5f0c1b371", size = 55493, upload-time = "2026-08-26T04:56:59.335Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5c/4da4d6af3f10635f6042ccfb5cc54ac90a53da9a14fdf1f9168892109b49/mmh3-5.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e0c195d2a2cf60c5f32928415d44d430129899b4c215d710f93179a056cf560b", size = 40158, upload-time = "2026-08-26T04:57:00.475Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bf/e4be8e728d94387c673510b11120d003f929858169017ab87b852ab5ac6e/mmh3-5.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bc6018e95814fd64ea8357c6e9e5608b97b1f33962c76cff60efd52f76b18a40", size = 39699, upload-time = "2026-08-26T04:57:01.561Z" }, + { url = "https://files.pythonhosted.org/packages/34/64/cbf9923093f18f16a655ed5405f8992498c542321b0cbfe92c650c778dce/mmh3-5.3.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:302e5b527b36a875abb611911142e92abac7580faec51318440149ab13b8e936", size = 97245, upload-time = "2026-08-26T04:57:02.641Z" }, + { url = "https://files.pythonhosted.org/packages/cb/74/71b8f3d20b201511cbf3a3d52f7aebf5b62f8a60d8ac1a258f093e447e09/mmh3-5.3.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0a301b316198758aabbfa3ee565e221b757645d9f94b8a9d2889bfefabbb77dd", size = 103246, upload-time = "2026-08-26T04:57:03.826Z" }, + { url = "https://files.pythonhosted.org/packages/81/87/95bb64aa64057349257c487a87c7590ca205d61cacd4248875c8ad5f0530/mmh3-5.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a78928eb29e96e8386eebc6c4271648e3918502a24296da7020607d74d33e622", size = 106195, upload-time = "2026-08-26T04:57:05.019Z" }, + { url = "https://files.pythonhosted.org/packages/78/36/4d87428276987949476258c116a092e2110b1cd33268236282c2a71be074/mmh3-5.3.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7adbd0f38ead7310e1e7428f254d450857645efc761c937a7d71100cce7a3a4", size = 113066, upload-time = "2026-08-26T04:57:06.365Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e2/d2edf34aff2af42afa6abcadb158096dd015234065811323208b63508c03/mmh3-5.3.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:422defa81ff5c114213033cb1ce45f3904427ec9dbfa0442a0f4656191f75f5b", size = 120634, upload-time = "2026-08-26T04:57:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/13/53/904a9e87364a0b472ebc3662a6c2e705b8051a726058cb7776a6c2a0966f/mmh3-5.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76601efc7bfe44014d7bf7d6ba9c75bbb0b426c93ce0f9216cd9ec5d384c224b", size = 99075, upload-time = "2026-08-26T04:57:08.7Z" }, + { url = "https://files.pythonhosted.org/packages/51/4c/4ee963b65c9581f429d39b1242d2579c4f74648c848c364634bb24e9a7ec/mmh3-5.3.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a5ada8e81fecfca0d934520be81117e5fdccbce7ae43612ae8aeb6c40dfd69ff", size = 98598, upload-time = "2026-08-26T04:57:09.885Z" }, + { url = "https://files.pythonhosted.org/packages/79/0a/0077b5ddb6e13c6cda29eb1b31c5cd12d3456cb625e9161e5f35cb0ed787/mmh3-5.3.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b93c17989d23a08104acc7e1912250b2eec4d24da9983d3c5f8ca9cda8d62a02", size = 106429, upload-time = "2026-08-26T04:57:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/e7/86/f07f14f4dd7c5738d0008e7311b47bb84c3a8ab55c753ba1dadc93a959c5/mmh3-5.3.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:762a03f0726850677f7f1e34a105d9121b4e11a0c20c28c56fc4b33a809c0e64", size = 109903, upload-time = "2026-08-26T04:57:12.371Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7f/356303be62b1053f57a506ac33b2f536a8665dc14a69c6a8468e9f5fb6d0/mmh3-5.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6f60209149e1011b4a3f3119ad2404b06507fe077755bdfcd4ea0090f64cb399", size = 97448, upload-time = "2026-08-26T04:57:13.552Z" }, + { url = "https://files.pythonhosted.org/packages/f2/57/c131c0c246714bd6483c52360fd4bbcea86ec007b1e653de3dbdf5bebb79/mmh3-5.3.0-cp314-cp314-win32.whl", hash = "sha256:1b51409765cbec88396b7a47d87220f9a34112c74a8d0db3ca2551941f023c74", size = 41008, upload-time = "2026-08-26T04:57:14.626Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f3/571defe22c618c25ba6724c7029fd2ee9977936910aef4f46398f5ec2bb6/mmh3-5.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:2585bef3623efaceb1cf4734c27bbba81b9bcb4ea5b8bf53823ca75b388933c9", size = 42437, upload-time = "2026-08-26T04:57:15.637Z" }, + { url = "https://files.pythonhosted.org/packages/34/ac/974345d0709558e4bd2f8d16c46fe480058d1ac4109e90cd5f2a2525bdb4/mmh3-5.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:c1fe81cd71d35fd2770dc206a4bd818bd73b515810314e1573b9db8c9a0284db", size = 39723, upload-time = "2026-08-26T04:57:16.625Z" }, + { url = "https://files.pythonhosted.org/packages/1a/fd/70b15f5f0038c8fe2cd2a30ca83404e1282af7643ba400214deff3a19498/mmh3-5.3.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:397b26f58cb062233abe36b6cdcb99f08204f529d2384e0f3ed07901dca6a2c0", size = 57172, upload-time = "2026-08-26T04:57:17.841Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8f/cf76d541a79d95254888834d0e848bab14b4f98a44e18942c26ce75bbc83/mmh3-5.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fe9b3b53b0688e9e5d7358e934e87c2da5ed34d997d0ef1ec403024ea760215b", size = 40953, upload-time = "2026-08-26T04:57:18.938Z" }, + { url = "https://files.pythonhosted.org/packages/fd/74/39ca2c868a1f921db0962067e8fd8f5c31a3a4b2d0ea6bb279e8e0948cd8/mmh3-5.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:045b44b299a658f02dfb43db4037437c66290f6d00984992030648b8ffc230d0", size = 40473, upload-time = "2026-08-26T04:57:19.969Z" }, + { url = "https://files.pythonhosted.org/packages/26/0b/4427f0134223b4061146bd6131db2c3b882fcaa08068e0f0d8c1f944a3f7/mmh3-5.3.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:060888503ab547f4700e93d13f45ee0d7633f5196028529c1e98c27d5b31520d", size = 109601, upload-time = "2026-08-26T04:57:20.967Z" }, + { url = "https://files.pythonhosted.org/packages/74/f9/6ab1a4d663f0c2fea4e7aeb0bfd6be70897981cf4a71f8339b2510d89ea6/mmh3-5.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:816db9f5ca3da2ecc9c74481c5b0474cc4368cb94e77f40bff8493abeeac81f0", size = 117370, upload-time = "2026-08-26T04:57:22.161Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ee/b559b1155998448b641ff73d03e1f1141f6390f63ace9df4e42a12120cb9/mmh3-5.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27706a3a4700d34718bd1eaf89719a13bbaa269606048dd00173997562c19c92", size = 120366, upload-time = "2026-08-26T04:57:23.362Z" }, + { url = "https://files.pythonhosted.org/packages/84/7e/79821c37f93fe15ca6710764c651276c6de63e376004dad7b63bc1ddbf2f/mmh3-5.3.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2d028a0e523d0c3e478544fc8350c024a0e0d73b549dd6013af4119a87589de2", size = 125940, upload-time = "2026-08-26T04:57:24.638Z" }, + { url = "https://files.pythonhosted.org/packages/77/3d/a109fc9bf5263d14989d7aaa4a51e4f21ef3563b9413ad9c05041c4d8403/mmh3-5.3.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10508e24e01f6b52c91577e22c4466d703c2c696d34fac1a9048e0da837a1a1c", size = 135162, upload-time = "2026-08-26T04:57:25.817Z" }, + { url = "https://files.pythonhosted.org/packages/f9/fe/fa430565444bbc68fef9fea7944ee976265bd0b39ac698cf170e266766e7/mmh3-5.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23bafb94a90f3ce2044b8a7a36c6c98ac6830ac8df1b3c0b65faa27a1bcbc49e", size = 109984, upload-time = "2026-08-26T04:57:26.982Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/a9810ece96d857dc05bda61341e48cbf65bca9c8b24b368724e0e150d2d4/mmh3-5.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2bc13752e7d80c7ea09d09368b78fca0d52f461c5c16abee5e4f78218f3d346a", size = 111111, upload-time = "2026-08-26T04:57:28.164Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e7/c17260aba7a7f111d211e382dfaa14891691ab171e0172f7d251498fd60b/mmh3-5.3.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e87e2c89016a83a6d7b8ff4e688ee6da843c5bf46bd1dcbdd36b181639575350", size = 116837, upload-time = "2026-08-26T04:57:29.336Z" }, + { url = "https://files.pythonhosted.org/packages/91/98/bb8f8ded55483672343d6b087e0fc74cd76bc9d1dc1c18c32aa468efd096/mmh3-5.3.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:93203131f4da1824dec6ffad11ff8afa03f0690f0075efa901ad7517a261a359", size = 122993, upload-time = "2026-08-26T04:57:30.542Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a7/e45b0a053c79c1567f64989d4f3bb6f44510d2da7c6a38b8f82eee94162b/mmh3-5.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d1cda1816c79bf9f7b0f7ced1b48bda64c8157a8532e1318727b4157062a5a67", size = 110150, upload-time = "2026-08-26T04:57:31.745Z" }, + { url = "https://files.pythonhosted.org/packages/16/0e/bacbff120a5218cbf33cbfb562d1435e10ff43bf20345d1125f4086ef842/mmh3-5.3.0-cp314-cp314t-win32.whl", hash = "sha256:a0eee1c2cafed49facb1fd300f684401ba5bd557d6d7dfa9bd0c13c5b0904f0c", size = 41461, upload-time = "2026-08-26T04:57:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c8/263a2f5af994a2c661fde9483b0e98e737b04831895bfdefc85fd81ed65d/mmh3-5.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:55347832dc6d49731283717087551839fa46a7997929403c96599b1bc93124f1", size = 43405, upload-time = "2026-08-26T04:57:33.983Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/221f238eb4259f42d72d154ce4c4383c65bdedd7d8823ed9e7a564978a76/mmh3-5.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:50958a182c7e189fa1408d9a28d9a37afbb3d72107cf8c6fb69b07b13506c246", size = 40286, upload-time = "2026-08-26T04:57:35.101Z" }, + { url = "https://files.pythonhosted.org/packages/7a/2e/7c57e51d1276594e2c790707d2d36cd497fd96127eb6fc311274cb8c887f/mmh3-5.3.0-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:88a79efc9949837126358be48d3a73628b615d0f9e4a81b2f819cf324b6fedd2", size = 41041, upload-time = "2026-08-26T04:57:36.309Z" }, + { url = "https://files.pythonhosted.org/packages/60/9c/e31280eba39ffcea51e1b895acfe743f5acb5a59162cfac6009360900068/mmh3-5.3.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:0e23bb59643dd36cfc1b5b6f32ca494dcb798f46281f2fa4561d34e7de777a24", size = 42168, upload-time = "2026-08-26T04:57:37.433Z" }, + { url = "https://files.pythonhosted.org/packages/43/62/228fbfe338dfb3fd410fba5f07765cd0180792d4be62581b717dc6160d11/mmh3-5.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e5133cc123cbbb69b585bb0b0166bf03c035787892a8b365238dd060ce02f8b1", size = 38834, upload-time = "2026-08-26T04:57:38.573Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6c/bba95309795fc92947b5e2ba86b1bff97ffcd8fc56d59c40073a33cc1259/mmh3-5.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:0619b41fc524c0e1bda7b29e47a9cdb4746be3ebd9413798f82c024894283d47", size = 39543, upload-time = "2026-08-26T04:57:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3d/bb4b474e5387bc4654cf945734edbdaaf9184dff507df09b624b9389208c/mmh3-5.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c1fc62fc688e884fb6300a536297a09c089c43f9eb8228790bafa66c28e28900", size = 39997, upload-time = "2026-08-26T04:57:40.637Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ba/88da42a2c9b0d3c7ce030c4c2895cdb01ed64df52c811e08e7358c16eb84/mmh3-5.3.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9438a4436d33b4c71b57f20cb982f791703c868f8c497c998ec33de3b9c4ce0b", size = 55547, upload-time = "2026-08-26T04:57:41.693Z" }, + { url = "https://files.pythonhosted.org/packages/2a/96/f4c4c46ef01757e10f4b89886bc49f0fdec904ecc2da9b3844fd5a331d7b/mmh3-5.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:453571409c955837178bedab51d5514aeda9ee4854dcde9e9134765f22da4380", size = 40156, upload-time = "2026-08-26T04:57:42.942Z" }, + { url = "https://files.pythonhosted.org/packages/2a/01/98f2f2ab0ab87eb608841ab277f94a75772661a2b20ce3a9c1bea38f809b/mmh3-5.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:d54b2f5dcd78b99893d0f6f265749886ae4d316fc54ab352444e581972555210", size = 39757, upload-time = "2026-08-26T04:57:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/af/0b/e7db2ea82300479c32e30c26082b88eda5744a49d41d8aa5a0fb60d6b206/mmh3-5.3.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c541249b3b9b5a023a9b6c9cc29e16777926e513c97fad3e2c44956e30d5fc7d", size = 98594, upload-time = "2026-08-26T04:57:45.029Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c9/046535bc0362a83b28630f68e4dbfdb2a5b5e56a6463b226a023a96ad593/mmh3-5.3.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1232c5667aaa2f1f77096d94dc0019ceb7ace106e9066b66d28773c4226364b0", size = 104104, upload-time = "2026-08-26T04:57:46.34Z" }, + { url = "https://files.pythonhosted.org/packages/e3/72/84dc86824533d3302dc497c8f2c7bfa5e0bcdbf340f378a3c63d05155096/mmh3-5.3.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28de4e0a140f86bea109a6d91de9f1e9a94c9d88e6ec9d4d3c78924e4f8c0a42", size = 106562, upload-time = "2026-08-26T04:57:47.503Z" }, + { url = "https://files.pythonhosted.org/packages/88/a3/085c3e201b23430974dfee96df108daecb7142745deffec71e569dfad0bf/mmh3-5.3.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1a9424ee53ac4600568f02683254237a3400af2f683060e7217f744d0833885", size = 113269, upload-time = "2026-08-26T04:57:48.613Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/5427b7b5c8ab602a1845550d02da15acece374fe71da602e3b0f37d171b8/mmh3-5.3.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:90ac257fd93bcd915672262b5f93f606400c1cc29e869bb2ff806038e7234a94", size = 121343, upload-time = "2026-08-26T04:57:49.866Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/59c41858157a8138e3e0f8a95cb014bbf8105598e3ebc7b53a2254561844/mmh3-5.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9063635665d5e62601d1f089526155ad2aba3c85e2050e439c774919447155aa", size = 99563, upload-time = "2026-08-26T04:57:51.069Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b0/411e361db86ded7b9db1db994cd9bd8fc1c50911a6d7a959c1e101214ed2/mmh3-5.3.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:def4d23969c4e944a9b01ad3187893edda1cf185d5913224420a72d1959245ff", size = 99563, upload-time = "2026-08-26T04:57:52.282Z" }, + { url = "https://files.pythonhosted.org/packages/e2/16/1f8a1d767c741653100496f2e62866cc857ee14a147628538b83b9cb223d/mmh3-5.3.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:48ae6e629a63cdadc29264a66edbaac9f18dd77feed973880c65393820867426", size = 106573, upload-time = "2026-08-26T04:57:53.514Z" }, + { url = "https://files.pythonhosted.org/packages/41/ae/ea3879a4da99713b278639d9e55176e45a67464df7809c41d5a0bed9ada1/mmh3-5.3.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:4785dec2f768b74c0735c9f0441b2392111a1467c463cae737bad30ac976b524", size = 110225, upload-time = "2026-08-26T04:57:54.707Z" }, + { url = "https://files.pythonhosted.org/packages/30/73/1bc3907f97cf636316414e56b929d990c0754baffd607b560d0d52e71fdf/mmh3-5.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:61104a5fc2923d5670f30e3ecbcb12ed70f49a17dfd757b32bc965eb90b53988", size = 98196, upload-time = "2026-08-26T04:57:55.91Z" }, + { url = "https://files.pythonhosted.org/packages/48/7a/726967f0adb26918095a0162f55a65ffdb10e7fa4909d251b9b725f7d361/mmh3-5.3.0-cp315-cp315-win32.whl", hash = "sha256:a4fa195374868fc65fe0ddefacf080c191f7405b36c8ee4ff7ce6c749b52dff9", size = 41010, upload-time = "2026-08-26T04:57:57.145Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f7/efd515b3b5665ecc5cf5113cc5c012dc8028404ace5865ab774af22f5d7a/mmh3-5.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:8d58d74be01b0eb74a14cfe768495c57f3a7a7c573a9db3c984b5f2c759a0fe5", size = 42427, upload-time = "2026-08-26T04:57:58.258Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/995893261597dbf57446862928c94b7bf3ab44f409b166d2d1404b226f06/mmh3-5.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:ae30d67c5f41be6c70d28557c0a3285787dccbde46a3c84a97d01996cbf43e7c", size = 39729, upload-time = "2026-08-26T04:57:59.341Z" }, + { url = "https://files.pythonhosted.org/packages/1e/61/a77fbc2aeb88c15300f3b179b9afb0c3f6395997371232929eb750b6103a/mmh3-5.3.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:db98041fc5d22f1cf66b38a361cf07a15321c0f2956b6134b007733de8befe2a", size = 57216, upload-time = "2026-08-26T04:58:00.474Z" }, + { url = "https://files.pythonhosted.org/packages/15/a2/07458f4eb072d684528e2e0aee011edb473842c26cd1d69cb09c197406e9/mmh3-5.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:70cd0d092d872b17cf1cd66c394a0537c291c97a539bd884fd0c2bdbf989cccb", size = 40961, upload-time = "2026-08-26T04:58:01.582Z" }, + { url = "https://files.pythonhosted.org/packages/f7/84/cf430e12828032c459994c09ce8a9f0da6069a02b76359ab229266a41a8c/mmh3-5.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:22cf74b87b11fbe732ce6e23699d59b53208114232515975ce486d1e6b5248f8", size = 40506, upload-time = "2026-08-26T04:58:02.641Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c5/00a0589cad767d51b8f431cbcd3295e385bfe4d6b1fea844dd8e8dae612f/mmh3-5.3.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6ff08246d6c6dcdd6306a7d806eb75867cea6238eeb88778fc9c577d8e9fe2d9", size = 108294, upload-time = "2026-08-26T04:58:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/c4/9f/730e34be444a2924cf3bfa4ac4b015b656eead79e735683efecce5a69c34/mmh3-5.3.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:44ca9d01e9761208de5ab845ce86096d3e730b35acc0f90cedaf58d3dfa4e325", size = 114249, upload-time = "2026-08-26T04:58:04.939Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d3/771008dfe72651a95e0b91c141f5938ae346585a3d26296d51d58c7c038c/mmh3-5.3.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2c36e6d747dcf42f5afc49b107c65c7e2be5dd8e80209244bc50c6bf2bfb81c", size = 117182, upload-time = "2026-08-26T04:58:06.164Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/95b7c8b851fb97a6974ea955f3dbe30bf7e3692ba982043f8011b0e39ffd/mmh3-5.3.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002414b951d980072a90dc25e84fe41570e399503c9db46a45edf61c7f0bd3bf", size = 122413, upload-time = "2026-08-26T04:58:07.689Z" }, + { url = "https://files.pythonhosted.org/packages/79/af/99ddc49b1993da421fd25cec9f46f558a624cc9f2e76695d999a03231197/mmh3-5.3.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bfc16e0bca99ecc91d4c9fdc708c432fac2eba3797e78577f4d280031456f37", size = 130998, upload-time = "2026-08-26T04:58:08.981Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/b9c7f0f6d6453a75eee6b455ee87f8839381bfdfba5ae43a2110911693e9/mmh3-5.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:166e3c09c56e980bfef1ee4fe4eded81d544615359eb92fb7ccda8c96f5d9538", size = 107781, upload-time = "2026-08-26T04:58:10.301Z" }, + { url = "https://files.pythonhosted.org/packages/89/b0/cd2a527717e3171ef9bb50f5391bc0fb8c5c9a5df90293132bd56a8783e9/mmh3-5.3.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:844f16c472352afa499d3a2ff0316cc812204bda42540af4768ac80a98b56277", size = 108860, upload-time = "2026-08-26T04:58:11.527Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/09e6e3e74386a73d533903f6e99e0fde75077af3dace436d460ae0e653f3/mmh3-5.3.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1e2cc33121171b1e4657146bfdaf77eb135787465f298d9f5ed04c14771c35d2", size = 114303, upload-time = "2026-08-26T04:58:12.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/d0996aa44f675f3dee627c2f90bf1c071f2424c203c5cfbe4270632bacfb/mmh3-5.3.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:af550cb399a702b1f277c375461fcd9533f46b435bba1e7847b4b729c6a316aa", size = 119889, upload-time = "2026-08-26T04:58:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/9cfe3e09689cbe4e381e09560be44824a143583938392bc3e48b35478dd0/mmh3-5.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ecd0172b50350cae19e8dbb07789e11234099de8ea44db0a981467a98c165170", size = 107518, upload-time = "2026-08-26T04:58:15.364Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/57df5ff90b968bcce8d173df2a69f668702d0940f0d66696f259f904a373/mmh3-5.3.0-cp315-cp315t-win32.whl", hash = "sha256:9e03132ea81849b5732122d3d2f39e46a327a461eb3d678f1e74747c9b7ef154", size = 41667, upload-time = "2026-08-26T04:58:16.579Z" }, + { url = "https://files.pythonhosted.org/packages/03/73/5f5d7a5c0bad6ae5568e3232d8b7bcee9639da0de45f8e664342f22bf682/mmh3-5.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1617be1ce93f97513bbf23cafa28a6ab1d20b0928de760054a4c603a89bdab7c", size = 43152, upload-time = "2026-08-26T04:58:17.706Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0a/033fef3d7a486aa369ec11cdf0455bbae6640df13491f6e7d35fd90aa000/mmh3-5.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:a8389010a2721e568fbe40ba94e7d8f931beabd59b95af2b2ab8238fd40a6b3e", size = 40275, upload-time = "2026-08-26T04:58:18.883Z" }, ] [[package]] @@ -2309,11 +2509,11 @@ wheels = [ [[package]] name = "narwhals" -version = "2.24.0" +version = "2.25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/1d/58946e5aab18393e793bd4add6985b95d0e01c3a2d832f38f54468b10dcd/narwhals-2.24.0.tar.gz", hash = "sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d", size = 661143, upload-time = "2026-07-13T10:49:19.086Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/7b/6248dada39781db1ab3ebf08943080df0796098515a87f6f8696d14ec744/narwhals-2.25.0.tar.gz", hash = "sha256:62c036c810662bf7820b7737077176313bc59350eeeefb808510f388c743e4b2", size = 677076, upload-time = "2026-08-20T18:10:15.454Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl", hash = "sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489", size = 461030, upload-time = "2026-07-13T10:49:17.571Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dc/55481808fd70ef1567cf13540ffd4702af3f74b112e35427564b03f79c2d/narwhals-2.25.0-py3-none-any.whl", hash = "sha256:1f0f403e8c7e4463cde9bfe78b12fdd809e3ae3dda6d9b2f802934fb9c7a6a8f", size = 467373, upload-time = "2026-08-20T18:10:13.834Z" }, ] [[package]] @@ -2358,7 +2558,7 @@ wheels = [ [[package]] name = "nbformat" -version = "5.10.4" +version = "5.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastjsonschema" }, @@ -2366,9 +2566,9 @@ dependencies = [ { name = "jupyter-core" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +sdist = { url = "https://files.pythonhosted.org/packages/31/72/b3446efab8756e7df4b8ec587f8e611cb5a7249e4323db480802f1d3be04/nbformat-5.11.1.tar.gz", hash = "sha256:32d4521c68c6e7d5b29c76defaeed9f42ea733142b9b19f88277ce10390b9c4d", size = 147775, upload-time = "2026-08-17T08:10:51.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, + { url = "https://files.pythonhosted.org/packages/1a/69/ee613f74085ca7103f79cd08d579c4f3177d1d26e5d3d9528d2d6536a707/nbformat-5.11.1-py3-none-any.whl", hash = "sha256:cc6698fa75f4fab8755ead786317815f13a6fee3b53311c0abb1a8b51d52f7ec", size = 79849, upload-time = "2026-08-17T08:10:50.18Z" }, ] [[package]] @@ -2426,225 +2626,265 @@ wheels = [ [[package]] name = "numba" -version = "0.66.0" +version = "0.67.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "llvmlite" }, { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/a0/570e3dc53e5602b49108f62a13e529f1eec8bfc7ef37d49c825924dcf546/numba-0.66.0.tar.gz", hash = "sha256:b900e63a0e26c05ea9a6d5a3a5a0a177cb64c5011887bf43edb8c3ed2c38d363", size = 2806181, upload-time = "2026-07-01T23:12:46.36Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/90/2544f4e3a61e501d6c9a5418fd4b905323222693d54a02cab0106a0af865/numba-0.67.0.tar.gz", hash = "sha256:cd75aa535b33fa05d9d930b1ae8af9f97a2881e96d72dfb38ec9b78284d9f851", size = 2836515, upload-time = "2026-08-11T23:04:00.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a3/70deb7f88461c1cd5d16aa990c2380604102661a427667b8950dcdccc27f/numba-0.66.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:53ca5900b7cab15109796030113a6b28576bae5ad7bb507ad6dd1360ddd81ba4", size = 2727264, upload-time = "2026-07-01T23:12:18.669Z" }, - { url = "https://files.pythonhosted.org/packages/2d/55/25c319845e9a4e08f16611ddbda56a192eb7b6ed13e1a2bff2da272ffb97/numba-0.66.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0999e3ee1b18c48e1fb51d11af35ef59852c7f4f50569c9550c25faef0616ad1", size = 3866252, upload-time = "2026-07-01T23:12:20.429Z" }, - { url = "https://files.pythonhosted.org/packages/71/ef/a82d6fd6bf1b0fe461651e924d3647eeec9ac17f8eee4896264bf7480930/numba-0.66.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efe0d2d5099790df945e0cb6e1b3104bd965d7bbfac50d62f1d5d1d6ade0825d", size = 3566974, upload-time = "2026-07-01T23:12:22.116Z" }, - { url = "https://files.pythonhosted.org/packages/fc/eb/9e6171e378822ab191c7abcfd3d8cfc8644516f6c7834c22e210e4acc070/numba-0.66.0-cp312-cp312-win_amd64.whl", hash = "sha256:b075a4e7ebc43dc6294f223e2821659656209fd5e0ce53245877c23d66d6e1a9", size = 2797403, upload-time = "2026-07-01T23:12:23.724Z" }, - { url = "https://files.pythonhosted.org/packages/03/52/176c02d005c5c5143cde10a85bbcdcb6236d9e34c3aac089380e0506cd1d/numba-0.66.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:380b2556a2019ccd1e956ae77dd257eaa39403f7520768b626d44b755112785e", size = 2727084, upload-time = "2026-07-01T23:12:25.434Z" }, - { url = "https://files.pythonhosted.org/packages/44/b5/e930010965568fe7f2c6c962fd2849d458cb9f62c3ab7584af8a19a2b40a/numba-0.66.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:939316d5d8619751207b8972a67852b5a7646665298cb4de693cd6bf135152f4", size = 3873663, upload-time = "2026-07-01T23:12:27.308Z" }, - { url = "https://files.pythonhosted.org/packages/d0/ec/5b51457cbe96e4831141d83e892e65191b23a1b78728456c62909d231ace/numba-0.66.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdf506775d9f02eb92a87bf5c5b1e0d25506fd18cafd769f4ed914a8feac73e7", size = 3573529, upload-time = "2026-07-01T23:12:28.944Z" }, - { url = "https://files.pythonhosted.org/packages/83/7e/cea7710e96913d3c7f2999f16db1b28e6c5be5171cbf40f77f98333a7243/numba-0.66.0-cp313-cp313-win_amd64.whl", hash = "sha256:c5bfe5350284509ab0474390321454c3a8627a188af5b68c910e83df3e2db4a7", size = 2797247, upload-time = "2026-07-01T23:12:30.774Z" }, - { url = "https://files.pythonhosted.org/packages/96/7a/7e0e73550eb4e41ede6e72fb5371f4539537a4d770a3b73fa9b61aea0622/numba-0.66.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:46ae5f2b19e2af3c33c2df100306a90ea2f981c8158b0390f8bf6c20eee7357e", size = 2727296, upload-time = "2026-07-01T23:12:32.39Z" }, - { url = "https://files.pythonhosted.org/packages/0f/26/885774c006de6620ed3d10f45d8e20fe0b8e6aad6d573211a2cbc8b3e528/numba-0.66.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e2b101f23b8b63978d334574d2039f27f0dccfe1d891756f33a2e2f3e4c88cf4", size = 3842720, upload-time = "2026-07-01T23:12:33.938Z" }, - { url = "https://files.pythonhosted.org/packages/93/99/edebf7de890b73973d839dd971cf73734adfb81ffa1b4504f84b9059c3e5/numba-0.66.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63b943eb2c9ba371908ce2cd6dfc643db51fc40f7966993376a1701bc922f537", size = 3543537, upload-time = "2026-07-01T23:12:35.566Z" }, - { url = "https://files.pythonhosted.org/packages/66/c5/b46ad28ac3681d035ea21365c5e052149062e1a0a9affd0563d2760ea6ff/numba-0.66.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd57790acd20f6a468e0ad333ef6b82355e309a92310fb7dff80e919f01a21a9", size = 2799250, upload-time = "2026-07-01T23:12:37.154Z" }, - { url = "https://files.pythonhosted.org/packages/10/6f/5e77a7397a37dd16f57a7b72e7e470db5227b68e3639df0d13a8e674883d/numba-0.66.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:db7735d15ea17a283d6485b9fa3504769f78fd86e5146638ad5e8da57c031b9e", size = 2730342, upload-time = "2026-07-01T23:12:38.758Z" }, - { url = "https://files.pythonhosted.org/packages/39/fd/e9c9680a3813f3d781c20e5d53c1074801b787d4feecca0472fdd7c05ce1/numba-0.66.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:651a2b53298340956db26ecbe7ab043106b50a40c2807e66d617a4917245a4ab", size = 3878695, upload-time = "2026-07-01T23:12:40.302Z" }, - { url = "https://files.pythonhosted.org/packages/61/3a/9b363287b85fcd4537ea3878793822878b2ac1008a78159d2096fea628de/numba-0.66.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c1144ba1720ea59ad79f4f488ed54d149b2613b357f7e445678b7d0739c70e9", size = 3596323, upload-time = "2026-07-01T23:12:42.805Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f2/dca53d50b8f2289dd01954ace9da261e0487d5b74b188b4304e4ecc3492c/numba-0.66.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d426178fb991a85714c43112a8ea7b9d9579ea856ad8dcdb9c1c3941903ba5be", size = 2804772, upload-time = "2026-07-01T23:12:44.399Z" }, + { url = "https://files.pythonhosted.org/packages/0d/58/915cddba90010348ed0444451132fdde9b000bcbaff1582029b5bf115d11/numba-0.67.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6004d8d5f28d4028687fb2d972d629295b13685943bd2ed5cd8810c3b848e219", size = 2745050, upload-time = "2026-08-11T23:03:25.607Z" }, + { url = "https://files.pythonhosted.org/packages/bb/38/926757caaac18a66f057d7544a63620bf360a07d281c9f7ecadd2aa83963/numba-0.67.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f63d43db06b4756424d6d2484737c902e0ae944a0eec3e8b0b4de2c695b15caa", size = 3884596, upload-time = "2026-08-11T23:03:27.688Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6d/58291dc58da39d98b32db7f044729f6d8d4920cd9622fbab3179b54ff4c4/numba-0.67.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76d3335aaeffb9dc88309420890e73497a00be08a7530441bc2b58ffe025bfa5", size = 3585290, upload-time = "2026-08-11T23:03:29.684Z" }, + { url = "https://files.pythonhosted.org/packages/6e/63/ab21828b4056afed71f9ecb40f4de26c2c19de731cc001961aca74b79464/numba-0.67.0-cp312-cp312-win_amd64.whl", hash = "sha256:50e2b72406c18cda5dd7431b0082cb85ea94e06c64c33607248fc8bef92cfb81", size = 2815645, upload-time = "2026-08-11T23:03:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/bd9fe772f6c84597b76cac229b3f2890f01a2c64fd70e48ceaae10dd65cb/numba-0.67.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:77e1c7173fee57a0d84e006c7e70346689d6cb3e7db503489bae58646b4eff7b", size = 2744872, upload-time = "2026-08-11T23:03:33.649Z" }, + { url = "https://files.pythonhosted.org/packages/a1/1c/c05609739cc41116d36e30cb2b41fb00f126bb52e1b0bac907051ad8a35d/numba-0.67.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9c4953387c77864b596d8296e2cfbdef82b0eea4166ab4864b05d226c51143e0", size = 3892004, upload-time = "2026-08-11T23:03:35.797Z" }, + { url = "https://files.pythonhosted.org/packages/4a/77/a5276ad4178250403e0e2251f3e1f8ac18feac779b0474a8bcb08558490d/numba-0.67.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88f6e0f5cb6c545e158b6ef0496c01b6d6958a7ccc6634a1576a94bbbab29ff2", size = 3591878, upload-time = "2026-08-11T23:03:37.845Z" }, + { url = "https://files.pythonhosted.org/packages/0f/80/d48f0ba7442516ceb5a1585f0c81d3aa531bc96bfcabcd9f8f925768c426/numba-0.67.0-cp313-cp313-win_amd64.whl", hash = "sha256:b68ad5125fe245339cc8dcc036081fc1ea482c5063387b9612a76ccd83dc91cd", size = 2815504, upload-time = "2026-08-11T23:03:39.736Z" }, + { url = "https://files.pythonhosted.org/packages/d7/16/345b1e4774a08247aafcfdb93d4e8d24a3646366cbe72de33053fc0de1b5/numba-0.67.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f99f880ff25f418a67f9a1d00d0ddfbc63430f627b523e515085a592a7567f4b", size = 2745088, upload-time = "2026-08-11T23:03:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/1b/36/e614ba2bc0f005ed0f37a6413f08fe705210297ddb9a37a475a8b9fdab61/numba-0.67.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269245a675abdd3e2c35ec6bb2f250355effa9032514d8f2354f0d2d10854bd", size = 3861040, upload-time = "2026-08-11T23:03:43.842Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/30c42a1dbc4176cf355e8e8be61803732c55597b1332925fe233912a43d9/numba-0.67.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f074a8e23db78490f11a3930c940be758316c10ac5985be83d2f298dc080acf7", size = 3561811, upload-time = "2026-08-11T23:03:46.037Z" }, + { url = "https://files.pythonhosted.org/packages/18/6d/21bd16f770476e394c5e5f504935817967442a71251d6b86c244a2767980/numba-0.67.0-cp314-cp314-win_amd64.whl", hash = "sha256:4d576e62bf2c9370f61312b51573c4bb1f3fe96798bbab56730847a368a316c4", size = 2817421, upload-time = "2026-08-11T23:03:47.922Z" }, + { url = "https://files.pythonhosted.org/packages/95/06/bb41b0e59b9ff52c94a2f01db24f6437df058caebb377b5f372fc343a6a2/numba-0.67.0-cp314-cp314-win_arm64.whl", hash = "sha256:7930748ce8355d2a5a28602abab056a61fdc676d17377f27d17993905428171f", size = 2788885, upload-time = "2026-08-11T23:03:49.967Z" }, + { url = "https://files.pythonhosted.org/packages/10/7c/aa07151fbd0f4283f78de437cc196f9084789be89a2b4de3fdc2f6a4b414/numba-0.67.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:4a2ed006635bbd0fe45681ed49f3b4f4bad1abf0c233bcc5842c9e3a34cabd61", size = 2748150, upload-time = "2026-08-11T23:03:51.755Z" }, + { url = "https://files.pythonhosted.org/packages/74/62/b8174ca95a4cc1a7ba1520767734e016991545590b8fbde521b681701a9f/numba-0.67.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa5f002f665bec321b950dacaa26ee009e1d720f6ac9d9856eed5efe1caa03a6", size = 3896986, upload-time = "2026-08-11T23:03:53.752Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f9/3a7b6dbf81e01a48958b45ad2239edbc64707522ab17f11f9f18c44bf6d1/numba-0.67.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83ab968b0e0fa744eba03351282dd8000796e6ec8e4518f47bd3ed86c0a20c7b", size = 3614644, upload-time = "2026-08-11T23:03:55.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5b/248f5681c121ca853a9f4e39d342a3e01b8a0261b0275853eb3d0d56aa20/numba-0.67.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00c964a5b94d3ae82d83ac162cd610755875b98dadb779fdde06e6bfcdbca47e", size = 2822870, upload-time = "2026-08-11T23:03:58.097Z" }, ] [[package]] name = "numpy" -version = "2.4.6" -source = { registry = "https://pypi.org/simple" } -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/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" }, +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" }, + { url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" }, + { url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" }, + { url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" }, + { url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" }, + { url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" }, + { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" }, + { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" }, + { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" }, + { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" }, + { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" }, + { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, ] [[package]] -name = "nvidia-cublas-cu12" -version = "12.8.4.1" +name = "nvidia-cublas" +version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" }, + { 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-cu12" -version = "12.8.90" +name = "nvidia-cuda-cupti" +version = "13.0.85" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" }, + { 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-cu12" -version = "12.8.93" +name = "nvidia-cuda-nvrtc" +version = "13.0.88" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" }, + { 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-cu12" -version = "12.8.90" +name = "nvidia-cuda-runtime" +version = "13.0.96" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" }, + { 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-cu12" -version = "9.10.2.21" +name = "nvidia-cudnn-cu13" +version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cublas", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, + { 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-cu12" -version = "11.3.3.83" +name = "nvidia-cufft" +version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, + { 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-cu12" -version = "1.13.1.3" +name = "nvidia-cufile" +version = "1.15.1.6" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" }, + { 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-cu12" -version = "10.3.9.90" +name = "nvidia-curand" +version = "10.4.0.35" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" }, + { 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-cu12" -version = "11.7.3.90" +name = "nvidia-cusolver" +version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "nvidia-cusparse-cu12", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cublas", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, + { 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-cu12" -version = "12.5.8.93" +name = "nvidia-cusparse" +version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, + { 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-cu12" -version = "0.7.1" +name = "nvidia-cusparselt-cu13" +version = "0.8.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" }, + { 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-cu12" -version = "2.27.5" +name = "nvidia-nccl-cu13" +version = "2.29.7" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457", size = 322348229, upload-time = "2025-06-26T04:11:28.385Z" }, + { 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-cu12" -version = "12.8.93" +name = "nvidia-nvjitlink" +version = "13.3.33" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/580ca6f29dcab0221db8706badca1bbbb084f1975c4d4e83329c3a7e31f0/nvidia_nvjitlink-13.3.33-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:26a6de7fb4c8fdaa7703d3dad720d6d427ddfea5c48a528fd97c11733ad830e5", size = 40742423, upload-time = "2026-05-26T16:54:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/69/30/45414e35ff2eee7db3da037e5707037ccf9d2b5218ffbdb055ea4d5aa98a/nvidia_nvjitlink-13.3.33-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ce48b37dfeb3cb1eae4cf85adacb47d7a6539ea2272870c9a3628ce275c2037e", size = 39168635, upload-time = "2026-05-26T16:54:13.906Z" }, ] [[package]] -name = "nvidia-nvshmem-cu12" +name = "nvidia-nvshmem-cu13" version = "3.4.5" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:042f2500f24c021db8a06c5eec2539027d57460e1c1a762055a6554f72c369bd", size = 139103095, upload-time = "2025-09-06T00:32:31.266Z" }, + { 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-cu12" -version = "12.8.90" +name = "nvidia-nvtx" +version = "13.0.85" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, + { 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 = "olefile" +version = "0.47" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c", size = 112240, upload-time = "2023-12-01T16:22:53.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f", size = 114565, upload-time = "2023-12-01T16:22:51.518Z" }, ] [[package]] @@ -2675,7 +2915,7 @@ wheels = [ [[package]] name = "onnxruntime" -version = "1.28.0" +version = "1.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "flatbuffers" }, @@ -2684,30 +2924,30 @@ dependencies = [ { name = "protobuf" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/98/f8/dcbe7700dca82fa540035abd3c868fe5ad0f86af00b9a3db7c2e27d15c7d/onnxruntime-1.28.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:26ff0fdd06efb6c155bae95387a09db1a2be89c7a03e4d0bffd5a171cc2826da", size = 19141362, upload-time = "2026-07-25T01:22:36.965Z" }, - { url = "https://files.pythonhosted.org/packages/28/5b/1d77e62097fdbe07e2dc827f389b1c4c0c275f6fab0369a8f46d2461af27/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e81a23df16e7acb9d51b06d30cc098e49315ef9180f97bc2221d167b4b04d9c", size = 17050628, upload-time = "2026-07-25T01:21:40.481Z" }, - { url = "https://files.pythonhosted.org/packages/95/df/5486ab03e9be288d5268867054c8b04bebcf95bfd12e801c05cc67703dab/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a83bdb70d143cede762b677789bf2a7acca54b3fb82565601d5c30695aa933c", size = 19214257, upload-time = "2026-07-25T01:22:01.695Z" }, - { url = "https://files.pythonhosted.org/packages/3e/3b/986ca67c274932ba9ac5332fb10de56f643dfd433c74e33f8ae8f847cf24/onnxruntime-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:c35064f9b3c43c81c5d5d282091401d0f1ff22796d93ccade4ea2ece5e137ab8", size = 13755036, upload-time = "2026-07-25T01:22:26.89Z" }, - { url = "https://files.pythonhosted.org/packages/1d/46/059dba81d46c6ba88e0c2d1c64321ac8098847d678423300a183d42ecbd6/onnxruntime-1.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:e02feeb0165c5f13b4cc954738078d59b90128516ac12b671ee24a530242bf02", size = 13454462, upload-time = "2026-07-25T01:22:17.38Z" }, - { url = "https://files.pythonhosted.org/packages/9c/12/3807e2b17d9eb71d3cb78ed2ba76869b05c637c9b9d6112e636098b0c97a/onnxruntime-1.28.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:31410f544674f534c2f27348af52ef81682ca9c8719154bf4d48f0ef23823b1e", size = 19141759, upload-time = "2026-07-25T01:21:53.765Z" }, - { url = "https://files.pythonhosted.org/packages/c0/23/b46045c3bf67a9cf54c12f5df0f018a422c65fbb9d6072b10071bebfaae2/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f649dd6f6452d12a8059888aa489fe519e062e18793dac72b9efa0f9fdb64135", size = 17049339, upload-time = "2026-07-25T01:21:43.005Z" }, - { url = "https://files.pythonhosted.org/packages/78/b6/8c5396e7894e77c5a7d1e026f3acb9dd39c4b5644e412e37a0055eaa3bc5/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fa221d669282bd8f582708ce4c96010a7e9fb0661f9006b37fe2fedafb73fe", size = 19214329, upload-time = "2026-07-25T01:22:04.133Z" }, - { url = "https://files.pythonhosted.org/packages/56/f1/51225c202edba4dfc94e1ea03f3d78f1aaf307da75fd792c0ce1946b2514/onnxruntime-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:1a1a19175464665c9b8d50bc916f216cc0b569110045b7bbca8f9f290b186f58", size = 13755033, upload-time = "2026-07-25T01:22:29.302Z" }, - { url = "https://files.pythonhosted.org/packages/f4/db/f59f715edfdd96a051f32b5ef0e680a20a8755d4ecd75f63090e960e347a/onnxruntime-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:cfab507abe09d6ffeb817eee07944d452fdc0b00fdcef34cab4db10a45e378c7", size = 13454175, upload-time = "2026-07-25T01:22:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/47/28/810314fa88647af9f4cdaf438a30ad1cfebebb53ded55499232d7a0094e6/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac301f53b1930402fc46c368e268acfed02f3207272aaff05070d7e09f96f031", size = 17057307, upload-time = "2026-07-25T01:21:45.492Z" }, - { url = "https://files.pythonhosted.org/packages/3d/cc/9e9f193cc0f29f263a8f09ec08487aed6c96ee856d5fd77da32a425c1949/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7f022a1103cae591c75fc4565589a515f2ddd14a6ac8e8a05812dfeda142e28", size = 19222954, upload-time = "2026-07-25T01:22:06.952Z" }, - { url = "https://files.pythonhosted.org/packages/4e/eb/952314c451d9463e5c9aed9978eec76cf32930d407d9ab8700dd0f4ea1ea/onnxruntime-1.28.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8adff67a3f28257b37cfe945a7e952e4122666aa8c91a0380862e9fd4c2ed19f", size = 19143748, upload-time = "2026-07-25T01:21:56.297Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e9/139180b4dd810329aaa42c238b4e6383c906202d98609ae29d66eb7c32b1/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2565e487b4896fb988d6383577d875d958e071fc5f6c3550bd5d02ae98264b", size = 17051950, upload-time = "2026-07-25T01:21:48.606Z" }, - { url = "https://files.pythonhosted.org/packages/03/88/9432428273356ad3c8aa01f52c1b3e7f53c4c0192748f41ad983872b436b/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6afdc83f1317c136e92fc29f5ee9f058de59d87c0b22cee3fdbfbaa0ccc2098a", size = 19214924, upload-time = "2026-07-25T01:22:09.727Z" }, - { url = "https://files.pythonhosted.org/packages/bb/e2/6feb3a43517aaf2b1bf7e46897ba5eb81a29717f7d7901420614d5ee4653/onnxruntime-1.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:f2a3b9e30ce880d4ca54999cb313569e36da4f62eefe25f87be18f43e9a3a4d5", size = 14093738, upload-time = "2026-07-25T01:22:31.629Z" }, - { url = "https://files.pythonhosted.org/packages/fc/8f/83974a1e201dc2e58e5e7111bcaeb1ca2413e9c41f505d26419ee9e3dddf/onnxruntime-1.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:07fb3cbe990d6bf0ab3c22bfbbfb0e314151266046ea6edb4a07f556b4258c5f", size = 13821117, upload-time = "2026-07-25T01:22:22.387Z" }, - { url = "https://files.pythonhosted.org/packages/0d/83/00e606bc25c756d76a267370c39b7516ad52f9cf134d7ff2bff8b6108bc4/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e562d6e36a749f6764481c0ddb0f2af3d0b5a3c164291361d08803c557f369af", size = 17055518, upload-time = "2026-07-25T01:21:51.08Z" }, - { url = "https://files.pythonhosted.org/packages/94/a9/68707e1ce345cbdbcd4df65932ebc82a673e917d63eda0007ebcff948691/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f6e92367ddce1e4d33cf295024f40192be6c6171a09208f515ba169ced06c8e", size = 19222976, upload-time = "2026-07-25T01:22:12.474Z" }, + { url = "https://files.pythonhosted.org/packages/d4/80/381c1e9efed9cc32d00aa7cab0547dc84116cec906c3ffe3613686d6963a/onnxruntime-1.29.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3a3814c041251d6a77fdf513fb282056538ee826d2f1178a0df3c549d3fff6ba", size = 21430049, upload-time = "2026-08-17T22:53:48.286Z" }, + { url = "https://files.pythonhosted.org/packages/30/12/4be0e345d38fe707a701ca07e8f63c05b152a2e6285d1e43a7faf63fedd2/onnxruntime-1.29.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d2fb19e848f7c33ed8d3182b52504aaa11c5e8da438bbb47296f85b133cbcf6b", size = 20816870, upload-time = "2026-08-17T22:53:51.169Z" }, + { url = "https://files.pythonhosted.org/packages/96/eb/e6968f5e41aac3125f2ff5708855f09cb0b70d85ed3115b625b0b58305ba/onnxruntime-1.29.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2b80d8c7ec2cc7438e4da3760b88c24568cba72c9ace96d668800a6c79419acb", size = 23136745, upload-time = "2026-08-17T22:53:53.92Z" }, + { url = "https://files.pythonhosted.org/packages/b4/80/5b28f1f1111210fc4a336ddbc6950f468ebf9a6a265420568f4f43fa33ce/onnxruntime-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:4acf2b4948b7ede87221ca6332344b8facdc8059d6ac751a7d367d04532b02dd", size = 14001407, upload-time = "2026-08-17T22:53:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d6/6883f89ea4b044e6e8447ebfaf9bcecdf457b7d80a683635e130b25498e0/onnxruntime-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:dc61a79cb39afd66ab3f01fd2c23591a7f01de89c1668e1fb6315067fc279164", size = 13746981, upload-time = "2026-08-17T22:53:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/41/f8/d375facf60edaf41f5732f9f689c98a800fcc52df5cf6ddfb406703eb5a1/onnxruntime-1.29.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:be0f8ed688cfb1d4d5765a137193b7bfab0c8ea214eed99260b380bb525a3a7f", size = 21429708, upload-time = "2026-08-17T22:54:01.44Z" }, + { url = "https://files.pythonhosted.org/packages/c9/17/b9ad04051a8c4f504852ce0e8e10f9a6b2f1a331eedcdcc503df776dd0ea/onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:d67673c5367727860922c5262d724472f1b5539fb7ccf4c81a638f9b71719803", size = 20816263, upload-time = "2026-08-17T22:54:04.088Z" }, + { url = "https://files.pythonhosted.org/packages/83/2c/d8eb945d2a372149df9705a8d5c8d7c6c46c987c5446dbcea9e1ea7f6556/onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e2128f31f449e922c62dbe5d8b6b7b079f0bcaf2d56a102fa203cb6e5bb5ab19", size = 23136817, upload-time = "2026-08-17T22:54:06.714Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3b/66b424c63fa92dfaa48d1719efaae66fc8c256b9426a832eda51d8dfe1e9/onnxruntime-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:2945e1f82f81f27e88decea88c7861f45baea23818950d467bf3909aa303119e", size = 14001310, upload-time = "2026-08-17T22:54:09.13Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/d6a700e3a6322fa3d56fbe7cee9ffc53f35e77ffcd6b7e97f4b7722a27ab/onnxruntime-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:4b940b0d777590c7e20bf298f5c16af1ea6ad1b400a1c822a6be192f64f4d954", size = 13747112, upload-time = "2026-08-17T22:54:11.608Z" }, + { url = "https://files.pythonhosted.org/packages/4a/89/c4af146de3d60a32c89fea48d5d34bfd044faaf8957270043a03bd1b462b/onnxruntime-1.29.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:533f8370ce124304e5cb08ab961836cf755631e3dd77adc5f3bbdab70c2b7d99", size = 20826136, upload-time = "2026-08-17T22:54:14.315Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/e6bbacd11dfe8d070613261a758795ea128b9fc9bea391a2a7da2e4c7a08/onnxruntime-1.29.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c1ad3f437153fe77f9d01a08fbaac0beb030e09b8a80ace1603bcf69b6c95481", size = 23138951, upload-time = "2026-08-17T22:54:17.154Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a3/718e1b83096a1bc7b0fc8014c23d4cf795559fe666961cfac4fc038a4871/onnxruntime-1.29.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e74b278af1d949876f5d91d1268fd6c680e79f2bac194967394eaba9fdf69e7e", size = 21431104, upload-time = "2026-08-17T22:54:20.118Z" }, + { url = "https://files.pythonhosted.org/packages/4e/17/c75e78ddc1fe69b6ebaef7fe88ac83f29bfe10955e3a0d2436d93473c91c/onnxruntime-1.29.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:939e5d65f332e6d399774b2bd0d3559fd8fa629c1e77833db29d968d2384f23d", size = 20818488, upload-time = "2026-08-17T22:54:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/54/9f197c578d3d3d7bea16971e233e5483981228eec73748585cf7b5933403/onnxruntime-1.29.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:6c0c37b92f67ed68dd36221ce0403e1d9bd4f7efce724439978a2597848530e5", size = 23136994, upload-time = "2026-08-17T22:54:26.321Z" }, + { url = "https://files.pythonhosted.org/packages/24/53/4616a55d2495679cfd0195f968feb3d74fe30e26467d168ee243ac97c089/onnxruntime-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:4a3129ae56e70d2618ff773920166916310370a7e3cacb60b9e0e8910092725f", size = 14350643, upload-time = "2026-08-17T22:54:28.794Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0f/c338cb5500a522c7e671a3bb1276f4562404fbecce8a0e274565aa968484/onnxruntime-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:e417ef8628dcce310d2d53023e750ea298ec14d4341ae6dc3a572bfd9bc7fa97", size = 14124294, upload-time = "2026-08-17T22:54:31.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e7/61064289a9a1301b25c1f0f574fe98aba31c2d388db3c1dbec664f78621f/onnxruntime-1.29.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:11264bb58f7b7cf6af835ab10d36838d73680580820fd6f51d90124a1ca8f449", size = 20826174, upload-time = "2026-08-17T22:54:34.283Z" }, + { url = "https://files.pythonhosted.org/packages/60/21/d0c04b561b46e9bff89b5f500fb7415b8ca0669f7902204f76ab06bb0c7e/onnxruntime-1.29.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1ea91cef3b971506e51ae9c37c16d027774ec64994a524ec1bdfb027d68a9832", size = 23138547, upload-time = "2026-08-17T22:54:37.491Z" }, ] [[package]] name = "ontocast" -version = "0.6.2" +version = "0.6.4" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -2899,21 +3139,19 @@ provides-extras = ["all", "anthropic", "dev", "doc-processing", "docs", "documen [[package]] name = "openai" -version = "2.48.0" +version = "3.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "jiter" }, { name = "pydantic" }, { name = "sniffio" }, - { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/d4d1835488c0350424009dac5095b9a3e173bee12fd2e421ee27e2142c42/openai-2.48.0.tar.gz", hash = "sha256:231b1e7661dda14574986c2f71451e9d584b7fe69e0ee6480e12ed090b48fc16", size = 1093427, upload-time = "2026-07-23T20:15:50.402Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/76/913b755a1a6b54e2d9140eb8d488aa0d47c7359b1d7eac5e864cb7913bbf/openai-3.6.0.tar.gz", hash = "sha256:18fe3f6e96390ef41ee27b152fc9effefca321c33673bd9b956a572493d3ab9b", size = 1455376, upload-time = "2026-08-28T22:29:18.268Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/2a/dcb891114e303c4379d4a498f10222e33eee540bcef4e1e493bd0af2b242/openai-2.48.0-py3-none-any.whl", hash = "sha256:c98df30aaaf93c51979f64d3e7c5b76464f8be0173368266229eb8fe6bd30f2c", size = 1648520, upload-time = "2026-07-23T20:15:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/a1/94/805b87ecc951c49ec8f247f5e8eb324ab064bd2ad73b6a0e704dd49aa073/openai-3.6.0-py3-none-any.whl", hash = "sha256:508e2158bf971687f953b62e44b02f207792c815aac306816386d7ba34d37f5f", size = 1699841, upload-time = "2026-08-28T22:29:16.436Z" }, ] [[package]] @@ -2968,55 +3206,54 @@ wheels = [ [[package]] name = "orjson" -version = "3.11.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, - { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, - { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, - { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, - { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, - { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, - { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, - { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, - { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, - { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, - { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, - { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, - { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, - { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, - { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, - { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, - { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, - { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, - { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, - { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, - { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, - { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, - { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, - { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, - { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, - { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, - { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, - { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, - { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, - { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, - { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, - { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, - { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, - { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, - { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, - { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +version = "3.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/742fb1f62b825f2c010697eaf4e828004bc2a81e7e806666989c132c7c42/orjson-3.12.0.tar.gz", hash = "sha256:d14203fb1aae2ad9b3d52f8a0e82aeb10197ef1c9bc61da7f358bd70b00123d5", size = 4142915, upload-time = "2026-08-14T16:13:30.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/4a/295da39c651c2faac8bd351a2a346f0fdedd9d50b847ee9dfc27d2207ef6/orjson-3.12.0-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:aa3e43a6846e91d7bde3d5a9c66090fcd8744f569a9b6cffc5e1ca38f6a461c0", size = 223427, upload-time = "2026-08-14T16:12:28.525Z" }, + { url = "https://files.pythonhosted.org/packages/29/98/758cf90fbeaaafb7f8141bfac75a432099959f3a2f5db93a412e876415d8/orjson-3.12.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:11edb4660a6680abee9788a3a9072208a2c96538cc1322bd79542065229d8e54", size = 123725, upload-time = "2026-08-14T16:12:30.013Z" }, + { url = "https://files.pythonhosted.org/packages/32/b5/5b934d251f8651f7e41df180ad0c57a6e1cabe15c7bd331638413a50ebc9/orjson-3.12.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2d3a9da945a4d96ae758fdaaca56742e6b73b6fd554c5d8876f252a6dad70b83", size = 113375, upload-time = "2026-08-14T16:12:31.209Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d2/37efb5b12a176ce3ced29f4144f20da57d02757f78ce549637dc1b4e1fc8/orjson-3.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:92ffc09e07233a6ab6d4e067f7841edcbcc134cb4812155cf171ea5255a421d7", size = 129983, upload-time = "2026-08-14T16:12:32.721Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/0644b87c73f13e0092df8f35a1fe280d991e5e90072087411e0dd7e44e0c/orjson-3.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf44e374aadde77b1f6109f1030be51433eb61984379852766b6f4e187db7b1e", size = 130629, upload-time = "2026-08-14T16:12:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/8c/57/80b986ebfecd9c6a177ddf1c2319717f0cd8feffb2b78946595a18a2fc88/orjson-3.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1192a7021b6d071aaf909864f6e924d6a2675ca360485b972b8401749311750b", size = 131245, upload-time = "2026-08-14T16:12:35.713Z" }, + { url = "https://files.pythonhosted.org/packages/80/3d/75c5ac5a69161f44492a68fbdde66f4cc4ce48cd5e1fb05918e46f0c8848/orjson-3.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:53c0c474a9d9aff9aebfc0c88de1f28f843d940e6e3a80729abdf6a20274356f", size = 135397, upload-time = "2026-08-14T16:12:37.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/93/4d71f2df314a97ff0d27a4559bf5888fc8406e3c6dec90e92291e3511215/orjson-3.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:532ff8cd4bd59a327a953a7dcde922c7fc25b85e29721bb8633265430d3a3873", size = 127693, upload-time = "2026-08-14T16:12:38.627Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1d/0dbc6be5adfd1730491072fb60beb6bcdf5d7b2596ee41b7fc2e298bfc09/orjson-3.12.0-cp312-cp312-win32.whl", hash = "sha256:a6cf4b18e7de173f209f2084ffbd736dd72389a396326ee80a7022168be232e5", size = 128000, upload-time = "2026-08-14T16:12:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c9/97b1ce0112ebf5e949c775ed5b1755e562233179f3584579673cc24d6378/orjson-3.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:010811c1b69773450a01cef97727a67b223242f350b77d4ca000e59a9ef2155a", size = 122106, upload-time = "2026-08-14T16:12:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/a8/6a/facd8b312e4a0d3a7fa978c7e15821f74a336adf1d65529faec33b48e18b/orjson-3.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:ad29eece0c601737f2a60edc2752a84e7a0785df3efb62e3012834700a5afe0d", size = 126869, upload-time = "2026-08-14T16:12:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/54/cb/d7b78218a987eb8a8ce4eeae0286b1bb679333eb631ea0eeaf6371680bfc/orjson-3.12.0-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9a36ec60f1796f9a3f13e3b98390295e17a1c7c10155b448d264098bf9ee5900", size = 223397, upload-time = "2026-08-14T16:12:44.003Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4a/bc87c45e7ec639d35ebefd62618e01939531ac8e171426606a01bda05914/orjson-3.12.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ad0422b92d5195443a39f80c3bcf731cc2e00f153bd32063a47b73b057bd0f03", size = 123662, upload-time = "2026-08-14T16:12:45.433Z" }, + { url = "https://files.pythonhosted.org/packages/94/ee/c9a4ff3f2dbedbbe9e635d0fa72c8866adede09b6335ef9644f53752f0d8/orjson-3.12.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:5a0fdbc216388f653d3752ff310e710f59253bd4ed6a2bfb3f4f06b84714bbd8", size = 113374, upload-time = "2026-08-14T16:12:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/75/09/3f330a026a796c8b4c97a6f429652a5e912e7065039bf96ed25e42aa7b25/orjson-3.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2eb5c56e534127b2b8fa38d2363c8b1b8190367ee0d1d16c041517d880843b94", size = 130029, upload-time = "2026-08-14T16:12:48.06Z" }, + { url = "https://files.pythonhosted.org/packages/7d/40/094cc53126a3d22f76cdf83b6ea67338bed01d774037621a785aa8e6e5ea/orjson-3.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:784106539f4b9d4b930e0b4eb8d45168507dae001945e71b4675a367f1e5e806", size = 130528, upload-time = "2026-08-14T16:12:49.362Z" }, + { url = "https://files.pythonhosted.org/packages/bc/74/89bb236deb9565f99434b13052bb40ddfcce4adf3afbfa3132ee7e421468/orjson-3.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c680706fc8396d95e7c4c1f9482563f552137aef91b57237a3ad5aaf64629df", size = 131075, upload-time = "2026-08-14T16:12:50.692Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ac/1176360d762c01b5bd34acd56fc098e936c491363d8b6b397ad4aa475547/orjson-3.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83445adc40cba26d6d621185a45128ce455b766af368cad2ab64b970603a7978", size = 135321, upload-time = "2026-08-14T16:12:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/7a/02/bbd881c8b9276d50b998de38b4e97de8ace1aac940b0ee545aedbf65ed00/orjson-3.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:644d005bc82f917337a95ce270c9f6f92f9834c2bed7b1477572f8db00784222", size = 127472, upload-time = "2026-08-14T16:12:53.517Z" }, + { url = "https://files.pythonhosted.org/packages/8e/02/a0934d7503e6dcbedd6afac3e7f3f8597fd09389949ad94d0f7540e9dbca/orjson-3.12.0-cp313-cp313-win32.whl", hash = "sha256:d8e78d3d93705e3d27cc17cdb209e44d7a8ea203010cac6ce9c7ffc1ae1996f1", size = 128000, upload-time = "2026-08-14T16:12:55.14Z" }, + { url = "https://files.pythonhosted.org/packages/52/87/69f98f8d40faff103a965a5fbb83f08241b01beaf92badb5413fbc9358cc/orjson-3.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b85931be5b6763c31283805c9bdaae1ca03ad9f6f12a15f1cbf6745b907932c2", size = 121841, upload-time = "2026-08-14T16:12:56.507Z" }, + { url = "https://files.pythonhosted.org/packages/e6/07/b83046a4e3cadcc0987d0f160696107c4af706a619b56e4ad01940cadadf/orjson-3.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:6a31348d7dfa64cd9c78bd1f510ff44c48fe64d71094e6b90e364dba3b55949e", size = 126765, upload-time = "2026-08-14T16:12:57.806Z" }, + { url = "https://files.pythonhosted.org/packages/12/9d/3931253e6f3148abf2cbe14830367042a4806b362ea520df2303db188fb9/orjson-3.12.0-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9e6fee342a48760e854d743e7a81534d8e2925a6f46e09f750cf56b50fd1de5d", size = 223391, upload-time = "2026-08-14T16:12:59.184Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0e/b4a4f1e305367245877b967a0bad70fcf001d77c54ac4339a120b66fdae4/orjson-3.12.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:8c3bb86dd10f39b3fbf434b7d5dc7cac77d6fc8ac572ae30a10731ede2c4b647", size = 123659, upload-time = "2026-08-14T16:13:00.548Z" }, + { url = "https://files.pythonhosted.org/packages/96/f3/6782c6fa85e2702bc66be183c3b421486167dcf266ee4dc1403fe3824870/orjson-3.12.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2bb3ce43203936072dd8b4917b01d3aecfc02329bfb42510cb7cfb24708adc9c", size = 113337, upload-time = "2026-08-14T16:13:02.009Z" }, + { url = "https://files.pythonhosted.org/packages/bf/79/b32ab64bacda9d0fa4942ef483bd03cabf0eaf2be819ca9fb7ff610c559d/orjson-3.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6a2a79c89984dc719817d388c8709e0efc2a2795a934eaa746b4882eb6045adc", size = 130112, upload-time = "2026-08-14T16:13:03.404Z" }, + { url = "https://files.pythonhosted.org/packages/ee/49/6e6142999ca01509219be5e5a9c338a3e5ea011f63e91ff473fbbf3734ed/orjson-3.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f06dd838d1e07d9b1de0932ec0485ec92c4d5f5d1ad4817a656268c3e88be1e1", size = 130520, upload-time = "2026-08-14T16:13:04.798Z" }, + { url = "https://files.pythonhosted.org/packages/49/d0/3745af0a4cc9867784f29722929cec4d10bd1c877cd754b01ba6d96eb21a/orjson-3.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6b11be792c3d2c6a4be2af4ebf97a68d0bf5f580aca6e86a418a354f6cc846a", size = 131053, upload-time = "2026-08-14T16:13:06.14Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/6fe5a22fa478fffb190e65c338c84df5c311ef597b363150a17cc57063c0/orjson-3.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:477ecaf6b9f88f873341b91fcc736119ca81b5e002a9f7f308ff5b4f2ce2a70e", size = 135321, upload-time = "2026-08-14T16:13:07.544Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/b1b0ec30289646a81a76e2dbaae2686b96fcccb7cb0323dc1dd78cbc7875/orjson-3.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3c0683136acdc29afdf88a5bc2f7d3d0e34087788d1d63c0144b805a87a196f", size = 127485, upload-time = "2026-08-14T16:13:08.88Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2b/277404bdcc21c93b112b963655b76443ebfe828f8a3ff1de7d90f8850eb3/orjson-3.12.0-cp314-cp314-win32.whl", hash = "sha256:d39f3f5c3927e2dc0913fe5bbc1a2f6b1b9d1bba1de6358340d0ad0d0c00ca92", size = 128048, upload-time = "2026-08-14T16:13:10.305Z" }, + { url = "https://files.pythonhosted.org/packages/41/2b/395b36fa2b4ce7af70b651d715e88f80d884b2c2b14a6b53e84d554fb5f0/orjson-3.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:0b1ac5bf6609b2716c7954011c5fef6254922df029f45d032ee4ebf5d363cbed", size = 121858, upload-time = "2026-08-14T16:13:11.634Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a3/833e895ff452859eebe75093d26691fe9108f1a7a6a08435d7a5780ea652/orjson-3.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:50fae885cb073eac7556353ff3df93312b0d5137b0a5056b2bb63f97ed9a93c7", size = 126749, upload-time = "2026-08-14T16:13:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/99c8947ece10c17176af9aae85c4948f1d109da77440ec14d87239efaf73/orjson-3.12.0-cp315-cp315-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:01efac2074fffb4cb1ea3fab7861e9d0f2a26913854a972f5ac760525dbdaf6e", size = 223398, upload-time = "2026-08-14T16:13:14.694Z" }, + { url = "https://files.pythonhosted.org/packages/3e/30/cf983fe09f2731420fda097a9f7ef4343f47fa216c228961ad8f6da44f3d/orjson-3.12.0-cp315-cp315-macosx_15_0_arm64.whl", hash = "sha256:ed4ca42bd55955aa34deedcfdfd0e0c31abf51143aae158ae2bc3520b626e517", size = 123655, upload-time = "2026-08-14T16:13:16.221Z" }, + { url = "https://files.pythonhosted.org/packages/11/50/9cb8ae73fa4749dbbc20f617004213b5ff01c20aaeec34c3f31124f2c1d8/orjson-3.12.0-cp315-cp315-manylinux_2_39_aarch64.whl", hash = "sha256:40f92192227505acca4e2533ce565f8e6b9535f7d0d09b0968452f18b7376b38", size = 130515, upload-time = "2026-08-14T16:13:17.601Z" }, + { url = "https://files.pythonhosted.org/packages/9f/0a/adb6ce1a5b5fbf9cb1790f9961bb668a0dd5429aadaf6cee044724681795/orjson-3.12.0-cp315-cp315-manylinux_2_39_armv7l.whl", hash = "sha256:33efefcf5d88eaf400b47e2eba02f91f319bb9951be61ca500b7d536d3f2079d", size = 113327, upload-time = "2026-08-14T16:13:18.927Z" }, + { url = "https://files.pythonhosted.org/packages/51/5c/d17f61581d8dbdde7048f87a330fa24915edec38db4d72b381fec14fbb56/orjson-3.12.0-cp315-cp315-manylinux_2_39_i686.whl", hash = "sha256:8e386b0bc0ddd7cd2056f884b5a0af33592bd01ac66a7ca4b42a65a7e7774a13", size = 130105, upload-time = "2026-08-14T16:13:20.317Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b7/938befcf33bee4704a92ecec6a2731224c539d939bf9429fd39396d28931/orjson-3.12.0-cp315-cp315-manylinux_2_39_x86_64.whl", hash = "sha256:58c58e1de0006ffb580368d6793c36c7b0b021db066479cf281bf5061e732328", size = 131049, upload-time = "2026-08-14T16:13:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/b0/15/cfa2021d64d5aa8bb5c9f604ef375e00ec8b657651b5dd650b1b7ad13df1/orjson-3.12.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:08231552159be266a7269555bd9f7c016aee7d9ad6dab06eb58796c5ccb7101c", size = 135320, upload-time = "2026-08-14T16:13:23.415Z" }, + { url = "https://files.pythonhosted.org/packages/1a/50/3e75dfe357c1e8f9e287c7a5740260ef15bd23a5299eae8d0835dcad5375/orjson-3.12.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a15f9a891bce5f5cc5d210e3ad8614d4d1b489a56448c099d6d2a7168b2d954a", size = 127488, upload-time = "2026-08-14T16:13:24.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/a6/79aed402eb3ab284dc5b4791a7ad62c5875127de01b8e3f04bd92d551298/orjson-3.12.0-cp315-cp315-win32.whl", hash = "sha256:03091c8a64db4be38746597ceea68f33c238e27acd9bfe99fb59420224ae7a55", size = 128048, upload-time = "2026-08-14T16:13:26.217Z" }, + { url = "https://files.pythonhosted.org/packages/64/f7/2723e264aab7248c1ed6ecaad8e5d0cb866c0cffde75442102ffa7491aba/orjson-3.12.0-cp315-cp315-win_amd64.whl", hash = "sha256:2b7bcefb9f40fa242fa6b06377232c048e655747790829609168c01162f60578", size = 121860, upload-time = "2026-08-14T16:13:27.577Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/630c9113ec8996778f1f0304b364b091b9a9db5fef5fdc17cca622f5ea24/orjson-3.12.0-cp315-cp315-win_arm64.whl", hash = "sha256:859fc4196855890150bb08e649b30d2c93b249b3e3edd0d3bb2231abf8aa8adc", size = 126754, upload-time = "2026-08-14T16:13:28.962Z" }, ] [[package]] @@ -3085,11 +3322,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.2" +version = "26.3" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] @@ -3259,11 +3496,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.11.0" +version = "4.11.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/06/cf1564dcc2e2261c8c8c6c05628dc8b418943bdae2a4e58640ceb2f770fa/platformdirs-4.11.5.tar.gz", hash = "sha256:e8b31f4f8bcbbedef91a6b57a706255e4f148d2a4e01648382a0a47342539173", size = 34823, upload-time = "2026-08-27T21:36:37.46Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, + { url = "https://files.pythonhosted.org/packages/c7/12/6f3fcd5067a9cbf4f8664b32957973498da8b083455203c8d9cab83a725c/platformdirs-4.11.5-py3-none-any.whl", hash = "sha256:89f8d42695853b89c7170bd49bc3dc593f98a71e695ede88e06a3b247bc4563b", size = 23900, upload-time = "2026-08-27T21:36:36.227Z" }, ] [[package]] @@ -3302,7 +3539,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.6.1" +version = "4.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -3311,9 +3548,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/3a/ddb78f32a0814e66b18a099377a106a2dcdce92d86a034d69d65df9b256e/pre_commit-4.6.1.tar.gz", hash = "sha256:03e809865c7d178b9979d06c761fcbfe6808fdaded8581a745bb110e52050421", size = 198646, upload-time = "2026-07-21T20:56:58.225Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl", hash = "sha256:0e3b2942510d1fb34eec167a3ec57331bf8442122f1153a9fb8b58f5c49b2717", size = 226186, upload-time = "2026-07-21T20:56:57.064Z" }, + { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, ] [[package]] @@ -3330,40 +3567,40 @@ wheels = [ [[package]] name = "primp" -version = "1.3.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/4b/7efa54f38da7de8df6b70dfed173bb41a52b740b144e4be24c1172db4209/primp-1.3.1.tar.gz", hash = "sha256:b04a5941bf9c876d011c5defaf5a25be093d56e7270b8da52c9788b9df2a829a", size = 1360029, upload-time = "2026-05-23T17:39:25.568Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/80/c4885a783a7493e396d89a592ba19fce63ef6bd6ad47230924a884a30ec0/primp-1.3.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:27b87e6370045a0c65c0e4dfdfacbfe637387d05673ce8ddcce400263f7c27f0", size = 5123967, upload-time = "2026-05-23T17:39:08.586Z" }, - { url = "https://files.pythonhosted.org/packages/58/c1/c965cc23f96a364803d44b4331f33e4465bb6f269add37e39d0ad77ffe33/primp-1.3.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:27a8804eb9a3f641f379ee2b443591428cf85c898816e93d04d3e7b6f229ebcb", size = 4743059, upload-time = "2026-05-23T17:39:15.536Z" }, - { url = "https://files.pythonhosted.org/packages/9c/99/f4248d8d833d43fd8ba78208f2f4bf7fba7d3aec8c516090a95d18d6f550/primp-1.3.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:862974796552a51af8e276bb19c5d5e189168ab8bad216aef7ce3726a8d3b1dd", size = 5100121, upload-time = "2026-05-23T17:39:04.64Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ad/519e32e0184763e1a76c9321fdeac0bb9b30bf85746f12058feec0cc4a27/primp-1.3.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ceb24198994799706f4020a00173ba9c1b491aa9805b1e014d87946677bc3c5d", size = 4738042, upload-time = "2026-05-23T17:39:35.967Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7b/723cb40694b47ec79a142ed8492835c0ecae9fef7acbed014f04b018d1de/primp-1.3.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3298b8afcf0a88ba6622bfc18e78aeb11afbb7d5afa4774f24acf7491f54a2d", size = 5001773, upload-time = "2026-05-23T17:39:03.01Z" }, - { url = "https://files.pythonhosted.org/packages/52/b8/80a2e3bdab1c51d738b82ea210a5ab93986b443c561e792e42cae296ec10/primp-1.3.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b8d38c5a6d0a863274cbcae9678f265fcdcead3c20d12d152244e88f5f2186b", size = 5334228, upload-time = "2026-05-23T17:39:24.214Z" }, - { url = "https://files.pythonhosted.org/packages/19/70/c95b8054c7d1fe2d84226ec60a5f48ce6c95a08b7c8b1702d7742082f444/primp-1.3.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96f831c78ddb5900873f51e294bf9bbb4bbfdac3a2f39ce4023f8c558d299332", size = 5157269, upload-time = "2026-05-23T17:38:48.142Z" }, - { url = "https://files.pythonhosted.org/packages/34/bb/9b66986b7ecf2eff987134cd94bde533142e3085d6f67531f1a369ceaaae/primp-1.3.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329d0c320841f65b39d80801d8bae126732b84ec1094ca17b14fda0bda1b20ff", size = 5347438, upload-time = "2026-05-23T17:39:17.405Z" }, - { url = "https://files.pythonhosted.org/packages/aa/29/5d127748d06f3c6a3367f3c4974e45b98cda61cd28ea79ef91ad3fe9e093/primp-1.3.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6c3c67670c38a03e9e8da45b212243d35afc8efa018317c46ecdce47f05329d1", size = 5264862, upload-time = "2026-05-23T17:39:20.625Z" }, - { url = "https://files.pythonhosted.org/packages/16/f3/1aac229425cac142c48418e2de9f70597161ea936543b5e3c9e7476e1921/primp-1.3.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9409a31028a8c62a609d389554ad4f5339aad075130300cd443beef0336d7179", size = 4969889, upload-time = "2026-05-23T17:39:22.412Z" }, - { url = "https://files.pythonhosted.org/packages/38/86/a94d6e6166139c76ae42eb941328679309ca85139e8753d639657a24474c/primp-1.3.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:88ca36c2bd1b7c64b96ad07ca367d2d111ac8e9670549be5f232da8bf795d21e", size = 5082679, upload-time = "2026-05-23T17:39:28.411Z" }, - { url = "https://files.pythonhosted.org/packages/cf/61/21d297db575ed660c6aaf35c9014c1874ace45d6dcb79d1a4d3d2608bffb/primp-1.3.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:74d13800b501aa003fb05c263d38f8d61656c83a60b2951046c0fc412bc73976", size = 5605392, upload-time = "2026-05-23T17:39:38.007Z" }, - { url = "https://files.pythonhosted.org/packages/36/d6/9262a7ebb1d980a2db0cd505bb902bb3e66acd8a1cb763a4c2921f2f6a5b/primp-1.3.1-cp310-abi3-win32.whl", hash = "sha256:09ada1752629fe89d7b128beeb59cb641f404af462e24177ba36aed1cf322299", size = 4270373, upload-time = "2026-05-23T17:38:44.98Z" }, - { url = "https://files.pythonhosted.org/packages/8f/68/f0c6a60fadff0c185aef232b951a6fa4bbb64511facc48d34734db14f16f/primp-1.3.1-cp310-abi3-win_amd64.whl", hash = "sha256:c0d1e294466cd5ec7ef173eedf8df25cbdc050138d40447a906e92b8553e7765", size = 4661498, upload-time = "2026-05-23T17:39:32.213Z" }, - { url = "https://files.pythonhosted.org/packages/7f/1d/232a52abc77384ac66b9c1741691dec3659b1207bb6c5e55c1e9b59d22f1/primp-1.3.1-cp310-abi3-win_arm64.whl", hash = "sha256:43304cb41cbb46f361de49faf1cbdba57f969f628c9297239c7ed8ef0cac420f", size = 4624481, upload-time = "2026-05-23T17:38:42.724Z" }, - { url = "https://files.pythonhosted.org/packages/e5/0b/34333b26c533c3122b936dad829f0a6e04f32065d39673c92b157d97aa16/primp-1.3.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:72249a4540d0a8965f36eb9a86cd16801d1c7e8dac2f0b0fa23a0a5a03402d36", size = 5116098, upload-time = "2026-05-23T17:38:55.219Z" }, - { url = "https://files.pythonhosted.org/packages/7a/56/7fe14708adf9a5cb5d6a15ad840a3de036cebfaf20692a5bc3b72e188a73/primp-1.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db4e2eaa5707e47899eeba6026f420f9b0108a28c08d63f1826d0cab8d50f06f", size = 4736300, upload-time = "2026-05-23T17:38:51.792Z" }, - { url = "https://files.pythonhosted.org/packages/31/cb/521a8c18e8808a75450b6e91dc62cc1149c0178b7d4a8697d3f9b73fa385/primp-1.3.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d62e7609c98b4bc99c9cecc47f16f332fb8fe1a023002176267b0043dedad0c7", size = 5093823, upload-time = "2026-05-23T17:38:50.059Z" }, - { url = "https://files.pythonhosted.org/packages/57/84/90f776fe46aeb0e3b86df72c674c0651326dd6a61846dd86bddbabe903ac/primp-1.3.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d692e912c2b25271163ba7719df0afdb733a7e7c3073c9094e9001882463543", size = 4734511, upload-time = "2026-05-23T17:39:34.166Z" }, - { url = "https://files.pythonhosted.org/packages/19/79/d9bfbc0df0394f18a98b512a65f4bcf3dd7d17bd871937127e1ce4549172/primp-1.3.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c08693517dc160a12c0f9e2565c5319173cef738893a303ff2fb28ecccbd84d", size = 4999315, upload-time = "2026-05-23T17:39:12.061Z" }, - { url = "https://files.pythonhosted.org/packages/bb/d8/5a986957ee1874d08567d7749668cd78a063048d47d6e46a874742b7fed1/primp-1.3.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d134ebfa31adc619e4e48289fe3e7eebc8310141560e6a6a04269cc94893d9ab", size = 5329375, upload-time = "2026-05-23T17:39:30.307Z" }, - { url = "https://files.pythonhosted.org/packages/5c/1d/321cac9902cc3992174ed530719141a0da2e426f54f8a90b7b971571d104/primp-1.3.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48e27e7c0e015a6de495cf79c0c8d599ba5f69d091af31572bec2de020522d9c", size = 5140921, upload-time = "2026-05-23T17:38:53.528Z" }, - { url = "https://files.pythonhosted.org/packages/a6/19/ccbe6b67e0e91beb5c9d5cf804354225d5a3a7a9adf84fee3d6acc53febd/primp-1.3.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c3d682df08c1b1f37b1f66b21fd173baebcfcb52490830b12292d8fe89b2147", size = 5344288, upload-time = "2026-05-23T17:39:18.965Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e5/a735751bd11558163e83e0961fc866e4f94634df9eb24937c5f59624e393/primp-1.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fabaac4280df0802377d34b869949d617a0ecf22ca7fd5f9bded3f5c981031f1", size = 5262909, upload-time = "2026-05-23T17:39:00.789Z" }, - { url = "https://files.pythonhosted.org/packages/09/8e/4e3d4520e2e751f2de825dbe2cb43f837d33a5528adc44255f9770ea125a/primp-1.3.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f510e5881e0a4c4b9e7dbc03722c316d58454388b88000a0e7bf18a4b36d601e", size = 4964809, upload-time = "2026-05-23T17:38:59.023Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b5/e7b7495687d07df693325a12c497a9e5185d5001b7b216f32019fa7437a0/primp-1.3.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0504de2901c97903a9c369856a4b186dc90a782d8320652c142b066e697d5a1a", size = 5083654, upload-time = "2026-05-23T17:39:06.598Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f9/e4652e93beb14a16cc4218cfb1ccc18eaca8ee7d93b517d614a135928ec9/primp-1.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c3b24e302d95d327e873834b9423823b9c8af2abf5e0bbf57a03f3354cfe528", size = 5594738, upload-time = "2026-05-23T17:39:27.001Z" }, - { url = "https://files.pythonhosted.org/packages/e2/49/e8c8a7bc6b741ab6f15896022eee4f906d04d7ccd15aeeb515dd04bbeb6d/primp-1.3.1-cp314-cp314t-win32.whl", hash = "sha256:4346dcef805279028bf4a54bb87dd43d0920130e25b5790689f5c96c9ba0d9e5", size = 4262615, upload-time = "2026-05-23T17:39:13.88Z" }, - { url = "https://files.pythonhosted.org/packages/a9/84/7ae4a257dec6dff329d4a8d9051d907316095c27ffc8d1ea15c359e6eeb5/primp-1.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6c55f152a73b6d6af8ac37bdb648d8bbfd7e656f9ef40d87feb3c0d81cee930a", size = 4660584, upload-time = "2026-05-23T17:39:10.081Z" }, - { url = "https://files.pythonhosted.org/packages/99/20/10e0d96bfaeef1f0cd339ccf9bb8feb4bf798fde93198f7a96c73441080a/primp-1.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:46a529d74583d6ceba52e15bf4c678fcf24e6d669c1ce935262d5490d1b25801", size = 4623226, upload-time = "2026-05-23T17:38:57.256Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/bc/2d/ca248003402f6863375acc77a886b0ab638efc6defb1be0ae9f99249cb66/primp-2.0.0.tar.gz", hash = "sha256:714ec75081b7a84f63d83f966eb36649b9a8ba93625113b142400c317f6e83c5", size = 1152502, upload-time = "2026-08-26T15:48:08.043Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/15/571bae369c3bb3ba2c5917fde3e6fa6db212bcde033f6c2b99fd94434c01/primp-2.0.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:5de7c1a2c3437394d77dca87841796a0cb704bf00e764dcbd2f7c073a11cc969", size = 5652784, upload-time = "2026-08-26T15:47:19.729Z" }, + { url = "https://files.pythonhosted.org/packages/76/8d/c62601ab82b831e12b1dee9ad9e7de2082c33a8774c4e71a76c3f0f76af3/primp-2.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c55f8ab86b7cc076b34ee0316ded6c30e9932ea010aeae3f8fe5cf30d9ecccf7", size = 5253591, upload-time = "2026-08-26T15:47:21.681Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/f29096e299afee742e4bf3e3aa0bba8bb10b187fa5ace15b8299a21d1310/primp-2.0.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd191d6f381eb814e1a7cb34b7eb5f47df81e73abdaecd8a99a8635a866357a3", size = 5663102, upload-time = "2026-08-26T15:47:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/496162339faa9a3016557b332bc9b9c55b87b9e7efb87df64902d2e77236/primp-2.0.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d7c48b027246e09540ad645536d28966f618e09c6a4dff148db4c221ccf858d8", size = 5274167, upload-time = "2026-08-26T15:47:25.674Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ce/fe272baed222c65ec5fad80346bd2a6155b170302ec69cbe5169b2df8c32/primp-2.0.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9179d4d7839cf0f912700d201af239b1a87ca22225101e2a43d1a34c2a46c95", size = 5615512, upload-time = "2026-08-26T15:47:27.359Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0c/203318c90bd2edbf5f6c7cf20d0d4b0de237caf3b0bf19d6507d55ddeb3c/primp-2.0.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d46cea402c5c0e70d65c237d946d41b46ef1a550fe3053ad6b66a3776decb759", size = 5756508, upload-time = "2026-08-26T15:47:28.929Z" }, + { url = "https://files.pythonhosted.org/packages/31/08/99c7e41d117ac3bed5b8814df9b295394420beb5a2249dad878927e64517/primp-2.0.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e8e16a3efc6a523da721fa56a7e4eab8ee62c36db910fb62782999398320d0a", size = 5914038, upload-time = "2026-08-26T15:47:30.597Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d9/2e7e75ccc0af97aa0a1b8506151c07453c2c5a1ec1a571f8ba8670ac8881/primp-2.0.0-cp310-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:e8d9354b078b8dc891d2546bd8e9109aa81f1351609262707f4c7cd2db5a7876", size = 5987036, upload-time = "2026-08-26T15:47:32.371Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/b5feb484e792359a674c9435a5a109f4ef7775321951316556adde0501d4/primp-2.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:75960909d205a88e234d4e50fbd4c7c36d8235ef51b0c085da67bb171900cadd", size = 5825231, upload-time = "2026-08-26T15:47:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/df/19/1b876edc4814cc7a56cc8c4daa4ffe5989743a16f7a0b1947c928bdeed52/primp-2.0.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5ccc2f3b333254d80a63f291b6d3e122a3eebe445833995b136ed1b5ae5c66e7", size = 5511105, upload-time = "2026-08-26T15:47:35.324Z" }, + { url = "https://files.pythonhosted.org/packages/89/82/474b834cb241cc2a655b7f78588b438a5bf1e7fa6c9ccf93fd618a69b4a2/primp-2.0.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:2bd730ca8d86912924a3e4b775ad7befbe2115e26d0cf5b3c214d09dee80cfba", size = 5695984, upload-time = "2026-08-26T15:47:36.853Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ba/75b8b2fcbea6ac2c8d073ef95337a9b363350c7d9d6a0de3aecc15e08895/primp-2.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:196896fa89c6b1eaa11331b14fe2cf539b9564b5e3398df8352c71ce30c46d84", size = 6173518, upload-time = "2026-08-26T15:47:38.725Z" }, + { url = "https://files.pythonhosted.org/packages/27/34/8ce5c50363aa627b45f7fedcc38676ae9e71642839130bfff8620f5a193e/primp-2.0.0-cp310-abi3-win32.whl", hash = "sha256:0776fd400adcc940e47bb70896fb474f6607ffa545f5f7107ccda8d055fa02c9", size = 4820008, upload-time = "2026-08-26T15:47:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/2307030e0ade6503b7e959daf0b2e7819a896c32816a60a4252c97512e74/primp-2.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:7f205260fd41a0a409157deec95e6c4ec670476a79d9b4810d4c767ee7f9e49e", size = 5218051, upload-time = "2026-08-26T15:47:41.872Z" }, + { url = "https://files.pythonhosted.org/packages/61/3b/d3f21f6fbfa5f255685dc475af5fa6ad976f4c3b787bc9bb75d912649c4d/primp-2.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:caf90ecc042a12b18414e4110ce767f4f1b0cfbc70310c5df39af948cb3eb516", size = 5115469, upload-time = "2026-08-26T15:47:43.529Z" }, + { url = "https://files.pythonhosted.org/packages/78/88/c7aa2c139282b417d447d05f441f7e70038fe6634b15673d1de230e7fba9/primp-2.0.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2ef2652eb2fd0072e474452663ddc790531f337dafed280a62a43a36f95d2c76", size = 5654299, upload-time = "2026-08-26T15:47:45.186Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fc/e470167fbd7fc0c9894e637edd49576398b4c6e09248cd1785edd42969b6/primp-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fdc9abaf01b82e0e4b5ea925273bf690c31338c3bb7bc28f64420bf93f66647b", size = 5243997, upload-time = "2026-08-26T15:47:46.927Z" }, + { url = "https://files.pythonhosted.org/packages/38/70/45699bf87b0ad15822dcd4e6790eecac944dbcb72f3df9b7413513c22151/primp-2.0.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b88af1baeaa70fb168a9cd8567cf40396b4d023d39895b4bab25b3e7dc6ddb20", size = 5667842, upload-time = "2026-08-26T15:47:48.424Z" }, + { url = "https://files.pythonhosted.org/packages/44/43/99c1adf495a0bc677fc7039efbbd28fb768a3e6a89735b5f2c0d5491688b/primp-2.0.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:db6a9efabcef034918e0ee92a78c21c2ccd93bdb994550c49f3c0edf207a0bc4", size = 5265143, upload-time = "2026-08-26T15:47:50.138Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ed/515452269a2e47928b34300ea7043cf478be96133e87a7def2de7f52bdc7/primp-2.0.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e820ed0573d67b5d495252dcf29e6dba14c279453c5c2134955eb2b0fd3d258", size = 5603987, upload-time = "2026-08-26T15:47:51.712Z" }, + { url = "https://files.pythonhosted.org/packages/09/96/15c9241d1848a9317be413201395e5e2b316be03ac8200fef7f300dd3f64/primp-2.0.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:819f1480be4efc1d0dca46fac7b9a3ec40fb0b89bc11932165b5abc7872dae00", size = 5760260, upload-time = "2026-08-26T15:47:53.296Z" }, + { url = "https://files.pythonhosted.org/packages/09/30/758ab1b0e7a22ad5d738fdc04d0e01728ba266138453ce895fabc56fe1b5/primp-2.0.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c97a87afd27c490b5384433748bc664ecd358a8d0a688abf48716a6baf03050", size = 5921145, upload-time = "2026-08-26T15:47:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8f/e46528c0829204eb01a9145301fb19c6953eb5cb48b2870e7c656ccde730/primp-2.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:ca4efea30417cf1119aac732ead82fc649af40978d0c989a9c280e901f6d0b3a", size = 5985385, upload-time = "2026-08-26T15:47:56.277Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6e/6aa7c98636a6d9381784af830e5734908f3a78f0896591e50aa91eff5f2e/primp-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0ea78a7a2fc5d48a825fa73182911b956bcb58940297cbd60958963ec30375d4", size = 5831995, upload-time = "2026-08-26T15:47:58.011Z" }, + { url = "https://files.pythonhosted.org/packages/79/68/161b26b1b6d1d6a9e21250a88f4791adb8bf3f05d5190a8bbb3e563bdbb5/primp-2.0.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0f309642cff4e9f966fb8f312bf19239df04c7698b1b4ae142285528d08c4ddd", size = 5503388, upload-time = "2026-08-26T15:47:59.52Z" }, + { url = "https://files.pythonhosted.org/packages/c9/52/35b48f1ecdcc9baa9b9f8bba0739c4b506995fd17e3776700752c11d4d6a/primp-2.0.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:422001e516110a3b2d44b106ac65d248c431fb98e1d6cad034e0a6ee8f1d6eb2", size = 5683792, upload-time = "2026-08-26T15:48:00.913Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7c/13f8d3e4465e5cd5968c79c6cf951475c286bf7605e328b23378dd744c34/primp-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1abcdcd5d0ef7b918bdd6e56ce07baeba5b2fd136e9df1fe9f9443c35a6af9ec", size = 6182628, upload-time = "2026-08-26T15:48:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f1/406105407cf42d7edbce6a9332a16303d05987efc76322e7b79ca5eab004/primp-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:8756ae0df3dda65591ef478e73c588588a4a07919ee5c8d871fdd4e78fefb357", size = 4811104, upload-time = "2026-08-26T15:48:03.688Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/3844343afe7154364be0767ff3fe03347aef5dc6a506522a4ac0c99fb720/primp-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a83bbfd798c1a9c2c7f1c8b02bbfb38f91fd63ac9072add967915e2463d68aa0", size = 5220594, upload-time = "2026-08-26T15:48:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b5/a194a527120442f76a417e4da89bbe25490a65be7756c8f785ebd9110fd8/primp-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5dfad2f926451a64bcc3a6d84e76593ec82840f77c5500d080e98387c7a018b8", size = 5112361, upload-time = "2026-08-26T15:48:06.592Z" }, ] [[package]] @@ -3403,17 +3640,17 @@ wheels = [ [[package]] name = "protobuf" -version = "7.35.1" +version = "7.36.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/e7/0553e21d25ca4d9f573135775348a372c3ec34a93a71d5f297c3bac38341/protobuf-7.36.0.tar.gz", hash = "sha256:e8e09cb0d794c6687926fa558a8a6e72aa10edb997d5ca61da0765f12a3e00ea", size = 510034, upload-time = "2026-08-20T16:34:01.071Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, - { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, - { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, - { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, - { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, - { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ae/58e3ca96cb2e118cc546b677359b3c6659f79a140935c08dec94c7998585/protobuf-7.36.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:9103532dffd80c6fab7e50c65a31007680a06eb57537d437bb1b35812c138a37", size = 453256, upload-time = "2026-08-20T16:33:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/f0/15/5162230af4912697f0fe406f6800f80760945babcff0e2c2fe6c84ef2d5d/protobuf-7.36.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:bf94a5917c71058262de683669bc0a797a7669d3de71f0b36d058e3194f47b44", size = 341436, upload-time = "2026-08-20T16:33:55.134Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/1670b2bfc9a45e807e520c3e9be36524db9ccc7dc05ea17af7681cabdc61/protobuf-7.36.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:3297e60abdff301e5f74393d87f6cc59dacab5f024a89548a6e8de1d26576b16", size = 354440, upload-time = "2026-08-20T16:33:56.077Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f8/bd5804695ba400e423c33fd4d9f58c28d86633d5ba1945c36ff3967d98cb/protobuf-7.36.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:70f5ec8eb0da81a44360c0dc0beac99a0d78071d21956a7076bae8bd2051841b", size = 340439, upload-time = "2026-08-20T16:33:56.992Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9f/acd02338235a3e7d03168c4303478347b7624fc8189ff4e7f0d2654bbe86/protobuf-7.36.0-cp310-abi3-win32.whl", hash = "sha256:7326fd717bdc419162a735938d89d4032332bcc3408804012b24ff3a37086071", size = 440216, upload-time = "2026-08-20T16:33:57.99Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4e/12cb93270967a2affff5b3f720694700d4d87712a67afd05c8cb3f6fa52c/protobuf-7.36.0-cp310-abi3-win_amd64.whl", hash = "sha256:1781cc1de61249b750848029bca452c0a8b7e990080316b9bbc2518b2117b488", size = 453731, upload-time = "2026-08-20T16:33:58.951Z" }, + { url = "https://files.pythonhosted.org/packages/01/c3/629999e78d46c1115c11886d51c6bd68c17ce4a944f1ea3e153a91316a33/protobuf-7.36.0-py3-none-any.whl", hash = "sha256:53374d53fc29a67f7dbbf0ade47d7526a0f0137bf0f9c90e48d8a60790ef748c", size = 177024, upload-time = "2026-08-20T16:34:00.053Z" }, ] [[package]] @@ -3502,38 +3739,38 @@ wheels = [ [[package]] name = "pyarrow" -version = "25.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/27/f3/95428098d1fa7d04432fb750eed06b41304c2f6a5d3319985e64db2d9d41/pyarrow-25.0.0.tar.gz", hash = "sha256:d2d697008b5ec06d75952ef260c2e9a8a0f6ccfce24266c04c9c8ade927cb3b4", size = 1199181, upload-time = "2026-07-10T08:29:50.116Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/44/fdd3a4377807b7dcabe2d4b5aa99dbbc98e2e5df3f1ca4e7f0aec492d987/pyarrow-25.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:149730a3d1f0fb59d663a0b8aa210adfd9c17c27cd94a0d143e60daea8320d4e", size = 35850884, upload-time = "2026-07-10T08:26:47.357Z" }, - { url = "https://files.pythonhosted.org/packages/bf/71/9f053177a7709b8c90abb00a2375b916286f9f0d6cfb21a5cadd4ef811e8/pyarrow-25.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:0721332c30fdd453fdd1fc203b2ac1f4c9db5aea28fa38d41f2574c4b068b9ec", size = 37616197, upload-time = "2026-07-10T08:26:53.564Z" }, - { url = "https://files.pythonhosted.org/packages/95/1a/22bfb6597dcdc861fa83c39c06e1457cb56f698940eff42fbb25de30e8e5/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fa1482b3da10cac2d4db6e26b81da543e237616af2ef6d466018b31ca586496f", size = 46841966, upload-time = "2026-07-10T08:27:07.685Z" }, - { url = "https://files.pythonhosted.org/packages/55/0e/cd705c042bc4fe7022478db577fcab4abdcfabb9bc37ab7a75556b3fcb2b/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5d1dbf24e151042f2fa3c129563f65d66674128868496fb008c4272b16bdf778", size = 50088993, upload-time = "2026-07-10T08:27:14.268Z" }, - { url = "https://files.pythonhosted.org/packages/98/ee/d822e1ee31fe31ec5d057210e0605c950b975dcd8d9a332976cc859a9df8/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:20887a762dd61dcc530f93a140840ab1f6aa7836b33270e42d627ab3cf11e537", size = 49941005, upload-time = "2026-07-10T08:27:21.274Z" }, - { url = "https://files.pythonhosted.org/packages/33/1b/207a90cc64619a095eb75a263ae069735f2810056d43c667befd573ec083/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58d1ab556b0cea1c93fdb799b24ad58adb2f2a2788dbce782a94f64ae1a5cc9b", size = 53112355, upload-time = "2026-07-10T08:27:27.911Z" }, - { url = "https://files.pythonhosted.org/packages/7e/fe/81d1e5f8beed15c01e98649d5c6e2167b67fd395884a2488f18bf1cf0dba/pyarrow-25.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f356afe61186395c861d5cd63dc21ff7d5fa335012a4668d979257df7fea0f5", size = 27945954, upload-time = "2026-07-10T08:27:32.903Z" }, - { url = "https://files.pythonhosted.org/packages/6c/c8/098ce17d778fd9d29e40bb8c5f19a40cc90c3f0b46c9057b0d7993f42f54/pyarrow-25.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:8831a3ba52fa7cdb78d368d968b1dcd06171e6dff5461e16d90de91d371e47bc", size = 35844549, upload-time = "2026-07-10T08:27:37.956Z" }, - { url = "https://files.pythonhosted.org/packages/bc/66/24c28877219abf6263d909b1592c97ff82c59f13a59acbed11fc87c0654f/pyarrow-25.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:5f4bacb60f91dd2fca6c52f1b9a0012cd090e0294f1f781dc1881a247a352f8e", size = 37610397, upload-time = "2026-07-10T08:27:43.803Z" }, - { url = "https://files.pythonhosted.org/packages/53/55/6d1d5f5aff317ec5de9421594679ed51ed828fe7e2ce209327f819d801e4/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:59516c822d5fd8e544aaa0dfe72f36fed5d4c24ea8390aab1bcd31d7e959c6be", size = 46841701, upload-time = "2026-07-10T08:27:49.741Z" }, - { url = "https://files.pythonhosted.org/packages/b5/5d/f790fb6965ab54c9da0dda7856abc75fd0d7648d865f8d603c111d203a64/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f9dbd83e91c239a1f5ee7ce13f108b5f6c0efbe40a4375260d8f08b43ad05e9", size = 50090118, upload-time = "2026-07-10T08:27:56.051Z" }, - { url = "https://files.pythonhosted.org/packages/0c/8c/faf025357ebf31bc96777f234277aa31e2aeca6dd4ecaa391f29085473c2/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18dcc8cc50b5e72eae6fcbfc6c8776c21a007176b27a3cdec5c2f5bcf126708d", size = 49945559, upload-time = "2026-07-10T08:28:01.927Z" }, - { url = "https://files.pythonhosted.org/packages/07/a1/bd051871708ea99a5e0fc711926c26c6f2c6d0130c7aaac8093e34998af6/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4ec1895a87aa834c3b99b7a1e758747eb8bb57f922b32c0e0fa04afb8d6998b1", size = 53114238, upload-time = "2026-07-10T08:28:08.594Z" }, - { url = "https://files.pythonhosted.org/packages/7c/31/737f0c3cffcd6af647849477d1dd68045deac2e3963c3f9f211bedc48540/pyarrow-25.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:77c8d1ae46a44b4006e8db1cc977bbcc6ce4873c92f74137d68e45503b97fb18", size = 27861162, upload-time = "2026-07-10T08:28:12.975Z" }, - { url = "https://files.pythonhosted.org/packages/55/c7/581ccbcdb3d897eb2893328d68db3d52eca373bf2a7e964d0a6276b8e85b/pyarrow-25.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:72132b9a8a0a1840197794d4dea26080069b6b0981c116bc078762dc9691b21b", size = 35878945, upload-time = "2026-07-10T08:28:18.222Z" }, - { url = "https://files.pythonhosted.org/packages/64/d1/ccb01db7329ea0411ef4fbd9b62a04d3268b36777d4e758d5e39b91ddeab/pyarrow-25.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e009ef945e498dca2f050ea10d2e9764cb44017254826fc4574fdb8d2530173b", size = 37630854, upload-time = "2026-07-10T08:28:23.452Z" }, - { url = "https://files.pythonhosted.org/packages/af/9f/2d81ba89d1e4198d0cb25fe7529de936830fdaec0db926bb52a1ef7080d4/pyarrow-25.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f57a39dbcb416345401c2e77a4373669b45fd111a1768e6cf267a7a0607ff0ec", size = 46905617, upload-time = "2026-07-10T08:28:29.376Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/0ed312ec800fb536f93783215126cee4b8977dcfeccba6f0f44df0cc87d7/pyarrow-25.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:447df764beb07c544f0178a5f6b70ef44b9ecf382b3cdfad4c2d7867353c3887", size = 50119765, upload-time = "2026-07-10T08:28:35.826Z" }, - { url = "https://files.pythonhosted.org/packages/ca/88/cab5063ba0c4d46a9f6b4b7eb1c9029dc0302d65cd5ab3510c949a386568/pyarrow-25.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac5dfeee59f9ceb4d45ba76e83b026c38c24334135bb329d8274baa49cec3c62", size = 50027563, upload-time = "2026-07-10T08:28:43.848Z" }, - { url = "https://files.pythonhosted.org/packages/7b/fb/4d24f1b7fe2e042dc4ef315ef75e4e702d8e46fe10c37e63caff00502b03/pyarrow-25.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0f100dacf2c0f400601664a79d1a907ced4740514bb2b00917341038e2ce76f", size = 53162437, upload-time = "2026-07-10T08:28:52.819Z" }, - { url = "https://files.pythonhosted.org/packages/fa/65/da20806de93ca6ee91e72cb6a9b08b3ac890b46efc8d94a7326c651c4c81/pyarrow-25.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:2e093efbecb5317372f819228fa4b4e6157eee48d3f0a7b0303705ebf81a7104", size = 28613262, upload-time = "2026-07-10T08:29:47.544Z" }, - { url = "https://files.pythonhosted.org/packages/86/9f/c632afb1d3ef4a7814cee236718235f3a47eac46e97eb87df40f550b6b48/pyarrow-25.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:26be35b80780d2d21f4bae3d568b1666337c3a89722cc1794c956a77017cb24e", size = 36120702, upload-time = "2026-07-10T08:28:59.577Z" }, - { url = "https://files.pythonhosted.org/packages/36/0a/093d53a0e72ad06e45d6443e00651bbc2d21af4211295086cbf4d873d3b9/pyarrow-25.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:6f4812bfbf11ca7d8faf59eb8fff8bf4dd25ce3a38b62baa010cc17a0926d1b2", size = 37750674, upload-time = "2026-07-10T08:29:06.916Z" }, - { url = "https://files.pythonhosted.org/packages/8a/18/b37fc31a69cff4bdfb8842683def5612f551b93fff6f44375e4a4a6a5535/pyarrow-25.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b8af8ceedf0c9c160fd2b63440f2d205b9404db85866c1217bfea601de7cfb50", size = 46912304, upload-time = "2026-07-10T08:29:14.656Z" }, - { url = "https://files.pythonhosted.org/packages/32/35/5cae19ba72493e5598022468b56f6a5571f399f485bf412f157356476caa/pyarrow-25.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c70a5fd9a82bd1a702fd482bdc62d38dcb672fb2b449b1d7c0d7d1f4be7b7bfe", size = 50073652, upload-time = "2026-07-10T08:29:22.467Z" }, - { url = "https://files.pythonhosted.org/packages/2e/a5/ddd508424bdfd5e6945765e9e2ffc687e2f6115972badc8ecf423076c407/pyarrow-25.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0490a7f8b38ffe11cc26526b50c65d111cb54ddac3717cec781806793f1244dc", size = 50058654, upload-time = "2026-07-10T08:29:29.689Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a4/324d0db203ff5eebe8694ec2d6ec5a23f9aaa5d02e5b8c692914c518c33c/pyarrow-25.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e83916bbcf380866b4e14255850b33323ff678dc9758411d0409cdd2523880b0", size = 53140153, upload-time = "2026-07-10T08:29:36.041Z" }, - { url = "https://files.pythonhosted.org/packages/bd/8d/d236e9c82fe315f9128885c8be3ec719f41965a1eb6b6f4b42470904cd41/pyarrow-25.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:13240f0d3dc5932ccd0bfa90cd76d835680b9d94a7661c635df4b703d40ce849", size = 28743657, upload-time = "2026-07-10T08:29:42.742Z" }, +version = "25.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/e2/9ab15b88cbfac28e16419ce5439ec29234c5172cb8259301b4ba639bdec0/pyarrow-25.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:df961f2e7ae9cf496459259d798652c70625f6c080650d6952f8c04053c58ee9", size = 35861559, upload-time = "2026-08-10T12:38:02.567Z" }, + { url = "https://files.pythonhosted.org/packages/58/79/a0036dbe1eabe1f73127427342f1d99982584c4a2cde2651d6c93499c6f6/pyarrow-25.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:cc4aa407fde9fc660be3939e49ea31f50f3e9fec17c0ec63159f7711edd3efc9", size = 37628383, upload-time = "2026-08-10T12:38:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/13/49/d93a57d375f4bf0cf82913dd6bb54acafde83dd993be2282c81ac5616cad/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:4340f0ba6c1d2e13f21658de1d7c662ca2545018568d0030a1e9afca159d87e3", size = 46820190, upload-time = "2026-08-10T12:38:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/60/c9/711ca85d79f1ec98f29a5eae2b051e25b4ecec5de3e3c0e2d5c5dcb15664/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5389cdf79447ed1515c9e31620e6e1e2302249564d603f2ad727d4f6d313e4c3", size = 50102437, upload-time = "2026-08-10T12:38:22.487Z" }, + { url = "https://files.pythonhosted.org/packages/80/53/8fb8359ff17cfb6263a1cf3ebf7caec9fe197de118719e84fcb1d0618026/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d51592cb7561e87877c506113e7adbf1342ab579e6c21f0ef44b8ba41cb74c80", size = 49942424, upload-time = "2026-08-10T12:38:28.755Z" }, + { url = "https://files.pythonhosted.org/packages/e8/83/4e5ae02a9341571b18a6fca380ac7a58ce6ddae7ab3c060208c0a1e79f02/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6109c94d8b9f3b17a041daca16cacb2f651ad8f1ef70a4232c2c0f37a23da2a8", size = 53144206, upload-time = "2026-08-10T12:38:34.862Z" }, + { url = "https://files.pythonhosted.org/packages/65/ee/197cbf47e49f83e6ebeb946a5259a48a638dea27ac774db42fe78022179d/pyarrow-25.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8858d7bfc22e3f51529aeaa4077225029724623e4595dc9eff8c793935c34140", size = 27953934, upload-time = "2026-08-10T12:38:39.808Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8d/8f271a7a034c834910ec925d56fa4b29733b1380f5289419f5aaa3b02777/pyarrow-25.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c7c534ec03c358a76ea3e505e74c1b6aef290af90c444dfd092dbfe23e755b85", size = 35855328, upload-time = "2026-08-10T12:38:45.489Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cd/5bac242f4e841b9971d5eb94fdfe2577e2b70be983e27401e72055786037/pyarrow-25.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dda9470024204d7bbf2042b47c6e8a0e47a3eeb8e34405882dfaea6577e0c153", size = 37622415, upload-time = "2026-08-10T12:38:51.107Z" }, + { url = "https://files.pythonhosted.org/packages/63/1f/96d03b4e1506524f7087adb0fd6b2f69f0c9c7aaff1ec36d8030082e15a5/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:44a9120ce5bd81936b8ab9a88076e3fd47c2c6838e0e43630fed83626aca81d9", size = 46813813, upload-time = "2026-08-10T12:38:57.773Z" }, + { url = "https://files.pythonhosted.org/packages/98/d6/33a411115b61dbfc16ad6ad73e71730f6fea654ee3667673bc53ab0e2fe7/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0befcf816e45a1af33ac775a9970b749e4868a230c7372f0ae5e932bee27039f", size = 50104452, upload-time = "2026-08-10T12:39:04.579Z" }, + { url = "https://files.pythonhosted.org/packages/33/ae/b1b97c9ca87f9f9ddbb5230c798df94eccce61bd79b9b45458c69a478588/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f89685964f46e4216103c75483aac0c0692a5f72212d7ca835adba5ede56ce3", size = 49951343, upload-time = "2026-08-10T12:39:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/98/9e/a112df5cfd5a68cb1d9fc31cfe38c28d5aec9f10865ce37ecef2e4450873/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6943e2fe7954d29d84de45d29d34c8dc36ce96570e67d89aa9976e650a4a9138", size = 53144784, upload-time = "2026-08-10T12:39:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/31/24/97e8bd98f1e3b07e2ba08bcdff690674fbe16d69a7d2712cc3884665e615/pyarrow-25.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:31e49a7888fcdf3a835da33ae777f6bb9a866334e5a789282fc26dcf426f7f15", size = 27870159, upload-time = "2026-08-10T12:39:26.161Z" }, + { url = "https://files.pythonhosted.org/packages/36/4c/b525824ad3094076919273cd97db61fb3d78252dee76fa3b8dc8f76774aa/pyarrow-25.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bf0b672390cdcb640d7288f96b826d71ff4e9abb254a86c89890baf51a29cee6", size = 35885255, upload-time = "2026-08-10T12:39:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/448bb0e940de41aec31d1a956e63ad9c54afdf122a103cc3ab20c2a3ce33/pyarrow-25.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:38a9a4b4b9613380e200641891495a56c3d5a98a092db4a870af9975e220471d", size = 37644461, upload-time = "2026-08-10T12:39:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9a/13587e38bd4806fd218f50fd13b8903fab60588a699ff0c406372e5b4043/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b726ad7e7b669be982b0c71c07fe4b037d654354130da79a7902a669e93a66b", size = 46877146, upload-time = "2026-08-10T12:39:43.722Z" }, + { url = "https://files.pythonhosted.org/packages/8d/61/1c5d1229fa21da4cff5365e41e57177aaac57c563c727f35419b8513d1c1/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9171748cdf796972d85a4b60157c279913e242992e350c90c7450182a9838b2a", size = 50131616, upload-time = "2026-08-10T12:39:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/43/20/291e1d65cc0b09aa19f03cf25cf51a2f5fa94b5db315178f2d254ed5cad4/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b7a296aac7a71fa0886c08e155ddb6c636a50013f801f6178daafa0f9e726188", size = 50008879, upload-time = "2026-08-10T12:39:56.891Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7c/1b7c9ec28e76576337e4f97b31141c9a181b89b6d1d6221e9d8205621a58/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0fe7c8b6c03969b49c8c66182e4a18e3819ab92d07cfab5d8370c531b9369ef0", size = 53170864, upload-time = "2026-08-10T12:40:04.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/75/f3d789dc06011a765d14d86bda799cf72ac1d715b6a6edecaa0d73d95062/pyarrow-25.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:f729cfdbd36fd99d543b67a914d2de044c84ebe45be8b34902b299b608c15c8f", size = 28620729, upload-time = "2026-08-10T12:40:51.41Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/647a8ee6f7c2662feb6921315617bc04dcd6034763fb61b1199720bf6162/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:59a2de54c0cbd954da861eee4d1d330f8e909c45b53455baef696380f2c55033", size = 36130288, upload-time = "2026-08-10T12:40:11.014Z" }, + { url = "https://files.pythonhosted.org/packages/93/f8/c9ee997554d7bea94520667dd1933f109ac1da3ee3556d2b49381e023484/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:35935cd5de130aa5cf4dea052a63e6bf2e17006c35c3a468194242b9b2bf5956", size = 37762187, upload-time = "2026-08-10T12:40:16.592Z" }, + { url = "https://files.pythonhosted.org/packages/a2/08/a28c01c7fe9e96e8233ce2d13df1d402f4f999f848f51d2daacd6bb4c036/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f3831aaa25c67a99f99dc8b05873cb9d64560390372e2aa197ce9dd4a3f06a44", size = 46888003, upload-time = "2026-08-10T12:40:23.242Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b9/58612e977d28dc58c878448866838369ee8da2f1e7cc8ed2c84b952aafee/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1fdfc6659b6b19022f2e50627fb5cf7156a66c46bf4299379955cbe742382a", size = 50079036, upload-time = "2026-08-10T12:40:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/72/13/66e1402dcc860e1dc2760b1e0292c9a569b62b3bccab69def1b3e907d006/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:169d3429d5be7c752125890620f75a60776d38b0035eddae939651640822332e", size = 50040226, upload-time = "2026-08-10T12:40:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/78/10/3f1a5497a7ef732ab0f03ecca3e66d89d9c0f57fdc61b4794c456b781f01/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:119297a6dc197e45d9c6d4415f7814a67ffa36c180d26f68c154c58067ae782d", size = 53149035, upload-time = "2026-08-10T12:40:41.454Z" }, + { url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" }, ] [[package]] @@ -3598,7 +3835,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.13.4" +version = "2.13.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -3606,107 +3843,107 @@ dependencies = [ { 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" } +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, ] [[package]] name = "pydantic-core" -version = "2.46.4" +version = "2.46.5" 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/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/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" }, +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, ] [[package]] name = "pydantic-settings" -version = "2.14.2" +version = "2.15.0" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, ] [[package]] name = "pygments" -version = "2.20.0" +version = "2.21.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" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] [[package]] @@ -3743,29 +3980,29 @@ wheels = [ [[package]] name = "pyld" -version = "3.1.0" +version = "3.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, { name = "frozendict" }, { name = "lxml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/51/5071adebc2de0385d459ab57f884ad3372d43ea7c1c48768c1273f55ac61/pyld-3.1.0.tar.gz", hash = "sha256:7d02699af8e22f896cd7da094c0199063b5916dd018b1d2ec2a966d429270401", size = 104696, upload-time = "2026-06-19T08:41:47.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/1b/f8680af7bca60931f338ca4fccb075523a41c3667e2fd9240500daef4e2c/pyld-3.2.0.tar.gz", hash = "sha256:ba2342354bb111ef7bf2b2e6c847610b6a7b1d057dd40bf8f3fd116e0c81e000", size = 109706, upload-time = "2026-08-17T12:56:40.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/35/dc35578f1cecdcc0c6495578d23867ad19365f00e0c4c40352cfce7cc6b2/pyld-3.1.0-py3-none-any.whl", hash = "sha256:bb6418dfdc159e354b8cb42673086c86ce928023559742366e84109723932ef3", size = 91820, upload-time = "2026-06-19T08:41:45.184Z" }, + { url = "https://files.pythonhosted.org/packages/16/9d/fe8e6c97a151d13cf37c5b9440fe0422f8261918d587d0e7125de7fd8df8/pyld-3.2.0-py3-none-any.whl", hash = "sha256:c53a2cbc9562178f329007a0c736fcc752e6256205476146b3e710325edf012f", size = 94473, upload-time = "2026-08-17T12:56:35.891Z" }, ] [[package]] name = "pymdown-extensions" -version = "11.0.1" +version = "11.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/17/2db4b414de89659144488e0d9c6c0bf0c8395841dc12d81d0532cc6ef310/pymdown_extensions-11.0.2.tar.gz", hash = "sha256:9506fcbe66fa355a775b768084334238dd6805020ac4b92bea0c0dda6f8f223d", size = 855419, upload-time = "2026-08-22T19:28:47.236Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, + { url = "https://files.pythonhosted.org/packages/a6/43/9f45ec4d14e596efc32c925a78104934790438b0c0628b70d741016734ad/pymdown_extensions-11.0.2-py3-none-any.whl", hash = "sha256:259910762019732caa1dfd76f3faa62c59f191d46573e80bcb1d13c0f675bbe5", size = 269929, upload-time = "2026-08-22T19:28:45.389Z" }, ] [[package]] @@ -3826,31 +4063,31 @@ wheels = [ [[package]] name = "pypdfium2" -version = "5.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/42/0b51bdf50ccf13f3deb3209ca996179a49761dc191748469cf0de55b0055/pypdfium2-5.12.1.tar.gz", hash = "sha256:d0e0648fb2e28f50efcd1ec0a5a18ced9f4d66b2c227fae9b603f0a883b2d13f", size = 274428, upload-time = "2026-07-17T10:01:22.713Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/09/7e/bd8df53b1131c582f6646372047b49162032bd01628d49b9a60cd94d2181/pypdfium2-5.12.1-py3-none-android_23_arm64_v8a.whl", hash = "sha256:05bab9b1ba2de7fc299ae2af25cb9c8a0543bc8bb893e879fe8c9ba8310e9ce4", size = 3392276, upload-time = "2026-07-17T10:00:47.376Z" }, - { url = "https://files.pythonhosted.org/packages/b2/13/a2b71e17b0439d2af78c817a381e3557371c6c56098581da8485aef65ea6/pypdfium2-5.12.1-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:d4ee061e566a6422b660cdddaaa799a2d1cbf2f016921bcaf24d61426d01d942", size = 2848776, upload-time = "2026-07-17T10:00:49.09Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a8/d7a61700db3022792b28bce5264be90a7d2e7104998362af6b93766f50b2/pypdfium2-5.12.1-py3-none-macosx_13_0_arm64.whl", hash = "sha256:66a9ed40d70a5d728cd42148fecb9d7a0917c6161d6bb67c844093a4ed1df089", size = 3480243, upload-time = "2026-07-17T10:00:50.674Z" }, - { url = "https://files.pythonhosted.org/packages/01/2c/d7a38fad74b6da0947cf8763aee0f8e6c9d3c12fc8e137aa615f7f8ae76c/pypdfium2-5.12.1-py3-none-macosx_13_0_x86_64.whl", hash = "sha256:847378a5ab41332998b2621b21bab2e96dc8c3eff36a08bce26695b964163983", size = 3643490, upload-time = "2026-07-17T10:00:52.236Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/aca739676323558595fcf8cdc8d7939d2b25aaaa6f538e829f1fee938cdd/pypdfium2-5.12.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6eabf028ad8e7bc7811c9acf3a72718c180569b624b844d2c6cc974609784275", size = 3649734, upload-time = "2026-07-17T10:00:53.776Z" }, - { url = "https://files.pythonhosted.org/packages/a4/e0/e4ecc05f4f1a11d11c8d684a24b2fc8be8207f341be985dac301caa4f6aa/pypdfium2-5.12.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7857cfa6642ec5a09db12ff8f5cf6b6494585b5e3a605399fddc4fb862837b63", size = 3380828, upload-time = "2026-07-17T10:00:55.377Z" }, - { url = "https://files.pythonhosted.org/packages/17/1b/c94c9d486791276e736350917a11fe2cf3acba2c6c7f03f9ac0d51f8952a/pypdfium2-5.12.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05bfa20a08a96584253bbe38b60e13f81a037eac31c5579e607ec1480ad25dbf", size = 3777202, upload-time = "2026-07-17T10:00:57.212Z" }, - { url = "https://files.pythonhosted.org/packages/14/aa/7f81f0c035fc32850dfab9daf78530814f215be226dad7491e3caa0a3e8c/pypdfium2-5.12.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9f059f7bdbdf4352eb83691071096940d769d6ae5930b8734237fdb1bd78fbc2", size = 4186083, upload-time = "2026-07-17T10:00:59.022Z" }, - { url = "https://files.pythonhosted.org/packages/23/16/21420a6f2bc5f981299c336817dd5d72709dad5fda30ac38cbd5f0f7b372/pypdfium2-5.12.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e10cbf41b21233ec5e20adfc170cf60edd77abead86a97dc708fff55a8a886c7", size = 3701734, upload-time = "2026-07-17T10:01:00.952Z" }, - { url = "https://files.pythonhosted.org/packages/36/a1/bb89f49e2b3ea3e945b67859d2ec6e73e722a83c006099c675692641e51d/pypdfium2-5.12.1-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07eeebb2784f4cd38d386b924235df43217a397442796673296bb6efbdaad1d0", size = 4030403, upload-time = "2026-07-17T10:01:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/80/a7/cea5eb0c39e9c6fdf9853a3008bf021d08f962228073af90354b60c5ccdc/pypdfium2-5.12.1-py3-none-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c3e6cbe43581af79526184643920ab03a9401a0c79f2226bea9d4d1e3d34008", size = 3994411, upload-time = "2026-07-17T10:01:04.25Z" }, - { url = "https://files.pythonhosted.org/packages/fc/43/5e470213b27c13b0d94d03d97fc3740507edadea1d1e2ce2049bfbec4aa0/pypdfium2-5.12.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4648f0905441bcb141687ca2263bbf38a1aa056b943eef06019f91cff3e1da4a", size = 4993687, upload-time = "2026-07-17T10:01:05.811Z" }, - { url = "https://files.pythonhosted.org/packages/db/da/af7972ca72f24ed720db6501333fd67bc66aa6aa5a5ec698551bd30ae62e/pypdfium2-5.12.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:bdff622181fab64f32328591c9c8287cdc745c9a1f2afc26ca3feba39e3e6645", size = 4534560, upload-time = "2026-07-17T10:01:07.291Z" }, - { url = "https://files.pythonhosted.org/packages/a9/63/06a7f2cd691f7e336cbc53fd65453fee516042c8ddb016bc50d1cd2bed45/pypdfium2-5.12.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:236dbdc88aa54f14b27937ccb2ebe3dcf08c10dbb8652f432ea982dc9af39732", size = 5237681, upload-time = "2026-07-17T10:01:08.997Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f5/937b080671758ab0b3d3d69b2657006682e4c6a0134b36774be4ed1afcfb/pypdfium2-5.12.1-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:9c8856ce7dd77a7827476c7d75afe1197d6cd505f5cb4167b6aacf661f3f8ea5", size = 5143027, upload-time = "2026-07-17T10:01:10.69Z" }, - { url = "https://files.pythonhosted.org/packages/32/02/094632700c24728fa443dc89d9d1c6e4fc05bb00778b22e8482fdb133da0/pypdfium2-5.12.1-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:5f257bb40fa44ce9ba18d2c919777dbd3f16bf22548b1d68fd56c7c92f1de530", size = 4647048, upload-time = "2026-07-17T10:01:12.559Z" }, - { url = "https://files.pythonhosted.org/packages/fe/d0/12d84bf55a4fcf2c0ed242afc94168933194f672067e1d162aa60a8e4426/pypdfium2-5.12.1-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:974082344172da76a5c3c0782eaedfe6069dbe88db77d8c671ef36b61e9b14e2", size = 5088747, upload-time = "2026-07-17T10:01:14.42Z" }, - { url = "https://files.pythonhosted.org/packages/92/9c/92a460bac1f6cfd6f96251802b3098a804fb251dfe0b5eb004ede958ae0e/pypdfium2-5.12.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:715ae16b34ea1d64884d58800155179ba700e9ea65a2f583b020666acd2bfb12", size = 5049695, upload-time = "2026-07-17T10:01:15.961Z" }, - { url = "https://files.pythonhosted.org/packages/f7/67/53c61d366222550220b42a9212131407f49d3dbcf050178c620bf80fa899/pypdfium2-5.12.1-py3-none-win32.whl", hash = "sha256:e5358d2ce4ebc5c899aab1df9ca5d215357244e9168aa443225d3c1e649c7eac", size = 3725466, upload-time = "2026-07-17T10:01:17.773Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c3/08b62718faf2f6b6aa49207626e3113ff3ec1b3cd076c0ef8fd852f0e57c/pypdfium2-5.12.1-py3-none-win_amd64.whl", hash = "sha256:9609be73a6701a68f29dffe0335f7a2e4b3ba581542ed65d35d49f761a4600ca", size = 3859845, upload-time = "2026-07-17T10:01:19.417Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d5/ad551e55790134c8bc87ea16e0866a3721def0620fbfa19112c8ad4a25a6/pypdfium2-5.12.1-py3-none-win_arm64.whl", hash = "sha256:afc0b7e0c975a429abc75875209ce17b66d749f6ac5cbe8ba72470e83901e304", size = 3674605, upload-time = "2026-07-17T10:01:21.008Z" }, +version = "5.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/78/a52cb80611339ec95f35c7a10d7bfe7a6f97f3b50a35a9f94283d062512e/pypdfium2-5.13.0.tar.gz", hash = "sha256:7ca2d8e31bd8d0d40c496416b7d8bea423388669ffd494929f50e8c3a82326b8", size = 273639, upload-time = "2026-08-13T10:58:15.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/9c/a49050af85055054299c7fab658ac63f8fddde575774aecbf8f71c7a9e5f/pypdfium2-5.13.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:882f4bbd4b17a335b43603169a14cde9341de12b238acd5c39e690cbca7c4293", size = 3417299, upload-time = "2026-08-13T10:57:40.522Z" }, + { url = "https://files.pythonhosted.org/packages/50/ad/f23027328843ee2bdd05afe16bb101f5906befd0c70de35fa8c53f60a5ff/pypdfium2-5.13.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:d96929bde3bd64c771ab3558ca1ffd7704cc4d872ab92cd9f8f8b8a20f7f36b8", size = 2864708, upload-time = "2026-08-13T10:57:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/08/99/1fe58428b69d2722dcbcfaa08ce71834a332c5b518fd58874bcef936b823/pypdfium2-5.13.0-py3-none-macosx_13_0_arm64.whl", hash = "sha256:da5c7b74eebf40b5c1fbe1de01aa1edc8827a79fb1efd999616bc20dcaf77ba4", size = 3507415, upload-time = "2026-08-13T10:57:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/06e26da88a4f5b4ed289325868717a186020661b7b221aa6df622711d31b/pypdfium2-5.13.0-py3-none-macosx_13_0_x86_64.whl", hash = "sha256:2abedfb5c70992b19c780ed58d7f7b929e8ce8ee52c9140158f44317c90ec6c7", size = 3670979, upload-time = "2026-08-13T10:57:45.607Z" }, + { url = "https://files.pythonhosted.org/packages/fe/31/f8210d53775f142be934336665b1d60e800c3f176f28c29b4908d945c518/pypdfium2-5.13.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ee8c2bb2e68b396ab4a763215ac100dacb6b96d0da5bebeb239a021aecc3a7e", size = 3676486, upload-time = "2026-08-13T10:57:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/94/50/d339fa09fbe592564b100bfc76833170a1104a764a458ac2abfffcb632f2/pypdfium2-5.13.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07f58e91b8c45ca144a1ff3008faf3c73ef8a5e9fb32988831788363288228cd", size = 3400883, upload-time = "2026-08-13T10:57:49.189Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e0/b10cf41b5e9f0212d014c40635659c6ab95bb4fcc6fc47f5d3c571f8d57f/pypdfium2-5.13.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46b2f5be9e7ae941ee4216e3d20b66f9dc3d81944a3d57756272de5275204709", size = 3803912, upload-time = "2026-08-13T10:57:50.865Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d8/25ba4ce9a9059ece82f4514df0658fde0aa9bbeafe135e76017c052bf56f/pypdfium2-5.13.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d96beb7f379e6c76d874ca93fcd182ac3168dd499056407070f9927fb1061b8e", size = 4218231, upload-time = "2026-08-13T10:57:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7c/74a2fb48e5b0d2402d9ca64b39074c722d67e9a8a2c58449a843a8c2329a/pypdfium2-5.13.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81df25c1ab4c13ff773102d3cbea1967511d079123b067fc077bd0c4d57d91d8", size = 3730077, upload-time = "2026-08-13T10:57:54.021Z" }, + { url = "https://files.pythonhosted.org/packages/59/12/8c922f00518c26dc47d3676cc09c1d3c95e991c1977e31067d23cc2215cb/pypdfium2-5.13.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d66a32d89fa5b4a2715810171239eb194df4aba604727483ab760512f3c6a851", size = 4031512, upload-time = "2026-08-13T10:57:55.736Z" }, + { url = "https://files.pythonhosted.org/packages/c6/48/a171d034c2dac01adcc57d3dad3c97ba11f19d916f421176002c9e02c904/pypdfium2-5.13.0-py3-none-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b90b0a5ac310bb34db8eb848e58fcab4e201e124e3cf3cb1ccb7b85293e034af", size = 3995485, upload-time = "2026-08-13T10:57:57.39Z" }, + { url = "https://files.pythonhosted.org/packages/36/2e/dcb24776d409bb9e5b7fb26a0c62a87b98ab0e30dfcca645eaf31e35123b/pypdfium2-5.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ada81c36483cd61d07e32bc7814620ee96256b4f421b913f566861bf91800248", size = 5016636, upload-time = "2026-08-13T10:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/93/24/1fab8470fc6de6f4481f009c90757b1a1ee0a61d8e864ed273f72ffca855/pypdfium2-5.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3826e521e895648983cb9ee6b934d4bf51552600043984f84e9c2b3b14b696f3", size = 4555251, upload-time = "2026-08-13T10:58:00.753Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ef/6e8dbea1eddcb55cf34172753ffccd39566333c803cc94d43c653f369f2f/pypdfium2-5.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5c029d7163a91f264eafab51fb442a84a33efd9fd83d5a06c0136a7857a3cc8d", size = 5263483, upload-time = "2026-08-13T10:58:02.48Z" }, + { url = "https://files.pythonhosted.org/packages/53/fe/2ff673730189a621c01f9193c74b0f6aa70d8740889fdf11949e1c541869/pypdfium2-5.13.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:be2dccbde0ce7efe334ecd8f348df4308db360756ede4f0821d82dfc9a58caa8", size = 5144135, upload-time = "2026-08-13T10:58:04.351Z" }, + { url = "https://files.pythonhosted.org/packages/19/0b/759b9037c007317fa5c990dd3f6eff2b99d3fbced251d1e2512be92f2e2e/pypdfium2-5.13.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:bcd81394fe101405e026eedb3e40bef84635c1e5d974dd6036420eb6937753c6", size = 4648156, upload-time = "2026-08-13T10:58:06.036Z" }, + { url = "https://files.pythonhosted.org/packages/db/3b/ffe29679c52efe8eb02d77aa6656e6d6201395423329af018ebd5923a3d0/pypdfium2-5.13.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:2ed32ff685f8e05e637c990bedbf5fca66727bf27718d8bc33eeab21ce0630d1", size = 5089852, upload-time = "2026-08-13T10:58:07.791Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b6/cebacc1601ddfdcd1e6a1dc321533d215ceccf9b825fa9b91b11c6dc39fb/pypdfium2-5.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9c777edba28d1d5fd15435ed3a78ee2fdb93dd069be37cb53b559bc122793770", size = 5074153, upload-time = "2026-08-13T10:58:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/54/40/cf14c4f534f817788966857afdedb90002198dca5ce4fe2c6ecb031955ae/pypdfium2-5.13.0-py3-none-win32.whl", hash = "sha256:d33ee7077db67478b75efe4b5ea9610fb96c5416a0bc4949227f0f59c34dfcd9", size = 3753164, upload-time = "2026-08-13T10:58:10.97Z" }, + { url = "https://files.pythonhosted.org/packages/5d/99/a37b6b902457569468ed5908c94e56cb6c4032541f02cf89f723d42a9148/pypdfium2-5.13.0-py3-none-win_amd64.whl", hash = "sha256:47dcca2a8d507b5fd24f94c3c9d48fb379430f097bc20f01beff6c963ffbcedb", size = 3885553, upload-time = "2026-08-13T10:58:12.709Z" }, + { url = "https://files.pythonhosted.org/packages/50/7f/d39f6e64375c2ffd50ea100e3c73af79085c880c2791eb7203bc61d8913f/pypdfium2-5.13.0-py3-none-win_arm64.whl", hash = "sha256:554a0b23376460af1410e3c915906895e2dac67a086b9e6ccde0643a795d3b0d", size = 3700026, upload-time = "2026-08-13T10:58:14.206Z" }, ] [[package]] @@ -3974,15 +4211,14 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.5.0" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, - { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/51/276f964496a5714ab9f320896195639086881c2b39c03b5ad13de84acbb8/python_discovery-1.5.0.tar.gz", hash = "sha256:3e014c6327154d3dda27939a9a0dc9c5c000439f1906d3f303b48f984bd2ecef", size = 72483, upload-time = "2026-07-21T13:14:14.641Z" } +sdist = { url = "https://files.pythonhosted.org/packages/91/96/0f93e27c9f60a650838f2118159aa115fd5732c0716247917b7ba7ede665/python_discovery-1.6.0.tar.gz", hash = "sha256:6393b4eae1be8b2182670635e7baff89ac21cb9f8e86fd1ff40c7b1144febb4c", size = 82849, upload-time = "2026-08-28T17:30:02.366Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/7b/14882602ddee241d7984a742fcb423cb4a30fb0d6efc546ac3129fba475a/python_discovery-1.5.0-py3-none-any.whl", hash = "sha256:70c4fc61b4e7404e44f01d6fc44a715c4d685ca6cea83d295922f05891877c98", size = 34205, upload-time = "2026-07-21T13:14:13.398Z" }, + { url = "https://files.pythonhosted.org/packages/43/5e/21abf578182fb15006a57faf3711a1e659e29d600d19b6e557eae908c81d/python_discovery-1.6.0-py3-none-any.whl", hash = "sha256:d4e244cf17b8b29819ed78003d55fbacf86eda23425b075454fff9271b79377a", size = 38451, upload-time = "2026-08-28T17:30:01.236Z" }, ] [[package]] @@ -4000,11 +4236,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.2" +version = "1.2.3" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] [[package]] @@ -4016,6 +4252,20 @@ 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 = "python-oxmsg" +version = "0.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "olefile" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/4e/869f34faedbc968796d2c7e9837dede079c9cb9750917356b1f1eda926e9/python_oxmsg-0.0.2.tar.gz", hash = "sha256:a6aff4deb1b5975d44d49dab1d9384089ffeec819e19c6940bc7ffbc84775fad", size = 34713, upload-time = "2025-02-03T17:13:47.415Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/67/f56c69a98c7eb244025845506387d0f961681657c9fcd8b2d2edd148f9d2/python_oxmsg-0.0.2-py3-none-any.whl", hash = "sha256:22be29b14c46016bcd05e34abddfd8e05ee82082f53b82753d115da3fc7d0355", size = 31455, upload-time = "2025-02-03T17:13:46.061Z" }, +] + [[package]] name = "python-pptx" version = "1.0.2" @@ -4110,50 +4360,55 @@ wheels = [ [[package]] name = "pyzmq" -version = "27.1.0" +version = "27.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "implementation_name == 'pypy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, - { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, - { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, - { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, - { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, - { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, - { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, - { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, - { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, - { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, - { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, - { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, - { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, - { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, - { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, - { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, - { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/e7/8d/5b3d5631c2f4b4b8862f64cd0c9eb777b5710eeb5125b4be8dd0a200a4c0/pyzmq-27.2.0.tar.gz", hash = "sha256:54d4259d1bfae24ecdb5ca79f7acc2eac6c286a02d6a0ae617797cb45f0726d3", size = 292316, upload-time = "2026-08-20T19:08:21.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/8a/153532fa53db30e116118164f3af269a1f3966b3e2ba32c89b12fe864bd8/pyzmq-27.2.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:591c8de5851c5ea372194469fe97587b97c3b641e9a70f31bb3474acbfde0241", size = 1431074, upload-time = "2026-08-20T19:06:40.601Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ef/c08b91248bb90a9efa81fa00ba81b69c157c74d0c5efbb2c319d91babb62/pyzmq-27.2.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:00e73942ef12cecbc7951c4a9104bb8ffaed742abb13af2da6833d90dd368cef", size = 973915, upload-time = "2026-08-20T19:06:42.037Z" }, + { url = "https://files.pythonhosted.org/packages/b4/78/a3a3a86c2b00fadb92ece1ca4f8f028d62b2ce9ac3526097239ab2d6fba9/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f8079d0521fe94bbb401fe9407578b28f3701627c8be2c9f7e0c5b77dcb0109", size = 697722, upload-time = "2026-08-20T19:06:43.325Z" }, + { url = "https://files.pythonhosted.org/packages/62/2c/d5828306f795e8d34676d266823b74e2101e0ad3760d12083de3e02abbb2/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dea74fd65f1fc5f7fe167916a473ebe6ed6174e5e5d9de11ea6583661be6cf43", size = 872258, upload-time = "2026-08-20T19:06:44.627Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/51253b78fd8739293e283407eeecb14215c02c71b6519af21f6eed8e69cd/pyzmq-27.2.0-cp312-abi3-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcc99ca132b667a4ed750afd42db4ea73288f18425a9b2e3c0af095665c491f5", size = 739591, upload-time = "2026-08-20T19:06:46.214Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3e/142c85b67a4c9678629b0cf6d5125b29663d75be69bfaa57a3cac344d780/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b8d5f66e4a8246cf77f7b8f7902af64f00553368fa0373c89d99b78f0ad79394", size = 1689031, upload-time = "2026-08-20T19:06:47.612Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ee/0776fb0f98ed1eb74d77240087fef0ab045b6ad15cb09555c6c5134c98ad/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:d1526b42a2e725b84ed226f37becedc250c6347594e5ed304e4e9aff68c9aec3", size = 2059547, upload-time = "2026-08-20T19:06:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/ec77f691a4aebe29ab6329f996fb0e0270c876a3016086e3ca6ef733bcae/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f707bcf2c1d007d14d70531d4dd7b41060881c73efa845580bf6faaf9ea24d42", size = 1910457, upload-time = "2026-08-20T19:06:50.783Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/1f5530ff4fc271b4597048371d5af972c2baab51be132ba15874e0327a6a/pyzmq-27.2.0-cp312-abi3-win32.whl", hash = "sha256:fdaaa4ea3242f6ad298eb5177eb042aea5c73c30e76d20caee7b15af20d24ec2", size = 563450, upload-time = "2026-08-20T19:06:52.307Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b83f7780dad22e0878e4c7bd9158ebd24ed12bc3d5e3a471cd0576f77ded/pyzmq-27.2.0-cp312-abi3-win_amd64.whl", hash = "sha256:2c218c6ab8bc447ba62054b581fd30209689d199c6ecb253f79615ca74a38e12", size = 628633, upload-time = "2026-08-20T19:06:53.809Z" }, + { url = "https://files.pythonhosted.org/packages/52/aa/3918b5ac7f9987bd9c421b065074fd7409ded88f856f2c704a24341877ec/pyzmq-27.2.0-cp312-abi3-win_arm64.whl", hash = "sha256:348d6fd3e4b81ae4580622ea8c2ea60224e84b2ac1b3be4482e6edc7de06e7a3", size = 556006, upload-time = "2026-08-20T19:06:55.242Z" }, + { url = "https://files.pythonhosted.org/packages/83/5e/d0541596b48c5a19f85dcbea83d6673d8e91681cdf853eb194c31fc9766e/pyzmq-27.2.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:c551b9e2f86dc625fcb1a032c0d68042678caf96a8dd7c28796766b673bd5b52", size = 1127193, upload-time = "2026-08-20T19:06:56.545Z" }, + { url = "https://files.pythonhosted.org/packages/50/9f/8c7411bb283982d46e6d56dca6a095678c87eb0398daead12776d9881ac2/pyzmq-27.2.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:288cc790da0e3064a14a38ddc56ba169dada8c8af4cb86518db2bcbd380eedbb", size = 1166833, upload-time = "2026-08-20T19:06:58.011Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/a849161ff88b2de9b991cc8ab332218824741122fdc4fdf222a5b822ac8c/pyzmq-27.2.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:3d45189c0c3c99f817b7fefff0d32eeef684cf33e1e3c0fc4281515357c54702", size = 1134452, upload-time = "2026-08-20T19:06:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/3c/34/ff4aaff0cfba2a4d7ad1a16ffedc52c6deb89fcf673d455085446b23f215/pyzmq-27.2.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d61910b52be5b2cd8b248dbcbe3a1b0275556a7d99fb613fc43323b546e273b8", size = 1167520, upload-time = "2026-08-20T19:07:01.283Z" }, + { url = "https://files.pythonhosted.org/packages/b6/07/42111e9dc1041d78b4443d6eb1b82b027f1a58178dc8a38385effbc72ad5/pyzmq-27.2.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3ab6eb88590e510ab16715c32dbba12000da9bee989fdadd9ee19a234c492eb7", size = 1466289, upload-time = "2026-08-20T19:07:02.738Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b4/def7a478458da78665840564161772e7e938600c32a89f28e8b221b54d2d/pyzmq-27.2.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecbdd131b9669f62d3a45afee5527c7ae9f141e4301267f21714c90bd21725f", size = 975868, upload-time = "2026-08-20T19:07:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/38/d5/e3e85f7fea37153097aaff49db9e33093909cc2a7b22c1ac4ebe546600fc/pyzmq-27.2.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3146385b94a760236c5eceff468a66a296a716ca98a2e0f9217b1518118466b1", size = 706054, upload-time = "2026-08-20T19:07:05.623Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ef/3b7d9449b223183222bf517245e1e53d5f1ab8c10be8b45f6a301b2f994a/pyzmq-27.2.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9846e881620dd62566ca76a53e384c3f37490faf4b9240aebc7498810dfca853", size = 878984, upload-time = "2026-08-20T19:07:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/be/a5/8b49dbd494f6dcfda69dc4cade322a4b02706ef4e3d30cc366d4e369899f/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d9527e3dbaef1edaeeb2446fa7379446814a43ade8adc7c4a5ebe69437815ddd", size = 1697489, upload-time = "2026-08-20T19:07:08.945Z" }, + { url = "https://files.pythonhosted.org/packages/da/5a/4bb8280901130c26ea25f0cbb4a6d39d94250860c6b3dbd912f1cf48fca7/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:56b48fa9d478a3af7254f397697a62f5ad3e1bb677e200b2701f0c290d97e5af", size = 2064236, upload-time = "2026-08-20T19:07:10.384Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/f433af66922554adb2b5f79e897018c8e19a90b9eaeb49c4814f8355ebe4/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bf0b6e4ce1bb089751c504c5493d6b0557eabd02dd21b76e9086cf964234b103", size = 1917424, upload-time = "2026-08-20T19:07:11.909Z" }, + { url = "https://files.pythonhosted.org/packages/36/81/ea1c1ae3f801d96ba2c269e056761ebcfe023476e651d3af2a7817962051/pyzmq-27.2.0-cp314-cp314t-win32.whl", hash = "sha256:fba8afcf265c6e9fbe1594cb045d4765c6c9a7d607653a8196067ef23566b843", size = 591103, upload-time = "2026-08-20T19:07:13.451Z" }, + { url = "https://files.pythonhosted.org/packages/8a/04/149a627707e780fa9f2c1ede3590c14fa6b18b5576d15744342622299a50/pyzmq-27.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d1bc1d380a91d954ed5fc9f12915dba014eed0978d2de05ee7ca688bdaac144a", size = 670215, upload-time = "2026-08-20T19:07:15.069Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/f9c3c1536c41ef3dbf765ea04218990e2056e558f98184ecd883767fc501/pyzmq-27.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c7cfb75caa83f5153c687e9d2107f64b5ef0ef0d6edd260d3ff920baaaa69101", size = 582252, upload-time = "2026-08-20T19:07:16.582Z" }, + { url = "https://files.pythonhosted.org/packages/fa/00/78fe097a304a408275747ce43f20428789130b059c5649956277c20f30cf/pyzmq-27.2.0-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:c5129a8fe43ecc49b99eb75616603d483a3c2fcaef504988fafe8ea392aea98b", size = 1134295, upload-time = "2026-08-20T19:07:17.94Z" }, + { url = "https://files.pythonhosted.org/packages/f2/83/1c36270658d2ee56e23a3f9ef5fbcb94cbd2f9fe966a6641f2f38e697162/pyzmq-27.2.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:baa2ce3485145653194d6c8c5beedd1e9f0bf46a0919c9fa2fe2204fc35b74d9", size = 1167492, upload-time = "2026-08-20T19:07:19.476Z" }, + { url = "https://files.pythonhosted.org/packages/58/b2/f0ae223438d7faa991f6feefdc823815f11cc604f898738376b59fd96515/pyzmq-27.2.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:e1ed46048d1920cabc96d952a0d5cfe4127ad8db572c335aae4e3c57b9278d7f", size = 1465992, upload-time = "2026-08-20T19:07:20.941Z" }, + { url = "https://files.pythonhosted.org/packages/59/46/fb56f3f37a6a0937b0e1d2885e808b5eedc171320bac85573cfae78fa9bc/pyzmq-27.2.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e0fa0bc6b1a184aee59b32efcd1b7f0e6d5b8f9387799e4c16a4cb66a86747d6", size = 976118, upload-time = "2026-08-20T19:07:22.577Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/a2c9bfd7c4d34eea1278493cd041bc000d41acb4463c89ceaad29dc813b6/pyzmq-27.2.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4bd6743e8bf854c3bfce892dd6578a514aabf128e37a4b2eafcf01856f7e44", size = 705968, upload-time = "2026-08-20T19:07:24.019Z" }, + { url = "https://files.pythonhosted.org/packages/d6/12/b906b269116b6591dc15c0acc5d04c043957c8a531d336999731f4b1d899/pyzmq-27.2.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95369ed6626afcfe2ac89832fb1b917c077fbeb905fbbe5d918349ce0222b89b", size = 879011, upload-time = "2026-08-20T19:07:25.428Z" }, + { url = "https://files.pythonhosted.org/packages/12/13/f96359534bfb77651c15f1fbfc4bfdd7ec3489d23f434706d39598dd0dcd/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40124779c3a56ad5d91902df1ff89159cb414b6c1a0ee697abcc66cf5e6db62d", size = 1697496, upload-time = "2026-08-20T19:07:26.821Z" }, + { url = "https://files.pythonhosted.org/packages/21/b4/2c007ae5f2fe5eca86cbfbc874ed86b5135f2f7812615dfd78606d3c93f6/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:ec8a318dfc27c7d946651b3d9e8025d5734f30c168a822195601827207bac09b", size = 2064347, upload-time = "2026-08-20T19:07:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/9b/88/767af3a6630c15215f3a66700ec79598a375edd1fdc9d75a3ad522178c01/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:88c0fac061bac269076edeb3a209acefc96cd6167c239daf1c2b404ac48d7012", size = 1917360, upload-time = "2026-08-20T19:07:29.693Z" }, + { url = "https://files.pythonhosted.org/packages/35/c1/80dd2d20d6e57bc68e1dce1e84bf3e76c9577c1bf728199985c8b4ea0fd1/pyzmq-27.2.0-cp315-cp315t-win32.whl", hash = "sha256:ac126d48cf18aa955daabef43bf0009ff76ad4deee437d09ecf15388214b5beb", size = 591073, upload-time = "2026-08-20T19:07:31.341Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b5/33b781666f3f52ae834bc9c8e38f4f0483a826c5a91cccc993292007bf10/pyzmq-27.2.0-cp315-cp315t-win_amd64.whl", hash = "sha256:edce90a1e588ec63adbf612cc0ad582de4169cd216c7ae53c15f42a2ee902f35", size = 670701, upload-time = "2026-08-20T19:07:32.895Z" }, + { url = "https://files.pythonhosted.org/packages/6e/97/bc4f0edefb992df4fdebcf9f0cc40f631cd4ed277e1ed59ef2cd99a5c8c5/pyzmq-27.2.0-cp315-cp315t-win_arm64.whl", hash = "sha256:a843094b4d3d633bc3623e47a2ff50742d6af02bc1f7606aa2e67e971e21878d", size = 581985, upload-time = "2026-08-20T19:07:34.19Z" }, ] [[package]] name = "qdrant-client" -version = "1.18.0" +version = "1.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, @@ -4164,9 +4419,9 @@ dependencies = [ { name = "pydantic" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/65/45/5b1bdd15a3c7730eefb9c113600829e20d689b82b5a23f9e07d107094004/qdrant_client-1.18.0.tar.gz", hash = "sha256:52e8ece1a7d40519801bf0b70713bfa0f6b7ae28c7275bbe0b0286fbed7f6db4", size = 352580, upload-time = "2026-05-11T14:12:38.702Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/33/c6e4ec45b4fca5a0b808e8804e60be54a6d7505b68b53c0b1d0d62ba86c1/qdrant_client-1.19.0.tar.gz", hash = "sha256:365395a04b0a26c309b25b7d8b1c99ef2071ec9a2b74bc8a5fd3b7a3642fe963", size = 350953, upload-time = "2026-08-04T14:32:56.923Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/10/c437bd2ac41ef30d3019063e6ce537dc111e9214473b337ee88f7fa6359a/qdrant_client-1.18.0-py3-none-any.whl", hash = "sha256:093aa8cf8a420ee3ad2a68b007e1378d7992b2600e0b53c193fc172674f659cd", size = 398126, upload-time = "2026-05-11T14:12:36.998Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3c/480c61cc8d5a3e76bb44e86231f408c93643e5498beadbbeb381ab55d02b/qdrant_client-1.19.0-py3-none-any.whl", hash = "sha256:13602a2b3478a95ecdf42f97b93d7f703b63a3361cd912a04495a33a5ac14121", size = 396157, upload-time = "2026-08-04T14:32:55.734Z" }, ] [[package]] @@ -4463,27 +4718,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, - { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, - { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, - { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, - { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, - { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, - { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, - { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, - { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, - { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, - { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, - { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, + { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, + { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, ] [[package]] @@ -4615,53 +4870,73 @@ wheels = [ [[package]] name = "scipy" -version = "1.18.0" +version = "1.18.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -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" }, +sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" }, + { url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" }, + { url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" }, + { url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" }, + { url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" }, + { url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3", size = 31089958, upload-time = "2026-08-21T23:24:35.8Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93", size = 28715106, upload-time = "2026-08-21T23:24:40.775Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d7/21d890274f75ea37a8209d5519e72da3da90302e3b9fb8397a0918386a62/scipy-1.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6", size = 20456846, upload-time = "2026-08-21T23:24:45.066Z" }, + { url = "https://files.pythonhosted.org/packages/ec/01/798430ecea2e78ec7c02663d5f71c007bb6abeca931080debd40d7fa55ea/scipy-1.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174", size = 23087986, upload-time = "2026-08-21T23:24:49.539Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5f/4634e9d35c68496e4e34cb6946eafab044458e6cedab42b40b6588e475b6/scipy-1.18.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315", size = 33998146, upload-time = "2026-08-21T23:24:54.714Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9", size = 35312578, upload-time = "2026-08-21T23:25:00.44Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/bf5a4be6a3525676499f6dff307991739ff6fdcad1481b1aeb6745339f58/scipy-1.18.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899", size = 35612621, upload-time = "2026-08-21T23:25:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07", size = 37457323, upload-time = "2026-08-21T23:25:12.483Z" }, + { url = "https://files.pythonhosted.org/packages/93/0e/e0348fbc0dbab65c114cf78957e7dfeb49f8e8b556b4d930cc12ff195e18/scipy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28", size = 36622841, upload-time = "2026-08-21T23:25:18.722Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/6a77f5f267c555108f0a864b6db714363dab567a8266422a79a385f9232b/scipy-1.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf", size = 24399315, upload-time = "2026-08-21T23:25:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" }, + { url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" }, + { url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" }, + { url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/4f/1bd37c883b67163e2ca1f60977a399500e6879c15defecac62831c8d078d/scipy-1.18.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331", size = 31091484, upload-time = "2026-08-21T23:27:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c5/ba929d7feb9b2332f96827c12e0e924b61973b59b4dea383b603372c65ce/scipy-1.18.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5", size = 28725057, upload-time = "2026-08-21T23:27:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/a4/19/68f1c50f609d955d230e66d25d02bd3e1e167ec540232135354fb9a4b9e3/scipy-1.18.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb", size = 20466734, upload-time = "2026-08-21T23:27:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6d/319fa29b73d1802fa80b32a6eaf3f5be456ef81526da2716a9493bcb5501/scipy-1.18.1-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23", size = 23089664, upload-time = "2026-08-21T23:27:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/30992f9b51a63de671daf3888ffd18378b6cb9ec9f2c972264238ffa7fd6/scipy-1.18.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0", size = 34054035, upload-time = "2026-08-21T23:27:29.409Z" }, + { url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5", size = 35333883, upload-time = "2026-08-21T23:27:34.293Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/12d78ce9f871fe945fca588d32644e6e63f553c2a35c564d73f3b22a3313/scipy-1.18.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa", size = 35673124, upload-time = "2026-08-21T23:27:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7", size = 37470753, upload-time = "2026-08-21T23:27:44.435Z" }, + { url = "https://files.pythonhosted.org/packages/17/6c/a776888ce618bee54fbde26172f0f46ac1da70d27b63861797fe78e1904b/scipy-1.18.1-cp315-cp315-win_amd64.whl", hash = "sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0", size = 37361483, upload-time = "2026-08-21T23:27:49.334Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/97b651691322ebee97999b017ffc18a15a0b815103844c97e8da9d469731/scipy-1.18.1-cp315-cp315-win_arm64.whl", hash = "sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298", size = 25035883, upload-time = "2026-08-21T23:27:53.596Z" }, + { url = "https://files.pythonhosted.org/packages/ed/0f/9ec20467bbabd0d44e2a77d0fd3d124f884b4d67df92af82c91d2d6a486f/scipy-1.18.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d", size = 31474926, upload-time = "2026-08-21T23:27:57.993Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/dcb79161e56efbedc50079fcd2f5fe427a0ebb53022eb476aa73c015ad8f/scipy-1.18.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35", size = 29164940, upload-time = "2026-08-21T23:28:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/71/d3/1eeea80c817fcb8ef7bd4a05a58824977a0e57a375cfc3d7ea7c911c01ad/scipy-1.18.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443", size = 20906742, upload-time = "2026-08-21T23:28:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/e59350428b6099301a20128108c995e2eb175a43f383af9a346e38824f9b/scipy-1.18.1-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd", size = 23472183, upload-time = "2026-08-21T23:28:12.109Z" }, + { url = "https://files.pythonhosted.org/packages/89/31/cc91623fa98f0621766a0f0aaaadb2c66de74a7ea7e3837164f6e4354260/scipy-1.18.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe", size = 34130796, upload-time = "2026-08-21T23:28:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305", size = 35374253, upload-time = "2026-08-21T23:28:23.732Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c6/59fdeffb4f1435299f93d9dc8140b43ad2916e6cfc944be6c3041fcec86d/scipy-1.18.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4", size = 35758543, upload-time = "2026-08-21T23:28:29.431Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0", size = 37521946, upload-time = "2026-08-21T23:28:35.245Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/5b7d5270621ab7cfa3f7766067bf95dc360b5efb6394694e8143b4156e2b/scipy-1.18.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230", size = 37408295, upload-time = "2026-08-21T23:28:40.724Z" }, + { url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" }, ] [[package]] @@ -4723,30 +4998,31 @@ wheels = [ [[package]] name = "sentence-transformers" -version = "5.6.1" +version = "6.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, { name = "numpy" }, { name = "scikit-learn" }, { name = "scipy" }, + { name = "tokenizers" }, { name = "torch" }, { name = "tqdm" }, { name = "transformers" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/75/80/573ab31b77bdfa8f18051188adff3405e928386287cd6f756eff5777dd82/sentence_transformers-5.6.1.tar.gz", hash = "sha256:16af5d682ef66672b076d58599a23905800e850ec2bfb1865938306bf684ad72", size = 452185, upload-time = "2026-07-23T14:40:41.589Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/43/6b53e6a2098440ce21478742facbc058f1a66ba2cb80b24bdc64942e1e2c/sentence_transformers-6.0.0.tar.gz", hash = "sha256:9e8c2c24f3b1c7473cd5f519a3d3cff60daaeb95533b82d045ffb43ee5f2dac4", size = 575048, upload-time = "2026-08-18T13:33:49.919Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ad/8f73f512dc7ad4031d2b64cbb67f70bdfb355756afbe0db610a5146415c1/sentence_transformers-5.6.1-py3-none-any.whl", hash = "sha256:cefbb17b6325a982a4732c8c49fb013375392687049d1de3d435c4b04060680b", size = 596677, upload-time = "2026-07-23T14:40:40.312Z" }, + { url = "https://files.pythonhosted.org/packages/04/fe/9d19b01fe87945f9455c617bf5d33dfbf29fe06ab6580bc0bea06080c788/sentence_transformers-6.0.0-py3-none-any.whl", hash = "sha256:b974ac67523ea2a955afa87b1024129305472bd884367dcc969261cb086790e9", size = 739640, upload-time = "2026-08-18T13:33:48.428Z" }, ] [[package]] name = "setuptools" -version = "83.0.0" +version = "84.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, ] [[package]] @@ -4829,11 +5105,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.9.1" +version = "2.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" }, ] [[package]] @@ -4852,15 +5128,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.3.1" +version = "1.6.0" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] [[package]] @@ -4930,61 +5206,68 @@ wheels = [ [[package]] name = "tifffile" -version = "2026.7.14" +version = "2026.8.23" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/2f/e5fe51c8f782241d86fdf7251594b195f0d6c2fcf9d389079de212599246/tifffile-2026.7.14.tar.gz", hash = "sha256:ce2703e5ef22c868f1528d5f5b4ef75eefb019cf628a1c9ec0d17e0afeca8ef5", size = 437660, upload-time = "2026-07-14T23:41:31.737Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/07/90078f49e60718d414d5440dc498301f11ff049458e65cf8d27c62a5c9d1/tifffile-2026.8.23.tar.gz", hash = "sha256:bd3c816f166f85c93329a54a0c9a1eccc9968a6a78f91d63b65d7b17675915f2", size = 446179, upload-time = "2026-08-23T18:45:05.976Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/e8/d381de4a3cc4e3682cba0338f43250893508aad0b310af1d0635f7b04413/tifffile-2026.7.14-py3-none-any.whl", hash = "sha256:4eb20372e76edf2c9fed922b1e3a0a0567be3560bd2008336115763bb1f3c034", size = 270614, upload-time = "2026-07-14T23:41:30.078Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/05347ae8b1b268c068b3ca38822558038f38c6494604837d7ca3cbcceb9c/tifffile-2026.8.23-py3-none-any.whl", hash = "sha256:a03045afce67d97cb4ef56a1cd47eae6ab8115c7e84cfedeb5cadbfbb6d14fbe", size = 273795, upload-time = "2026-08-23T18:45:04.466Z" }, ] [[package]] name = "tiktoken" -version = "0.13.0" +version = "0.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "regex" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/85/8e/144bde4e01df66b34bb865557c7cd754ed08b036217ebd79c9db5e9048a9/tiktoken-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791", size = 1034888, upload-time = "2026-05-15T04:50:31.579Z" }, - { url = "https://files.pythonhosted.org/packages/36/18/d4ac9d20956cdebca04841316660ed584c2fecdc2b81722a28bc7ad3b1e4/tiktoken-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b", size = 982970, upload-time = "2026-05-15T04:50:32.961Z" }, - { url = "https://files.pythonhosted.org/packages/74/ed/6bb8d05b9f731f749fee5c6f5ca63e981143c826a5985877330507bd13b7/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7", size = 1115741, upload-time = "2026-05-15T04:50:34.475Z" }, - { url = "https://files.pythonhosted.org/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649", size = 1136523, upload-time = "2026-05-15T04:50:35.782Z" }, - { url = "https://files.pythonhosted.org/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b", size = 1181954, upload-time = "2026-05-15T04:50:36.99Z" }, - { url = "https://files.pythonhosted.org/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91", size = 1240069, upload-time = "2026-05-15T04:50:38.221Z" }, - { url = "https://files.pythonhosted.org/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41", size = 874748, upload-time = "2026-05-15T04:50:39.587Z" }, - { url = "https://files.pythonhosted.org/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154", size = 1034228, upload-time = "2026-05-15T04:50:40.988Z" }, - { url = "https://files.pythonhosted.org/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545", size = 982978, upload-time = "2026-05-15T04:50:42.195Z" }, - { url = "https://files.pythonhosted.org/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2", size = 1116355, upload-time = "2026-05-15T04:50:43.564Z" }, - { url = "https://files.pythonhosted.org/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf", size = 1135772, upload-time = "2026-05-15T04:50:44.782Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486", size = 1182415, upload-time = "2026-05-15T04:50:46.422Z" }, - { url = "https://files.pythonhosted.org/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615", size = 1239879, upload-time = "2026-05-15T04:50:48.052Z" }, - { url = "https://files.pythonhosted.org/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7", size = 874829, upload-time = "2026-05-15T04:50:49.202Z" }, - { url = "https://files.pythonhosted.org/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67", size = 1033600, upload-time = "2026-05-15T04:50:50.4Z" }, - { url = "https://files.pythonhosted.org/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a", size = 982516, upload-time = "2026-05-15T04:50:52.03Z" }, - { url = "https://files.pythonhosted.org/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d", size = 1115518, upload-time = "2026-05-15T04:50:53.543Z" }, - { url = "https://files.pythonhosted.org/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce", size = 1136867, upload-time = "2026-05-15T04:50:55.191Z" }, - { url = "https://files.pythonhosted.org/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2", size = 1181826, upload-time = "2026-05-15T04:50:56.296Z" }, - { url = "https://files.pythonhosted.org/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f", size = 1239489, upload-time = "2026-05-15T04:50:57.918Z" }, - { url = "https://files.pythonhosted.org/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec", size = 873820, upload-time = "2026-05-15T04:50:59.528Z" }, - { url = "https://files.pythonhosted.org/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471", size = 1034977, upload-time = "2026-05-15T04:51:00.957Z" }, - { url = "https://files.pythonhosted.org/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd", size = 983635, upload-time = "2026-05-15T04:51:02.629Z" }, - { url = "https://files.pythonhosted.org/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881", size = 1116036, upload-time = "2026-05-15T04:51:04.082Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24", size = 1135544, upload-time = "2026-05-15T04:51:05.229Z" }, - { url = "https://files.pythonhosted.org/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273", size = 1182217, upload-time = "2026-05-15T04:51:06.517Z" }, - { url = "https://files.pythonhosted.org/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51", size = 1239404, upload-time = "2026-05-15T04:51:07.843Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58", size = 918686, upload-time = "2026-05-15T04:51:08.925Z" }, - { url = "https://files.pythonhosted.org/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b", size = 1034454, upload-time = "2026-05-15T04:51:10.035Z" }, - { url = "https://files.pythonhosted.org/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448", size = 982976, upload-time = "2026-05-15T04:51:11.23Z" }, - { url = "https://files.pythonhosted.org/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a", size = 1115526, upload-time = "2026-05-15T04:51:12.608Z" }, - { url = "https://files.pythonhosted.org/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad", size = 1136466, upload-time = "2026-05-15T04:51:13.926Z" }, - { url = "https://files.pythonhosted.org/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e", size = 1181863, upload-time = "2026-05-15T04:51:15.025Z" }, - { url = "https://files.pythonhosted.org/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424", size = 1239218, upload-time = "2026-05-15T04:51:16.103Z" }, - { url = "https://files.pythonhosted.org/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07", size = 918110, upload-time = "2026-05-15T04:51:17.237Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/66/62/167a842aa0429d45f5e797354fd4343a96f6043d67d0513c675c7b8d36e6/tiktoken-0.14.0.tar.gz", hash = "sha256:231dec90efcdccf1b565a1416107736f1e09b1a08fe736ef9d6363e626d03874", size = 38898, upload-time = "2026-08-17T19:49:49.514Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/da/e273746b9d24a63c776bc60fba914351573ad9c575b52601eb5e60632564/tiktoken-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8e947aefe98ef74cce94923f90e48c98fe34eb1ec0a6bfdfadfc5a96359bfc36", size = 1094408, upload-time = "2026-08-17T19:48:49.269Z" }, + { url = "https://files.pythonhosted.org/packages/69/9f/fe6b1aca23331aa5271df5a4bd07bf68a7059254d47faee1b8272592a777/tiktoken-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d6cebe67765569df3dafac8474e4eccf5c19d24140492567a5e58a11445732a4", size = 1038499, upload-time = "2026-08-17T19:48:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/0b/35/e9f47647c9e163bd1de30fe1a491669b7248cfc67b7404c35c009a701e1a/tiktoken-0.14.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:7db45b98e94adf4173a5cd7422b150999a7ee11ff847783a14f6e1b80cc38cb6", size = 1186355, upload-time = "2026-08-17T19:48:51.93Z" }, + { url = "https://files.pythonhosted.org/packages/51/11/9976ad86980a00cdef05e730a0127a2578a1bc6d11644d8d47246de2eb26/tiktoken-0.14.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7896eea257fe497a2b7134474d909156c6744ce8da35bce88011a960e008aa0d", size = 1204197, upload-time = "2026-08-17T19:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9c/7035b0bcfaa68d1ee4803fc5be5214ad865669b05bd20e7105ae8a18afc6/tiktoken-0.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b950248272f1b303dc32986396e2dccfa10cf6d1e83ec8f0bba1776660305482", size = 1250635, upload-time = "2026-08-17T19:48:54.392Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1d/69cabf18bed7f4366da076735816abce0d4db3fae491ae338a6612128777/tiktoken-0.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3de75343041a1c57333b1e707ac8a9769738241d7d6a55d39e12cf84548337c6", size = 1316085, upload-time = "2026-08-17T19:48:55.525Z" }, + { url = "https://files.pythonhosted.org/packages/bd/bd/a2e884fb1402cba5be08836590320012b2d8ada0e2eef9911a64df4bcd2d/tiktoken-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:087538c080e5ff421abd3a0785ed63c5111d06af98e6cd0d374dbe5969147ca3", size = 941208, upload-time = "2026-08-17T19:48:56.938Z" }, + { url = "https://files.pythonhosted.org/packages/50/53/ee1453623bf65f019328721ccb6587846d2c5b7b82f34e73ca09101f072e/tiktoken-0.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9c5fe393aab56469f04e432ff851216d3def3436cf5f07e442a240164bf500f", size = 1094198, upload-time = "2026-08-17T19:48:57.955Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5f/6448cfe278c3664ba9ec5b5ac08344341f7dc3d42888476e215a14eda2be/tiktoken-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbe2cc3bba939bcdaf103e03df9d5039d33887080b315624be28ec69059e5f94", size = 1038820, upload-time = "2026-08-17T19:48:59.015Z" }, + { url = "https://files.pythonhosted.org/packages/69/3b/d67eac1bcce9dee3abe23aff5e3ded3116bbebaf67b80a0811c06d3806fc/tiktoken-0.14.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2157f52e4b4d7ac5ecc7457b3716834706e7ef9a46f5144029bfeb7cf71f4e06", size = 1186175, upload-time = "2026-08-17T19:49:00.068Z" }, + { url = "https://files.pythonhosted.org/packages/37/62/cae690d9783146b0f81f564ada0f8f611de68178c0c9c7e1e969f0516b48/tiktoken-0.14.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:26e60f6a956ee171ab728b37b8439905d7ea1db435c30f9822f291e9861c861d", size = 1203884, upload-time = "2026-08-17T19:49:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1e/633e30237b94e383cf814145499079f3bb9cdd4aeafc1bc42e01b0f810a6/tiktoken-0.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:380873f330b741c4435574f37edb20813d04603ace2d53e0a63560e1fec83010", size = 1250980, upload-time = "2026-08-17T19:49:02.274Z" }, + { url = "https://files.pythonhosted.org/packages/cb/56/4c12f07b812f84206f38d723eb1ebfdd34bad9309b5dbc0bee6bbcff4cbf/tiktoken-0.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3fd7c14b1cb45b486c39fc9b3443bb341f3e2fc7e6f31247f3435a5836651632", size = 1315434, upload-time = "2026-08-17T19:49:03.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e0/c65603f0c44811def666d3fbf611bf2af3b5e1ef613e06c19411419830b3/tiktoken-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:90a762670c7f968184723769a06ed51f5cf5ce5dcd1e30164f25c72d85c2d1f1", size = 940883, upload-time = "2026-08-17T19:49:04.583Z" }, + { url = "https://files.pythonhosted.org/packages/59/b0/1cf129f4af8fc513931f931023def596b7c4bfc77026513cd9d851da9e88/tiktoken-0.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e067f4cbcc5d036e8aff7fe7a6b530a8f4de2e4616ad9005a24a1879e24e6450", size = 1096273, upload-time = "2026-08-17T19:49:05.807Z" }, + { url = "https://files.pythonhosted.org/packages/62/85/2ae74575e321148484147e10b53c3b1717c59ebaa9edb4fe18b1f5c055f8/tiktoken-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f2af4a336ea56d6c14f27741a0e1d8294a35dd0b038bcf990d232ebb54eb994b", size = 1040269, upload-time = "2026-08-17T19:49:06.943Z" }, + { url = "https://files.pythonhosted.org/packages/89/29/92a1120a12e4bcf2d5464350d1a91b68a433d63ce656bb7f806c27aec09c/tiktoken-0.14.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f702e0aeeb6506e57687e881c59e844ebe8f0a6a097ddafe20e3ab25f387be4e", size = 1186101, upload-time = "2026-08-17T19:49:08.102Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7d/144af98dc5ad68108451a82e2f5a17f80e2663f5115058b8dfd215c1ad02/tiktoken-0.14.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e3442bbb2f0c588cec876061e37ae67b455b9df9978b003c8fe30e45f2ef5b42", size = 1204457, upload-time = "2026-08-17T19:49:09.28Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1f/be7cb06ab2108f612f3e92e7b76cf391e192db0db37a984616f0cc32aafc/tiktoken-0.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:979c1524f753b662b0f3cd261b135afe6659cce33caaa7a5ea00dd1756b3055c", size = 1251716, upload-time = "2026-08-17T19:49:10.509Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6b/81f158d0f90adb826cd704069c2129a046cb784a2a09861009519fc41cf4/tiktoken-0.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2cc19ac87b41c9493c9778ff5847f0c8bbcf5bd0ec6b87ce06c1c802adc8a771", size = 1315432, upload-time = "2026-08-17T19:49:11.844Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ec/f5fa35ec13f07279fdcaf3cc9c04bbb154ea591d23978651f2b672593e8a/tiktoken-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:eceeff0c62419bc78d4b6e70a4762a4d25df3ae8f2d5946e3853ce93e7a57098", size = 988046, upload-time = "2026-08-17T19:49:13.282Z" }, + { url = "https://files.pythonhosted.org/packages/68/c9/7756717408d3d0dfea3f046c9466144b28afde39ff69d5808f2475dcd7f5/tiktoken-0.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6eb94895c45f26bb8f5546e5fd8a069efcf6e3f108ea9d5cbe3bf6f7f3983438", size = 1096261, upload-time = "2026-08-17T19:49:14.351Z" }, + { url = "https://files.pythonhosted.org/packages/79/29/46ad8061f57bd9f8b2ea0aa82bf574e0f2aa040b0857a1582adba9957899/tiktoken-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:86951a971c53979ec857bd8c4a32dc227ab0fd33f6c12a3bd62d3fbf5f0bfcaa", size = 1040183, upload-time = "2026-08-17T19:49:15.707Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7c/3184d17b868456f17b60b1a75f5ec0405618a43aa753336df341d8f11781/tiktoken-0.14.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:e2eca764c53490f8930dbce329e0769f11108d87d908282a80c5c130e26e7037", size = 1186719, upload-time = "2026-08-17T19:49:16.84Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e8/46de4400d5bf859f640feee85bd7e32235f68ddf25db53c63be78e581e3a/tiktoken-0.14.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:26cc4b4840fa0e9f4b72ed489883e12f57e00d1021ca794720e3c29a12f0edef", size = 1204660, upload-time = "2026-08-17T19:49:17.987Z" }, + { url = "https://files.pythonhosted.org/packages/29/ce/af8964c38bc8226dd8950305b7a255fa33345d5572f78af7275a313d28e0/tiktoken-0.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2fc834fbe3f6a0736905c36ab709537e6840dbd63b982dc9e0216ae7d305ba1a", size = 1250932, upload-time = "2026-08-17T19:49:19.28Z" }, + { url = "https://files.pythonhosted.org/packages/1d/4b/323631116fc986d9cc5bbeb2b8223c7c85e61a8bb94ea5ab4951023b149b/tiktoken-0.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ca4db6ff5c5bf600f9b7761a0070ed44dfe5797a76bd432fb978bc480ef40c58", size = 1315190, upload-time = "2026-08-17T19:49:20.467Z" }, + { url = "https://files.pythonhosted.org/packages/18/8b/ba48a73729c9270989b36f37ab2ed5525e52690d715097c9fa791aaa5d05/tiktoken-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7aab286a020660a039097912a088236b985d18a3090d73f136c4413d29d37ca0", size = 987717, upload-time = "2026-08-17T19:49:21.704Z" }, + { url = "https://files.pythonhosted.org/packages/1d/10/b73b7e319179e0f60b32475f783b044f9cece872c53b6662664e9084b0d0/tiktoken-0.14.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:14b47e3674f2624803a8acc8fb367b7e24fc53055f9df3296482fe9a3a34a232", size = 1096280, upload-time = "2026-08-17T19:49:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/c2/6b/09999a9bf1d559670d1680e8f8e419ac0e2c5f6aac82e9bfdf70f260b30a/tiktoken-0.14.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:19d643d701fdaa70e5b9c7f8f96abcaffe77ca5e482a3a1a7dde46feb4284695", size = 1040433, upload-time = "2026-08-17T19:49:23.998Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7b/8537be0836f3df99b2a636b44399bfa43cd757f2b8b4097dacb794cf24a7/tiktoken-0.14.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:e4ddf863b59347deaa92302dcd90e5eb003cdc9be06ec2b692c38d1bdd9efd49", size = 1186989, upload-time = "2026-08-17T19:49:25.021Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9d/f9c56d7a943a4468abf9ef37661bb9b8e0cd3aa8aa87368c7146cc3f3222/tiktoken-0.14.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:60c47ca69ddda0dea8256fffd12e1b86f4b59734a20e4a70c61f63cc5f021df4", size = 1204615, upload-time = "2026-08-17T19:49:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d2/98a38579db25c4a8a84e31dd95d9072ec5f21f7e70de591da0412e29b25b/tiktoken-0.14.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:728303a072163130c5b477b1f20d6211895569c1d5302c24ffc93a3009160871", size = 1251828, upload-time = "2026-08-17T19:49:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/0c/83/467be424746c039c5493c0f4102feab16b9b48eb6f5c089b2a2438e3cde2/tiktoken-0.14.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3c5349c9f916283bba32bec8af69b763e4faa304dc004d0eaaea66a3cf004c1f", size = 1316260, upload-time = "2026-08-17T19:49:29.101Z" }, + { url = "https://files.pythonhosted.org/packages/02/ee/ddf46ca78e371f5890e96b6e7d089a85b3536432be219851eb0481786ca8/tiktoken-0.14.0-cp315-cp315-win_amd64.whl", hash = "sha256:1b6e4adcfd285c44502aed51df98aaaca4f0fea028165dbf8a9e857b9f98d8ea", size = 988230, upload-time = "2026-08-17T19:49:30.246Z" }, + { url = "https://files.pythonhosted.org/packages/2a/00/5162e90c851a28da18ed382d34898b79a8022548e5619a64e14c03ce7c3d/tiktoken-0.14.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:11d8211b290855d2721334ff17dd9b3a17bfb26872be01f25d73612ef7ece890", size = 1096186, upload-time = "2026-08-17T19:49:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/65/97/a5a7bfccf25b1bb65e82bae8edff11ac3c9c041c374b7b4a823d60c38133/tiktoken-0.14.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d0781223705199b289faa59601bb9c2441712d4c600dd13c43d8fd6a33d22cd5", size = 1039947, upload-time = "2026-08-17T19:49:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ba/ef427fc638f1439181c5e12dd26b70e881861f89c007aa7e5b36300f8342/tiktoken-0.14.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:2ea70afba6b9eddbf22c165142e5f0a2ad7aa36a452873c48b57bb2aeb8492ae", size = 1186997, upload-time = "2026-08-17T19:49:34.121Z" }, + { url = "https://files.pythonhosted.org/packages/3e/88/2f3f85a968cdc514152129af0a060ebcccb067005a2f29b0d5ef3c838514/tiktoken-0.14.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:78571efc311c30b73f31eb949a921d6dac39a5d9dc42d1cfa8f8db157b3447b1", size = 1205211, upload-time = "2026-08-17T19:49:35.284Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f6/80760e98a08e6649d2d68afb6035af713121dfb615acce8c4f73810ec438/tiktoken-0.14.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86f66c85e796f5d05d5c4a60ec1d40cbfebc47a32464053528c797163fa9ab89", size = 1251479, upload-time = "2026-08-17T19:49:36.419Z" }, + { url = "https://files.pythonhosted.org/packages/c5/84/50966fb6918a0fb9b32721277e5342bf729a2d74350074d662fbedf9772e/tiktoken-0.14.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:149d97453c4c98c04b081d64a85e635921269b532710d6faf81e9e82b790e7d3", size = 1316673, upload-time = "2026-08-17T19:49:37.756Z" }, + { url = "https://files.pythonhosted.org/packages/35/5e/9b01afd037bfa22a0033963fa091e0f75b6fb15cd85bffb42ff86e697323/tiktoken-0.14.0-cp315-cp315t-win_amd64.whl", hash = "sha256:561e7580f84a79859af1ef6f676968e9030fcc3fe195700b15235bca64f009c9", size = 987929, upload-time = "2026-08-17T19:49:38.947Z" }, ] [[package]] @@ -5027,67 +5310,46 @@ wheels = [ [[package]] name = "torch" -version = "2.10.0" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-bindings", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "cuda-bindings", marker = "python_full_version < '3.15' and sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, { name = "filelock" }, { name = "fsspec" }, { name = "jinja2" }, { name = "networkx" }, - { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cufile-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nvshmem-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and 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 = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "triton", marker = "python_full_version < '3.15' and sys_platform == 'linux'" }, { name = "typing-extensions" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/54/a2ba279afcca44bbd320d4e73675b282fcee3d81400ea1b53934efca6462/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:13ec4add8c3faaed8d13e0574f5cd4a323c11655546f91fbe6afa77b57423574", size = 79498202, upload-time = "2026-02-10T21:44:52.603Z" }, - { url = "https://files.pythonhosted.org/packages/ec/23/2c9fe0c9c27f7f6cb865abcea8a4568f29f00acaeadfc6a37f6801f84cb4/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:e521c9f030a3774ed770a9c011751fb47c4d12029a3d6522116e48431f2ff89e", size = 79498254, upload-time = "2026-02-10T21:44:44.095Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:98c01b8bb5e3240426dcde1446eed6f40c778091c8544767ef1168fc663a05a6", size = 915622781, upload-time = "2026-03-11T14:17:11.354Z" }, - { url = "https://files.pythonhosted.org/packages/ab/c6/4dfe238342ffdcec5aef1c96c457548762d33c40b45a1ab7033bb26d2ff2/torch-2.10.0-3-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:80b1b5bfe38eb0e9f5ff09f206dcac0a87aadd084230d4a36eea5ec5232c115b", size = 915627275, upload-time = "2026-03-11T14:16:11.325Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f0/72bf18847f58f877a6a8acf60614b14935e2f156d942483af1ffc081aea0/torch-2.10.0-3-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:46b3574d93a2a8134b3f5475cfb98e2eb46771794c57015f6ad1fb795ec25e49", size = 915523474, upload-time = "2026-03-11T14:17:44.422Z" }, - { url = "https://files.pythonhosted.org/packages/f4/39/590742415c3030551944edc2ddc273ea1fdfe8ffb2780992e824f1ebee98/torch-2.10.0-3-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b1d5e2aba4eb7f8e87fbe04f86442887f9167a35f092afe4c237dfcaaef6e328", size = 915632474, upload-time = "2026-03-11T14:15:13.666Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8e/34949484f764dde5b222b7fe3fede43e4a6f0da9d7f8c370bb617d629ee2/torch-2.10.0-3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0228d20b06701c05a8f978357f657817a4a63984b0c90745def81c18aedfa591", size = 915523882, upload-time = "2026-03-11T14:14:46.311Z" }, - { url = "https://files.pythonhosted.org/packages/cc/af/758e242e9102e9988969b5e621d41f36b8f258bb4a099109b7a4b4b50ea4/torch-2.10.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5fd4117d89ffd47e3dcc71e71a22efac24828ad781c7e46aaaf56bf7f2796acf", size = 145996088, upload-time = "2026-01-21T16:24:44.171Z" }, - { url = "https://files.pythonhosted.org/packages/23/8e/3c74db5e53bff7ed9e34c8123e6a8bfef718b2450c35eefab85bb4a7e270/torch-2.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:787124e7db3b379d4f1ed54dd12ae7c741c16a4d29b49c0226a89bea50923ffb", size = 915711952, upload-time = "2026-01-21T16:23:53.503Z" }, - { url = "https://files.pythonhosted.org/packages/6e/01/624c4324ca01f66ae4c7cd1b74eb16fb52596dce66dbe51eff95ef9e7a4c/torch-2.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2c66c61f44c5f903046cc696d088e21062644cbe541c7f1c4eaae88b2ad23547", size = 113757972, upload-time = "2026-01-21T16:24:39.516Z" }, - { url = "https://files.pythonhosted.org/packages/c9/5c/dee910b87c4d5c0fcb41b50839ae04df87c1cfc663cf1b5fca7ea565eeaa/torch-2.10.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:6d3707a61863d1c4d6ebba7be4ca320f42b869ee657e9b2c21c736bf17000294", size = 79498198, upload-time = "2026-01-21T16:24:34.704Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6f/f2e91e34e3fcba2e3fc8d8f74e7d6c22e74e480bbd1db7bc8900fdf3e95c/torch-2.10.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5c4d217b14741e40776dd7074d9006fd28b8a97ef5654db959d8635b2fe5f29b", size = 146004247, upload-time = "2026-01-21T16:24:29.335Z" }, - { url = "https://files.pythonhosted.org/packages/98/fb/5160261aeb5e1ee12ee95fe599d0541f7c976c3701d607d8fc29e623229f/torch-2.10.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6b71486353fce0f9714ca0c9ef1c850a2ae766b409808acd58e9678a3edb7738", size = 915716445, upload-time = "2026-01-21T16:22:45.353Z" }, - { url = "https://files.pythonhosted.org/packages/6a/16/502fb1b41e6d868e8deb5b0e3ae926bbb36dab8ceb0d1b769b266ad7b0c3/torch-2.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2ee399c644dc92ef7bc0d4f7e74b5360c37cdbe7c5ba11318dda49ffac2bc57", size = 113757050, upload-time = "2026-01-21T16:24:19.204Z" }, - { url = "https://files.pythonhosted.org/packages/1a/0b/39929b148f4824bc3ad6f9f72a29d4ad865bcf7ebfc2fa67584773e083d2/torch-2.10.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:3202429f58309b9fa96a614885eace4b7995729f44beb54d3e4a47773649d382", size = 79851305, upload-time = "2026-01-21T16:24:09.209Z" }, - { url = "https://files.pythonhosted.org/packages/d8/14/21fbce63bc452381ba5f74a2c0a959fdf5ad5803ccc0c654e752e0dbe91a/torch-2.10.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:aae1b29cd68e50a9397f5ee897b9c24742e9e306f88a807a27d617f07adb3bd8", size = 146005472, upload-time = "2026-01-21T16:22:29.022Z" }, - { url = "https://files.pythonhosted.org/packages/54/fd/b207d1c525cb570ef47f3e9f836b154685011fce11a2f444ba8a4084d042/torch-2.10.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6021db85958db2f07ec94e1bc77212721ba4920c12a18dc552d2ae36a3eb163f", size = 915612644, upload-time = "2026-01-21T16:21:47.019Z" }, - { url = "https://files.pythonhosted.org/packages/36/53/0197f868c75f1050b199fe58f9bf3bf3aecac9b4e85cc9c964383d745403/torch-2.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff43db38af76fda183156153983c9a096fc4c78d0cd1e07b14a2314c7f01c2c8", size = 113997015, upload-time = "2026-01-21T16:23:00.767Z" }, - { url = "https://files.pythonhosted.org/packages/0e/13/e76b4d9c160e89fff48bf16b449ea324bda84745d2ab30294c37c2434c0d/torch-2.10.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:cdf2a523d699b70d613243211ecaac14fe9c5df8a0b0a9c02add60fb2a413e0f", size = 79498248, upload-time = "2026-01-21T16:23:09.315Z" }, - { url = "https://files.pythonhosted.org/packages/4f/93/716b5ac0155f1be70ed81bacc21269c3ece8dba0c249b9994094110bfc51/torch-2.10.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:bf0d9ff448b0218e0433aeb198805192346c4fd659c852370d5cc245f602a06a", size = 79464992, upload-time = "2026-01-21T16:23:05.162Z" }, - { url = "https://files.pythonhosted.org/packages/69/2b/51e663ff190c9d16d4a8271203b71bc73a16aa7619b9f271a69b9d4a936b/torch-2.10.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:233aed0659a2503b831d8a67e9da66a62c996204c0bba4f4c442ccc0c68a3f60", size = 146018567, upload-time = "2026-01-21T16:22:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/5e/cd/4b95ef7f293b927c283db0b136c42be91c8ec6845c44de0238c8c23bdc80/torch-2.10.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:682497e16bdfa6efeec8cde66531bc8d1fbbbb4d8788ec6173c089ed3cc2bfe5", size = 915721646, upload-time = "2026-01-21T16:21:16.983Z" }, - { url = "https://files.pythonhosted.org/packages/56/97/078a007208f8056d88ae43198833469e61a0a355abc0b070edd2c085eb9a/torch-2.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:6528f13d2a8593a1a412ea07a99812495bec07e9224c28b2a25c0a30c7da025c", size = 113752373, upload-time = "2026-01-21T16:22:13.471Z" }, - { url = "https://files.pythonhosted.org/packages/d8/94/71994e7d0d5238393df9732fdab607e37e2b56d26a746cb59fdb415f8966/torch-2.10.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f5ab4ba32383061be0fb74bda772d470140a12c1c3b58a0cfbf3dae94d164c28", size = 79850324, upload-time = "2026-01-21T16:22:09.494Z" }, - { url = "https://files.pythonhosted.org/packages/e2/65/1a05346b418ea8ccd10360eef4b3e0ce688fba544e76edec26913a8d0ee0/torch-2.10.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:716b01a176c2a5659c98f6b01bf868244abdd896526f1c692712ab36dbaf9b63", size = 146006482, upload-time = "2026-01-21T16:22:18.42Z" }, - { url = "https://files.pythonhosted.org/packages/1d/b9/5f6f9d9e859fc3235f60578fa64f52c9c6e9b4327f0fe0defb6de5c0de31/torch-2.10.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d8f5912ba938233f86361e891789595ff35ca4b4e2ac8fe3670895e5976731d6", size = 915613050, upload-time = "2026-01-21T16:20:49.035Z" }, - { url = "https://files.pythonhosted.org/packages/66/4d/35352043ee0eaffdeff154fad67cd4a31dbed7ff8e3be1cc4549717d6d51/torch-2.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:71283a373f0ee2c89e0f0d5f446039bdabe8dbc3c9ccf35f0f784908b0acd185", size = 113995816, upload-time = "2026-01-21T16:22:05.312Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3a/ed0f4d4d1dcde03bced7aac9a28e800abcdc0cbd06b6775044c9fbd877b7/torch-2.13.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2fe228aba290d14b9f31b049be550dbd469c3fd3013d7a19705b30454da97027", size = 111213045, upload-time = "2026-07-08T16:05:22.997Z" }, + { url = "https://files.pythonhosted.org/packages/df/a9/f6a2a4d763ff1df02e9a64c477029db614295bc9367f4131223791ccc243/torch-2.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:572df8be8ffb4599c88cbd6a0726f1f854f4da65d2e3c09f0e2c2283333cd6d4", size = 427210998, upload-time = "2026-07-08T16:04:37.708Z" }, + { url = "https://files.pythonhosted.org/packages/f3/82/fea946351658e6534db52d2cc12bc53087cbf87f9440c5f180f367c1950b/torch-2.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:796633c4cdf0fe2cdced72d8f88f22e73dbcfce83132763162f6d4bff13b820b", size = 526605292, upload-time = "2026-07-08T16:04:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/21/d6/e8f3c6f7e01f626f77259de9860d2a78bc84c40539e28e79b7e98b0bb659/torch-2.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:024c6cc0c1b085f2f91f20a3dc27b0471d021c31ce84b81be3afdc39f791fd9d", size = 122057313, upload-time = "2026-07-08T16:03:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fa/c1c10b7aff4a9a3e8956d4f0a5f468fa6db7abc3208805719076772b4833/torch-2.13.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:33449899ce5496c1b84b4853179d94fd102028ae1407314d9fb956bb79e70d09", size = 111213743, upload-time = "2026-07-08T16:03:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/11/18/9ecb37b56293a0be8d80f810bf672a72fe7e02f8b475d5ef1b9bf8a0d748/torch-2.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1e09d6a722504957c694faceca843acde562786df1144ebcc5a74075ec7f6005", size = 427213008, upload-time = "2026-07-08T16:03:44.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5a/7c50ba1b7b713d71d34669c6d13dab0a11531a3eceb0307a5162dbfec0f7/torch-2.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:a3a9a21312872af8a26950b2c15680335a386a1f56ed03e780653d78b9607e9e", size = 526602329, upload-time = "2026-07-08T16:03:12.649Z" }, + { url = "https://files.pythonhosted.org/packages/91/3d/e7adcc6aaf36961cd18f56cf8ad0f3058c3a5c84ccf391762176c94581b8/torch-2.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:49b58f1e2c52440abb6f17c28f0335fe6c6d01ad1a7f55b0183b81e4b34d64e6", size = 122057920, upload-time = "2026-07-08T16:03:01.808Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/6dcc7f0c07052102dd36f83cbc5800842a909c8c3fbf1a7f8a5844954de9/torch-2.13.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d849b390e07d8d333ce8ecaf91b273c656c598379a19c9acf1318a883f6b391c", size = 111227066, upload-time = "2026-07-08T16:03:33.6Z" }, + { url = "https://files.pythonhosted.org/packages/e9/09/2c10e8cd0e00fa5d23c052df6ce467eaa7182399f5e0f824f1e4ff42ccae/torch-2.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:a3893dc2da0a972a8ca5d698c85a9f967559ac5f8ee1797b77408aa8734d073c", size = 427226309, upload-time = "2026-07-08T16:02:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/76/c6/22c2102bbef14ca6a6cb4c20e42f088e49c5f812be4e160ae57502e325f9/torch-2.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:49f1ea385c754e54919408a9bb3b5a72b0b755bbe2c916c1d6f70afbec4908a2", size = 526614507, upload-time = "2026-07-08T16:02:16.441Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0c/7d1deb6bce5bc3e6042caf39100ac768eba3b9a098e1dddd16f75bd6489b/torch-2.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f8573e3ce9ebcd53fe922f01077a6085ccdfbe5f12fd215883a9d87d7a744fd", size = 122051871, upload-time = "2026-07-08T16:03:23.521Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ce/aa8b7f9949d32e0f2f624f342bc3b48112c1b8a130288465938bc83bcbf9/torch-2.13.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c28def70706c2f9ecc752574766e8ae4da9b810ab6676b611166761a78a9f1e1", size = 111537025, upload-time = "2026-07-08T16:02:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/69/d1/491e3a0389430946145888b0203f2b6a759ce2a61481b96a85c2da4f2ced/torch-2.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:31061ff56ed8fbf26c749806905aeb749ebeb819810fd5d52508aa5afd90dddc", size = 427219769, upload-time = "2026-07-08T16:02:31.18Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1d/38006e045bf0a1fc28ef01e757c554e59e59a8770c284bc4f47b14e60441/torch-2.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cc26eead4cf51d0b544e31e364dcf000846549c273bd148936fe9d24d29acb92", size = 526571320, upload-time = "2026-07-08T16:01:59.348Z" }, + { url = "https://files.pythonhosted.org/packages/56/94/655c91992a882bd5071aa0b5d22a07dbb130d801e872be97c0b627a7c693/torch-2.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a7de8a313090dc5c7d7ba4bfe5c3be222528f9a4dba1acc83bddb1157360c4b8", size = 122306773, upload-time = "2026-07-08T16:02:39.832Z" }, ] [[package]] name = "torchvision" -version = "0.25.0" +version = "0.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, @@ -5095,43 +5357,39 @@ dependencies = [ { name = "torch" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/56/3a/6ea0d73f49a9bef38a1b3a92e8dd455cea58470985d25635beab93841748/torchvision-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2abe430c90b1d5e552680037d68da4eb80a5852ebb1c811b2b89d299b10573b", size = 1874920, upload-time = "2026-01-21T16:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/51/f8/c0e1ef27c66e15406fece94930e7d6feee4cb6374bbc02d945a630d6426e/torchvision-0.25.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:b75deafa2dfea3e2c2a525559b04783515e3463f6e830cb71de0fb7ea36fe233", size = 2344556, upload-time = "2026-01-21T16:27:40.125Z" }, - { url = "https://files.pythonhosted.org/packages/68/2f/f24b039169db474e8688f649377de082a965fbf85daf4e46c44412f1d15a/torchvision-0.25.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f25aa9e380865b11ea6e9d99d84df86b9cc959f1a007cd966fc6f1ab2ed0e248", size = 8072351, upload-time = "2026-01-21T16:27:21.074Z" }, - { url = "https://files.pythonhosted.org/packages/ad/16/8f650c2e288977cf0f8f85184b90ee56ed170a4919347fc74ee99286ed6f/torchvision-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:f9c55ae8d673ab493325d1267cbd285bb94d56f99626c00ac4644de32a59ede3", size = 4303059, upload-time = "2026-01-21T16:27:11.08Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5b/1562a04a6a5a4cf8cf40016a0cdeda91ede75d6962cff7f809a85ae966a5/torchvision-0.25.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:24e11199e4d84ba9c5ee7825ebdf1cd37ce8deec225117f10243cae984ced3ec", size = 1874918, upload-time = "2026-01-21T16:27:39.02Z" }, - { url = "https://files.pythonhosted.org/packages/36/b1/3d6c42f62c272ce34fcce609bb8939bdf873dab5f1b798fd4e880255f129/torchvision-0.25.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5f271136d2d2c0b7a24c5671795c6e4fd8da4e0ea98aeb1041f62bc04c4370ef", size = 2309106, upload-time = "2026-01-21T16:27:30.624Z" }, - { url = "https://files.pythonhosted.org/packages/c7/60/59bb9c8b67cce356daeed4cb96a717caa4f69c9822f72e223a0eae7a9bd9/torchvision-0.25.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:855c0dc6d37f462482da7531c6788518baedca1e0847f3df42a911713acdfe52", size = 8071522, upload-time = "2026-01-21T16:27:29.392Z" }, - { url = "https://files.pythonhosted.org/packages/32/a5/9a9b1de0720f884ea50dbf9acb22cbe5312e51d7b8c4ac6ba9b51efd9bba/torchvision-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:cef0196be31be421f6f462d1e9da1101be7332d91984caa6f8022e6c78a5877f", size = 4321911, upload-time = "2026-01-21T16:27:35.195Z" }, - { url = "https://files.pythonhosted.org/packages/52/99/dca81ed21ebaeff2b67cc9f815a20fdaa418b69f5f9ea4c6ed71721470db/torchvision-0.25.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a8f8061284395ce31bcd460f2169013382ccf411148ceb2ee38e718e9860f5a7", size = 1896209, upload-time = "2026-01-21T16:27:32.159Z" }, - { url = "https://files.pythonhosted.org/packages/28/cc/2103149761fdb4eaed58a53e8437b2d716d48f05174fab1d9fcf1e2a2244/torchvision-0.25.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:146d02c9876858420adf41f3189fe90e3d6a409cbfa65454c09f25fb33bf7266", size = 2310735, upload-time = "2026-01-21T16:27:22.327Z" }, - { url = "https://files.pythonhosted.org/packages/76/ad/f4c985ad52ddd3b22711c588501be1b330adaeaf6850317f66751711b78c/torchvision-0.25.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c4d395cb2c4a2712f6eb93a34476cdf7aae74bb6ea2ea1917f858e96344b00aa", size = 8089557, upload-time = "2026-01-21T16:27:27.666Z" }, - { url = "https://files.pythonhosted.org/packages/63/cc/0ea68b5802e5e3c31f44b307e74947bad5a38cc655231d845534ed50ddb8/torchvision-0.25.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5e6b449e9fa7d642142c0e27c41e5a43b508d57ed8e79b7c0a0c28652da8678c", size = 4344260, upload-time = "2026-01-21T16:27:17.018Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1f/fa839532660e2602b7e704d65010787c5bb296258b44fa8b9c1cd6175e7d/torchvision-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:620a236288d594dcec7634c754484542dc0a5c1b0e0b83a34bda5e91e9b7c3a1", size = 1896193, upload-time = "2026-01-21T16:27:24.785Z" }, - { url = "https://files.pythonhosted.org/packages/80/ed/d51889da7ceaf5ff7a0574fb28f9b6b223df19667265395891f81b364ab3/torchvision-0.25.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b5e7f50002a8145a98c5694a018e738c50e2972608310c7e88e1bd4c058f6ce", size = 2309331, upload-time = "2026-01-21T16:27:19.97Z" }, - { url = "https://files.pythonhosted.org/packages/90/a5/f93fcffaddd8f12f9e812256830ec9c9ca65abbf1bc369379f9c364d1ff4/torchvision-0.25.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:632db02300e83793812eee4f61ae6a2686dab10b4cfd628b620dc47747aa9d03", size = 8088713, upload-time = "2026-01-21T16:27:15.281Z" }, - { url = "https://files.pythonhosted.org/packages/1f/eb/d0096eed5690d962853213f2ee00d91478dfcb586b62dbbb449fb8abc3a6/torchvision-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:d1abd5ed030c708f5dbf4812ad5f6fbe9384b63c40d6bd79f8df41a4a759a917", size = 4325058, upload-time = "2026-01-21T16:27:26.165Z" }, - { url = "https://files.pythonhosted.org/packages/97/36/96374a4c7ab50dea9787ce987815614ccfe988a42e10ac1a2e3e5b60319a/torchvision-0.25.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad9a8a5877782944d99186e4502a614770fe906626d76e9cd32446a0ac3075f2", size = 1896207, upload-time = "2026-01-21T16:27:23.383Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e2/7abb10a867db79b226b41da419b63b69c0bd5b82438c4a4ed50e084c552f/torchvision-0.25.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:40a122c3cf4d14b651f095e0f672b688dde78632783fc5cd3d4d5e4f6a828563", size = 2310741, upload-time = "2026-01-21T16:27:18.712Z" }, - { url = "https://files.pythonhosted.org/packages/08/e6/0927784e6ffc340b6676befde1c60260bd51641c9c574b9298d791a9cda4/torchvision-0.25.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:846890161b825b38aa85fc37fb3ba5eea74e7091ff28bab378287111483b6443", size = 8089772, upload-time = "2026-01-21T16:27:14.048Z" }, - { url = "https://files.pythonhosted.org/packages/b6/37/e7ca4ec820d434c0f23f824eb29f0676a0c3e7a118f1514f5b949c3356da/torchvision-0.25.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f07f01d27375ad89d72aa2b3f2180f07da95dd9d2e4c758e015c0acb2da72977", size = 4425879, upload-time = "2026-01-21T16:27:12.579Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/c1cab1ecbb3ff1a380a3f99283db1dee61b8afe354f6352c643b65937130/torchvision-0.28.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:e9f54c30cd52e3ef7fd034cc69b7bb7e0964e1c8f8743e018ab92e95b40f9eee", size = 1856020, upload-time = "2026-07-08T16:07:52.182Z" }, + { url = "https://files.pythonhosted.org/packages/f0/4c/95233776e2def960e5abb7a07931230a545f43717a56a1e1140162033598/torchvision-0.28.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5cf78ebc401ce64ae19b8c55de866bb836797d559a4de9c25ccbe74cfa642d3a", size = 7842127, upload-time = "2026-07-08T16:07:53.446Z" }, + { url = "https://files.pythonhosted.org/packages/93/e4/e9b2495d0d57b9f60d63c57d0a910410a81b4b073bf70917bef815291119/torchvision-0.28.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:028a3d481b37d785605620d7cdad897064c5a55bae2aa1f2658766333e291940", size = 7675040, upload-time = "2026-07-08T16:07:58.017Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9c/55ed9cb6dfe3ee9c837df5cd0e758372e5829aa38b8dd71343aa632cc4e2/torchvision-0.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:87dc16b2df427c1318ad335f1e2be2b3b15b2cf20f7934c83b0505a48425ee5d", size = 4085785, upload-time = "2026-07-08T16:07:50.928Z" }, + { url = "https://files.pythonhosted.org/packages/20/55/08a726c14c67b37c8aca04b077766909f1c7ed23f76116884fe63b9bd033/torchvision-0.28.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:d483b4aa3f5237569053f749cd1a2b5bb548ca456e40461a5dd087f21149d123", size = 1856021, upload-time = "2026-07-08T16:07:45.386Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/40beacd53809194f5259e590d1afaeaa8ad57da15f77c646e6560bcc4616/torchvision-0.28.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:bb6dd6918460ed89cc7644adcc2402991474d6933cf1ce92b390641cb233fddf", size = 7797014, upload-time = "2026-07-08T16:07:43.04Z" }, + { url = "https://files.pythonhosted.org/packages/32/db/062cdb5a84380a60439775311fff34d89229760d2a50680393dc18699956/torchvision-0.28.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad7b3a439265cc3739a4ab5b4c998c0e38ea99c0ee7ca4dea35c5d0b099ec237", size = 7674669, upload-time = "2026-07-08T16:07:38.91Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a6/b4081e2d04e1541abf82785ac9e5178a494c19330391f551356c8c18b7b3/torchvision-0.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:7e9dd6f60d6e15f8dc27d4f877fdb6002fc70d70272412135f1c2ff9cfa08d3b", size = 4157380, upload-time = "2026-07-08T16:07:40.22Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b9/da40eca5bbe9596c12ae9899ab7abaf887f5e20f29d08b924b4633714821/torchvision-0.28.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3bd9dba55224a9db4a2d77f6feaa5651770d8c8e86d3d0ddb0fa6bec54c8712b", size = 1856014, upload-time = "2026-07-08T16:07:44.282Z" }, + { url = "https://files.pythonhosted.org/packages/06/d6/313aafd3df4eaf5f330211bd4e75b7598bddbfee4f55580d3b58536e1b20/torchvision-0.28.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:89f90e29b0966352811b12589f3a3c61943bf2bb9487b9d7bbec10efb1096bb5", size = 7796873, upload-time = "2026-07-08T16:07:30.907Z" }, + { url = "https://files.pythonhosted.org/packages/b3/41/31f8e959ab8f942600b6357f8999c21d779d5fd3304b0fd204ff4b518239/torchvision-0.28.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:36beb0782976906069ca03d4c9aacaf4b6b838b06ed6c20960ea9c51cce7acdd", size = 7674634, upload-time = "2026-07-08T16:07:29.657Z" }, + { url = "https://files.pythonhosted.org/packages/15/15/4c5115253fd470672cdac0a1cf139e06b4f3e29d041238a2b255937f63be/torchvision-0.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:3557cc7b539f46dabcda2b6f2b14017ccbeef024de466d4fc5835fc3f287f769", size = 4184005, upload-time = "2026-07-08T16:07:35.805Z" }, + { url = "https://files.pythonhosted.org/packages/6a/80/822a6163da716f8a78141cf6678d74e26a572285d4ea866ef8aa657bb307/torchvision-0.28.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:09ce8f56e81f19b9c378ae7bb109f83f6659fd8bc3cd14241a48e4af46e9ed49", size = 1856011, upload-time = "2026-07-08T16:07:33.404Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d1/cd3f9463b39a790ec8c0c2f6e6c8061edb1562114d04fcdfa786ed889345/torchvision-0.28.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:62c7d110f86a039245b587e4fae60278c649f3bd42ff79cfbc1178eca4e72542", size = 7796742, upload-time = "2026-07-08T16:07:28.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/3e0a7ad18e99831e2d7f4713d3be717b7159ff5a920862dd5c23c454aa71/torchvision-0.28.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:904cf89af220f8c6b2ed0296bb5065b474ce43b77558e48b2bf9de8b0ba17204", size = 7675526, upload-time = "2026-07-08T16:07:34.572Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/23aea03b28297bc66a4461f55ae4296368a9d85fa9a454bafcb2a5348bd7/torchvision-0.28.0-cp314-cp314t-win_amd64.whl", hash = "sha256:46f581979c010ad6da6bd85ee602aa707e1ff44312670223b7a0ee517ad06d47", size = 4291452, upload-time = "2026-07-08T16:07:32.236Z" }, ] [[package]] name = "tornado" -version = "6.5.7" +version = "6.5.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/d3/343e5bb989d6515b1646cf3d40135d73f3d5e45339bded401b56cdac24dd/tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f", size = 520493, upload-time = "2026-08-07T02:12:42.971Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, - { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, - { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, - { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, - { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, - { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, - { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d5/007086fd8df5489338e204f65adce33fd4f21a4999dbb2b9cff2f897b5f4/tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403", size = 449487, upload-time = "2026-08-07T02:12:28.682Z" }, + { url = "https://files.pythonhosted.org/packages/70/c8/5a24a99495903f594f6a199dd7beead1cbc0a13e2cb9102727bcaaf2a997/tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb", size = 447649, upload-time = "2026-08-07T02:12:30.306Z" }, + { url = "https://files.pythonhosted.org/packages/6e/de/f2e733f386b85962d1b1dc82cd63d169b5b4580062b35397eac9244a41fe/tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58", size = 450707, upload-time = "2026-08-07T02:12:31.95Z" }, + { url = "https://files.pythonhosted.org/packages/0b/94/20efeee9a01c141e9ac47c397f81679dfda24b32768fc4fff24e76d36c2c/tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808", size = 451677, upload-time = "2026-08-07T02:12:33.512Z" }, + { url = "https://files.pythonhosted.org/packages/42/ec/a96ccb8ccf0de2b7bc2c5fa1608a4803735018242e90c4882365a9fd418f/tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22", size = 451510, upload-time = "2026-08-07T02:12:35.346Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/93185859245ad3f00e62175f29607346788b696369347f0146e0421286bb/tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195", size = 450917, upload-time = "2026-08-07T02:12:36.963Z" }, + { url = "https://files.pythonhosted.org/packages/97/cf/fe33cf062834487d34d1559746a4a12521033c22645b6d74d4bca702e018/tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836", size = 451952, upload-time = "2026-08-07T02:12:38.512Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e1/468ad54333e92ccb62627e62cb88e5fc14a2171daa67ed47b1b8542d5b86/tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee", size = 452391, upload-time = "2026-08-07T02:12:39.971Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3e/cd5e4f06e34cde33b8ef66cf36aa2b5ad46354cc1af7d2136bbe365fee1d/tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26", size = 451411, upload-time = "2026-08-07T02:12:41.469Z" }, ] [[package]] @@ -5148,11 +5406,11 @@ wheels = [ [[package]] name = "traitlets" -version = "5.15.1" +version = "5.16.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/2e/a7fbfe268c8a3b32546930c0297c101d65a4a14c304ad5790a9f478f0e4e/traitlets-5.16.1.tar.gz", hash = "sha256:ed900c2b631aa3a112811139fa97b8d2c3bad5e989656bba4b7e52c7852c18c1", size = 166137, upload-time = "2026-08-03T08:32:36.848Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/0d785f0bc5e4315a96c989bb476d0fc07ea4f85132550c7b156ca2035d52/traitlets-5.16.1-py3-none-any.whl", hash = "sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b", size = 86211, upload-time = "2026-08-03T08:32:34.48Z" }, ] [[package]] @@ -5272,54 +5530,66 @@ wheels = [ [[package]] name = "triton" -version = "3.6.0" +version = "3.7.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" }, - { url = "https://files.pythonhosted.org/packages/f9/0b/37d991d8c130ce81a8728ae3c25b6e60935838e9be1b58791f5997b24a54/triton-3.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c7f76c6e72d2ef08df639e3d0d30729112f47a56b0c81672edc05ee5116ac9", size = 188289450, upload-time = "2026-01-20T16:00:49.136Z" }, - { url = "https://files.pythonhosted.org/packages/35/f8/9c66bfc55361ec6d0e4040a0337fb5924ceb23de4648b8a81ae9d33b2b38/triton-3.6.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d002e07d7180fd65e622134fbd980c9a3d4211fb85224b56a0a0efbd422ab72f", size = 188400296, upload-time = "2026-01-20T16:00:56.042Z" }, - { url = "https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a17a5d5985f0ac494ed8a8e54568f092f7057ef60e1b0fa09d3fd1512064e803", size = 188273063, upload-time = "2026-01-20T16:01:07.278Z" }, - { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" }, + { 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 = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, ] [[package]] name = "ty" -version = "0.0.64" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/aa/14c9965d3b173105692473897cc89c34cd91241368b2044e43167e1c17ff/ty-0.0.64.tar.gz", hash = "sha256:d12ddbb05f15158bb518af619378b385486450def95fb06f8ab98037febe9f2c", size = 6350966, upload-time = "2026-07-27T18:32:45.403Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/4c/c54937e4ff3fa7b34a99ea3387ec766bf0ad98dc8df8d792e89b388e658e/ty-0.0.64-py3-none-linux_armv6l.whl", hash = "sha256:3830a6675ab43635ced1c4c557f380ac4a49e9414e03975e4e4e8db644c64944", size = 12118357, upload-time = "2026-07-27T18:32:08.702Z" }, - { url = "https://files.pythonhosted.org/packages/ce/aa/a839ee2bc78e943d079e6abe199a97b4eeffb7e5c9a57326d69de452186a/ty-0.0.64-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3ff07d7bc32a2135f58afe57393789a32ea2fed1a66216129a8e535e76043903", size = 11790882, upload-time = "2026-07-27T18:32:10.997Z" }, - { url = "https://files.pythonhosted.org/packages/4c/de/19f14357888a7198438926303753cf749428e3d62e8980ff1e9a72a78402/ty-0.0.64-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4f6d1c7f897cca05d12bacbf1435150d5ffa496099515aa6ed303c8b29e1d0bb", size = 11317394, upload-time = "2026-07-27T18:32:13.162Z" }, - { url = "https://files.pythonhosted.org/packages/08/2f/f54462300535ab99b551eda733177be2eef5dbc2997d3fdb357c4ddd760a/ty-0.0.64-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:138d6c37ad4bf8583aa7a9b29d90954151d7856f9910a03eae3eb34b34c57215", size = 11863042, upload-time = "2026-07-27T18:32:15.307Z" }, - { url = "https://files.pythonhosted.org/packages/5e/95/dbecf745520ebe8bd7b02fc55eee6441c9be312ebf6addce605eb52740dd/ty-0.0.64-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ed9719d1b7b66fb8efe073d860208a44c40af1f6cd5c2364aa9b323a1e579b4", size = 11910730, upload-time = "2026-07-27T18:32:17.467Z" }, - { url = "https://files.pythonhosted.org/packages/3b/26/12cfd40028e51ceed7b3cb645281c61c02eb64ff9fb0c09231d65c30ff25/ty-0.0.64-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68b23e5169e2137b5f1de7169ab0cebecab6c8eda374c34c1f6394308f58242", size = 12631936, upload-time = "2026-07-27T18:32:19.533Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d0/65ffc2b0a686347193c6f98e9421a7fc2a96fc3cd0b1001cf7cf284baff9/ty-0.0.64-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f41cb07d89d32626fcaf3ed4d262778fcb28b2628b6ed1e172cd7b18820668d8", size = 13171049, upload-time = "2026-07-27T18:32:22.026Z" }, - { url = "https://files.pythonhosted.org/packages/f6/a4/975a5961842dcd6fa60f0770a102c0bba7509da909ab652919bfcdcd4fc7/ty-0.0.64-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5f24e1504ab9e212f92356b82fe088fdeb3a39f9a2f4ff25e505d2e1d0db9056", size = 12826438, upload-time = "2026-07-27T18:32:24.178Z" }, - { url = "https://files.pythonhosted.org/packages/af/ef/dfb9b7f9bcc032d3b540b0d1f55f532a336e2fb41b1bd539c05ae81a151a/ty-0.0.64-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86db830cb914bb33bb8247b66ccea58de4496c2391bd42658aba744b439f3290", size = 12440880, upload-time = "2026-07-27T18:32:26.341Z" }, - { url = "https://files.pythonhosted.org/packages/ee/d0/bedac20505e8a8f5501ad73d7d15d8e421a563fef59993909a23036929a4/ty-0.0.64-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:652ee3d6d03bea76cd2fe8949c78bb5970394ebd7c5fd270e9518c0dee1b931d", size = 12782439, upload-time = "2026-07-27T18:32:28.642Z" }, - { url = "https://files.pythonhosted.org/packages/79/d5/795733f13ceff1378f08b3de0c49d0f518df220ed856b0dfac869f3b7c81/ty-0.0.64-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4838768295774a86e95f9ec5633e739d016adbbb69dcbdc62f3549578a11f624", size = 11814821, upload-time = "2026-07-27T18:32:30.632Z" }, - { url = "https://files.pythonhosted.org/packages/52/3e/9d99cd1e1831003434f508ed9f258a56543194afc3bbe051eba2545fa676/ty-0.0.64-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5a3d700669868599edf39ce5125196682f15a8880cc8c669bc2a83b99984d99b", size = 11928678, upload-time = "2026-07-27T18:32:32.888Z" }, - { url = "https://files.pythonhosted.org/packages/b8/3d/448f49a3503fb119a34348a5714bb92001f252fadbbb12d645f2e744b557/ty-0.0.64-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b161f0a82a8e2f2432db3bf7702b4d3924fa9486ba0014f6710a160fc157df0d", size = 12202249, upload-time = "2026-07-27T18:32:34.905Z" }, - { url = "https://files.pythonhosted.org/packages/dd/9b/75768e562cec990d189dc05807ae72890b20ffbd1e1f43597bb98db63c60/ty-0.0.64-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:39b9dd42908df47c2dc57dda87e656fab97097ffd2618474bbdea986af0d6a9d", size = 12548817, upload-time = "2026-07-27T18:32:36.995Z" }, - { url = "https://files.pythonhosted.org/packages/34/89/44cc276ea6ca0245495014758ffeadde1798fb0b5840c9cf36d8d2ed3250/ty-0.0.64-py3-none-win32.whl", hash = "sha256:d0676ab0e0935795e5843baa28dd5e366dc343d7c0945a996df9eab8e0644885", size = 11545474, upload-time = "2026-07-27T18:32:39.389Z" }, - { url = "https://files.pythonhosted.org/packages/01/7e/d1c8a871a38d17c8f168b9a6975f6247f7660f8334e517656a5e4b4a4858/ty-0.0.64-py3-none-win_amd64.whl", hash = "sha256:dcb9bd31f54097e362b776c26ab4564d4564cdd1355cb883481167a26c03cc3f", size = 12542987, upload-time = "2026-07-27T18:32:41.412Z" }, - { url = "https://files.pythonhosted.org/packages/35/4d/6d18640d0204cacd69abbaca95ad6a34c6d7e9169e9051d0117b17b827ec/ty-0.0.64-py3-none-win_arm64.whl", hash = "sha256:82cc34c1ad9a8feb6059aef193bebcecc656e548f6fab3d518bcd8b57d198d39", size = 11899263, upload-time = "2026-07-27T18:32:43.366Z" }, +version = "0.0.75" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/d0/d0c96f898d6974a4a3569ab3efdf9512c04ad99f9203effb55f72497fe97/ty-0.0.75.tar.gz", hash = "sha256:4c5eead33dfbf6e2ebb4f400f74b51ffc9bab702a6f23ddb648a1cbb740387e3", size = 6868326, upload-time = "2026-08-26T20:23:40.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/6c/b12d03505f17581f0cfa3c12273fe34c1d67b36dfda1bc561a6bdc16512b/ty-0.0.75-py3-none-linux_armv6l.whl", hash = "sha256:e5409f50db2246fd4bd039d93d261e0cfa1daa554a4fb77256f91072c570349a", size = 12972606, upload-time = "2026-08-26T20:22:59.716Z" }, + { url = "https://files.pythonhosted.org/packages/d1/aa/30f11eecd9215a9f87e8fe8baaf48f3ce905f5d75b8e4aac70f0091f130c/ty-0.0.75-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5e7b8b3472fb9bb2eeab314984b265df08a7a9d518867a9e6020eebc06570be2", size = 12527158, upload-time = "2026-08-26T20:23:02.767Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/7fd7001b0b5c6610bfbad7357e47d5fe6f82d4e84e94c53776a478f5e9f8/ty-0.0.75-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c6ccf34169821fe0d23e3360deeef981d217963412f1d087b9bdd32ec57f7a57", size = 12400533, upload-time = "2026-08-26T20:23:04.965Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7f/1e284ea3d348d7be02f12d83bc22ed9ef193033f863f05b64db99027f141/ty-0.0.75-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:842ebb41e9c6c334b40768704e20b1a69d5c6b08805b289d5e0e2565f49f2de1", size = 12420592, upload-time = "2026-08-26T20:23:07.427Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ab/d813271543370c47fd74b5118f2066ab32b0983e907b1821f3f9a6d0fa7f/ty-0.0.75-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf7a5a723c5f1e0fab4ffbfe9bd95123a526ed48f206e5f25cb2161ca294007a", size = 12739219, upload-time = "2026-08-26T20:23:09.809Z" }, + { url = "https://files.pythonhosted.org/packages/31/5b/95b49cc5570fd92a7bf63732f649b31906158721e03c7fcb1b5be74ee3bf/ty-0.0.75-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54382f98e5da292fcd7104391afef5105c35bb2f312e29bea6f5fa419935255c", size = 13494046, upload-time = "2026-08-26T20:23:12.191Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0d/502d2dd68173cf020e1ad2bdbab9544c86776de0b0e2ed15f8c2fe006e3d/ty-0.0.75-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac13b180dc2aade2cd243f56b01650e78bf091a2e522ad3bc947245d7837c613", size = 13938899, upload-time = "2026-08-26T20:23:14.764Z" }, + { url = "https://files.pythonhosted.org/packages/20/5b/f3b12a25c07224456219fc2bd20db0ad7e40b304be0ff6aad728da0135f9/ty-0.0.75-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:752df7951a443219d7f1ff817e3723c85d428565ff449e08a7a93ba821661526", size = 13656711, upload-time = "2026-08-26T20:23:17.145Z" }, + { url = "https://files.pythonhosted.org/packages/51/7b/f090ad306e2b15a07b332d647138c5264b89d9758855ecce8b8a10bcb153/ty-0.0.75-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fd399feedf7cee816563c1baec45fc1c0b3c89f1ea42364920b688004b5b7da", size = 13093499, upload-time = "2026-08-26T20:23:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4b/f69b99aaaca0c7c65d5f114b186b26b21666f767b0c69eec99a2bdccc061/ty-0.0.75-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d7625f6f56c7dc1e873579fdc9e432a0e21e302afe847ab60704d2303442a92e", size = 13520580, upload-time = "2026-08-26T20:23:21.789Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e7/692c5f905c0345a15d2255fc74066d660f030254ae8dcdaf33f5a5c2f279/ty-0.0.75-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:89e7d527e95a2534b70cae29e94c104b84082760ea05927d23bb87280969c104", size = 12524095, upload-time = "2026-08-26T20:23:24.026Z" }, + { url = "https://files.pythonhosted.org/packages/ba/9a/f42b12cf265ea95344bf554764c4791cfb273bdd628aadd7c209af7cadc3/ty-0.0.75-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0843f134440740706e01bee5f88f4cfc10e9b018bddb9e4ef4c12dc9fc0c9aef", size = 12756591, upload-time = "2026-08-26T20:23:26.126Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5e/9b180c133cb9cce48179a7d2bf9e1802d992aa8176a918e0e05205760b42/ty-0.0.75-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1bd0ec0e50ee1376875c88891efe6f549c3560fa5b2ddad79a425cd5a6218b9c", size = 12998754, upload-time = "2026-08-26T20:23:28.353Z" }, + { url = "https://files.pythonhosted.org/packages/39/f6/3c6ef5dd550103e29905121c67fb96a374564f31a2f44c6faa1af98c2d61/ty-0.0.75-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1f9eafd561f90110d5e29f589ec3e956c4686e2f6631348d99276436f5cbe4d1", size = 13316474, upload-time = "2026-08-26T20:23:30.857Z" }, + { url = "https://files.pythonhosted.org/packages/bb/52/12776337874c821076bd5368e352ccd9e67174790abe3b856f749cb3524b/ty-0.0.75-py3-none-win32.whl", hash = "sha256:05063a6fafe2154b794a7f964515d148e51acd186d72d4a3acd347ee9fa19336", size = 12316315, upload-time = "2026-08-26T20:23:33.528Z" }, + { url = "https://files.pythonhosted.org/packages/53/e6/bb51e16af5c7138c9f52f8f3d0a401a371c6798d092e3b74926f186a9814/ty-0.0.75-py3-none-win_amd64.whl", hash = "sha256:81cf1ba5f6b7536ad56747865214255d9bc8e80533a689dbb9ddeaad464b09f1", size = 12917267, upload-time = "2026-08-26T20:23:35.978Z" }, + { url = "https://files.pythonhosted.org/packages/39/73/4542f829107468b5de4231af67f29927c093bfad11f3c1e5b2c08fb1206b/ty-0.0.75-py3-none-win_arm64.whl", hash = "sha256:541c9af5b7a0ad23d15ec315a7da81150833c359f48124ed3789ff25eacd6f42", size = 12711024, upload-time = "2026-08-26T20:23:38.159Z" }, ] [[package]] name = "typer" -version = "0.24.2" +version = "0.26.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, - { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/b8/9ebb531b6c2d377af08ac6746a5df3425b21853a5d2260876919b58a2a4a/typer-0.24.2.tar.gz", hash = "sha256:ec070dcfca1408e85ee203c6365001e818c3b7fffe686fd07ff2d68095ca0480", size = 119849, upload-time = "2026-04-22T17:45:34.413Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/d1/9484b497e0a0410b901c12b8251c3e746e1e863f7d28419ffe06f7892fda/typer-0.24.2-py3-none-any.whl", hash = "sha256:b618bc3d721f9a8d30f3e05565be26416d06e9bcc29d49bc491dc26aba674fa8", size = 55977, upload-time = "2026-04-22T17:45:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, ] [[package]] @@ -5333,14 +5603,14 @@ wheels = [ [[package]] name = "typing-inspection" -version = "0.4.2" +version = "0.4.4" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] [[package]] @@ -5446,15 +5716,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.51.0" +version = "0.52.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, + { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, ] [package.optional-dependencies] @@ -5501,7 +5771,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.7.0" +version = "21.7.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -5509,9 +5779,9 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/25/e367a7229b0914772ca8d81b41fde012d9feda68523b52644a571bb21ce8/virtualenv-21.7.0.tar.gz", hash = "sha256:7f9519b9432ff11b6e1a3e94061664efc2ff99ea21780e3cf4f6bd0a5da8b37c", size = 5527510, upload-time = "2026-07-21T13:12:14.109Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/41/c3f34799487924f2a6f43b8a8b7acd345a6c61aac2211d4bced8621ca4f1/virtualenv-21.7.7.tar.gz", hash = "sha256:6874376f99ba6b8d4e3ee8bde67f9285412400c7d5b29ba41ee6daa5e0221bdc", size = 5347022, upload-time = "2026-08-28T18:59:50.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/7a/ae29312b1e88a22e81f5d21fc11526d2a114089776c2550d2b205b6c2a47/virtualenv-21.7.0-py3-none-any.whl", hash = "sha256:a8370c1c5530fbabf955e40b8fbbc68a431648b10f9433faa587db30a06e51dd", size = 5507078, upload-time = "2026-07-21T13:12:12.136Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/dcec8dc767b812193316c7debed1e2b53e586e59c267bfe517688ff90275/virtualenv-21.7.7-py3-none-any.whl", hash = "sha256:67a6a68fef3ad8ca16b8b89f33fd8f97996cc0bf0db31629d07ecf8dec539a2c", size = 5324620, upload-time = "2026-08-28T18:59:48.056Z" }, ] [[package]] @@ -5626,51 +5896,97 @@ wheels = [ [[package]] name = "wcwidth" -version = "0.8.2" +version = "0.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/57/ed58088fafdf4c55a0ad6bde846502567645424d7ebf325230b9237f4085/wcwidth-0.8.3.tar.gz", hash = "sha256:d128512515fbf4612e0ff21fd6380399210318b7b54a9af59dff8454cf9730eb", size = 1458450, upload-time = "2026-08-28T18:10:06.875Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0e/57f6bb3024a597b2e8ec4aee710ffe62ddc95af2e2bb1ee7a7abdc22c68c/wcwidth-0.8.3-py3-none-any.whl", hash = "sha256:d5b73dba6158a595ec9370350e7f2637bcac8d6c5e4fde34f30fcffb6103a5e4", size = 331669, upload-time = "2026-08-28T18:10:04.909Z" }, ] [[package]] name = "webencodings" -version = "0.5.1" +version = "0.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/a0/8fd707bcb776a7be556bad06a2ea5fb9bd519df78ef8e26f70ccf0f38bff/webencodings-0.6.1.tar.gz", hash = "sha256:565f9ad031c702dae404e27a099e3e09186a3ab1b9520f06d215502b651fd910", size = 15001, upload-time = "2026-08-15T14:22:57.549Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl", hash = "sha256:7fab6269c8bf237c657876b52058ccb182e861518d1c695c1a9aaa8c1c105d5b", size = 8745, upload-time = "2026-08-15T14:22:56.31Z" }, ] [[package]] name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +version = "16.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, + { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, ] [[package]] @@ -5693,115 +6009,148 @@ wheels = [ [[package]] name = "xxhash" -version = "3.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/63/71aa56b151a1b28770037a61bd4e461c2619cfc8866a4fcaf1548605e325/xxhash-3.8.1.tar.gz", hash = "sha256:b0de4bf3aa66363552d52c6a89003c479911f12098cd48a53d44a0f7a25f7c46", size = 86223, upload-time = "2026-07-06T10:49:58.937Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/91/f65c34a7aa7b4e7cf4854f8e6ef3f7ee32ceac41d4f008da0780db0612f6/xxhash-3.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e6e49370822c1f4d8d90e678b06dbcb08b51a026a7c4b55479e7d467f2e813bc", size = 34680, upload-time = "2026-07-06T10:44:40.932Z" }, - { url = "https://files.pythonhosted.org/packages/57/04/b10a245a4c09a9cfa88f8e9ae755029413ad1ac17047f9a61906e5ae0799/xxhash-3.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:220d68130f83f7cc86d6edfdeab176adc73d7200bf3a8ec10c629e8cf605c215", size = 32397, upload-time = "2026-07-06T10:44:42.196Z" }, - { url = "https://files.pythonhosted.org/packages/3a/75/45ab795b5945b6388583bd75202106af505537935566c15a1577797a0e08/xxhash-3.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d365ee1892c1fa803536f8c6ce21d24b29c9718ec75eb856095c07830f8c478", size = 220549, upload-time = "2026-07-06T10:44:43.603Z" }, - { url = "https://files.pythonhosted.org/packages/13/44/5ba2bd0a14ddf4193fc7d8ec29625f659f22c06d60b28f04bf46305d8330/xxhash-3.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:852bfe059720632e2f16a6a4745e41d20937b2bf2a42a401e2412046bb6971cc", size = 241186, upload-time = "2026-07-06T10:44:45.534Z" }, - { url = "https://files.pythonhosted.org/packages/23/32/c4147def4d1e4538b906f82731e0ba23424377fc50a7cddd03cd284c8f63/xxhash-3.8.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2f8c25a7061d952de589bd0ea0eaadee32378ff83dd6a677b267f9cd86f401f8", size = 264852, upload-time = "2026-07-06T10:44:47.199Z" }, - { url = "https://files.pythonhosted.org/packages/6c/bd/71ed14f4f0318bb7fd7b2ec51999413487fa8da8d41208e84d50d1ef0f98/xxhash-3.8.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:868a8dcaff1a84ba78038e1cef14fc88ccf84d9b4d12ea604696e0693296aa56", size = 242663, upload-time = "2026-07-06T10:44:48.846Z" }, - { url = "https://files.pythonhosted.org/packages/91/09/70af22c565a8473b3f2ae73f88e7721af281bc4a575236dbd1970c9f76f6/xxhash-3.8.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6536d8677d2fff7e64cd0b98b976df9de7aee0e69590044c2af5f51b76b7a170", size = 473510, upload-time = "2026-07-06T10:44:50.695Z" }, - { url = "https://files.pythonhosted.org/packages/18/96/34db781c8f0cf99c544ca1f2bc2e5bf55426e1eb4ca6de8ea5da56a9f352/xxhash-3.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82c0cedd280eab2e8291270e6c04894dbc096f8159a39dcf1807429f026ca3cc", size = 220469, upload-time = "2026-07-06T10:44:52.422Z" }, - { url = "https://files.pythonhosted.org/packages/93/5f/9a184f615fa5a4dce30c01534f62946ce5a11ce40f73785cbd356ccabaa9/xxhash-3.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daa86e4b68221d38e669bb236ba112d0335353829fb627c82e5909e4bbe8694c", size = 310290, upload-time = "2026-07-06T10:44:54.142Z" }, - { url = "https://files.pythonhosted.org/packages/a9/dc/9b9a9789011ee153723a5eb9e7dd7fcbae2ba9b3fe7a729249ca7c252056/xxhash-3.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2bc7113e6f2b6b3922dd61796ca9f36af09da3773898e7003038dc992fc83b8d", size = 238173, upload-time = "2026-07-06T10:44:55.693Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4d/71c6005ada9dcb608a4e1902e8475ecadb5f3fbfa04e1e244d276a2d0c43/xxhash-3.8.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5eed32dad81d6ba8e62dc7b9ffa0500199385d7810a8dd9d4eafaceb8c6e20bb", size = 269026, upload-time = "2026-07-06T10:44:57.424Z" }, - { url = "https://files.pythonhosted.org/packages/2f/87/d6c036ba25dfbd9c8633be5aa86fc9474bbb9e2c68212a841d090abe7344/xxhash-3.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:83697b0ea1f10e7f5d8b26a4906fa851393c61546c63839643a2b7fe2d868061", size = 224970, upload-time = "2026-07-06T10:44:59.085Z" }, - { url = "https://files.pythonhosted.org/packages/48/62/4c1f035a41c5752aa05e195b6c904c07b94fe9061a16de61e72a6e6b135f/xxhash-3.8.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:36fc69160465ae75c6ec4ac9f781bb2aa16ae7ff869e73c26fee85fbb11b9887", size = 240820, upload-time = "2026-07-06T10:45:00.746Z" }, - { url = "https://files.pythonhosted.org/packages/da/14/d39d565069b87e86d21a2af2a31d04db79249d25aa8d5b62959056a89857/xxhash-3.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:445e0f5a31f2f3546ae0895d4811e159518cdc9d824c11419898d40cfadb677e", size = 300619, upload-time = "2026-07-06T10:45:02.716Z" }, - { url = "https://files.pythonhosted.org/packages/13/22/75467acc887edc8cf71c97ab1708feb3df7a88bda589b9f399765c6387d2/xxhash-3.8.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:dfe0580fbfd5e4af87d0cc52d2044f155d55ebd8c8a93568758a2ea7d8e15975", size = 443267, upload-time = "2026-07-06T10:45:04.653Z" }, - { url = "https://files.pythonhosted.org/packages/a4/b6/1da3baa5fa6ef705e3425fddd382be7dfc4dfba2686df90a20f16e9c7b1b/xxhash-3.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:095e1323fa108be1292c54c86da3ef3c7a7dc015b105a52133973bc07a6ad11a", size = 217338, upload-time = "2026-07-06T10:45:06.304Z" }, - { url = "https://files.pythonhosted.org/packages/78/dd/b5295a9f97484e7a1c2b283a742ca45e3104991c55a1ef670dde161829ba/xxhash-3.8.1-cp312-cp312-win32.whl", hash = "sha256:bf28f55e427e0483acb1f666bd0d869b6d5e5a716680c216ad7befe3d4cfba2e", size = 31970, upload-time = "2026-07-06T10:45:07.823Z" }, - { url = "https://files.pythonhosted.org/packages/ec/31/3fa0b807d7e21515cd975e7fe5c039d52ac3e9401a96d6ad68dae6305215/xxhash-3.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:2256e80e4960ee282f63428adb349cb7f8bd8efe4db770d88eb815f4b9860724", size = 32741, upload-time = "2026-07-06T10:45:09.42Z" }, - { url = "https://files.pythonhosted.org/packages/b8/05/86feada74e239600e6875aa507afb40482a89b92700aa74a92da83bdcb77/xxhash-3.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:9df56e6df96a60590935e22373041cccc91fd55858763dcffb55bf63b3a2b396", size = 29234, upload-time = "2026-07-06T10:45:10.809Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8c/446bb782cd0d27007a917b5569a08dd73219c3e8d6e459014db104b27bdb/xxhash-3.8.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:3c682fcd96eb4bf64be32a4d95f96107e1588005831bd8a741b324fdda01b913", size = 38562, upload-time = "2026-07-06T10:45:12.425Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ec/c0c45627eaa6be7a5d6117423adf8f7a15b17ee74b4b17072cca5959a225/xxhash-3.8.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:036a024d8b9c01f70782e09ed98d532e76fd23f950ae7154bd950fe94e90ebec", size = 36656, upload-time = "2026-07-06T10:45:13.932Z" }, - { url = "https://files.pythonhosted.org/packages/f6/94/8324c04cc7597154caaeba6c094e01fbd2e7601d01e7a13eea9f5420e77b/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d6a5c0bce213b23b0166fe0d35bcbbe23ce4b968f257cc7eb6fd57cb8e1e6297", size = 31169, upload-time = "2026-07-06T10:45:15.687Z" }, - { url = "https://files.pythonhosted.org/packages/40/a4/beb6bb26e1184e126dbe7a5682330214ef54dcfbf882078aa9f4b5428d42/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5177aa44eddaa97c6ef0cc00c6d540edb64d51781d2f8fb941612ec61a92c9ed", size = 32177, upload-time = "2026-07-06T10:45:17.035Z" }, - { url = "https://files.pythonhosted.org/packages/56/0f/fc4c92a5a528f839b34b6419b2e53c8597f2a629d5a1f5d721f65bfa1fd6/xxhash-3.8.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7801b7223db017b9c0c9ccf37e44524edb35a1544a1c032add22c061c6af0276", size = 34642, upload-time = "2026-07-06T10:45:18.39Z" }, - { url = "https://files.pythonhosted.org/packages/d4/58/edbfb141d4000767ac6a9694f8ac0763e2c2e983e65c9e31620ba56e2667/xxhash-3.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e80238259655bf69d7bcd08226a970d7f42605f3157786bfa76dd13472d7fa0", size = 34684, upload-time = "2026-07-06T10:45:20.033Z" }, - { url = "https://files.pythonhosted.org/packages/07/3f/5072f1f0f5714186f0ac2a0b5a4929ce30d4b845e94886b6c01b6ebda0be/xxhash-3.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bcab50a389cc04d87f90092af78a6adba2ab3deca63175a3344ca83514045315", size = 32401, upload-time = "2026-07-06T10:45:21.414Z" }, - { url = "https://files.pythonhosted.org/packages/49/c7/802ea2f9c2ed59219934d6d65c470d502b1788043eae277a52af8658bda6/xxhash-3.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2489d3a776fa380cb8e71f54c7fda268a9baf3de9b1395093fd280f95735907", size = 220617, upload-time = "2026-07-06T10:45:23.234Z" }, - { url = "https://files.pythonhosted.org/packages/99/a8/e10488efd31fcb13fcd6acbc6e788f10c6f8e3a0cc4ae3eb89dc19c55a12/xxhash-3.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32ab1e5432690276e71192be7401b55f96db2d0eedea5d44eb1f164505669cc0", size = 241295, upload-time = "2026-07-06T10:45:25.364Z" }, - { url = "https://files.pythonhosted.org/packages/18/cc/14180b17d44892a631f8ae7323c30bfbb1328efc8209e528a480293528ac/xxhash-3.8.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b30e01a0b97a4bc3f519a4d7a82da3dc53251fb0de5eeea8660dcd4ff094c0c2", size = 264688, upload-time = "2026-07-06T10:45:27.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/72/a14019d0c5f6c41ee407a503036ae32787c91325ca218a96a9b5627be651/xxhash-3.8.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f44275ddb0978b67a58a951501903f04d49335a91f7681c9ce122ecb8ccb329", size = 242740, upload-time = "2026-07-06T10:45:28.753Z" }, - { url = "https://files.pythonhosted.org/packages/68/08/92550e556c6fcfcb96c6a336945eb53a431ed43120ed749636debb16c5cf/xxhash-3.8.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3b87cbd974512c0c5fc7b469c36b2cdc9ee6d76e4ec78bccb2c7184611c49b0", size = 473599, upload-time = "2026-07-06T10:45:30.524Z" }, - { url = "https://files.pythonhosted.org/packages/29/83/e361d3c1acd1b21e1d489616de6fa4aaf843365d8179f612e3743eac20a9/xxhash-3.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98ee81b4b7f3023c9cb04a78cc67610baffcb5812d92f2096cb5a5efc6f19437", size = 220559, upload-time = "2026-07-06T10:45:32.979Z" }, - { url = "https://files.pythonhosted.org/packages/05/01/006a4243c2c2a6831827f9999f6d1c23feeef100eb023c1f886022a00bf3/xxhash-3.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2666f059a1588a99267e33605365ed89cea92f424b3522806a9f4bd8ad2e3d62", size = 310383, upload-time = "2026-07-06T10:45:35.875Z" }, - { url = "https://files.pythonhosted.org/packages/d8/20/af388e8bf9f9a0f89eeef7d2a1935d176ee1c20bc6adeda05035879379cf/xxhash-3.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0093cf7eeb91b84776e8742113afa4bdf47533d36cf719179aaaf1f56f6f8bf", size = 238228, upload-time = "2026-07-06T10:45:38.02Z" }, - { url = "https://files.pythonhosted.org/packages/63/6b/4666579a87eebd1744663c404297355fa0658617b015cedfa58810ee7036/xxhash-3.8.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3a800912a2e5e975d4128969d645c4a2a80aa886ccd6c9b1c6f44529e327e8cf", size = 269137, upload-time = "2026-07-06T10:45:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/de/d3/e963a8a46f900a137d91b02144d8ea07a8f812971b138204a3b2f8b8e55c/xxhash-3.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0fe37f72a207223d22a4eddc3149d4298993385aa9daef25c039246ca5a309f3", size = 225068, upload-time = "2026-07-06T10:45:41.718Z" }, - { url = "https://files.pythonhosted.org/packages/aa/80/9d181dbcde4b0fe48375f48833a5832d4b8cd2b349b15110c92ee472d874/xxhash-3.8.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5db43f249b4be9f99ef4b967863f37094fb40e67effafb78ba4f0356b6396104", size = 240874, upload-time = "2026-07-06T10:45:43.414Z" }, - { url = "https://files.pythonhosted.org/packages/39/15/ce3ab5a1cd27ead25a5196e55a7284220f6ad6e316da494ffd900b2b600f/xxhash-3.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c4ed42965c2cd9081f011be22f69d0e65d3b6165fe7734072fd0c232840bbd4e", size = 300702, upload-time = "2026-07-06T10:45:45.135Z" }, - { url = "https://files.pythonhosted.org/packages/96/c0/2281a8ab5f2a62dbf57a23c58a01ccc1d98abf40f71193c8a81f59e759b5/xxhash-3.8.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3557bec8fcb11738a8920eeb68974bc76b75262f6947998d3147954ce0a4b893", size = 443351, upload-time = "2026-07-06T10:45:47.188Z" }, - { url = "https://files.pythonhosted.org/packages/81/2e/071a58c1a53a52d4f7a3aa0987be0c396dffd40da8204805fe1b130a81f4/xxhash-3.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00de40f3b42240db23a82a5c682b55d7263d84a26a953240c1aee463409660e3", size = 217396, upload-time = "2026-07-06T10:45:48.925Z" }, - { url = "https://files.pythonhosted.org/packages/68/44/36ab58134badd9d3433fc7b53c4ca8d113d8e807782885628640f8297a4d/xxhash-3.8.1-cp313-cp313-win32.whl", hash = "sha256:b5196cc2574cfec572a5f3fb7cfa5ade27305ae3d06516a082132441aff4c83a", size = 31974, upload-time = "2026-07-06T10:45:50.591Z" }, - { url = "https://files.pythonhosted.org/packages/96/2a/2a0b84798448e766f7b89ceed073cb0cb5a43fc9ebbacbdea74a38de18e3/xxhash-3.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:538f5f865df6cd8c32dd63158a0e5b4f5dd08d732a7da8b7228a5a0776c8ce55", size = 32739, upload-time = "2026-07-06T10:45:52.221Z" }, - { url = "https://files.pythonhosted.org/packages/d4/60/bb51dbf7c363ff88a7cbd50b7959718219577ef44d7cf255929ffc4a2194/xxhash-3.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:a6617f30641ba0d8baa1635fbefb1dffc5165ec36d26921bd5cee13497cd937a", size = 29239, upload-time = "2026-07-06T10:45:53.714Z" }, - { url = "https://files.pythonhosted.org/packages/56/d3/827ca123c2ee5443a6aaed3c5dd199237dc2f010e2bebd7ec09ef36f3a5f/xxhash-3.8.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:bfcd82852c62a60e314670a9602de354c4460f8adad916e2e42a20860c7870bc", size = 34964, upload-time = "2026-07-06T10:45:55.535Z" }, - { url = "https://files.pythonhosted.org/packages/05/67/67ae2a3ccdeb8b8ef025d35aee9edd1d26c3abe5051d47da9286232afbf8/xxhash-3.8.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:08ea2081f5e88615fec8622a9f87fbe21b8ea58d88cfc02163ca11026ee62a92", size = 32697, upload-time = "2026-07-06T10:45:57.288Z" }, - { url = "https://files.pythonhosted.org/packages/38/5a/3d3994346e1f45493679cb5c1ffc2bf454e410e9d1e8a662d253becee91e/xxhash-3.8.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2e32855b6f9e5b18f449e59d45e3d5778bdeb660632ef2693cca267a11246c75", size = 225954, upload-time = "2026-07-06T10:45:58.897Z" }, - { url = "https://files.pythonhosted.org/packages/3f/2c/53169270309b7cd8e05504e07fe123bac053b89d00ac63617faacf0a2ec0/xxhash-3.8.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6e088bd7870775624256a0d84c2a6714afd223b2eeb56b0ca58398e52a32fda", size = 249776, upload-time = "2026-07-06T10:46:00.977Z" }, - { url = "https://files.pythonhosted.org/packages/70/e0/5c551d8d592f944506f7c5185e210255c15e672a3c6008c156a1bd9b775e/xxhash-3.8.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:72eb5ae575cc7ae2b23f6f8064a8b10f638c7149819ae9cc6d20ebd4d37a1629", size = 274776, upload-time = "2026-07-06T10:46:02.869Z" }, - { url = "https://files.pythonhosted.org/packages/a0/2a/d3a762270cee2d7bcd0e25e28c623e5f3f5c0dc637b66e3e47dd5b0bb3f0/xxhash-3.8.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d0b48cdf690a64cedf7258c3dc9506cc41fc86edd7739c40e3098952265dc068", size = 252056, upload-time = "2026-07-06T10:46:04.688Z" }, - { url = "https://files.pythonhosted.org/packages/c1/8f/b78e4373b2cb6d1c42af60ea2d7e9146ad0710b239ac7f706d5d31d5bb98/xxhash-3.8.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb9e256a357dfcede7818c6d34e70db2d6b664394803d1de4b6984d2de76c0f1", size = 482108, upload-time = "2026-07-06T10:46:06.498Z" }, - { url = "https://files.pythonhosted.org/packages/e6/0d/642d923336ea61a15f8ce64fc7e078729e6e06c3a026e517fa79b2c23b7a/xxhash-3.8.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51f71a6e2ad071e70c937e41fcb6c19f82c3f9f49831eba850ed4a106ffbb647", size = 226739, upload-time = "2026-07-06T10:46:08.598Z" }, - { url = "https://files.pythonhosted.org/packages/a6/0a/a37d6da6427d45a8d23e3ee3a0ca9c9d4a90364849c6637fe2963a755f9b/xxhash-3.8.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4a6443968c4e8dc69967e12776776a5952c119cc1bd94168ad1c5ad667c2be1", size = 319658, upload-time = "2026-07-06T10:46:10.504Z" }, - { url = "https://files.pythonhosted.org/packages/4a/51/ebbd40da8a3f1bc53b4b7a9a87f8e28bd95c5f21bc14b8a57860cf367d1b/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:714503083a1f2065c9ad15340dd49ac8a8e948a505a705ffa1750cb951519113", size = 246059, upload-time = "2026-07-06T10:46:12.634Z" }, - { url = "https://files.pythonhosted.org/packages/24/4c/d9014030147e1f0bb26e7da47aa240dd9ec61c763c573e558111d869f8e1/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:77f74e45a1e5574bbbf80181c8027b3a4c65c2248fffbd557bd596fff13102f9", size = 275535, upload-time = "2026-07-06T10:46:14.614Z" }, - { url = "https://files.pythonhosted.org/packages/84/86/caee2db41fadcd5a25aa4323213f9afec5a8586d4e419241e3d659362bd7/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e0e1b0fb0259c1b75d1251ac0bb4d7ab675d36f7a6bf4ba6aa630dae94f9ffa", size = 231292, upload-time = "2026-07-06T10:46:16.452Z" }, - { url = "https://files.pythonhosted.org/packages/0b/60/f52f08bcdc904c4514ea5c25caa19e9f3214144434a6ff96dc82dc1cbddd/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:10e4393ec33633c2f05ad01869e546ad080b1a18f2650503731f153774608b31", size = 250490, upload-time = "2026-07-06T10:46:18.318Z" }, - { url = "https://files.pythonhosted.org/packages/24/a0/94dc7ae310838f250669c6ad7168e6d6fca17d49dac1053f06dc232c4a56/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b3ba794c3d885803db6c3116686923f1ec13bc86e621e169a375282b63ea1cc6", size = 309861, upload-time = "2026-07-06T10:46:20.503Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f9/adeead7d0eb28cdfc2832544ea639ffbc6749ccde47a8e228d667459182e/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:57189a69c0891e4818853feaa521c972d22c880a001453addea015f48e3c3398", size = 448739, upload-time = "2026-07-06T10:46:22.79Z" }, - { url = "https://files.pythonhosted.org/packages/04/a4/22ec0e07db57d901c9298ae98aa3cf2be45bafded6f07c13131e85b89032/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d59e71153fe9ff85648d00e18649b07e9b22c797291abb7e27274fa06df8b838", size = 223657, upload-time = "2026-07-06T10:46:24.831Z" }, - { url = "https://files.pythonhosted.org/packages/94/32/8a9531f37b59e5a013003db7cb7414baf4ce7e0e1268e0d5947cd3d6a2df/xxhash-3.8.1-cp313-cp313t-win32.whl", hash = "sha256:5b96f0024e9840f449bd91b2d005c921a4b666055a0d1b6492463799f32aae22", size = 32377, upload-time = "2026-07-06T10:46:26.86Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ab/2ca45fd7f671de5f81fc297ef1c95080b40c86ec6be0cc6034b8f7707ac8/xxhash-3.8.1-cp313-cp313t-win_amd64.whl", hash = "sha256:37d5a56c36dcc0b9a87b814cd992598d33863ff683749de6c86081f278d5e629", size = 33274, upload-time = "2026-07-06T10:46:28.39Z" }, - { url = "https://files.pythonhosted.org/packages/5a/54/20d7163463ddb6438b73a427d1655a77a502cf9b9b0c3ada3599629d9c0a/xxhash-3.8.1-cp313-cp313t-win_arm64.whl", hash = "sha256:6696c8752aded28ff3b16f33ef28ce28fb5d209b80c206746f943199fcf5fd65", size = 29375, upload-time = "2026-07-06T10:46:29.962Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8b/df2ba04f22a6cd6b39f96a6577329a8471a55c90ef8d8e2f7c102363613f/xxhash-3.8.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:9db455cb649dcfe4504d6d68a6d83a7315a99a3ca59871dc3ff840671f99adba", size = 38430, upload-time = "2026-07-06T10:46:31.496Z" }, - { url = "https://files.pythonhosted.org/packages/b2/4f/6a059e8ad3ca8deedc91dfe335b211204900895152212c03ebbe721de68b/xxhash-3.8.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:affb37f152e55b5e4494bb9d0107f7bb08515c6704fbed82d9f61214d74adc17", size = 36558, upload-time = "2026-07-06T10:46:33.078Z" }, - { url = "https://files.pythonhosted.org/packages/cb/95/40be178205acce092ae418feb20ac737b32a02c7b864926ed0717354c9f8/xxhash-3.8.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:460261045936975193bfd20549a0de1cd52a33b405cbb972f0d80940c42266cd", size = 31181, upload-time = "2026-07-06T10:46:34.793Z" }, - { url = "https://files.pythonhosted.org/packages/3f/89/2da4dbf051bafa156c0e3f12012db2b0ac3b84ff37ca1f021f6bfffcdfbb/xxhash-3.8.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:38c887aedb696ef8bca19983206d270848558cfae4a91afa6a2fb05dde58ffc5", size = 32192, upload-time = "2026-07-06T10:46:36.393Z" }, - { url = "https://files.pythonhosted.org/packages/7c/4e/e000bbae3566bc8e0be771a8a0f294aa99075e3f0bc4ef43922ebffdebc8/xxhash-3.8.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:594131ce1aad18db3689781f806db1b065cdaa04f4df36b4c038d2013aefd0bf", size = 34691, upload-time = "2026-07-06T10:46:38.1Z" }, - { url = "https://files.pythonhosted.org/packages/b4/4a/ea954aacc7d1c8711880ac2b55da94429a9b4296b151c4fc0966549ca1ee/xxhash-3.8.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:78c794b643d214f1522e7a288bcf5a2de120d26cd170516749a4009dc92722c9", size = 34807, upload-time = "2026-07-06T10:46:39.647Z" }, - { url = "https://files.pythonhosted.org/packages/ca/29/df598e738ff37558ac627264deb2e560902d9bf7f46d3bd5175c9eee593e/xxhash-3.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af0c9fedc4a2c24e8664953882fe8185f3790b8338c9c700f76f5ad660817711", size = 32410, upload-time = "2026-07-06T10:46:41.359Z" }, - { url = "https://files.pythonhosted.org/packages/59/9c/81ab40e7d33ada0b3df5d1bc884894d15dbf4f805cd645b685e4606bb8e0/xxhash-3.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:115772daeb71b2f3b9381177017f53e6cf3f3439c840737fdabd21aba6e54920", size = 220564, upload-time = "2026-07-06T10:46:43.463Z" }, - { url = "https://files.pythonhosted.org/packages/fd/6f/62ae6f5c8606320a0e2a41c2dc8c6d91cc5d63d0f84dd9582e9543779dd8/xxhash-3.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:000435984a0469b0f822fe76f35bddea0f96a4d6521b3339a60a6428cdee1edc", size = 241462, upload-time = "2026-07-06T10:46:45.509Z" }, - { url = "https://files.pythonhosted.org/packages/15/a1/9c3a0ec6cb524396f551eddd102a76690a795494eb9784fc67542b0daa37/xxhash-3.8.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2f1c68394818e0595569c2ff3cbc1e6d5a36a434e796f5c526b987b80c8a8c62", size = 264491, upload-time = "2026-07-06T10:46:47.655Z" }, - { url = "https://files.pythonhosted.org/packages/64/f2/700a4674e4308eb59d2fdb973977e82eae231bea5044753fee5c9eec0e0c/xxhash-3.8.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46b39976d008e2a845758650f0ff7136bca004f40da0c8798bd37ac37860154f", size = 242905, upload-time = "2026-07-06T10:46:49.857Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8a/72d9874375c8d4cbc64a8cd1d659d5695a8765c3db82efa82dc5bd9f14d0/xxhash-3.8.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d5006c65ec507a333479e76e00e2c368781f16c24ededa764763956b32a0e93e", size = 473873, upload-time = "2026-07-06T10:46:51.953Z" }, - { url = "https://files.pythonhosted.org/packages/03/f0/6db07590ed7e0a77f186ef0bcea8d52553bf1ba57833e09467a2411f0f2d/xxhash-3.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31a2649bcf1fe97cf11c79848d761df33ac46b3896942d31b640557b486ff6b", size = 220765, upload-time = "2026-07-06T10:46:55.41Z" }, - { url = "https://files.pythonhosted.org/packages/8f/10/00d12d8b8beabbf49a8bbc626fb9f40445145a8887eb41a6acfb69149ac4/xxhash-3.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f759eed402448c2bdbb492e4fba1f20668ffe29688605ea61f0f67f9e4e386d", size = 310478, upload-time = "2026-07-06T10:46:57.729Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f9/12a82394eefb0f185d15a7f7b9f627c61c475a72dd83718436a5b84b42ac/xxhash-3.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5f97ecfede10d5b2870383620e2d25c8561e217c7bf9081073802b54248d2b", size = 238393, upload-time = "2026-07-06T10:46:59.87Z" }, - { url = "https://files.pythonhosted.org/packages/20/f3/53f963e320b9ce678337aa7273f39ce692ded8b99e3d22a866ec722159ab/xxhash-3.8.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1da930bbcac3e8fbe2191850e2abb57977a99348c12c4b385e1058ac1b0a9ecc", size = 268704, upload-time = "2026-07-06T10:47:01.806Z" }, - { url = "https://files.pythonhosted.org/packages/0a/50/5b5badbd87c82d9f9b5f58ac74a3f29ef08f6fc387b324b8fd482450b862/xxhash-3.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:747476436f6891b9773374ce8d48edcc8b12cb5b61b67c6fb6289633747d088f", size = 225015, upload-time = "2026-07-06T10:47:03.784Z" }, - { url = "https://files.pythonhosted.org/packages/30/93/3ca68265afe7b4e69435e08a7b6a1d9d0f2a071e889da1f8041ed00fe878/xxhash-3.8.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef09bbc2519a93cd0f95f2ceb5f7b85919dffea643278e02362bf40e3c4bed1", size = 240951, upload-time = "2026-07-06T10:47:05.816Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a6/27e19670c40f46b5e76e11f2f4713d21054804568425d870670e757172ad/xxhash-3.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a5eed9d41995a83f3332b4e3396abb7f433cac584222bd7e305b606d8353861e", size = 300751, upload-time = "2026-07-06T10:47:07.95Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fb/b33e27689959fe7ed2ae0b830af41560d65213943983afa9db3a8d481bce/xxhash-3.8.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:53f3ed9118397074ff63a79b66b7fec1c84c782eecde35c5bc94e420a971c231", size = 443480, upload-time = "2026-07-06T10:47:10Z" }, - { url = "https://files.pythonhosted.org/packages/26/60/0e0d973be5fe280753ef02fbc89349492ad6e903bf1dcb870b668f94b662/xxhash-3.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d247b34bf433c92b41689318fd25d246313cab2275a6a47e2efac178b80d6efe", size = 217657, upload-time = "2026-07-06T10:47:12.196Z" }, - { url = "https://files.pythonhosted.org/packages/ad/68/c9e3ecef4a9a417d464cb5bd200aa12f73192dee677901b9e08e0ad0d1bb/xxhash-3.8.1-cp314-cp314-win32.whl", hash = "sha256:d58ce8b6cfa9c4d2f230557f69caf7c06369e318015d0b19485095bc2c5963ab", size = 32690, upload-time = "2026-07-06T10:47:14.204Z" }, - { url = "https://files.pythonhosted.org/packages/d7/99/e9e44588c0b62837bbec5ba7927816de0afa03406b1a0b6c7a7e1d1a30a0/xxhash-3.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:6cee733fe4ccb1737e0997135283c82341e5cfa9cf214b165f9087fb663aaf4f", size = 33460, upload-time = "2026-07-06T10:47:16.021Z" }, - { url = "https://files.pythonhosted.org/packages/45/2b/64f36d86380b3657ad9031967ab814f3ef31307174650853f69c18932ebc/xxhash-3.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:58346024d47e84f7d8b3e7f5d6faa1d58acbbe49a8771497872059f58c1d8ea5", size = 30092, upload-time = "2026-07-06T10:47:17.81Z" }, - { url = "https://files.pythonhosted.org/packages/92/cb/18b64bff88c58a0ca209dc533e63cf02d7ae5aa6b1b9a9fd14e81b5dbd60/xxhash-3.8.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:01cab782f8a0a05ecad2c63d7ef10f7ab475f660e0d6419d069418c14d88de7c", size = 35024, upload-time = "2026-07-06T10:47:19.821Z" }, - { url = "https://files.pythonhosted.org/packages/af/1d/72d8a70520e5dcddb472ea0486d299da3240745a10658290cd7b5690ede2/xxhash-3.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:717b12fdc51819833704e85e6926d76981ffa3f780ef92e33ebb8b26d46bb230", size = 32697, upload-time = "2026-07-06T10:47:21.649Z" }, - { url = "https://files.pythonhosted.org/packages/9c/b8/e041f555903c56db3d0a731b3d72a6575d75e0ed868b1bd2e5176111ca44/xxhash-3.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ec55d80e9b8a519d742669e0b49e8ce9e6747be42bf3c138158b6543a9c8e489", size = 226044, upload-time = "2026-07-06T10:47:23.612Z" }, - { url = "https://files.pythonhosted.org/packages/3a/7e/5cdcf06bf6ec4b5d2ac073feb23432ec1d603fd438864cbd2c09c7cb45e1/xxhash-3.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98d8ac1129b4dd39098cffed94d1284aceb61c3aa396757ccc736ac392e4cee5", size = 249899, upload-time = "2026-07-06T10:47:25.812Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c0/eb7e059cb5e1dba11fd30d2fdf882f56e5a417a3eaa43669d43623767f45/xxhash-3.8.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3bc0fa90830df1e1277f33cc6e55de9990b83c0319fd8c7412866cfde38b025e", size = 274892, upload-time = "2026-07-06T10:47:27.931Z" }, - { url = "https://files.pythonhosted.org/packages/66/74/a600aaf7cd39957fd1510adeedb1749c1e7eb82bd632a1153d9c664c3135/xxhash-3.8.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c73b6f652f0745425aa6378319c331293b5341756262e9408ed3d45f183375e6", size = 252243, upload-time = "2026-07-06T10:47:30.288Z" }, - { url = "https://files.pythonhosted.org/packages/ad/04/78d88fa75a6763e5d09bf1b947a392a27988903381b219006f92f3c68fc8/xxhash-3.8.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6114692261eff4266386cdec0f7d87eee24e317ab397c218b7ae6a76b4c6339", size = 482191, upload-time = "2026-07-06T10:47:32.45Z" }, - { url = "https://files.pythonhosted.org/packages/7f/06/07a8aea1108d682de8791ce608cdf367d75ff4e7e57cd3c154bdc6f47b23/xxhash-3.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df57c0b161ec1b3ed0526a67b0db0914b557e86ee8aae51887aec941b261542", size = 226877, upload-time = "2026-07-06T10:47:34.705Z" }, - { url = "https://files.pythonhosted.org/packages/ed/b5/86bade5618a524d2c06c4041aa2fe8e5749ce16e88afba60d67c1684a21f/xxhash-3.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9043877a917be88ccf230aa5667c1bd059bce80f4c2727e4defa1b29b7f48b08", size = 319794, upload-time = "2026-07-06T10:47:37.08Z" }, - { url = "https://files.pythonhosted.org/packages/23/69/9b1a2b89b1621bb740fbcb7beb512f60f99480c1bdc680c0c90e1f56ff75/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:559e3cabe522231909f9de98ef06929edbd53782046bd21aae0c72db6f2a0775", size = 246202, upload-time = "2026-07-06T10:47:39.676Z" }, - { url = "https://files.pythonhosted.org/packages/08/ea/662ed6cb49f1d34078b6a3a3e0f3d29ff93fd7b5a03c0bc9ecfd9b2159c3/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:264710bd335016f303763ce1275c6486df30bb57c2245c91b224c983d7ac39b8", size = 275628, upload-time = "2026-07-06T10:47:41.99Z" }, - { url = "https://files.pythonhosted.org/packages/13/f5/49fc9e4c6728a5a3bd8fe639199d2fa67609b3a84f938aff6e8568dd3e4f/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e14800b9b10bb39d7a60ad4a310e403164d7b8988a27ae933d4e40618a44088e", size = 231390, upload-time = "2026-07-06T10:47:44.233Z" }, - { url = "https://files.pythonhosted.org/packages/64/9d/3acaf8f599c0e0b30e910a3a11ba32929da53c86dc73c7c55fe6a010b4e9/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ea6a3e734b0fd41b82784a400be946821900daebe610c050a5e0760838a34f99", size = 250600, upload-time = "2026-07-06T10:47:47.611Z" }, - { url = "https://files.pythonhosted.org/packages/23/64/8acab4c5ec60dbe664b5b9858fd44c2413b07e535b09556a0a5022e78aa6/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cf399fac542a1c7a4734a435b93df2c55e858c7d31abf6c1bdf46f9ae67fbfd0", size = 310032, upload-time = "2026-07-06T10:47:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/56/47/a0288d7329b1fe63e2734a32d19d444a96ae2b4810f545bc61e561224917/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:44c89d915a75c11d2547eaee9098fcd80398987c4bff2974a0497a925bf92c07", size = 448882, upload-time = "2026-07-06T10:47:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/01/e7/3071dfd3beb5c38204ce1cf56bf7749fce08de900fa92714b81d1d8ca1f2/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:358650d5bda9c635da699c53adf4e8134af492ecc79c960f917eebf088bb6799", size = 223728, upload-time = "2026-07-06T10:47:55.093Z" }, - { url = "https://files.pythonhosted.org/packages/12/11/b99949f0ba2b07e9f9ffe83b9c86faa685f9080725dc21a916a607313be5/xxhash-3.8.1-cp314-cp314t-win32.whl", hash = "sha256:c240939e963653054fc7e4a17c382829cda4aa88a7daf0af841715dbded1b497", size = 33150, upload-time = "2026-07-06T10:47:57.274Z" }, - { url = "https://files.pythonhosted.org/packages/54/1c/09703eb341f8416e74e58d6c6732d4b5c46de59c942363203cb237cc95b0/xxhash-3.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7258ee276e8772599bc19e14b36f6260306e21b637190cd7cb489a2449d48684", size = 34005, upload-time = "2026-07-06T10:47:59.434Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f9/6ed7251bb6a8af10ac73b1821c60583d2826e5b2064e45a979c935287c98/xxhash-3.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:8f454166c2ffed45636c8d501741e649851ba2f346c4eb73a64c07ac00428f20", size = 30239, upload-time = "2026-07-06T10:48:01.874Z" }, +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/a5/1386f35da1475fcaeef42581deae73417c6d2a6a0b2d2e8914de18844dcd/xxhash-4.0.1.tar.gz", hash = "sha256:d55bf4ef10eb09b8b6866790e083d26d087d84caa3cc0946ba87c3ca7ecaf7b7", size = 101513, upload-time = "2026-08-17T08:24:08.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/6c/dc7cffeadd06336cd934947187cd38abb263103bbc552ca0f55fe4ff595a/xxhash-4.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1ee523f51718e41753f04f7102bb4dc55a18d2ea5cbaceef8ec7ca08571bd428", size = 38444, upload-time = "2026-08-17T08:21:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/75/c9/cf736f6db8c3273af18925061572db0d4357818a9ce425f4b5fb0021918e/xxhash-4.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:515a822c73abbf6a0b7c70976d9662be342835c9d78b8dc7c023411f39c35dbc", size = 36195, upload-time = "2026-08-17T08:35:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/da/a2/ca1929354b6851529d0148f7f335b5e2b0281f83bab3e19f0896dc579796/xxhash-4.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f5d031f35962e5483a613214e61f09fe24ab523062c3646d592dc16c4a217451", size = 253113, upload-time = "2026-08-17T08:20:52.152Z" }, + { url = "https://files.pythonhosted.org/packages/de/bb/542005206af59518bc8d78a210f1e0172217bc53beb32f64a5b632e72b6b/xxhash-4.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0264844a09b538c894e5eff25313d941deb4dedec2131b98418a71a3c9944e", size = 276525, upload-time = "2026-08-17T08:21:01.886Z" }, + { url = "https://files.pythonhosted.org/packages/1b/df/607cff25dcb0f1d35c3b04493f6ad8471edb03fd4eacbdcc5ceddef1f3e9/xxhash-4.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1642907941ee4b75aacc3db688af52ea02ca2305ab22af7ee686ed726b332684", size = 297703, upload-time = "2026-08-17T08:21:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/15/ba/9d2275eea0b9d9c6b02921be23f7588356c60df95c763b25f0e045894d43/xxhash-4.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4af350bc3f329970c0e3a59af84a8a30998bf8a9167eb50cd48e59baaa1d7bec", size = 280252, upload-time = "2026-08-17T08:20:47.299Z" }, + { url = "https://files.pythonhosted.org/packages/1d/aa/2299d9f6369e550aef2abb64945e39daa34412725aa46a20d99b74d76f67/xxhash-4.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ba782ca3bf1e81492611152b9a0d5264971339e95e34d69de0ac2c926be496d", size = 511041, upload-time = "2026-08-17T08:20:36.771Z" }, + { url = "https://files.pythonhosted.org/packages/83/97/31bd8b8279e6935a0719f6910ced15e9d5a2cd554b253f6027ce1b5a1c2c/xxhash-4.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:237b8f63a2a0fcfb1ffc06e21dad23add44e6d354b2b014364a1d41e419a4dee", size = 261812, upload-time = "2026-08-17T08:22:00.469Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/d180a2da23c105d8e0b02d54f9f5841013fc81c233010ec781e31f1aee4c/xxhash-4.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:81507a68ba84c55241fb61cce1469f473a5da4205fc8ef6f698e5948eea8dd88", size = 339878, upload-time = "2026-08-17T08:35:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3d/f584cd3172fe934f0f5a0a3917d0d7ce781f74d794fd43bb72be71c3ef6f/xxhash-4.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1ea31d61bcd2cd2f3ec4ca80a64187bbd7948f490b63cf0dcbc6e717b4c1e9", size = 272871, upload-time = "2026-08-17T08:20:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/2c7956b2b551682e00b9aebce9ceb0a991a131d65f9850c09f5f9760be2e/xxhash-4.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:06713a5aaf1d0905c5579416c020c02e42b3ceb931e86c7d3b7fb85403dee3f3", size = 301440, upload-time = "2026-08-17T08:21:35.911Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/0739f6482184a8026f4b022718f5f815d352059312e80696825433f0a8e7/xxhash-4.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8cda075b10bb3917b002c74a04f9e02b7d13b5bf732571404d51c52b11c7329", size = 260157, upload-time = "2026-08-17T08:22:01.416Z" }, + { url = "https://files.pythonhosted.org/packages/a1/25/b31a7bcf1d7d116842812e54f9b944843b4236ea4fa85634e8259f342212/xxhash-4.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c10b9206753b64aa791b35b201485477525b26fdec5bf86e8364c388a03e2592", size = 278233, upload-time = "2026-08-17T08:21:15.674Z" }, + { url = "https://files.pythonhosted.org/packages/db/e8/5293bae090fc6119dbc5fcf5c4cc0e1536394b52d73b7904d033836c73db/xxhash-4.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f3e1a44af01b6692de0ec6caba5f0bf93ceb36896e02b7fc00952c6ea7ef39e1", size = 330270, upload-time = "2026-08-17T08:20:51.128Z" }, + { url = "https://files.pythonhosted.org/packages/72/9e/e2ab12d40921f3f34c9317637d65e011aeababf8288356ea8d527de2c1d0/xxhash-4.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6fc415b5568bd9accc7187f1729a99707330c0a67a8b9f93c1149ed573ed75d", size = 478555, upload-time = "2026-08-17T08:22:04.183Z" }, + { url = "https://files.pythonhosted.org/packages/6d/32/c6148d39a49efa95f39b4cf0d41ef35a487f3b30f6fb1fc8fe8d8eab577e/xxhash-4.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96d8de55029d42251945531f6aa7590c32b48163c66a43bf29d8657d7446a377", size = 258174, upload-time = "2026-08-17T08:35:21.18Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fb/0b04b68d6c5bc71c7a2c344f1287327b67e607f28fbcfd937697caca64b6/xxhash-4.0.1-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:0163b5d259de23ae9e07b7eabf435ce4704f6f205589a2b154e6af4be985ce1b", size = 20767, upload-time = "2026-08-17T08:21:00.806Z" }, + { url = "https://files.pythonhosted.org/packages/a6/be/476092aba34d1fcd313e1613a3bb3bc692f253d167b54bc90049043b5034/xxhash-4.0.1-cp312-cp312-win32.whl", hash = "sha256:1216f7ba5683f17a89eb7dcb4bc50a0b743dfe1902278d7b3d0786f538118433", size = 34669, upload-time = "2026-08-17T08:21:49.486Z" }, + { url = "https://files.pythonhosted.org/packages/aa/02/f9413d94fae43cec6d1a74c4f12156c6f4a7f5fd50e1d34defebdee3dec9/xxhash-4.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c2d525a3afabcd8e3549d85fc7e111fde6bc302d06a1893fe73adb79823415e", size = 37073, upload-time = "2026-08-17T08:22:04.886Z" }, + { url = "https://files.pythonhosted.org/packages/c1/83/6fe93c1b95acf962bc61a246df09dc2dcce895ccfc1080c9f48d0b652b92/xxhash-4.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:86b2b12bec60c678ed8f5cca0258ad93a8928ebddb6ca7732f0875afe1451d1a", size = 33299, upload-time = "2026-08-17T08:35:12.708Z" }, + { url = "https://files.pythonhosted.org/packages/f3/dd/c707286b527722f776e1fb81dd202c45623355ba1a2972337a2a26075b2b/xxhash-4.0.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:8c9fe122444e129881afd1d4d1c7ac0d3ce2d91b68c2b40173b6025ff1c31f9a", size = 43639, upload-time = "2026-08-17T08:20:54.945Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3b/bb71639a0f95635f61936a6f2653599c4261b645ddddd8d00f9dfe3613e2/xxhash-4.0.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:1f3346c5c287ac3c7f38b20380f55e8768230e7252af59fabcf3b87ab21e4256", size = 40657, upload-time = "2026-08-17T08:22:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/3c/91/76f3f5385faa9886a36f21fcc603f40b4c0c40ce622382f133160c48b4d9/xxhash-4.0.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:4e5141543c7f7fe3087500bbb4ac2845cb528a980aa91f8f1e661e2292ff4a5d", size = 34708, upload-time = "2026-08-17T08:35:24.614Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4a/f48f0e3e1b1ab072979fff2a5be899234e28090883e8b519d0b10215d708/xxhash-4.0.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f09ee747e2a5f876cc5ad56947734811828335e13b403dd8ea1e06d77a9dd48d", size = 35650, upload-time = "2026-08-17T08:21:09.337Z" }, + { url = "https://files.pythonhosted.org/packages/c4/53/b73d7472b196101ad1f57ed0674af3af803ac3e9ec2feadd650a7b262562/xxhash-4.0.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:acf52474b2494ef66dc7e0fb6d5e2b50c18313039ad4d275fbf9f9907c804bc5", size = 37958, upload-time = "2026-08-17T08:22:10.616Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/024946ad8fa532074af4e4380179da54b7ec9facc8bd0b279ec0fac4e63a/xxhash-4.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1b3cccf75eeb5b01639b2feadb042a8e07889293b7ca72fa2985e7dcb64763cf", size = 38032, upload-time = "2026-08-17T08:22:09.535Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/934af8d99bb5885711006bec30a691f728edd513d2c40f053f887d8e7577/xxhash-4.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd878d32f5c6cbce9783f8d6897561fb772211edba9dde49d85672b88ed45276", size = 35895, upload-time = "2026-08-17T08:35:16.53Z" }, + { url = "https://files.pythonhosted.org/packages/20/5f/a8011f6a1558f7ca66d9077bb4f192b1871afcea62fbd5733605d2015755/xxhash-4.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:41e579025a6e13a99e6d71e39c9cfc621a0dcdbbf19106325e145fa858f2d794", size = 259464, upload-time = "2026-08-17T08:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/ff/89/9665a44397547e7a3d58c0942425a976d58dcfd4b538f33220a312bf6912/xxhash-4.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74379a577a9f3b6afbdedf1b90e5c7764467051977f18a326d7d607336d743bd", size = 283949, upload-time = "2026-08-17T08:22:17.003Z" }, + { url = "https://files.pythonhosted.org/packages/34/2d/78774141266457468f29f3f5803092df4db87d8148ba74e4debd041649db/xxhash-4.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:acb31ecdd1a97fab5cd39a84ee9f515e727d319f796fec48703b8339b9998360", size = 303898, upload-time = "2026-08-17T08:35:27.951Z" }, + { url = "https://files.pythonhosted.org/packages/59/48/d78d22de576b42528bff87c14207de50de4f0b888221a50ff7c9d675d670/xxhash-4.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b7875ac1a2edcb691f27642b8b94b904baa6bcecb7d79c72df2228ba8cb5c51", size = 287241, upload-time = "2026-08-17T08:21:13.042Z" }, + { url = "https://files.pythonhosted.org/packages/4c/de/7a1755a59c59fd46176f293bbdd99e399a6537ba9537fc723aa4d1bf6e27/xxhash-4.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4751f1d7eecae6b2d2a773630f1a7248f125c9a92a456694d03c15bceffc9d68", size = 519856, upload-time = "2026-08-17T08:22:15.35Z" }, + { url = "https://files.pythonhosted.org/packages/6f/fb/76580c08e916507859b0f335393cb5fdc59452c4402edbc6bcca6e47e7df/xxhash-4.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a51b061d54cda8b83e62c44458bfbf0dabbef9b975dd9649952ba5076b9f349", size = 268572, upload-time = "2026-08-17T08:22:14.533Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/1abde3e07b8f2077a38b4fbfaf764115008bfe0ff03bc7756a52c9fd0607/xxhash-4.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74a164e8b63f1e9cf35c9a7809d082b033d1a00e7375d5d814415436e7867e57", size = 344967, upload-time = "2026-08-17T08:35:23.569Z" }, + { url = "https://files.pythonhosted.org/packages/5c/15/80b6ddf0732eef48a8b5fe717398274794392bd6dbe82af38d189d214772/xxhash-4.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f5e5c6df4b703afcbe9352d238a51efd97c3b91fdc3a2052e40fdacb1e7505f", size = 279956, upload-time = "2026-08-17T08:21:24.97Z" }, + { url = "https://files.pythonhosted.org/packages/77/e0/11cbc43c205bf81fad50d69c7319cd1b1ccc01a66cd4fb8766357126c43d/xxhash-4.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d54b8ae068af532c8cdf56abb9e09a60fbe7b10792444c9c27987bb6d3b450fa", size = 307583, upload-time = "2026-08-17T08:22:22.541Z" }, + { url = "https://files.pythonhosted.org/packages/1c/11/cf0bc07feb2791045b6ac075d4bf64f1a5beedef2f46ae70d7104d63a19f/xxhash-4.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1749f0688020209fe0d357ce1e1cd9ec9c6161ed0405ea949d24581c4c43fa91", size = 265848, upload-time = "2026-08-17T08:35:31.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c4/7ada4bea2a2795073dfc42d96842930efbe7a0c1857ef4b522e4e90e5d83/xxhash-4.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:94ac8a6b8c47951173f0b67bf862bcb971bf24e493b9fbbdb0e010cbbc7d9f54", size = 284409, upload-time = "2026-08-17T08:21:23.156Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f4/d8ce83dd6b99ccfbdadaf2db968ae40334d2e5f73a0297e593b9ddb3df39/xxhash-4.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a33de7633c948ab2dc144af370a66e7e7af29b425dcd0f7e4f59689fb9391b53", size = 335921, upload-time = "2026-08-17T08:22:21.802Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9f/f47d8724bd8bc45b395b06b7cacea2dae0d00031af1b707184a091161df6/xxhash-4.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:247ece770647c0aef080561fa996f9774b4dadce2d0c42eeb98229db7dcf820d", size = 487023, upload-time = "2026-08-17T08:22:19.729Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/2d87098f3371cc1e42dd04d2285ad56bca4c56667bc501bff02d2b9fd6b5/xxhash-4.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a4553d36cc0b7fce1f35ba8a94dfd775aa3ed12f5eab2dc3b46ac75a0706b0bb", size = 264333, upload-time = "2026-08-17T08:35:27.001Z" }, + { url = "https://files.pythonhosted.org/packages/27/b8/93795ca5898ec7d7d0455283ad261c0fc76b4f0c0a69e86233bd7badb0bd/xxhash-4.0.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:87aa309a93bd5ec13f14309a305ff4e9bf74c5363fc46c264c0a22edfd5b0670", size = 20581, upload-time = "2026-08-17T08:21:39.207Z" }, + { url = "https://files.pythonhosted.org/packages/b6/96/926f7335a0a1647952c00421e8da877f658094f61336306c7cadc335c94d/xxhash-4.0.1-cp313-cp313-win32.whl", hash = "sha256:cba763d84b06bda2c38d5185dee76f1b9dfdc0789e96e476d9e10005526d0788", size = 34449, upload-time = "2026-08-17T08:22:29.362Z" }, + { url = "https://files.pythonhosted.org/packages/ea/61/8a5aeb811de093bab3434e77eff0e9461624a1a56a6a93d315d080aab2aa/xxhash-4.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:97b94fb29abf21f5f0bde15f7dbdd3a4aa2dc59f37026adc7b4bee8563b84375", size = 36520, upload-time = "2026-08-17T08:35:34.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/14/97f3c74000ca36955e9cb86f6d270dcd5848b5c65afa623453f5cf2d83d6/xxhash-4.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:08ed8da18cd4fd0a6a5d6a444852d8fbd0e565388a74a4937085451b5f1a312a", size = 33428, upload-time = "2026-08-17T08:21:31.713Z" }, + { url = "https://files.pythonhosted.org/packages/81/0e/ea406a02b561d3275232ccfdb3e29df80f7a65414940e3a15721c7bea40f/xxhash-4.0.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:af05a3f650220a6c59fa0ad2410249f2d2470a05225807c378fb67458693f8df", size = 43747, upload-time = "2026-08-17T08:22:31.37Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f0/b0c94d61ccf6b5d1f8847b58ef8f923125ac4919ed5bd0eb082750ca7cbd/xxhash-4.0.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:a6e3653df1a70b8ac4191216324242e4be2bca18c9a7c10934e1bd56dc7ca15e", size = 40749, upload-time = "2026-08-17T08:22:29.431Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c5/8085881a538983be0fd1c865d5df236242fea496044e2c8ca32b9f2ba39c/xxhash-4.0.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:4528cf80ebbbf57d40edfb31521ae265daa6dd636d615b1cf0ac86209579e59d", size = 34734, upload-time = "2026-08-17T08:35:33.68Z" }, + { url = "https://files.pythonhosted.org/packages/d3/94/8803d13c968fc75ca434eea991d29ac5fd8a36b4afc9a6a9803c53933db4/xxhash-4.0.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:90cb2a1c9cc503a054a19612b48ff6e8e47805f618bdb3224a07568aad03a37e", size = 35671, upload-time = "2026-08-17T08:21:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/85/d5/ad91d7f0fd294190d37c08236fe661f5c4e3f83dcd1a121877a2e64681ce/xxhash-4.0.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a949b072ea59c6eca0811ccd9e95133cc50d2afda8d464b5b077c78f78efa269", size = 38094, upload-time = "2026-08-17T08:22:39.763Z" }, + { url = "https://files.pythonhosted.org/packages/89/f4/2b7ebdc1869caca5f02c4cba8379b631050d3c3d4adb9187e4dc1a6b8d3c/xxhash-4.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:79a3203aadf39637869dfea1185227d8452844d78b837e54fb1117b4d34ba5c3", size = 38244, upload-time = "2026-08-17T08:35:38.081Z" }, + { url = "https://files.pythonhosted.org/packages/90/9d/f66cf6935f528e575f1ae4d6560d376e7587569747186f4fae8777cadc1b/xxhash-4.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d9f3848ffaf010bdbabdbf4c25641fa258b6227ff27bc74a4d06edef521a4873", size = 35904, upload-time = "2026-08-17T08:21:37.358Z" }, + { url = "https://files.pythonhosted.org/packages/07/29/34569d7b482f0dc060074faafd163c588f915cbc3e3e218f1ffd8a3ad340/xxhash-4.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9283d9dd6b44acad35118e2976fc763a065509e4118debdb61916ec322ed17b9", size = 259595, upload-time = "2026-08-17T08:22:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/a2370acfcd48732cf5c2b87f06cfbf7fa51c0ce0dd736bde42939eb9ebf7/xxhash-4.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c7c642a0f79c3e3cf2965475507574d3d1a50ec71060039d60cb87358667cb2", size = 284279, upload-time = "2026-08-17T08:22:36.396Z" }, + { url = "https://files.pythonhosted.org/packages/08/15/17d33c24e6c4a1c0b9ddc5584f0c25d51d48b34bacde1416a2235a19db4b/xxhash-4.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96dedccfb09a73a25751053a183159b88f4ee75f388df8166040c152ac0531c6", size = 303973, upload-time = "2026-08-17T08:35:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e0/4ec0d69ad5738729098a61e631b7ed2df22a922b0e03014b597c72bd863d/xxhash-4.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81664268dba92e037b740ecf37fa02f1cab4a391f93f28e35792b3341c60648f", size = 287535, upload-time = "2026-08-17T08:21:52.158Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8b/4f9b17e7a9eb71c65548ecddd9c18b84e3c18ca41c4d436ad2a3000d3f7b/xxhash-4.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:839f58c5bd9989875be0fd28446dbf32cace2c2cd8bf2f6762acdc38a95cd1aa", size = 519257, upload-time = "2026-08-17T08:22:43.272Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/3276b3e743b8ddbed9c3f71c76d9dd6a75d72aa4e678b1447b635cfd92e0/xxhash-4.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa44b4c7c5d0ffa31356b4428659516c0e47647825c74079a296b3857b6d99d", size = 268190, upload-time = "2026-08-17T08:35:44.985Z" }, + { url = "https://files.pythonhosted.org/packages/08/d4/f1555de3c96721320930dbb7988c8482d82b85970076aba1a8d40e83ad43/xxhash-4.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e681a6fc7e4f715252b9b5acfb30536ec7dd1f75033a32dc617e6fa95af1a3fd", size = 345553, upload-time = "2026-08-17T08:21:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/ac/98/c28908f27007087b61139d290f908dd827ffd40b88af0c43f9e1a1a7ffd5/xxhash-4.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6301d92545c591ad31c3e050aa40a5f8a4c16413f1f9e6f9322c6f0f9d2b736", size = 280499, upload-time = "2026-08-17T08:22:52.236Z" }, + { url = "https://files.pythonhosted.org/packages/a9/76/3ef57622c65816348f8196273485baab4752aae064959901e85cd867e067/xxhash-4.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6efb8f21cc136c79b3e5bb747c8682d37916fb202cdbbc32182de5c4e47f821f", size = 307211, upload-time = "2026-08-17T08:22:40.815Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/5804504bbc808968e57d6a50286dd8f8cc06e0ddd6e4ab4b1dc89ae42f35/xxhash-4.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:760de77279e9cf9c81d012ce0705cba13afccee9b09c480f17d778c8c5cefae8", size = 265865, upload-time = "2026-08-17T08:35:42.727Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ee/8572fdfd70e7aaaf150af899871c2cc0bb88c3295ca82172a31e04ca5168/xxhash-4.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a16a3fa6936e36bb1414d16a6bd012c9033e5161b68b426805b61d895392437d", size = 284545, upload-time = "2026-08-17T08:21:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f8/6eadcca0904660c848b466524e82a233d16c9d2d5258433aaf3546142d86/xxhash-4.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9c3c4b9aa9a27196b921197f7daf9e6c1412739df06a99cfa6e923879362eff6", size = 336022, upload-time = "2026-08-17T08:22:46.346Z" }, + { url = "https://files.pythonhosted.org/packages/27/df/4aa107b81602d6d6d09ab5a607c530d2d3a6b28e2e9a59b01875bd877c54/xxhash-4.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:863f3d3b44110f7243e86cf994aa5c5d88f2348b6e84ab4402fadadfbf9f7da7", size = 486671, upload-time = "2026-08-17T08:35:49.016Z" }, + { url = "https://files.pythonhosted.org/packages/45/b7/b2bf9b5301e9cd5f2e335fea8da0f5cf209a6594cb1fe77754774ad4a6fd/xxhash-4.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63aa52659bc32bb9bd7cb5caf523b4d14429a477762cfac886132d687c1f80fc", size = 263744, upload-time = "2026-08-17T08:21:56.165Z" }, + { url = "https://files.pythonhosted.org/packages/0b/96/35b1c02177ae26234892c2310fb4822ba62411acccbf425ab8f9fd99354a/xxhash-4.0.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:67e57b834e07ed973cee7b6da1548ff28a56458d77696fd2a5f397f340694848", size = 20563, upload-time = "2026-08-17T08:35:11.924Z" }, + { url = "https://files.pythonhosted.org/packages/51/c2/a06300b165fbd6b0cb4a9742987f2e997a9f447ce3bf7c6ac97b862ce62a/xxhash-4.0.1-cp314-cp314-win32.whl", hash = "sha256:b6c1f9c59bbe593f88a0aad30be4150f15bd57bd64efb95feeabcb8e563f1ecd", size = 35151, upload-time = "2026-08-17T08:22:44.283Z" }, + { url = "https://files.pythonhosted.org/packages/06/96/c5b37296b78f80fc97124c0fee0c7bbd1bdb6f3b18bcd8748bb113b2d8fc/xxhash-4.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:da544672efd9ad76077928a3e6c5d894e52ce82d3bf14002db4a1bf17d1a36a2", size = 37156, upload-time = "2026-08-17T08:35:46.551Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5e/248f9cd169c2fb62236bedfba246d213bce728f74901e99047e3f3c55875/xxhash-4.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:d0d24a4f3fb63852cd09af46ae4b7a4d00cc8b8615a046dca543786e728d1056", size = 34379, upload-time = "2026-08-17T08:21:59.446Z" }, + { url = "https://files.pythonhosted.org/packages/58/c8/db1d37c0da0324d0298f6abd931ca1d4736e049d9f2081230a8421da74d2/xxhash-4.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:349775ac30372b344d2338b2a168c0a1312a644194da25b8bec476d55761a128", size = 38656, upload-time = "2026-08-17T08:22:49.119Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/e18998ec465fb977bc74272e5bf3c2e886c13b014cbef916cd607802c709/xxhash-4.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:43e5f9169e73d0f0db33b5f6b8554bcce69ac278c966daf83d5eb4eb2f13829f", size = 36306, upload-time = "2026-08-17T08:35:52.853Z" }, + { url = "https://files.pythonhosted.org/packages/ef/1a/b83f86f8a987a3cbcb7e005a6824ff64aecae35abc1395a0d44ee16c3319/xxhash-4.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4a252fb862b0ae2590587e625f47a0e03da05cf0205e8830b67b6596c06038b1", size = 273729, upload-time = "2026-08-17T08:21:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/02/4e/2db15aa8508e0cd5b632927a53b98234f24039ea65377e6cf996c06d2d4f/xxhash-4.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2df3ca8757dc381e75e90a4d7995a6324f58a923c7145220a7b2c0231f66fddc", size = 301083, upload-time = "2026-08-17T08:35:14.113Z" }, + { url = "https://files.pythonhosted.org/packages/26/94/ed759787ffe802bd8e31cfcdad3755cbeca2dcdafd2f790cd6f25d195199/xxhash-4.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bfed61996d618eb90d6eaae0178002e3466a28b06bfc557a7a3a7266378d8c5a", size = 312745, upload-time = "2026-08-17T08:22:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/f64b4a4cc8b51e950709207f55f7f56ae9c5af6631dd31d7fb443312418c/xxhash-4.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9761ff4a0ffa583fe850731ad24fe82c88cccb7a2294727db0955f3279a4cb3f", size = 301419, upload-time = "2026-08-17T08:35:50.143Z" }, + { url = "https://files.pythonhosted.org/packages/a0/71/bac313b8de073569b8db3152044a7cfcce87a3fa9698c18fe9f914dee6b1/xxhash-4.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edccc2ec58435a580f96a48a3ccae8cd0a480824119165dd90108718ad81ae6e", size = 534485, upload-time = "2026-08-17T08:22:11.515Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0c/16b5e419f24e59507ee05626d2bb0deafdb03f9f27783bc0785a9849602e/xxhash-4.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4741d42d59e4e5fa1a86c17ab9c27dc8ea459c700d91b6742fdb9138d9a516cb", size = 279605, upload-time = "2026-08-17T08:22:52.934Z" }, + { url = "https://files.pythonhosted.org/packages/5f/55/5787dd6e2d8d5b61256a5039f6b18c2193c7c1de4a2fd2413288d0d9c604/xxhash-4.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:440c401e146ce64bdb3beb8ff0c84677b6f21307c28a34779071cecee5d4d70c", size = 358924, upload-time = "2026-08-17T08:35:58.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/68/89be41991f3b0a2e91f940bdf3128852c3ed571cf560d98ad0f67024afe4/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5b7979f71d06ae45a769de0699900a246d8cb632db1e8bfdc79ec019063a503c", size = 295305, upload-time = "2026-08-17T08:22:13.683Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5a/52ff0a0cc361aad393ff9a46ffe3aabbcf9c03d6c8f2612da7d553048276/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:62198213fc3e0c56e567894b318ba45834e007d065f84ba6dc9165d21546fc56", size = 320228, upload-time = "2026-08-17T08:35:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/0f/b5/91c60ff22c7f6cd5f6d7a5bad5a2cdcb4c33987dfa50bf13f0d856279b2e/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b3bece52127ac20044311ee73567f9f0893b5de64f9028aecc90cc740cfd525a", size = 279414, upload-time = "2026-08-17T08:23:03.212Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/9685954804d47d0390871a64bec606a0d536406382d71a784df3a5883fb4/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a865d2d470220e659220fdb59d5b6c4422802d8d6098e1324bc4d12444798914", size = 297594, upload-time = "2026-08-17T08:35:57.881Z" }, + { url = "https://files.pythonhosted.org/packages/89/62/b67ac9412907b7a07a2a0c08c3440b9e4480231a7b3de0767e87011e4564/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8580aab306888224074c7edeec734de0c3c5ccde65b2da4e6c9a5e28f7c0a1bd", size = 348526, upload-time = "2026-08-17T08:22:18.571Z" }, + { url = "https://files.pythonhosted.org/packages/37/ed/6723cc49a9f567d52d01fd7c1741b0f2e3a13e71d15f7ac49d753a20c115/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2d52dc7c33c1b83082b707f6b7814dc76d2faaa2ea62bd9c5fab4b36f83c087f", size = 499307, upload-time = "2026-08-17T08:22:56.52Z" }, + { url = "https://files.pythonhosted.org/packages/fd/2e/7b10e101ab988d93b791023be7191d7661271d6ab31ac082276b9091042a/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a9f98af872355e0c02439e48583958eee00e60b928bb20476460d9d40cb7b4e", size = 274989, upload-time = "2026-08-17T08:36:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/7eabcc8d29cce40621443cff24c07d7306ef574b8956c47ac59f21098005/xxhash-4.0.1-cp314-cp314t-win32.whl", hash = "sha256:a14578102a6081465aec9cf73c76c3cd3f79f0709bdb3b8ae7ab0b54c9d8b089", size = 35482, upload-time = "2026-08-17T08:22:32.336Z" }, + { url = "https://files.pythonhosted.org/packages/ca/89/2a4268e1971f63038b79fb75e3b9c8de942cd77acabbb0c5625352a31940/xxhash-4.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c57963970d359a72262f7fe6be88f945e2334d4bc41462b7f08c37b0abf35ca6", size = 37490, upload-time = "2026-08-17T08:35:22.475Z" }, + { url = "https://files.pythonhosted.org/packages/90/7b/950ecab1fe4cf421d0a6211ddd9a0ac82e39e55c45a111ceb90953dc6c9a/xxhash-4.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:b659fad79c99b0238c7ad7e9d7dbf4eebfea9097c2dba65fa0a4d18a25b29a2f", size = 34596, upload-time = "2026-08-17T08:23:10.001Z" }, + { url = "https://files.pythonhosted.org/packages/c4/03/7dc3b85fac10751613bfedb0e120734e0e8710054abad3f931e9d3843a14/xxhash-4.0.1-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:5adf927dca8c47fde7e683fe69efdd81bc865c4db1fb6bb00b391e2b6185207b", size = 43749, upload-time = "2026-08-17T08:36:00.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/bfac071c5b1c6d6a3d48ab1ab96a15e958a1d7061f4afc97804292d87264/xxhash-4.0.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:c30dd1af66a820820398b26e0d74e7a9aa43cae705924f23ed828cd8e5c26c3d", size = 40758, upload-time = "2026-08-17T08:22:30.209Z" }, + { url = "https://files.pythonhosted.org/packages/79/87/49a260e685d1a74c56a69432a8ee0527ddcbd684a3c51f87edc3b75639c5/xxhash-4.0.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1bc591533fc975614f7e13594daee76af96b8e1fbcf8de76c8773858fa9e7cea", size = 34788, upload-time = "2026-08-17T08:23:09.014Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/50d72ed2170dae872e1c0fe333d0908e0a2afbffe74c5c9037d5406a4b89/xxhash-4.0.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:567cbc630302a46a8ecfd943b309ccf5372bb3718f1f3762d452df30f033bcf0", size = 35746, upload-time = "2026-08-17T08:36:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/66/f0/969deaa2bab3bfd5ad5b023442124d2255b9961eef6f797ec74eb8683bdf/xxhash-4.0.1-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e998cb3685b92101ec5de0fb4d9485cf01e50bc418211955c55d98064664cf4c", size = 38098, upload-time = "2026-08-17T08:22:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/86/aa/45ed7d7b8d7b66202a47bf8ff3b77cea28d2ea54dfcdd202b4cfe043e3dc/xxhash-4.0.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c3074db513c81f764053e3da079312ecf85a50d8350c71f4cc0105d9662a9e6c", size = 38251, upload-time = "2026-08-17T08:35:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9d/45e7520a7856e13800a5dc8cd038d34c6372429465b163af0c5722f16918/xxhash-4.0.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3088dadbffa33c29e0518578430a7dff2e901a212e487aefa5faaa0dc06dad34", size = 35986, upload-time = "2026-08-17T08:23:25.854Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/5ad466e5fea18c9f9bdc5828c0506f62190061b4a1b0e688aa54969d0a9e/xxhash-4.0.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1b50223d92df94d54e1a31469335a2c74b16692e6c1cb726f1e6949514458706", size = 264609, upload-time = "2026-08-17T08:36:04.229Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cf/8f269f85217e3dbd45e31e25e46cc26f3aff0e159ef05d228b4b982c778c/xxhash-4.0.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:427b62d62d4f967fbb10b82a3813e4875c2a6e7e7634739f17265b650c7f65a6", size = 285910, upload-time = "2026-08-17T08:22:38.589Z" }, + { url = "https://files.pythonhosted.org/packages/ca/30/2fc1a16ee0f9501d074b798ebfae52e24fa602c7117f5c4b81de71eada72/xxhash-4.0.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c6370189e8e66b7e608f533b939a9de092ddca6cce084ca0d3d414d2ed5b5d59", size = 306566, upload-time = "2026-08-17T08:23:16.895Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a7/08375cf2b997e1903663fe7525c5973b1987a4f8ad2b8d47463e9143f2ee/xxhash-4.0.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec1a470c6db94ac4589c203921e89ac1bc13e796a8b1784d8135e1893559cd3b", size = 287978, upload-time = "2026-08-17T08:36:09.296Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/90a7b404c11add9e53a497d06236152852490c3b2f21e468d97a58f26afe/xxhash-4.0.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37f667dee0f867c42894b34e2a6fe26bf195c0ea4683d9d2b713db023f242c3a", size = 520098, upload-time = "2026-08-17T08:22:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/11/02/7fba10b1b17eb46308f09cc0a4ed513d74dff16b1e22a1c439f011c77129/xxhash-4.0.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f18732adcc271741bd651c3e56fa519d8a237d2cccda01fe3afb226bf87f783b", size = 268273, upload-time = "2026-08-17T08:35:29.043Z" }, + { url = "https://files.pythonhosted.org/packages/54/49/c21b228877357a3be43eeeaa22182ad1685796f415390ada475922c084e4/xxhash-4.0.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b42a5a26607e4b2409fea174773a66f2dff9dfdbf2c1a851bb7b804e2c97535", size = 350164, upload-time = "2026-08-17T08:23:29.494Z" }, + { url = "https://files.pythonhosted.org/packages/00/3c/c15bb4aa33d94b78a5553b52e7fa1070565f0199925aeadec3871de20ce9/xxhash-4.0.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:99166cc98637e8bf550cda2aab07f4f1d5f899c45fbd721801aeabcc9d404824", size = 281975, upload-time = "2026-08-17T08:36:08.139Z" }, + { url = "https://files.pythonhosted.org/packages/18/7a/b1d0388315fe7752b7725b68a912667526a1dd48ed492fcc031ac03f4b52/xxhash-4.0.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6cf633df84d80a1668fcf61e330791dae46825e395549e7d34f376411e75088a", size = 307872, upload-time = "2026-08-17T08:22:42.206Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a1/037cb2dd8cf725c9565dfe3712b2915c0e0276a9154913dbfcbcecbeb672/xxhash-4.0.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:e259bb7e1e2d8de6b35f430f5c7220b1c0ebf3962d1ba7ec7545980d5931edb8", size = 268241, upload-time = "2026-08-17T08:23:23.997Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a9/67c44422d0ee082169b238ce24bd2796b82d7c21ed953471365df8c508d8/xxhash-4.0.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:704381264b36a18b9c62ecbabe2e71d0fc58c77c129c15355c989b10bf05b6b0", size = 284970, upload-time = "2026-08-17T08:36:13.476Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/254a5f51c4014cacc77a26f321372338b924f54e89efb730164ee336d850/xxhash-4.0.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:e90b4bcf1d9eb1010fdaee7c9209fb667e74c0684f3ba17f9032bd7319da90c9", size = 338074, upload-time = "2026-08-17T08:22:51.166Z" }, + { url = "https://files.pythonhosted.org/packages/64/03/f21c4830118d72ef3a958ce8bf2152f49e0d4cf200907616c9be6caf372a/xxhash-4.0.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:a65785e653573fcd1e33062760ab4c3c3440e8e910765018e4b6ed4ad07b54a0", size = 487626, upload-time = "2026-08-17T08:35:32.768Z" }, + { url = "https://files.pythonhosted.org/packages/45/1f/268a689d741d7da649317eb4ce41760140beb4179aaf43a7216fdbe8100c/xxhash-4.0.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e3996ff9b6f99180357024336bf5749a8ad6476a9a2523e535c5212b995b12a2", size = 263852, upload-time = "2026-08-17T08:23:41.871Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/adaf8101cd7f143191a0b390600294d83924b32cb13770fde8803dce27a2/xxhash-4.0.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:99054b838b74d8d3995ea0d410976ae967c46207ae22d6ddfc535e809197dab9", size = 20569, upload-time = "2026-08-17T08:36:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/ee/2c/56a5eb8c993420fc07114c08f447a2b66ee996510b4764cb368b9b44c9f0/xxhash-4.0.1-cp315-cp315-win32.whl", hash = "sha256:6c45258a37fc22721395c09927cb982d3e7a83607cab15be7e2416501bd3a330", size = 35145, upload-time = "2026-08-17T08:22:50.038Z" }, + { url = "https://files.pythonhosted.org/packages/67/c7/65f210db43e62157d0fef3b4d4d7b394821e7733c8bb4ece49f91410a725/xxhash-4.0.1-cp315-cp315-win_amd64.whl", hash = "sha256:0ab851b45c70d4992be7cdeeee16f97a0b677408c758c4b1efb1cfe8030bfd37", size = 37161, upload-time = "2026-08-17T08:23:32.438Z" }, + { url = "https://files.pythonhosted.org/packages/33/ae/1a641d1d60ba219756d9ebe907ff0ecf4445adcf4fa96f6e3da57b91d439/xxhash-4.0.1-cp315-cp315-win_arm64.whl", hash = "sha256:a5b21b42a01a343096a1c018d35e9b7aec9c7065dda53ae8da071e37478b2cea", size = 34378, upload-time = "2026-08-17T08:36:15.912Z" }, + { url = "https://files.pythonhosted.org/packages/48/7f/7698b320b251806d1249e513922a626f19027e104c829a611272250350eb/xxhash-4.0.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:44ab12e8cd17d4f001769f00ad465208b4bcb897ed29e65f058f74466b57a98f", size = 38610, upload-time = "2026-08-17T08:22:55.203Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/436497e775b647b3b3e9a4ffe8c76c59fa4aa7a9fab6447cb59acf1b50ea/xxhash-4.0.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:45e88111ebe331de478ef8d4293efbe88f3cf8b863386c9a2357136b838e1af0", size = 36378, upload-time = "2026-08-17T08:35:36.18Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d8/17a4f8182b9257898aa2a77c2a45f70233eb8e50681a280e8e09d2ee76e9/xxhash-4.0.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf430c587f447a554c53768ad76b9846fe7c5632180ef6f69c4fce8b0552fbd0", size = 273559, upload-time = "2026-08-17T08:23:51.075Z" }, + { url = "https://files.pythonhosted.org/packages/83/28/121bd5a5c5adb88e0da772c7bef61964cf9da92956a7a237c7d24c4351b8/xxhash-4.0.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adbd48b30e3f82c89fb2b3e6a87cdd28d113b190a5ed0ee2dee286323ee9a621", size = 299133, upload-time = "2026-08-17T08:36:14.731Z" }, + { url = "https://files.pythonhosted.org/packages/11/8f/57c7b6e04642ed738a0d08a31bed7fc63fdacb661d665f98739cc9751b62/xxhash-4.0.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e71b34978e77868cbf2d18c5206a4603f9c644dd7181bec5643bd40141d3b8c5", size = 314527, upload-time = "2026-08-17T08:22:54.224Z" }, + { url = "https://files.pythonhosted.org/packages/8e/18/42793917dbab0ea1ff71458aea4875e17a7263f2797b798af048dc81e867/xxhash-4.0.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:488ca5c5e28ef56ec4bbb12f835b3f1cbecc5f3510062e70117bc6594851932a", size = 299199, upload-time = "2026-08-17T08:23:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/37/60/51dc92443923d8e908d5614f1145d8d696450f9d6c8f1abe243c6f2a0222/xxhash-4.0.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:421b94f3ba7067958d02e38960d987756347aa150df06df11aa68ae1af78c619", size = 531967, upload-time = "2026-08-17T08:36:18.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/c5/d0de77de09661fac71742c4155b1cd65e274f7cc277819d702b6c8ff2db5/xxhash-4.0.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f33cf0baa91eccd2cb7b62bf00f10c2264ef578b71dd33a12962e71a36eb4d32", size = 278764, upload-time = "2026-08-17T08:22:58.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/9a/589929c655aba1bfb2c41ee03e50eec1547c39c3042a66bda9c173a9614b/xxhash-4.0.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23a4376b4a3183cb50d4d2a3179f887a7773cc695eb2c908e551bec3221b8c60", size = 359876, upload-time = "2026-08-17T08:35:40.35Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a8/c1d8c94d54d91db2215565f4b4151c1593af3e6d27ac4c00fd1e8d714a02/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:38c3d22129a6958846a3098d68bc8e661704461c0be4793ae28836e4690c8478", size = 293548, upload-time = "2026-08-17T08:23:54.951Z" }, + { url = "https://files.pythonhosted.org/packages/8b/67/85d8abca94508a4dd10561d9dea3e6e68843c6986dd6d9c1b3729c8622e4/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:87cbdec1a7dd930079671a60b249f3ca4e773e6fbd0676e21e36fdc9dd0f3b00", size = 318780, upload-time = "2026-08-17T08:36:17.623Z" }, + { url = "https://files.pythonhosted.org/packages/1b/16/2b920ed456b9cdcfc99ddc20c3afe42f9f807ee5850773c12fd891f3c08d/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:6cbf4e21ef0890804b5bb9ad25c48f9c127758d7f6c66bef374efcacc63c738a", size = 277582, upload-time = "2026-08-17T08:22:57.156Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cc/5811b5997aebb8452047f5800d32fc50eaa29d0ba08d4e426f84450b9c2f/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:c101180495cb4ba3617b279a944345c53a5e73b0c150053d1fa8d8af32de9579", size = 295116, upload-time = "2026-08-17T08:23:40.868Z" }, + { url = "https://files.pythonhosted.org/packages/2d/dc/c2f3f9c2f4d6aadb79f17a9f1c9a7ee82638cc873680da044cf29537d2ee/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:c0e6ccc2b19ec8a726b2e26062ac71ea63e15500d6bf85910e42481844fdffc1", size = 347065, upload-time = "2026-08-17T08:36:21.618Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4c/750cc642c92252e10772ec09e1a1d995581ba4c3ceb24f6e2d57c7ce47ca/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:8bcba9456242ebf180a04d9443812fd85ffe6bd12bda464dd116fcece8886ff3", size = 497208, upload-time = "2026-08-17T08:23:17.88Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2d/58693cb13d6395f39b6b9bb40c5e0db53a5df7c9fce805aa7e792f64a1a5/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:83b8c2013edb5dc1f9e7268b6496130705bc48d79c86bb8817b3d210b81a5513", size = 274338, upload-time = "2026-08-17T08:35:44.062Z" }, + { url = "https://files.pythonhosted.org/packages/4a/08/9aa9787586d9b3e92d63343ce7dc24f0f445fd9e74ff5d6e85dd82233df5/xxhash-4.0.1-cp315-cp315t-win32.whl", hash = "sha256:aa6ccc7f31018484d652cf52db020003433f3c9fa83189c028bd807d2adde503", size = 35471, upload-time = "2026-08-17T08:24:05.795Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ab/4615789c333bee331ac417885c50105715eeb8244bfc68d2bc37dcfd63ca/xxhash-4.0.1-cp315-cp315t-win_amd64.whl", hash = "sha256:daade8936c4deaaf7b01561324ce438ba4f885d717e9adc62b4d67212ad7d7bd", size = 37488, upload-time = "2026-08-17T08:36:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/fb/81/49f718beb0c55d0411bc4bd90b50a3fbe5863a0e97a2f4d11682ba13d298/xxhash-4.0.1-cp315-cp315t-win_arm64.whl", hash = "sha256:f00330ac7e24769e2032203f2b01794d670916b0c1799fd261340f1af9499875", size = 34590, upload-time = "2026-08-17T08:23:19.597Z" }, + { url = "https://files.pythonhosted.org/packages/86/79/9127ff42a887a348dc4ce3211cf1a962836887adee6f57078132bfba78b4/xxhash-4.0.1-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:ff48915bf1871a1f19f74c11834c6329443d306cedc0c05fe7fe617810422a80", size = 31836, upload-time = "2026-08-17T08:36:28.261Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/f238693bfdd642adb59c99683964d46d9947fe721ff44d3bd850ae675407/xxhash-4.0.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a76345f5aceb4ec404918edf9c7f2b5507db864dc0d7455982009ac0890b57b", size = 34453, upload-time = "2026-08-17T08:23:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/40/4b/796ace33cdfb75c91ba6d11615c3bd436355b9f3103e05865bbee9abce57/xxhash-4.0.1-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31d86f9e81f3e84e00131ac7c54caf5119ae4ddd82c09c31cff597c813ce1ee2", size = 38488, upload-time = "2026-08-17T08:23:59.901Z" }, + { url = "https://files.pythonhosted.org/packages/ad/23/2d549e5d5d7759eaf9ac2d2d2ab81ff60f1bb2b52cdaae8e5ec5c6524354/xxhash-4.0.1-graalpy312-graalpy250_312_native-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deca2a30d983d240b8375ec2ee0a4288e72042827fc61df2f7671f8467e4cb2f", size = 38206, upload-time = "2026-08-17T08:36:32.193Z" }, + { url = "https://files.pythonhosted.org/packages/79/98/1ee576b27f78e6107ee4ea8ac03e8a52888dff256e57d560f8282c195563/xxhash-4.0.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:7c343ee174d417a44d0c3355602c0cbbfa52a04d1bbbf1723378c7d2c8f60626", size = 37127, upload-time = "2026-08-17T08:23:42.705Z" }, ] [[package]]