From 93c2ddad944436f49180236c32fedaee5afa2133 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 11 Jun 2026 20:29:10 +0200 Subject: [PATCH 1/9] fix: resolution correctness, validation sweep, and crash fixes from API stress test (0.1.3) Fixes ~30 verified bugs across resolution behavior (dotted abbreviations, mixed-case routing, iso_numeric padding, snap label candidates, punctuation no-match), crashes (polars bulk, record output, pickling), eager parameter validation with did-you-mean across all enum-like kwargs, candidate-aware ambiguity hints, configure() sentinel semantics, query-cache mutation isolation, BYOD normalization alignment, and stale docs. Suite: 3717 passed. Geo eval gate: 0.8856 >= 0.8200. --- CHANGELOG.md | 47 +++ docs/explanation/confidence.md | 10 +- docs/explanation/how-resolution-works.md | 10 +- docs/getting-started/first-resolution.md | 6 +- docs/getting-started/install.md | 2 +- docs/reference/api.md | 21 +- docs/reference/resolver.md | 2 +- pyproject.toml | 2 +- src/resolvekit/_convenience.py | 64 ++- src/resolvekit/core/api/batch.py | 7 +- src/resolvekit/core/api/bulk.py | 79 +++- src/resolvekit/core/api/cache.py | 25 +- src/resolvekit/core/api/code_lookup.py | 34 +- src/resolvekit/core/api/entity_lookup.py | 5 +- src/resolvekit/core/api/query_prep.py | 26 +- src/resolvekit/core/api/resolver.py | 170 +++++++- src/resolvekit/core/api/snap.py | 38 +- src/resolvekit/core/byod/build.py | 47 ++- src/resolvekit/core/byod/builder.py | 9 +- src/resolvekit/core/byod/cache.py | 2 +- src/resolvekit/core/config.py | 26 +- src/resolvekit/core/engine/multi_runner.py | 14 +- src/resolvekit/core/errors.py | 84 +++- src/resolvekit/core/explain/result_html.py | 8 +- src/resolvekit/core/model/bulk_result.py | 8 +- src/resolvekit/core/model/entity.py | 26 +- .../core/model/entity_attributes.py | 15 +- src/resolvekit/core/model/name_grammar.py | 29 +- src/resolvekit/core/model/query.py | 30 +- src/resolvekit/core/model/result.py | 15 + src/resolvekit/core/parse/offsets.py | 25 +- src/resolvekit/packs/geo/extractor.py | 6 +- src/resolvekit/packs/geo/routing.py | 30 +- .../packs/geo/sources/_short_input.py | 31 ++ .../packs/geo/sources/exact_code.py | 4 +- tests/api/test_cache_no_mutation_leak.py | 94 +++++ tests/core/test_byod_regressions.py | 150 +++++++ .../test_output_and_configure_validation.py | 334 ++++++++++++++++ tests/core/test_resolver_api.py | 4 +- .../geo/test_dotted_and_casing_integration.py | 85 ++++ tests/packs/geo/test_routing_casing.py | 61 +++ .../packs/geo/test_short_input_initialisms.py | 59 +++ tests/parse/test_automaton.py | 72 ++++ tests/parse/test_offsets.py | 71 ++++ tests/parse/test_parse_api.py | 39 ++ tests/test_ambiguous_error_hints.py | 105 +++++ tests/test_bulk_param_validation.py | 378 ++++++++++++++++++ tests/test_entity_attributes.py | 2 +- tests/test_error_ux.py | 10 +- tests/test_iso_numeric_regressions.py | 371 +++++++++++++++++ tests/test_resolver_param_validation.py | 236 +++++++++++ tests/test_snap_label_candidates.py | 272 +++++++++++++ uv.lock | 2 +- 53 files changed, 3147 insertions(+), 155 deletions(-) create mode 100644 tests/api/test_cache_no_mutation_leak.py create mode 100644 tests/core/test_byod_regressions.py create mode 100644 tests/core/test_output_and_configure_validation.py create mode 100644 tests/packs/geo/test_dotted_and_casing_integration.py create mode 100644 tests/packs/geo/test_routing_casing.py create mode 100644 tests/packs/geo/test_short_input_initialisms.py create mode 100644 tests/test_ambiguous_error_hints.py create mode 100644 tests/test_bulk_param_validation.py create mode 100644 tests/test_iso_numeric_regressions.py create mode 100644 tests/test_resolver_param_validation.py create mode 100644 tests/test_snap_label_candidates.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 847e782..88a58ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # Changelog +## 0.1.3 (2026-06-11) + +Bug-fix release from a systematic stress-test of the public API surface. ~30 +verified fixes, no new features. + +**Resolution correctness.** Dotted abbreviations are no longer misclassified +as missing-value markers: `"U.S.A."` resolves to `country/USA` (it previously +resolved to an unrelated org entity) and `"U.K."` to `country/GBR`, while +genuine null markers (`#N/A`, `--`, `.`) still return no match. Mixed-case +inputs (`"fRaNcE"`, `"SUDan"`) resolve like their standard casings. +Zero-padded ISO numeric codes (`"004"`) now resolve, `to="iso_numeric"` emits +the canonical zero-padded form, and the pycountry-style `numeric` alias works +(`entity("France").numeric` → `"250"`). `snap()` accepts free-text candidate +labels as documented, alongside entity IDs. Punctuation-only inputs (`"."`, +`"?"`) return no match instead of an internal error. `EntityRecord.aliases` +no longer leaks the canonical name or duplicates. + +**Crashes.** `bulk()` no longer crashes on polars Series input without a +pivot; `output="record"` builds primitive records that `to_polars()` accepts. +`ResolutionResult` pickles cleanly. + +**Validation and errors.** Enum-like parameters are validated eagerly with +did-you-mean suggestions instead of silently accepting typos: `on_ambiguous` +(`resolve_id`), `on_missing`/`on_error`/`on_ambiguous` (`bulk`), `default_to` +types (`configure`), `confidence_threshold` and `domain` (`parse`), +`name:` language segments, and `ResolutionContext.country`. +`AmbiguousResolutionError` hints are candidate-aware (no longer suggesting +`entity_types=` when the tied candidates share one type) and `str()` previews +the top candidates. `to=` errors suggest the closest code system +instead of dumping all of them, and the `domain=`-with-auto-routing error no +longer references internals. `from_records()` reports the offending row and +column for empty name cells. + +**Behavior consistency.** `configure()` no longer clears settings that are +omitted from the call; passing `None` explicitly resets a setting to its +default (`cache_dir`, `default_to`). Mutating a returned result's lists no +longer corrupts the query cache. `as_of=` accepts ISO date strings on +`members_of`/`is_member`/`related`/`within`. `bulk()` pandas output preserves +`None` under pandas 3. `available_entity_types()` returns the fine-grained +types that `entity_types=` accepts. BYOD labels containing +NFKC-compatibility characters (`™`, `№`) now round-trip; existing BYOD disk +caches are rebuilt automatically. + +**Docs.** Corrected the `snap()` candidate guidance and the +`UnknownOutputError`/`UnknownCodeSystemError` reference entries; refreshed +stale confidence figures in the tutorials. + ## 0.1.2 (2026-06-11) **Fixed.** `download()` crashed on a clean install with "Missing package diff --git a/docs/explanation/confidence.md b/docs/explanation/confidence.md index 5e6b481..33afb6d 100644 --- a/docs/explanation/confidence.md +++ b/docs/explanation/confidence.md @@ -16,15 +16,15 @@ Match quality drives the score directly. An exact code or canonical-name match s import resolvekit as rk result_exact = rk.resolve("Germany") -print(result_exact.confidence) # ≈ 0.91 +print(result_exact.confidence) # ≈ 0.93 print(result_exact.match_tier) # exact_name result_fuzzy = rk.resolve("Germny") # one character missing -print(result_fuzzy.confidence) # ≈ 0.91 +print(result_fuzzy.confidence) # ≈ 0.93 print(result_fuzzy.match_tier) # exact_name ``` -Both resolve to `country/DEU`. A canonical-name query like `"Germany"` hits `exact_name`; a code query like `"DEU"` or `"DE"` hits `exact_code` and scores slightly higher (≈ 0.95). The one-character typo in `"Germny"` goes through SymSpell correction first, so it also lands on `exact_name` at a similar confidence level. +Both resolve to `country/DEU`. A canonical-name query like `"Germany"` hits `exact_name`; a code query like `"DEU"` or `"DE"` hits `exact_code` and scores slightly higher (≈ 0.96). The one-character typo in `"Germny"` goes through SymSpell correction first, so it also lands on `exact_name` at a similar confidence level. !!! note Exact confidence values shift slightly when the calibrator is retrained on new labeled data. Treat reported floats as approximate. @@ -84,12 +84,12 @@ r = rk.Resolver.auto(confidence_threshold=0.99) result = r.resolve("Germny") print(result.status) # no_match -print(result.confidence) # ≈ 0.91 +print(result.confidence) # ≈ 0.93 print(result.reasons) # [] r.close() ``` -Here `confidence ≈ 0.91` tells you there's a strong candidate (`country/DEU`) that just didn't clear the stricter threshold. A confidence of `None` on a `no_match` means no candidate reached the pipeline at all—nothing remotely matched. +Here `confidence ≈ 0.93` tells you there's a strong candidate (`country/DEU`) that just didn't clear the stricter threshold. A confidence of `None` on a `no_match` means no candidate reached the pipeline at all—nothing remotely matched. ## Adjusting the threshold diff --git a/docs/explanation/how-resolution-works.md b/docs/explanation/how-resolution-works.md index 6982006..26b0c4e 100644 --- a/docs/explanation/how-resolution-works.md +++ b/docs/explanation/how-resolution-works.md @@ -100,8 +100,8 @@ The confidence is meaningful in absolute terms — it's calibrated against real | Match tier | Typical confidence | |---|---| -| `exact_code` | ~0.95 | -| `exact_name` | ~0.91 | +| `exact_code` | ~0.96 | +| `exact_name` | ~0.93 | | `fts` (accent-stripped or partial) | ~0.84 | | `fuzzy` | varies; often 0.70–0.88 | @@ -130,7 +130,7 @@ import resolvekit as rk # Resolved: single clear winner rk.resolve("Germany").status # resolved rk.resolve("Germany").match_tier # exact_name ("Germany" is a name, not a code) -rk.resolve("Germany").confidence # ≈ 0.91 +rk.resolve("Germany").confidence # ≈ 0.93 # Ambiguous: two Congos, similar confidence rk.resolve("Congo").status # ambiguous @@ -159,7 +159,7 @@ Query: "United States" Normalized: "united states" Status: RESOLVED Entity: country/USA -Confidence: 90.8% +Confidence: 93.3% Reasons: exact_name_match Pack: geo Match Tier: exact_name @@ -182,7 +182,7 @@ Match Details: - very close edit-distance match ``` -`"United States"` resolves at 90.8% via `exact_name` rather than ~95% via `exact_code` because the input was matched against the canonical name index, not a code. The score is high but not as high as submitting `"USA"` directly. +`"United States"` resolves at 93.3% via `exact_name` rather than ~96% via `exact_code` because the input was matched against the canonical name index, not a code. The score is high but not as high as submitting `"USA"` directly. Verbosity levels are `"minimal"` (status + entity_id + confidence), `"standard"` (adds sources, features, and alternatives), and `"full"` (adds trace events and timing). diff --git a/docs/getting-started/first-resolution.md b/docs/getting-started/first-resolution.md index b166311..f47b1bc 100644 --- a/docs/getting-started/first-resolution.md +++ b/docs/getting-started/first-resolution.md @@ -85,10 +85,10 @@ print(round(r.confidence, 2)) ``` ``` -0.91 +0.93 ``` -That's about 91%. The score reflects how the match was found: exact code matches score near 1.0; alias hits like "Brasil" score a bit lower. A score below ~0.70 is abstained by default — you won't see it come back as a resolved result. +That's about 93%. The score reflects how the match was found: exact code matches score near 1.0; alias hits like "Brasil" score a bit lower. A score below ~0.70 is abstained by default — you won't see it come back as a resolved result. For a full breakdown, call `.explain()`: @@ -106,7 +106,7 @@ Query: "Brasil" Normalized: "brasil" Status: RESOLVED Entity: country/BRA -Confidence: 90.8% +Confidence: 92.8% Reasons: exact_name_match Pack: geo Match Tier: exact_name diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 8c31f5e..7fe0ca1 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -75,7 +75,7 @@ rk.download("geo.admin1") # ~12 MB; verifies checksum, then marks is_available ```python >>> import resolvekit as rk >>> rk.__version__ -'0.1.2' +'0.1.3' >>> rk.resolve_id("United States") 'country/USA' ``` diff --git a/docs/reference/api.md b/docs/reference/api.md index d21390f..a2c620b 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -250,8 +250,8 @@ Return the closest matching candidate from a caller-supplied list, or `None` whe None ``` -!!! warning "Heads up" - `snap` works reliably when `candidates` contains entity IDs (e.g. `"country/TZA"`). Passing plain name strings (e.g. `"Tanzania"`) will likely return `None` at the default threshold — the resolver resolves each candidate name first, and near-miss names don't always clear 0.5 confidence. Use entity IDs for predictable results. +!!! note "Candidate forms" + `candidates` accepts entity IDs (e.g. `"country/TZA"`), plain labels (e.g. `"Tanzania"`), or a mix. Labels are resolved to entities first; a label that cannot be resolved unambiguously is skipped from the candidate set. --- @@ -1255,7 +1255,7 @@ except EntityNotFoundError as e: ### `UnknownOutputError(ValueError, ResolverError)` { #unknownoutputerror } -Raised at configuration or compile time when `default_to` (or a per-call `to=`) contains a malformed token or names a code system that no loaded pack carries. +Raised at configuration or compile time when `default_to` contains a malformed token (including a malformed `name:` grammar segment in a per-call `to=`) or names a code system that no loaded pack carries. A per-call `to=` naming an unknown code system raises [`UnknownCodeSystemError`](#unknowncodesystemerror) instead. ```python from resolvekit.errors import UnknownOutputError @@ -1268,6 +1268,21 @@ from resolvekit.errors import UnknownOutputError Carries `.hint` with difflib did-you-mean suggestions. +### `UnknownCodeSystemError(ValueError, ResolverError)` { #unknowncodesystemerror } + +Raised when a per-call `to=` (or `EntityRecord.to(system)`) names a code system that no loaded pack carries, and by `Resolver.members_of` when the requested `as_codes` is not loaded. + +```python +from resolvekit.errors import UnknownCodeSystemError +``` + +| Attribute | Type | Meaning | +|---|---|---| +| `.system` | `str` | The requested code system name. | +| `.available` | `list[str]` | Code system names available in the relevant scope. | + +Carries `.hint` with difflib did-you-mean suggestions. + ### `OutputMissingError(ResolverError)` { #outputmissingerror } Raised at runtime when a resolved entity (and the full fallback chain) has no value for the requested output, under `on_missing="raise"` (or `on_missing="auto"` for scalar `resolve()`/`snap()`). diff --git a/docs/reference/resolver.md b/docs/reference/resolver.md index be655b4..8356ffe 100644 --- a/docs/reference/resolver.md +++ b/docs/reference/resolver.md @@ -660,7 +660,7 @@ Resolver.lite().domains # ['geo'] ```python r.info.data_version # "2026.06" -r.info.resolvekit_version # "0.1.2" +r.info.resolvekit_version # "0.1.3" r.info.domains # ("geo", "org") r.info.routing_mode # "auto" r.info.closed # False diff --git a/pyproject.toml b/pyproject.toml index 449a281..18b643e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "resolvekit" -version = "0.1.2" +version = "0.1.3" description = "Entity and place resolution system that maps messy place/entity strings and codes to canonical entities" requires-python = ">=3.12" diff --git a/src/resolvekit/_convenience.py b/src/resolvekit/_convenience.py index 8f848f7..6b13149 100644 --- a/src/resolvekit/_convenience.py +++ b/src/resolvekit/_convenience.py @@ -15,7 +15,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Literal -from resolvekit.core.config import _UNSET as _ON_MISSING_UNSET +from resolvekit.core.config import _UNSET as _CONFIG_UNSET if TYPE_CHECKING: from resolvekit.core.api.modules import ModuleInfo @@ -94,9 +94,9 @@ def default() -> Resolver: def configure( *, auto_download: bool | None = None, - cache_dir: str | Path | None = None, - default_to: str | list[str] | None = None, - on_missing: Literal["raise", "null", "auto"] = _ON_MISSING_UNSET, # type: ignore[assignment] # ty: ignore[invalid-parameter-default] + cache_dir: str | Path | None | object = _CONFIG_UNSET, + default_to: str | list[str] | None | object = _CONFIG_UNSET, + on_missing: Literal["raise", "null", "auto"] | object = _CONFIG_UNSET, ) -> None: """Configure resolvekit runtime behavior and invalidate the singleton. @@ -104,13 +104,19 @@ def configure( call to :func:`resolve` (or any module-level function) rebuilds it with the updated configuration. + Omitting a parameter leaves any previously configured value unchanged. + Args: auto_download: If True, remote packs are downloaded automatically - when needed. Default is False. + when needed. ``None`` leaves the current setting unchanged. cache_dir: Custom cache directory for remote data packs. + ``None`` resets to the platform default (removes any custom path). + Omitting leaves the current setting unchanged. default_to: Default output code system or name variant for module-level resolve/bulk/snap (e.g. ``"iso3"``, - ``["iso3", "name"]``, ``"name:fr"``). ``None`` clears the default. + ``["iso3", "name"]``, ``"name:fr"``). ``None`` clears the default + so resolve() returns a raw ResolutionResult. Omitting leaves + the current setting unchanged. on_missing: Miss policy for the default output chain. ``"auto"`` = raise for scalar resolve/snap, null + ``UserWarning`` for bulk; ``"raise"`` always raises @@ -119,6 +125,8 @@ def configure( unchanged. Raises: + ValueError: When ``default_to`` is not a ``str``, ``list[str]``, or + ``None``. UnknownOutputError: Immediately when ``default_to`` contains a malformed ``name:`` grammar token, or — when a default resolver singleton already exists — when ``default_to`` names an unknown @@ -128,25 +136,45 @@ def configure( from resolvekit.core.api.output_spec import _validate_grammar_only from resolvekit.core.config import configure as _configure_core - # Grammar-only validation is always eager to catch malformed name: tokens. - _validate_grammar_only(default_to) - - # If a singleton exists, compile fully against its code_systems to surface - # typo'd code systems immediately rather than deferring. Fall back to "auto" - # for compile when on_missing was not explicitly passed. - compile_on_missing = "auto" if on_missing is _ON_MISSING_UNSET else on_missing - if _default is not None and default_to is not None: + if default_to is not _CONFIG_UNSET: + # Type validation: only str, list[str], or None are accepted at configure time. + if default_to is not None and not isinstance(default_to, (str, list)): + raise ValueError( + f"default_to must be a str, list of str, or None;" + f" got {type(default_to).__name__!r}" + ) + if isinstance(default_to, list): + bad = [x for x in default_to if not isinstance(x, str)] + if bad: + raise ValueError( + f"default_to list must contain only strings;" + f" got {[type(x).__name__ for x in bad]}" + ) + # Grammar-only validation is always eager to catch malformed name: tokens. + if default_to is not None: + _validate_grammar_only(default_to) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + + # If a singleton exists and default_to is a non-None value, compile fully + # against its code_systems to surface typo'd code systems immediately. + compile_on_missing = "auto" if on_missing is _CONFIG_UNSET else on_missing + if ( + _default is not None + and default_to is not _CONFIG_UNSET + and default_to is not None + ): from resolvekit.core.api.output_spec import compile_output_spec compile_output_spec( - default_to, compile_on_missing, known_systems=_default.code_systems() + default_to, # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + compile_on_missing, # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + known_systems=_default.code_systems(), ) _configure_core( auto_download=auto_download, - cache_dir=cache_dir, - default_to=default_to, - on_missing=on_missing, # passes through _ON_MISSING_UNSET when not provided + cache_dir=cache_dir, # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + default_to=default_to, # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + on_missing=on_missing, ) # Invalidate the singleton so the next call rebuilds with new config. reset() diff --git a/src/resolvekit/core/api/batch.py b/src/resolvekit/core/api/batch.py index e8fb899..9ae247c 100644 --- a/src/resolvekit/core/api/batch.py +++ b/src/resolvekit/core/api/batch.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING from resolvekit.core.api.loading import _normalize_domain +from resolvekit.core.api.query_prep import _auto_routing_domain_error from resolvekit.core.engine import RoutingMode from resolvekit.core.model import ( ReasonCode, @@ -137,11 +138,7 @@ def resolve_series_dedup( import pandas as pd if domain is not None and self._routing_mode == RoutingMode.AUTO: - raise ValueError( - "Cannot specify domains with AUTO routing mode. " - "Use RoutingMode.EXPLICIT for caller-controlled pack selection, " - "or remove domain to let AUTO mode decide." - ) + raise ValueError(_auto_routing_domain_error()) mask_na_arr = series.isna().to_numpy() # Cast to object before filling: typed (e.g. Int64, categorical) Series diff --git a/src/resolvekit/core/api/bulk.py b/src/resolvekit/core/api/bulk.py index 139195e..c7ac835 100644 --- a/src/resolvekit/core/api/bulk.py +++ b/src/resolvekit/core/api/bulk.py @@ -10,6 +10,7 @@ from __future__ import annotations +import difflib import warnings import weakref from typing import Any, Literal @@ -37,6 +38,17 @@ from resolvekit.core.model.entity_attributes import dispatch_pivot from resolvekit.core.model.result import ReasonCode +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _closest_match(value: str, choices: tuple[str, ...]) -> str | None: + """Return the closest match to *value* from *choices*, or None.""" + matches = difflib.get_close_matches(value, choices, n=1, cutoff=0.6) + return matches[0] if matches else None + + # --------------------------------------------------------------------------- # Module-level sentinels for the crosswalk short-circuit # --------------------------------------------------------------------------- @@ -103,10 +115,32 @@ def _detect_input_kind(values: Any) -> tuple[_InputKind, Any]: setattr(err, "hint", "materialize first: list(values)") # noqa: B010 raise err - raise TypeError( - f"bulk() values must be a list, tuple, numpy ndarray, pd.Series, or " - f"pl.Series; got {type(values).__name__!r}" + # Check if a DataFrame was passed — give a column-extraction hint. + _df_hint: str | None = None + try: + import pandas as pd + + if isinstance(values, pd.DataFrame): + _df_hint = "extract one column first: values['col_name']" + except ImportError: + pass + if _df_hint is None: + try: + import polars as pl + + if isinstance(values, pl.DataFrame): + _df_hint = ( + "extract one column first: " + "values['col_name'] or values.get_column('col_name')" + ) + except ImportError: + pass + + _base_msg = ( + f"bulk() values must be a list, tuple, dict, numpy ndarray, " + f"pd.Series, or pl.Series; got {type(values).__name__!r}" ) + raise TypeError(_base_msg + (f"; {_df_hint}" if _df_hint else "")) # --------------------------------------------------------------------------- @@ -549,6 +583,27 @@ def _bulk_dispatch( f"output={output!r} is not valid; " "expected one of 'series', 'record', 'frame'" ) + if on_error not in {"raise", "null", "keep"}: + _did_you_mean = _closest_match(on_error, ("raise", "null", "keep")) + raise ValueError( + f"on_error={on_error!r} is not valid; " + f"expected one of 'raise', 'null', 'keep'" + + (f"; did you mean {_did_you_mean!r}?" if _did_you_mean else "") + ) + if on_ambiguous not in {"raise", "null", "best"}: + _did_you_mean = _closest_match(on_ambiguous, ("raise", "null", "best")) + raise ValueError( + f"on_ambiguous={on_ambiguous!r} is not valid; " + f"expected one of 'raise', 'null', 'best'" + + (f"; did you mean {_did_you_mean!r}?" if _did_you_mean else "") + ) + if on_missing not in {"raise", "null", "auto"}: + _did_you_mean = _closest_match(on_missing, ("raise", "null", "auto")) + raise ValueError( + f"on_missing={on_missing!r} is not valid; " + f"expected one of 'raise', 'null', 'auto'" + + (f"; did you mean {_did_you_mean!r}?" if _did_you_mean else "") + ) kind, raw = _detect_input_kind(values) items, orig_index, orig_name, orig_polars_name, orig_keys = _flatten_input( @@ -683,9 +738,19 @@ def _bulk_dispatch( def _record_from_result(result: ResolutionResult, pivot_value: Any) -> dict[str, Any]: - """Build the per-row record dict used by output='record' / 'frame'.""" + """Build the per-row record dict used by output='record' / 'frame'. + + When no pivot is active, ``pivot_value`` is the raw ``ResolutionResult`` + object. We extract ``entity_id`` as a primitive rather than embedding the + model — polars cannot store nested pydantic objects in a Struct Series. + """ + if isinstance(pivot_value, ResolutionResult): + # No pivot was applied; use entity_id as the scalar "value". + value: Any = pivot_value.entity_id + else: + value = pivot_value return { - "value": pivot_value, + "value": value, "status": result.status.value, "entity_id": result.entity_id, "confidence": result.confidence, @@ -769,11 +834,11 @@ def _build_native( if kind == "pandas": import pandas as pd - return pd.Series(values, index=orig_index, name=orig_name) + return pd.Series(values, index=orig_index, name=orig_name, dtype=object) if kind == "polars": import polars as pl - return pl.Series(name=orig_polars_name or "", values=values) + return pl.Series(name=orig_polars_name or "", values=values, dtype=pl.Object) if kind == "numpy": import numpy as np diff --git a/src/resolvekit/core/api/cache.py b/src/resolvekit/core/api/cache.py index c8ce7d8..f32aac4 100644 --- a/src/resolvekit/core/api/cache.py +++ b/src/resolvekit/core/api/cache.py @@ -48,6 +48,29 @@ class CacheInfo(NamedTuple): currsize: int +# ResolutionResult fields that are mutable containers and therefore must not +# be shared between a cached entry and the result handed back to a caller. +# In-place mutation (e.g. ``result.reasons.append(...)``) would otherwise +# poison every subsequent cache hit for the same query. +_MUTABLE_LIST_FIELDS = ("candidates", "reasons", "refinement_hints") + + +def _detach_mutables(result: ResolutionResult) -> ResolutionResult: + """Return a shallow copy of *result* with fresh mutable list fields. + + The query cache returns the same instance on every hit, and pydantic's + ``model_copy`` is shallow, so list fields would otherwise be shared with + the cached entry. We rebuild those lists (the elements are frozen models / + enums, so copying the containers alone is sufficient) and preserve the + ``_explainer`` back-reference that ``model_copy`` may drop. + """ + copy = result.model_copy( + update={field: list(getattr(result, field)) for field in _MUTABLE_LIST_FIELDS} + ) + copy._explainer = result._explainer + return copy + + class _QueryCache: """Per-instance LRU wrapping ``functools.lru_cache``. @@ -90,7 +113,7 @@ def get_or_call( domain_key = frozenset(domains) if domains else frozenset() key = (raw_text, 0 if context is None else id(context), domain_key) try: - return self._lookup(key) + return _detach_mutables(self._lookup(key)) finally: self._pending = None diff --git a/src/resolvekit/core/api/code_lookup.py b/src/resolvekit/core/api/code_lookup.py index 5124696..c6f89ef 100644 --- a/src/resolvekit/core/api/code_lookup.py +++ b/src/resolvekit/core/api/code_lookup.py @@ -35,7 +35,7 @@ _CODE_SYSTEM_PRIORITY: list[str] = [ "iso3", "iso2", - "numeric", + "iso_numeric", "dcid", "wikidata", ] @@ -51,6 +51,22 @@ ] +def _iso_numeric_lookup_value(value_norm: str) -> str: + """Return the store-side lookup key for an iso_numeric value. + + Bundled data stores iso_numeric values unpadded (e.g. ``'4'`` for + Afghanistan). Strip leading zeros so zero-padded canonical inputs + like ``'004'`` still hit the correct row. + + Args: + value_norm: Normalised (casefolded) iso_numeric value. + + Returns: + Value with leading zeros stripped, or ``'0'`` for the all-zero input. + """ + return value_norm.lstrip("0") or "0" + + def looks_like_code(value: str) -> bool: """Return True if *value* matches a known code-shape pattern. @@ -77,7 +93,7 @@ def __init__(self, *, runner: ResolverBackend) -> None: def sorted_code_systems(self) -> list[str]: """Return code systems in stable priority order for auto-detect. - Priority: iso3 > iso2 > numeric > dcid > wikidata > iso2 > iso_numeric > dcid > wikidata > > . The first five slots are hard-wired; remaining systems are sorted alphabetically. """ @@ -160,8 +176,13 @@ def resolve_or_lookup( value_norm = self._runner.normalize_code_value( from_system, value, pack_filter=pack_filter ) + lookup_norm = ( + _iso_numeric_lookup_value(value_norm) + if from_system == "iso_numeric" + else value_norm + ) entity_ids = self._runner.lookup_code( - from_system, value_norm, pack_filter=pack_filter + from_system, lookup_norm, pack_filter=pack_filter ) if not entity_ids: return ResolutionResult( @@ -188,8 +209,13 @@ def resolve_or_lookup( value_norm = self._runner.normalize_code_value( system, value, pack_filter=pack_filter ) + lookup_norm = ( + _iso_numeric_lookup_value(value_norm) + if system == "iso_numeric" + else value_norm + ) ids = self._runner.lookup_code( - system, value_norm, pack_filter=pack_filter + system, lookup_norm, pack_filter=pack_filter ) if ids: hits[system] = ids[0] diff --git a/src/resolvekit/core/api/entity_lookup.py b/src/resolvekit/core/api/entity_lookup.py index 5a86552..1a8de26 100644 --- a/src/resolvekit/core/api/entity_lookup.py +++ b/src/resolvekit/core/api/entity_lookup.py @@ -19,6 +19,7 @@ from typing import TYPE_CHECKING +from resolvekit.core.api.code_lookup import _iso_numeric_lookup_value from resolvekit.core.errors import AmbiguousResolutionError from resolvekit.core.model import CandidateSummary @@ -65,7 +66,7 @@ def _entity_dispatch( if alpha_3 is not None: named_codes["iso3"] = alpha_3 if numeric is not None: - named_codes["numeric"] = numeric + named_codes["iso_numeric"] = numeric if iso2 is not None: named_codes["iso2"] = iso2 if iso3 is not None: @@ -90,6 +91,8 @@ def _entity_dispatch( value_norm = resolver._runner.normalize_code_value( system, value, pack_filter=pack_filter ) + if system == "iso_numeric": + value_norm = _iso_numeric_lookup_value(value_norm) entity_ids = resolver._runner.lookup_code( system, value_norm, pack_filter=pack_filter ) diff --git a/src/resolvekit/core/api/query_prep.py b/src/resolvekit/core/api/query_prep.py index b0942c0..2b2b9d3 100644 --- a/src/resolvekit/core/api/query_prep.py +++ b/src/resolvekit/core/api/query_prep.py @@ -25,6 +25,26 @@ from resolvekit.core.engine.interfaces import ResolverBackend +def _auto_routing_domain_error() -> str: + """Actionable message for ``domain=`` under the default AUTO routing mode. + + The module-level ``resolvekit.resolve()`` / ``resolvekit.snap()`` singleton + runs in AUTO mode and exposes no way to switch routing, so the remedy is to + build a resolver with explicit routing. The message names the public string + spelling (``routing_mode="explicit"``) rather than the internal + ``RoutingMode`` enum, which is not importable from the public namespace. + """ + return ( + "domain= is not supported under the default AUTO routing mode " + "(AUTO selects packs from the query itself). To filter by domain, " + "build a resolver with explicit routing, e.g. " + 'resolvekit.Resolver.auto(routing_mode="explicit") or ' + 'resolvekit.Resolver.from_modules(module_ids=[...], routing_mode="explicit"), ' + "then call .resolve(text, domain=...). Otherwise drop domain= and let " + "AUTO decide." + ) + + class QueryPreparer: """Owns normalization and query-preparation logic; no Resolver dependency. @@ -73,11 +93,7 @@ def prepare_query( UnknownDomainError: When a domain name is not registered. """ if self._routing_mode == RoutingMode.AUTO and domains: - raise ValueError( - "Cannot specify domains with AUTO routing mode. " - "Use RoutingMode.EXPLICIT for caller-controlled pack selection, " - "or remove domains to let AUTO mode decide." - ) + raise ValueError(_auto_routing_domain_error()) if domains: available_packs = self._runner.available_packs if available_packs: diff --git a/src/resolvekit/core/api/resolver.py b/src/resolvekit/core/api/resolver.py index 2664714..4d33804 100644 --- a/src/resolvekit/core/api/resolver.py +++ b/src/resolvekit/core/api/resolver.py @@ -3,7 +3,7 @@ import logging import weakref from collections.abc import Sequence -from datetime import date +from datetime import date, datetime from functools import cached_property from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, cast, overload @@ -83,6 +83,72 @@ # Default maximum query length for safety DEFAULT_MAX_QUERY_LENGTH = 1000 +# Accepted on_ambiguous policy values for resolve_id(). +_ON_AMBIGUOUS_VALUES = ("raise", "null", "best") + + +def _on_ambiguous_error(value: object) -> str: + """Build the ValueError message for an invalid on_ambiguous value.""" + import difflib + + msg = ( + f"on_ambiguous={value!r} is not valid; " + f"expected one of {', '.join(repr(v) for v in _ON_AMBIGUOUS_VALUES)}" + ) + if isinstance(value, str): + close = difflib.get_close_matches( + value.lower(), _ON_AMBIGUOUS_VALUES, n=1, cutoff=0.5 + ) + if close: + msg += f"; did you mean {close[0]!r}?" + return msg + + +def _validate_confidence_threshold(value: object) -> None: + """Eagerly validate a ``confidence_threshold`` argument. + + Mirrors the eager, named validation ``timeout=`` gets: a non-numeric or + out-of-range value raises at the call boundary instead of crashing deep in + the parse/link pipeline only when a span happens to resolve. + """ + if value is None: + return + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError( + f"confidence_threshold={value!r} is not valid; " + "expected a number in [0.0, 1.0] or None" + ) + if not 0.0 <= value <= 1.0: + raise ValueError( + f"confidence_threshold={value!r} is out of range; " + "expected a number in [0.0, 1.0] or None" + ) + + +def _coerce_as_of(value: "date | str | None") -> "date | None": + """Coerce an ``as_of`` argument to ``datetime.date``. + + Mirrors ``ResolutionContext(as_of=...)``: ISO date strings + (``"2020-01-01"``) are accepted and coerced, ``datetime``/``date`` pass + through, and anything else raises a clear ``ValueError`` / ``TypeError`` + at the call boundary rather than crashing deep in the store layer. + """ + if isinstance(value, datetime): + return value.date() + if value is None or isinstance(value, date): + return value + if isinstance(value, str): + try: + return date.fromisoformat(value) + except ValueError as e: + raise ValueError( + f"as_of={value!r} is not a valid ISO date; " + "expected a datetime.date or an ISO-8601 string like '2020-01-01'" + ) from e + raise TypeError( + f"as_of must be a datetime.date or ISO-8601 string, got {type(value).__name__}" + ) + # Shared singleton used when callers pass context=None. Reusing the same # instance across calls keeps the query cache's id(context) key stable. @@ -465,13 +531,71 @@ def info(self) -> "ResolverInfo": ) def available_entity_types(self) -> frozenset[str]: - """Return all entity type prefixes declared by loaded packs.""" - return self._runner.available_entity_types + """Return the fine-grained entity types declared by loaded packs. + + Returns dotted types such as ``"geo.country"`` — the same granularity + ``ResolutionContext(entity_types=...)`` accepts and that + :func:`resolvekit.modules` reports — so callers can feed the result + straight into a refinement query. + + Resolvers built from the bundled catalog (``lite()``, ``auto()``, + ``from_modules()``) report full types from the module manifest. A + resolver built from raw datapack paths that are absent from the + manifest falls back to the coarse domain prefixes the runner declares + (e.g. ``"geo"``). + """ + full = self._full_entity_types() + return full if full is not None else self._runner.available_entity_types + + def _full_entity_types(self) -> frozenset[str] | None: + """Map every loaded module to its manifest entity types. + + Returns ``None`` when any loaded module is missing from the manifest + (e.g. raw ``from_datapacks`` paths), signalling the caller to fall back + to the runner's coarse prefixes rather than report mixed granularity. + """ + from resolvekit.core.api.modules import modules as _list_modules + + loaded_ids = { + module.metadata.module_id + for modules in self._loaded_modules.values() + for module in modules + } + if not loaded_ids: + return None + by_id = {info.module_id: info.entity_types for info in _list_modules()} + if not loaded_ids <= by_id.keys(): + return None + types: set[str] = set() + for module_id in loaded_ids: + types.update(by_id[module_id]) + return frozenset(types) if types else None def code_systems(self) -> frozenset[str]: """Return all code system names known to loaded packs.""" return self._runner.available_code_systems + def _validate_parse_domain(self, domain: str | list[str] | None) -> None: + """Reject unknown ``domain`` names for parse(), mirroring resolve(). + + ``parse()`` detects over every loaded pack regardless of routing mode, + so an unknown name is always a caller typo. The parse engine silently + intersects requested domains with the available packs; validating here + surfaces a typo as ``UnknownDomainError`` instead of an empty result + indistinguishable from "no entities found". + """ + if domain is None: + return + requested = _normalize_domain(domain) + if not requested: + return + available = self._runner.available_packs + if not available: + return + unknown = sorted(requested - available) + if unknown: + raise UnknownDomainError(unknown, sorted(available)) + # ------------------------------------------------------------------ # Group / membership surface — thin delegations to GroupAPI # ------------------------------------------------------------------ @@ -480,7 +604,7 @@ def members_of( self, group: str, *, - as_of: date | None = None, + as_of: date | str | None = None, as_codes: str | None = None, ) -> list[str]: """Return entity IDs (or codes) of all members of the given group. @@ -490,6 +614,8 @@ def members_of( Examples: "EU", "European Union", "country/EuropeanUnion", "NATO", "EU27", "G8". as_of: Reference date for membership lookup. Defaults to today. + Accepts a ``datetime.date`` or an ISO-8601 string + (``"2020-01-01"``); an invalid string raises ``ValueError``. **Warning:** For snapshot entities (frozen membership, e.g. "EU27", "EU28", "G8", "BRIC"), as_of has no effect — passing one emits a UserWarning so callers iterating future-state scenarios @@ -519,7 +645,7 @@ def members_of( raise RuntimeError("Resolver has been closed") return self._group_api.members_of( group, - as_of=as_of, + as_of=_coerce_as_of(as_of), as_codes=as_codes, resolve_fn=self.resolve, # type: ignore[arg-type] # ty: ignore[invalid-argument-type] ) @@ -529,14 +655,15 @@ def is_member( country: str, group: str, *, - as_of: date | None = None, + as_of: date | str | None = None, ) -> bool: """Check whether a country is a member of a group on the given date. Args: country: Country name, code, or entity ID (same forms as resolve()). group: Group name, abbreviation, or entity ID. - as_of: Reference date. Defaults to today. + as_of: Reference date. Defaults to today. Accepts a + ``datetime.date`` or an ISO-8601 string (``"2020-01-01"``). **Warning:** For snapshot groups, as_of has no effect; passing one emits a UserWarning. @@ -557,7 +684,7 @@ def is_member( return self._group_api.is_member( country, group, - as_of=as_of, + as_of=_coerce_as_of(as_of), resolve_fn=self.resolve, # type: ignore[arg-type] # ty: ignore[invalid-argument-type] ) @@ -1066,7 +1193,7 @@ def related( entity_or_id: "str | EntityRecord", *, relation: str | None = None, - as_of: date | None = None, + as_of: date | str | None = None, to: str | None = None, ) -> "list[EntityRecord] | list[str | None]": """Return resolved related entities for *entity_or_id*, deduped, in edge order. @@ -1091,7 +1218,8 @@ def related( ``"contained_in"``). ``None`` follows all edge types. as_of: When given, only edges whose validity window includes this date are considered (half-open ``[valid_from, valid_until)``). - ``None`` returns all edges regardless of date. + ``None`` returns all edges regardless of date. Accepts a + ``datetime.date`` or an ISO-8601 string (``"2020-01-01"``). to: When given, pivot each resolved entity via ``EntityRecord.to(to)`` and return code/attribute strings instead of EntityRecord objects. Must be a scalar code system @@ -1136,8 +1264,9 @@ def related( entity = self._resolve_entity_arg(entity_or_id) + as_of_date = _coerce_as_of(as_of) effective_as_of_str: str | None = ( - as_of.isoformat() if as_of is not None else None + as_of_date.isoformat() if as_of_date is not None else None ) seen: set[str] = set() out: list[EntityRecord] = [] @@ -1254,7 +1383,7 @@ def within( entity_type: str | list[str] | None = None, recursive: bool = True, max_depth: int | None = None, - as_of: date | None = None, + as_of: date | str | None = None, to: str | None = None, ) -> "list[EntityRecord] | list[str | None]": """Return entities geographically contained in *container*, recursively. @@ -1303,6 +1432,8 @@ def within( max_depth: Bound the descent in hops (1 = direct children). None = unbounded. as_of: Half-open [valid_from, valid_until) filter. None = all edges. + Accepts a ``datetime.date`` or an ISO-8601 string + (``"2020-01-01"``). to: Scalar pivot (e.g. "iso3"); returns code strings (None where absent) instead of EntityRecords. @@ -1331,6 +1462,7 @@ def within( to, available_code_systems=self._runner.available_code_systems ) + as_of = _coerce_as_of(as_of) container_entity = self._resolve_container(container) # Normalize entity_type to frozenset for the collaborator. @@ -1681,7 +1813,11 @@ def resolve_id( AmbiguousResolutionError: If ``on_ambiguous="raise"`` and the resolution is ambiguous. ResolutionError: If the resolution pipeline errored. + ValueError: If ``on_ambiguous`` is not one of + ``"raise"``, ``"null"``, or ``"best"``. """ + if on_ambiguous not in _ON_AMBIGUOUS_VALUES: + raise ValueError(_on_ambiguous_error(on_ambiguous)) # Pass to=None explicitly so a bound default_to spec does NOT pivot here. # resolve_id always returns entity_id regardless of any configured default output. result = self.resolve( @@ -1967,9 +2103,14 @@ def parse( RuntimeError: If the resolver has been closed. ImportError: If ``ahocorasick_rs`` is not installed; install with ``pip install 'resolvekit[parsing]'``. + ValueError: If ``confidence_threshold`` is not a number in + ``[0.0, 1.0]``. + UnknownDomainError: If ``domain`` names an unavailable pack. """ if self._closed: raise RuntimeError("Resolver has been closed") + _validate_confidence_threshold(confidence_threshold) + self._validate_parse_domain(domain) from resolvekit.core.parse._pivot import apply_to_pivot from resolvekit.core.parse.engine import parse_one @@ -2041,9 +2182,14 @@ def parse_bulk( TypeError: If ``values`` is an unsupported type. ImportError: If ``ahocorasick_rs`` is not installed; install with ``pip install 'resolvekit[parsing]'``. + ValueError: If ``confidence_threshold`` is not a number in + ``[0.0, 1.0]``. + UnknownDomainError: If ``domain`` names an unavailable pack. """ if self._closed: raise RuntimeError("Resolver has been closed") + _validate_confidence_threshold(confidence_threshold) + self._validate_parse_domain(domain) from resolvekit.core.api.bulk import _detect_input_kind from resolvekit.core.parse._pivot import apply_to_pivot, coerce_to_str_list diff --git a/src/resolvekit/core/api/snap.py b/src/resolvekit/core/api/snap.py index b0cfb6f..749c92e 100644 --- a/src/resolvekit/core/api/snap.py +++ b/src/resolvekit/core/api/snap.py @@ -22,16 +22,31 @@ def _apply_to(resolver: Resolver, entity_id: str, to: Any) -> Any: - """Fetch entity and apply an explicit ``to=`` pivot; returns ``None`` on miss.""" + """Fetch entity and apply an explicit ``to=`` pivot. + + Returns ``None`` when the entity is missing or the entity lacks the + requested output value. Raises for programming errors (invalid ``to=`` + type, unknown code system) consistent with :func:`dispatch_pivot`. + """ if to is None: return entity_id entity = resolver._runner.get_entity(entity_id) if entity is None: return None - try: - return dispatch_pivot(entity, to) - except Exception: - return None + return dispatch_pivot(entity, to) + + +def _resolve_candidate_to_id(resolver: Resolver, candidate: str) -> str | None: + """Return the entity_id for *candidate*, resolving free-text labels if needed. + + Strings containing ``"/"`` are treated as entity IDs and returned as-is + (the caller's filter will discard them if they don't exist in the store). + Plain strings are resolved via an exact lookup; labels that cannot be + resolved unambiguously return ``None`` and are silently skipped. + """ + if "/" in candidate: + return candidate + return resolver.resolve_id(candidate, on_ambiguous="null") def _snap_dispatch( @@ -58,7 +73,8 @@ def _snap_dispatch( Args: resolver: The resolver instance to search and fetch entities from. query: The query string to match. - candidates: Entity IDs to constrain the match to. + candidates: Entity IDs or free-text labels to constrain the match to. + Labels are resolved to entity IDs; unresolvable labels are skipped. max_distance: Confidence floor; below this threshold returns ``None``. to: Explicit pivot target. Defaults to ``UNSET``; callers that pass ``None`` explicitly force entity_id (pre-spec behavior). @@ -71,10 +87,16 @@ def _snap_dispatch( The closest matching candidate, pivoted according to the active output path, or ``None`` when below threshold or entity is missing. """ - if not candidates: + # Resolve free-text labels to entity IDs; entity IDs pass through unchanged. + # Unresolvable labels are silently dropped; an empty result returns None early. + candidate_set: set[str] = { + eid + for c in candidates + if (eid := _resolve_candidate_to_id(resolver, c)) is not None + } + if not candidate_set: return None - candidate_set = set(candidates) min_confidence = 1.0 - max_distance # Use the search path to get ranked candidates without a hard decision cut-off. diff --git a/src/resolvekit/core/byod/build.py b/src/resolvekit/core/byod/build.py index 8f6f80f..10f8b10 100644 --- a/src/resolvekit/core/byod/build.py +++ b/src/resolvekit/core/byod/build.py @@ -6,6 +6,8 @@ from __future__ import annotations import itertools +import re +import unicodedata from pathlib import Path from typing import Any, NamedTuple @@ -20,6 +22,32 @@ ) from resolvekit.core.byod.intake import ByodRecord, RecordSchema +# --------------------------------------------------------------------------- +# Custom build-time normalizer +# --------------------------------------------------------------------------- + + +class _CustomBuildNormalizer: + """Build-time normalizer for the custom domain. + + Applies NFC + casefold + whitespace collapse — matching the query-time + ``CUSTOM_NORMALIZATION_PROFILE`` in ``packs/custom/pack.py`` exactly. + Using NFKC here would decompose compatibility characters (™ → TM, ² → 2, + ℠ → SM, etc.) at build time while the query side preserves them, making + labels with those characters unreachable by their stored form. + """ + + _WHITESPACE = re.compile(r"\s+") + + def normalize_name(self, value: str) -> str: + result = unicodedata.normalize("NFC", value) + result = self._WHITESPACE.sub(" ", result).strip() + return result.casefold() + + def normalize_code(self, system: str, value: str) -> str: + return value.strip().casefold() + + # --------------------------------------------------------------------------- # Builder registry # --------------------------------------------------------------------------- @@ -58,9 +86,7 @@ def _domain_normalizer(domain: str) -> Any: from resolvekit.packs.org.normalizer import OrgNormalizer return OrgNormalizer() - from resolvekit.core.linking.base_normalizer import BaseNormalizer - - return BaseNormalizer() + return _CustomBuildNormalizer() # --------------------------------------------------------------------------- @@ -271,7 +297,7 @@ def _run_build( # Auto-sequence counter for rows without an id. counter = itertools.count() - for row in records: + for row_index, row in enumerate(records): record: ByodRecord = schema.row_to_record(row, normalizer=builder._normalizer) # Determine the entity_id seed. @@ -282,8 +308,19 @@ def _run_build( ) mint_entity_id = f"{namespace}/{seed}" - # Normalise canonical name for the "name" strategy. + # Guard: minting requires a non-empty name. Raise early so the error + # message names the row and column rather than surfacing an opaque + # RuntimeError from the builder internals. canonical_name = record.canonical_name + if canonical_name is None and not resolved_link_on: + name_cols = schema.names + col_hint = name_cols[0] if len(name_cols) == 1 else str(name_cols) + raise ValueError( + f"record {row_index}: {col_hint!r} is empty — " + "every record must have a non-empty name" + ) + + # Normalise canonical name for the "name" strategy. name_value_norm: str | None = None if canonical_name is not None: name_value_norm = builder._normalizer.normalize_name(canonical_name) diff --git a/src/resolvekit/core/byod/builder.py b/src/resolvekit/core/byod/builder.py index 37965fd..64159e0 100644 --- a/src/resolvekit/core/byod/builder.py +++ b/src/resolvekit/core/byod/builder.py @@ -16,8 +16,9 @@ class GenericDataPackBuilder(BaseDataPackBuilder): - ``DOMAIN_PACK_ID = "custom"`` - ``FEATURE_SCHEMA_VERSION = "custom.features.v1"`` - ``set_base_modules`` wires ``BaseLinker`` + ``BaseNormalizer`` so that - build-time and query-time normalization agree for custom packs. + ``set_base_modules`` wires ``BaseLinker`` + ``_CustomBuildNormalizer`` so + that build-time and query-time normalization agree for custom packs (both + use NFC + casefold, not NFKC). """ DOMAIN_PACK_ID = "custom" @@ -29,9 +30,9 @@ def set_base_modules(self, base_paths: Sequence[str | Path]) -> None: Args: base_paths: Paths to datapack directories in the base composition. """ + from resolvekit.core.byod.build import _CustomBuildNormalizer from resolvekit.core.linking.base_linker import BaseLinker - from resolvekit.core.linking.base_normalizer import BaseNormalizer self._open_base_stores(base_paths) self._linker = BaseLinker() - self._normalizer = BaseNormalizer() + self._normalizer = _CustomBuildNormalizer() diff --git a/src/resolvekit/core/byod/cache.py b/src/resolvekit/core/byod/cache.py index 286e265..f933e72 100644 --- a/src/resolvekit/core/byod/cache.py +++ b/src/resolvekit/core/byod/cache.py @@ -41,7 +41,7 @@ # Bump when the build logic or metadata schema changes in a way that # invalidates all existing cached BYOD packs. -BYOD_CACHE_VERSION = "2" +BYOD_CACHE_VERSION = "3" # Sub-directory inside the resolvekit cache dir for BYOD packs. _BYOD_SUBDIR = "byod" diff --git a/src/resolvekit/core/config.py b/src/resolvekit/core/config.py index 69772c9..8a91670 100644 --- a/src/resolvekit/core/config.py +++ b/src/resolvekit/core/config.py @@ -9,7 +9,7 @@ class _Unset: - """Sentinel type distinguishing "on_missing not passed" from a valid value.""" + """Sentinel type distinguishing "parameter not passed" from a valid value.""" def __repr__(self) -> str: return "" @@ -32,19 +32,25 @@ class _Config: def configure( *, auto_download: bool | None = None, - cache_dir: str | Path | None = None, - default_to: str | list[str] | None = None, + cache_dir: str | Path | None | _Unset = _UNSET, + default_to: str | list[str] | None | _Unset = _UNSET, on_missing: Literal["raise", "null", "auto"] | object = _UNSET, ) -> None: """Configure resolvekit runtime behavior. + Omitting a parameter leaves any previously configured value unchanged. + Args: auto_download: If True, remote packs are downloaded automatically - when needed. Default is False. + when needed. ``None`` leaves the current setting unchanged. cache_dir: Custom cache directory for remote data packs. + ``None`` resets to the platform default (removes any custom path). + Omitting leaves the current setting unchanged. default_to: Default output code system or name variant for module-level resolve/bulk/snap (e.g. ``"iso3"``, - ``["iso3", "name"]``, ``"name:fr"``). ``None`` clears the default. + ``["iso3", "name"]``, ``"name:fr"``). ``None`` clears the default + so resolve() returns a raw ResolutionResult. Omitting leaves + the current setting unchanged. on_missing: Miss policy for the default output chain. ``"auto"`` (default) = raise for scalar resolve/snap, null + ``UserWarning`` for bulk; ``"raise"`` always raises @@ -54,9 +60,13 @@ def configure( """ if auto_download is not None: _config.auto_download = auto_download - if cache_dir is not None: - _config.cache_dir = Path(cache_dir) - _config.default_to = default_to + if cache_dir is not _UNSET: + # None resets to platform default (removes any custom path). + _config.cache_dir = ( + Path(cache_dir) if cache_dir is not None else None # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + ) + if default_to is not _UNSET: + _config.default_to = default_to # type: ignore[assignment] # ty: ignore[invalid-assignment] if on_missing is not _UNSET: _config.on_missing = on_missing # type: ignore[assignment] # ty: ignore[invalid-assignment] diff --git a/src/resolvekit/core/engine/multi_runner.py b/src/resolvekit/core/engine/multi_runner.py index a541934..a40702c 100644 --- a/src/resolvekit/core/engine/multi_runner.py +++ b/src/resolvekit/core/engine/multi_runner.py @@ -38,7 +38,7 @@ from resolvekit.core.registry import DomainPack from resolvekit.core.store import EntityStore from resolvekit.core.store.store_view import StoreView -from resolvekit.core.util.normalization import TextNormalizer +from resolvekit.core.util.normalization import NormalizationError, TextNormalizer # Cross-pack ambiguity threshold - when top results from different packs # are within this gap, we return AMBIGUOUS @@ -352,9 +352,15 @@ def _run( # Re-normalize query for this pack if pack-specific normalizer exists pack_query = query if pack_id in self._pack_normalizers: - normalized = self._pack_normalizers[ - pack_id - ].normalize_with_original(query.raw_text) + try: + normalized = self._pack_normalizers[ + pack_id + ].normalize_with_original(query.raw_text) + except NormalizationError: + # The pack's normalizer emptied the input (e.g. a + # punctuation-only query like '.'): unmatchable in + # this pack, not a pipeline error. + continue pack_query = Query( raw_text=query.raw_text, normalized=normalized, diff --git a/src/resolvekit/core/errors.py b/src/resolvekit/core/errors.py index bce54e8..873573d 100644 --- a/src/resolvekit/core/errors.py +++ b/src/resolvekit/core/errors.py @@ -373,6 +373,77 @@ def __init__( super().__init__(msg, hint=hint) +def _single_candidate_type( + candidates: list[CandidateSummary] | None, +) -> str | None: + """Return an entity type carried by exactly one candidate, else ``None``. + + Such a type uniquely identifies one candidate, so narrowing to it reduces + the set to that single entry — the precise condition under which an + ``entity_types`` filter is a proven fix. Shared by the HTML disambiguation + hint (``explain.result_html``) and reused for candidate-aware error advice. + """ + if not candidates: + return None + type_counts: dict[str, int] = {} + for c in candidates: + if c.entity_type: + type_counts[c.entity_type] = type_counts.get(c.entity_type, 0) + 1 + return next((t for t, n in type_counts.items() if n == 1), None) + + +def entity_types_would_disambiguate( + candidates: list[CandidateSummary] | None, +) -> bool: + """True when an ``entity_types`` filter could break the *contended* tie. + + The ambiguity is driven by the two highest-ranked, near-tied candidates, + not the long tail. A type filter only helps when those finalists carry + different entity types. When they share a type (e.g. ``country/COD`` vs + ``country/COG`` for ``"Congo"``, both ``geo.country``) the filter provably + cannot separate them — even if a lower-ranked outlier carries a different + type — so we report ``False``. + + Candidates are assumed confidence-ordered, which the pipeline guarantees. + """ + if not candidates or len(candidates) < 2: + return False + first, second = candidates[0], candidates[1] + if first.entity_type is None or second.entity_type is None: + return False + return first.entity_type != second.entity_type + + +def _ambiguous_resolution_hint(candidates: list[CandidateSummary] | None) -> str: + """Candidate-aware disambiguation advice for ``AmbiguousResolutionError``. + + Suggests ``entity_types`` only when a single-type filter would actually + reduce the candidate set (see :func:`entity_types_would_disambiguate`). + For same-type ambiguity it points callers at the candidate set itself + instead of steering them to a filter that cannot help. + """ + if entity_types_would_disambiguate(candidates): + return ( + "use ResolutionContext(entity_types=...) to keep the matching type, " + "or inspect .candidates and pass on_ambiguous='best' to take the top match" + ) + return ( + "the candidates share one entity type, so entity_types cannot separate them; " + "inspect .candidates and select an entity_id, refine the query text, or pass " + "on_ambiguous='best' to take the top match" + ) + + +def _candidate_preview(candidates: list[CandidateSummary], *, limit: int = 3) -> str: + """Render ``entity_id (conf)`` for the top *limit* candidates.""" + parts: list[str] = [] + for c in candidates[:limit]: + conf = f" ({c.confidence:.2f})" if c.confidence is not None else "" + parts.append(f"{c.entity_id}{conf}") + suffix = ", ..." if len(candidates) > limit else "" + return ", ".join(parts) + suffix + + class AmbiguousResolutionError(ResolutionError): """Multiple plausible entities matched the query. @@ -387,12 +458,19 @@ def __init__( hint: str | None = None, ) -> None: n = len(candidates) if candidates else 0 + if candidates: + preview = _candidate_preview(candidates) + message = ( + f"ambiguous resolution: {n} candidates [{preview}]; " + "see .candidates for the full set" + ) + else: + message = f"ambiguous resolution: {n} candidates" super().__init__( status=ResolutionStatus.AMBIGUOUS, candidates=candidates, - message=f"ambiguous resolution: {n} candidates", - hint=hint - or "use ResolutionContext(entity_types=...) or ResolutionContext(parent_ids=...) to disambiguate", + message=message, + hint=hint or _ambiguous_resolution_hint(candidates), ) diff --git a/src/resolvekit/core/explain/result_html.py b/src/resolvekit/core/explain/result_html.py index 2e18ae0..4b7c24a 100644 --- a/src/resolvekit/core/explain/result_html.py +++ b/src/resolvekit/core/explain/result_html.py @@ -142,16 +142,14 @@ def disambiguate_hint(result: ResolutionResult) -> str | None: only suggest it if filtering by a single type would actually reduce the candidate set to one. """ + from resolvekit.core.errors import _single_candidate_type + if result.query_text is None: return None lines = did_you_mean_lines(result) if lines is not None: return lines - type_buckets: dict[str, int] = {} - for c in result.candidates: - if c.entity_type: - type_buckets[c.entity_type] = type_buckets.get(c.entity_type, 0) + 1 - disambiguating_type = next((t for t, n in type_buckets.items() if n == 1), None) + disambiguating_type = _single_candidate_type(result.candidates) if disambiguating_type is not None: return ( f"resolvekit.resolve(text={result.query_text!r}, " diff --git a/src/resolvekit/core/model/bulk_result.py b/src/resolvekit/core/model/bulk_result.py index a707d2a..35a0d0a 100644 --- a/src/resolvekit/core/model/bulk_result.py +++ b/src/resolvekit/core/model/bulk_result.py @@ -157,7 +157,7 @@ def to_polars(self) -> pl.Series: ) if self.kind == "polars": return self.values # type: ignore[return-value] - return pl.Series(self.to_list()) + return pl.Series(self.to_list(), dtype=pl.Object) # ------------------------------------------------------------------ # Diagnostics @@ -482,9 +482,9 @@ def __getitem__( def __repr__(self) -> str: s = self.summary() return ( - f"BulkResult(total={s.total}, resolved={s.resolved}, " - f"no_match={s.no_match}, ambiguous={s.ambiguous}, " - f"error={s.error}, kind={self.kind!r})" + f"" ) def _repr_html_(self) -> str: diff --git a/src/resolvekit/core/model/entity.py b/src/resolvekit/core/model/entity.py index 53c47c1..00183d6 100644 --- a/src/resolvekit/core/model/entity.py +++ b/src/resolvekit/core/model/entity.py @@ -109,10 +109,12 @@ def codes_dict(self) -> dict[str, str]: """All code records as a ``{system: value}`` mapping. When a system appears more than once, the first occurrence wins. + ``iso_numeric`` values are zero-padded to the canonical 3-digit form. """ result: dict[str, str] = {} for cr in self.codes: - result.setdefault(cr.system, cr.value) + value = cr.value.zfill(3) if cr.system == "iso_numeric" else cr.value + result.setdefault(cr.system, value) return result @property @@ -127,8 +129,8 @@ def iso3(self) -> str | None: @property def numeric(self) -> str | None: - """ISO 3166-1 numeric code, or ``None`` if not available.""" - return self.codes_dict.get("numeric") + """ISO 3166-1 numeric code, zero-padded to 3 digits, or ``None`` if not available.""" + return self.codes_dict.get("iso_numeric") @property def name(self) -> str: @@ -156,8 +158,20 @@ def continent(self) -> str | None: @property def aliases(self) -> list[str]: - """All non-canonical name values, in declaration order.""" - return [nr.value for nr in self.names if not nr.is_preferred] + """All non-canonical name values, deduplicated and in declaration order. + + Excludes the canonical name and any duplicate values. + """ + seen: set[str] = {self.canonical_name} + result: list[str] = [] + for nr in self.names: + if nr.is_preferred: + continue + if nr.value in seen: + continue + seen.add(nr.value) + result.append(nr.value) + return result # ------------------------------------------------------------------ # Helper methods @@ -239,7 +253,7 @@ def _repr_html_(self) -> str: if aliases_str: rows.append(("aliases", aliases_str)) for key, val in self.codes_dict.items(): - if key not in {"iso2", "iso3", "numeric"}: + if key not in {"iso2", "iso3", "iso_numeric"}: rows.append((key, val)) table_rows = "".join( diff --git a/src/resolvekit/core/model/entity_attributes.py b/src/resolvekit/core/model/entity_attributes.py index bfa3be1..9520b80 100644 --- a/src/resolvekit/core/model/entity_attributes.py +++ b/src/resolvekit/core/model/entity_attributes.py @@ -80,7 +80,8 @@ def dispatch_pivot( (``_resolve_target`` in ``output_spec.py``) uses ``codes_dict.get`` directly and never raises; that asymmetry is intentional and deferred. 5. ``target`` in ``entity.attributes`` → that attribute value. - 6. Raise ``UnknownCodeSystemError`` with a hint listing available options. + 6. Raise ``UnknownCodeSystemError`` with did-you-mean suggestion built by + the error class itself from the available list. Args: entity: The resolved ``EntityRecord``. @@ -119,14 +120,10 @@ def dispatch_pivot( attrs = entity.attributes if (val := attrs.get(target)) is not None: return val - hint = ( - f"available: codes={sorted(codes)} " - f"| attrs={sorted(str(k) for k in attrs)} " - f"| computed={sorted(KNOWN_PIVOTS)}" - ) - raise UnknownCodeSystemError( - target, list(codes) + list(KNOWN_PIVOTS), hint=hint - ) + # Let UnknownCodeSystemError build its own did-you-mean suggestion + # from the available list — no explicit hint override. + available = sorted(set(list(codes) + list(KNOWN_PIVOTS))) + raise UnknownCodeSystemError(target, available) if isinstance(target, list): err = TypeError( diff --git a/src/resolvekit/core/model/name_grammar.py b/src/resolvekit/core/model/name_grammar.py index 6180156..19f58ed 100644 --- a/src/resolvekit/core/model/name_grammar.py +++ b/src/resolvekit/core/model/name_grammar.py @@ -15,6 +15,7 @@ from __future__ import annotations +import re from dataclasses import dataclass from typing import TYPE_CHECKING, Literal @@ -34,6 +35,10 @@ # ``abbr`` is a data-side synonym for ``acronym``; fold before the kind test. _KIND_ALIASES: dict[str, str] = {"abbr": "acronym"} +# ISO 639-1 two-letter or ISO 639-2 three-letter lowercase codes only. +# Whitespace, locale tags (en-US), and other non-alpha segments are rejected. +_LANG_RE = re.compile(r"^[a-z]{2,3}$") + # --------------------------------------------------------------------------- # OutputTarget dataclass # --------------------------------------------------------------------------- @@ -69,9 +74,9 @@ def parse_name_grammar(token: str) -> OutputTarget: Middle-token disambiguation (kind wins): if the middle segment is in ``KNOWN_KINDS`` (after folding ``abbr``→``acronym``), it is treated as a - kind selector; otherwise it is treated as a language code. The kind-set - is closed (5 names) and collision-free with the 10 ISO-639-1 langs present - in the data (en/fr/es/de/ru/ja/it/pt/zh/ar). + kind selector; otherwise it is validated as a language code (must match + ``^[a-z]{2,3}$``). The kind-set is closed (5 names) and collision-free + with the ISO-639-1 langs present in the data (en/fr/es/de/ru/ja/it/pt/zh/ar). Args: token: A raw token starting with ``"name"`` (e.g. ``"name"``, @@ -83,7 +88,8 @@ def parse_name_grammar(token: str) -> OutputTarget: Raises: UnknownOutputError: When the token starts with ``"name:"`` but the - grammar is malformed (e.g. empty segment, too many parts). + grammar is malformed (e.g. empty segment, too many parts, invalid + language code shape). """ if token == "name": # Bare ``name`` — computed terminal, never misses. @@ -113,6 +119,21 @@ def parse_name_grammar(token: str) -> OutputTarget: raw=token, kind="name", name_kind=folded, name_script=script ) + # Validate language shape: must be 2-3 lowercase ASCII letters (ISO 639-1/2). + # Reject whitespace ('name: en'), locale tags ('name:en-US'), and other + # invalid shapes that can never match stored language codes. + if not _LANG_RE.match(middle): + raise UnknownOutputError( + token, + sorted(KNOWN_KINDS), + hint=( + f"invalid language code {middle!r} in {token!r}; " + f"language must be a 2-3 letter ISO 639-1/2 code" + f" (e.g. 'en', 'fr', 'zho')," + f" or use a kind selector: {sorted(KNOWN_KINDS)}" + ), + ) + # Treat as language selector. return OutputTarget(raw=token, kind="name", name_lang=middle, name_script=script) diff --git a/src/resolvekit/core/model/query.py b/src/resolvekit/core/model/query.py index 3be0e83..28bec20 100644 --- a/src/resolvekit/core/model/query.py +++ b/src/resolvekit/core/model/query.py @@ -55,7 +55,7 @@ class ResolutionContext(BaseModel): as_of: Point-in-time for temporal validity checks entity_types: Entity type hints (e.g., {"geo.country", "geo.state"}) parent_ids: Parent/container entity hints - country: ISO country code hint (useful for geo + org) + country: ISO 3166-1 alpha-2 country code hint (useful for geo + org) languages: Preferred languages for name matching attributes: Escape hatch for domain-specific attributes (use sparingly) """ @@ -70,7 +70,8 @@ class ResolutionContext(BaseModel): default=None, description="Parent/container entity hints" ) country: str | None = Field( - default=None, max_length=2, description="ISO country code hint" + default=None, + description="ISO 3166-1 alpha-2 country code hint (e.g. 'US')", ) languages: list[str] | None = Field(default=None, description="Preferred languages") attributes: dict[str, str | int | float | bool] = Field( @@ -88,6 +89,31 @@ def _reject_bare_string_entity_types(cls, value: Any) -> Any: ) return value + @field_validator("country", mode="before") + @classmethod + def _validate_country(cls, value: Any) -> Any: + if value is None: + return value + if not isinstance(value, str): + raise ValueError("country must be a string") + if not value.isalpha(): + raise ValueError( + f"country must be an ISO 3166-1 alpha-2 code" + f" (two uppercase letters), got {value!r}" + ) + if len(value) == 2: + return value.upper() + if len(value) == 3: + raise ValueError( + f"country must be an ISO 3166-1 alpha-2 code (two letters)," + f" got {value!r} — pass the alpha-2 code instead" + f" (e.g. 'US' not 'USA')" + ) + raise ValueError( + f"country must be an ISO 3166-1 alpha-2 code" + f" (two uppercase letters), got {value!r}" + ) + def replace(self, **updates: Any) -> "ResolutionContext": """Return a new ResolutionContext with the specified fields replaced. diff --git a/src/resolvekit/core/model/result.py b/src/resolvekit/core/model/result.py index 5bf7541..bf022b3 100644 --- a/src/resolvekit/core/model/result.py +++ b/src/resolvekit/core/model/result.py @@ -258,6 +258,21 @@ class ResolutionResult(BaseModel): _resolve_domain: str | list[str] | None = PrivateAttr(default=None) _resolve_context: ResolutionContext | None = PrivateAttr(default=None) + # ------------------------------------------------------------------ + # Pickle support — drop the unpicklable weakref on serialization + # ------------------------------------------------------------------ + + def __getstate__(self) -> dict[str, Any]: + state = super().__getstate__() + # Null out the _explainer weakref; weakrefs are never valid cross-process. + # The unpickled result will use the existing graceful path in explain() + # (raises ExplainNotAvailableError when ref is None). + priv = state.get("__pydantic_private__") + if priv is not None and priv.get("_explainer") is not None: + state = dict(state) + state["__pydantic_private__"] = {**priv, "_explainer": None} + return state + # ------------------------------------------------------------------ # Proxy properties — delegate to self.entity when present # ------------------------------------------------------------------ diff --git a/src/resolvekit/core/parse/offsets.py b/src/resolvekit/core/parse/offsets.py index f073a65..241c56c 100644 --- a/src/resolvekit/core/parse/offsets.py +++ b/src/resolvekit/core/parse/offsets.py @@ -21,10 +21,20 @@ This two-anchor design ensures that trailing dropped characters (e.g. closing ``**`` after an emphasis group) are absorbed into the *preceding* char's -``ends`` value and do NOT bleed into the following span's ``starts``. The -round-trip invariant therefore holds exactly:: +``ends`` value and do NOT bleed into the following span's ``starts``. - normalize_aligned(raw[starts[ns]:ends[ne-1]], profile)[0] == normalized[ns:ne] +Raw surface recovery is always clean: ``raw[starts[ns]:ends[ne-1]]`` never +splits a raw codepoint even when a casefold expansion (e.g. ``ß`` → ``ss``) +maps multiple normalized chars to the same raw position, because all expansion +chars share the same ``ends`` value (the one-past position of that codepoint). + +Round-trip invariant: holds for spans whose boundary does not fall +mid-expansion (i.e. ``ne`` does not land between two normalized chars that +both originated from the same raw codepoint). When a gazetteer pattern like +``weis`` fires on raw ``Weiß``, the recovered surface ``Weiß`` re-normalizes +to ``weiss``, not ``weis`` — the invariant breaks at ``ne``, but the raw +slice itself is a valid token and ``link_span`` resolves through the full +pipeline independently, so no offset corruption occurs in practice. Supported profile flags (geo and org today): - unicode_nfc @@ -363,9 +373,12 @@ def normalize_aligned( raw_end = ends[ne - 1] surface = raw[raw_start:raw_end] - Round-trip invariant: for any span ``[ns, ne)`` produced by the automaton, - ``normalize_aligned(raw[starts[ns]:ends[ne-1]], profile)[0]`` equals - ``normalized[ns:ne]``. + Raw surface recovery is always clean: the slice ``raw[starts[ns]:ends[ne-1]]`` + never splits a raw codepoint. The round-trip invariant + ``normalize_aligned(raw[starts[ns]:ends[ne-1]], profile)[0] == normalized[ns:ne]`` + holds for spans whose ``ne`` boundary does not fall between two normalized + chars produced by the same raw codepoint (e.g. mid-casefold-expansion of + ``ß`` → ``ss``). Supported flags: unicode_nfc, casefold, strip_whitespace, strip_punctuation, preserve_digits, decode_html_entities, diff --git a/src/resolvekit/packs/geo/extractor.py b/src/resolvekit/packs/geo/extractor.py index 67177fa..b7f76d6 100644 --- a/src/resolvekit/packs/geo/extractor.py +++ b/src/resolvekit/packs/geo/extractor.py @@ -54,7 +54,11 @@ def extract( query_text = query.normalized.original query_len = len(query_text) query_has_digits = any(c.isdigit() for c in query_text) - query_is_upper = query_text.isupper() + # ``query_is_upper`` gates the acronym/admin mismatch suppression in the + # scorer (NASA -> admin "Nasa"). Period-delimited abbreviations + # (U.S.A., D.C.) are not bare acronyms and legitimately alias geo + # entities, so they must not trip that gate. + query_is_upper = query_text.isupper() and "." not in query_text # Retrieval features from sources exact_code_hit = False diff --git a/src/resolvekit/packs/geo/routing.py b/src/resolvekit/packs/geo/routing.py index c9fb88b..2015afa 100644 --- a/src/resolvekit/packs/geo/routing.py +++ b/src/resolvekit/packs/geo/routing.py @@ -26,6 +26,18 @@ # the geo pack but contain digits that exclude them from _GEO_ACRONYM_PATTERN. _GEO_SNAPSHOT_ALIAS_PATTERN: Final = re.compile(r"^[A-Z]{1,5}[0-9]{1,2}$") +# Period-delimited letter initialism (U.S.A., U.K., D.C.). These alias real +# geo entities but contain only single letters and periods, so they miss every +# alphabetic-token pattern above. Matched case-insensitively. +_DOTTED_INITIALISM_PATTERN: Final = re.compile(r"^(?:[A-Za-z]\.){1,5}[A-Za-z]?$") + +# Uppercase ratio at/above which an alphabetic token reads as an acronym-shaped +# token. Mirrors the org pack's ``ACRONYM_UPPERCASE_RATIO`` so that whenever +# org boosts a mixed/upper-case token as an acronym, geo boosts it too and stays +# in multi-pack routing — otherwise mixed-case names ("fRaNcE", "CHIna") drop +# geo entirely and never reach the country pack. +_ACRONYM_UPPERCASE_RATIO: Final = 0.5 + # Geographic name suffixes (e.g., Finland, Pakistan, California) _GEO_SUFFIXES: Final = frozenset( {"land", "stan", "ia", "ica", "nia", "ria", "ey", "ay"} @@ -57,14 +69,20 @@ def geo_scoring_fn(text: str, text_lower: str) -> float: # These are geo entities; the same +0.15 as long alphabetic acronyms is # enough to include geo in multi-pack routing alongside org. score += 0.15 + elif _DOTTED_INITIALISM_PATTERN.match(text): + # Period-delimited initialisms (U.S.A., U.K., D.C.) alias real geo + # entities and read as acronyms to the org pack; match its boost so geo + # stays in routing instead of conceding to org. + score += 0.15 elif _GEO_ACRONYM_PATTERN.match(text) and ( - sum(c.isupper() for c in text) / len(text) >= 0.75 + sum(c.isupper() for c in text) / len(text) >= _ACRONYM_UPPERCASE_RATIO ): - # Boost longer mostly-uppercase acronyms: many geo group entities (DPRK, - # NATO, ASEAN, BRICS, OPEC, MENA, SIDS, LDCs…) use this pattern. The - # moderate +0.15 ensures geo is included in multi-pack routing alongside - # org, so the higher-confidence geo resolution can win when the entity - # exists there. + # Boost alphabetic acronym-shaped tokens: many geo group entities (DPRK, + # NATO, ASEAN, BRICS, OPEC, MENA, SIDS, LDCs…) use this pattern, and + # mixed-case country names ("fRaNcE", "CHIna") land here too. The org + # pack treats any token with >= 0.5 uppercase as an acronym; matching + # that threshold keeps geo in multi-pack routing for those casings so + # the higher-confidence geo resolution can win when the entity exists. score += 0.15 if any(text_lower.endswith(suffix) for suffix in _GEO_SUFFIXES): diff --git a/src/resolvekit/packs/geo/sources/_short_input.py b/src/resolvekit/packs/geo/sources/_short_input.py index 863fb23..c14b391 100644 --- a/src/resolvekit/packs/geo/sources/_short_input.py +++ b/src/resolvekit/packs/geo/sources/_short_input.py @@ -115,6 +115,32 @@ def single_letter_code_allowed(raw_text: str) -> bool: return not (len(raw) == 1 and raw.isascii() and raw.isalpha()) +def is_dotted_initialism(normalized_text: str) -> bool: + """Return True for period-delimited letter initialisms (``U.S.A.``, ``U.K.``). + + Shape: one or more single letters, each separated and/or trailed by + periods, with no other punctuation (``u.s.a.``, ``u.s.a``, ``u.k.``, + ``d.c.``). These are conventional abbreviations that alias real geo + entities, so they must not be treated as missing-value noise. + + Null markers are excluded because they either carry non-period + punctuation (``#n/a``, ``n/a`` use ``#``/``/``) or have no letters at + all (``.``, ``--``, ``?``). + """ + raw = normalized_text.strip() + if "." not in raw: + return False + segments = raw.split(".") + has_letter = False + for segment in segments: + if not segment: + continue # interior/trailing separator + if len(segment) != 1 or not segment.isascii() or not segment.isalpha(): + return False + has_letter = True + return has_letter + + def is_punctuation_noise(normalized_text: str) -> bool: """Return True for short tokens dominated by punctuation/symbols. @@ -126,9 +152,14 @@ def is_punctuation_noise(normalized_text: str) -> bool: (``# / \\ - _ . , ; : ! ? * | ( ) [ ] { } ' " `` whitespace), the remaining alphanumeric content must be either empty or a short alpha fragment that ``short_alpha_code_allowed`` would already block. + + Period-delimited initialisms (``U.S.A.``, ``U.K.``) are exempt: they are + real abbreviations aliasing geo entities, not missing-value markers. """ if not normalized_text: return True + if is_dotted_initialism(normalized_text): + return False stripped = normalized_text for ch in "#/\\-_.,;:!?*|()[]{}'\"`": stripped = stripped.replace(ch, "") diff --git a/src/resolvekit/packs/geo/sources/exact_code.py b/src/resolvekit/packs/geo/sources/exact_code.py index 3a6840b..b790076 100644 --- a/src/resolvekit/packs/geo/sources/exact_code.py +++ b/src/resolvekit/packs/geo/sources/exact_code.py @@ -62,7 +62,9 @@ class CodeSystemSpec(BaseModel): CodeSystemSpec( name="iso_numeric", matches=lambda raw, norm: bool(re.match(r"^\d{3}$", norm)), - lookup_values=lambda raw, norm: (norm,), + # Stored values are unpadded ('4'), so zero-padded canonical input + # ('004') must also try the stripped form. + lookup_values=lambda raw, norm: (norm, norm.lstrip("0") or "0"), display_value=lambda raw, norm: norm, ), CodeSystemSpec( diff --git a/tests/api/test_cache_no_mutation_leak.py b/tests/api/test_cache_no_mutation_leak.py new file mode 100644 index 0000000..60ac013 --- /dev/null +++ b/tests/api/test_cache_no_mutation_leak.py @@ -0,0 +1,94 @@ +"""Regression tests for query-cache mutation leakage . + +In-place mutation of a returned result's list fields must not poison the +query cache: a cache hit must return a result whose mutable containers are not +shared with the cached entry. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from resolvekit.core.api.cache import _detach_mutables, _QueryCache +from resolvekit.core.model import ( + CandidateSummary, + ReasonCode, + ResolutionResult, + ResolutionStatus, +) + + +def _make_result() -> ResolutionResult: + return ResolutionResult( + query_text="France", + status=ResolutionStatus.RESOLVED, + entity_id="country/FRA", + confidence=0.93, + reasons=[ReasonCode.EXACT_NAME_MATCH], + candidates=[CandidateSummary(entity_id="country/FRA", confidence=0.93)], + ) + + +class TestDetachMutables: + def test_lists_are_fresh_objects(self) -> None: + original = _make_result() + detached = _detach_mutables(original) + assert detached.reasons == original.reasons + assert detached.reasons is not original.reasons + assert detached.candidates is not original.candidates + assert detached.refinement_hints is not original.refinement_hints + + def test_mutating_detached_does_not_touch_original(self) -> None: + original = _make_result() + detached = _detach_mutables(original) + detached.reasons.append(ReasonCode.INTERNAL_ERROR) + assert ReasonCode.INTERNAL_ERROR not in original.reasons + + +class TestQueryCacheNoLeak: + def test_cache_hit_returns_detached_lists(self) -> None: + cache = _QueryCache(maxsize=8) + result = _make_result() + + first = cache.get_or_call( + raw_text="France", context=None, domains=None, inner=lambda: result + ) + # Poison the FIRST returned result's reasons in place. + first.reasons.append(ReasonCode.INTERNAL_ERROR) + + # A subsequent hit must not see the poison. + second = cache.get_or_call( + raw_text="France", + context=None, + domains=None, + inner=lambda: pytest.fail("inner should not run on a cache hit"), + ) + assert ReasonCode.INTERNAL_ERROR not in second.reasons + assert second.reasons == [ReasonCode.EXACT_NAME_MATCH] + + +class TestResolverEndToEnd: + @pytest.fixture + def resolver(self, geo_test_datapack: Any) -> Any: + from resolvekit.core.api.resolver import Resolver + + r = Resolver.from_datapacks(datapack_paths=[geo_test_datapack]) + yield r + r.close() + + def test_reasons_mutation_does_not_poison_cache(self, resolver: Any) -> None: + first = resolver.resolve("United States") + if not first.reasons: + pytest.skip("fixture resolution carries no reasons to mutate") + first.reasons.append("CORRUPTED") + second = resolver.resolve("United States") + assert "CORRUPTED" not in second.reasons + + def test_candidates_mutation_does_not_poison_cache(self, resolver: Any) -> None: + first = resolver.resolve("United States") + before = len(first.candidates) + first.candidates.clear() + second = resolver.resolve("United States") + assert len(second.candidates) == before diff --git a/tests/core/test_byod_regressions.py b/tests/core/test_byod_regressions.py new file mode 100644 index 0000000..ac939ee --- /dev/null +++ b/tests/core/test_byod_regressions.py @@ -0,0 +1,150 @@ +"""Regression tests for BYOD bugs #18 and #19. + +#18: from_records with an empty/whitespace name cell must raise a clear + ValueError naming the record index and column, not an opaque RuntimeError. + +#19: BYOD custom packs must be reachable by labels containing NFKC- + compatibility characters (™, ℠, №, ²) because both build-time and + query-time normalization now use NFC + casefold. +""" + +from __future__ import annotations + +import pytest + +from resolvekit.core.api.resolver import Resolver + +# --------------------------------------------------------------------------- +# #18 — empty/whitespace name raises clear ValueError +# --------------------------------------------------------------------------- + + +class TestEmptyNameCell: + def test_empty_string_raises_value_error(self) -> None: + """from_records where the name column is '' raises ValueError naming row 1.""" + with pytest.raises(ValueError, match="record 1") as exc_info: + Resolver.from_records( + [{"id": "a", "label": "Alpha"}, {"id": "b", "label": ""}], + domain="custom", + name="label", + id="id", + cache=False, + ) + assert "label" in str(exc_info.value) + + def test_whitespace_only_raises_value_error(self) -> None: + """from_records where the name column is ' ' (whitespace) raises ValueError.""" + with pytest.raises(ValueError, match="record 0"): + Resolver.from_records( + [{"id": "a", "label": " "}], + domain="custom", + name="label", + id="id", + cache=False, + ) + + def test_error_is_not_runtime_error(self) -> None: + """The error must be ValueError, not RuntimeError (internal builder type).""" + with pytest.raises(ValueError): + Resolver.from_records( + [{"id": "x", "label": ""}], + domain="custom", + name="label", + id="id", + cache=False, + ) + + def test_valid_records_unaffected(self) -> None: + """Non-empty names still build and resolve normally.""" + r = Resolver.from_records( + [{"id": "w1", "label": "Widget"}], + domain="custom", + name="label", + id="id", + cache=False, + ) + result = r.resolve("Widget") + assert result.entity_id == "custom/w1" + + def test_error_message_names_the_column(self) -> None: + """The ValueError message must mention the name column used.""" + with pytest.raises(ValueError, match="my_name_col") as exc_info: + Resolver.from_records( + [{"id": "a", "my_name_col": ""}], + domain="custom", + name="my_name_col", + id="id", + cache=False, + ) + _ = exc_info # accessed above via match= + + +# --------------------------------------------------------------------------- +# #19 — NFKC-compatibility characters round-trip correctly +# --------------------------------------------------------------------------- + + +class TestNfkcCompatibilityRoundtrip: + def test_trademark_symbol_exact_label_resolves(self) -> None: + """Resolver built with 'Acme™ Corp' resolves the exact stored label.""" + r = Resolver.from_records( + [{"id": "w1", "label": "Acme™ Corp"}], + domain="custom", + name="label", + id="id", + cache=False, + ) + result = r.resolve("Acme™ Corp") + assert result.entity_id == "custom/w1", ( + f"exact stored label should resolve; status={result.status}" + ) + + def test_service_mark_symbol_resolves(self) -> None: + """'Widget℠' stored label is reachable by its exact form.""" + r = Resolver.from_records( + [{"id": "w1", "label": "Widget℠"}], + domain="custom", + name="label", + id="id", + cache=False, + ) + result = r.resolve("Widget℠") + assert result.entity_id == "custom/w1", ( + f"exact stored label should resolve; status={result.status}" + ) + + def test_plain_ascii_name_still_resolves(self) -> None: + """Existing round-trips for plain ASCII names are unchanged.""" + r = Resolver.from_records( + [{"id": "p1", "label": "Plain Name"}], + domain="custom", + name="label", + id="id", + cache=False, + ) + result = r.resolve("Plain Name") + assert result.entity_id == "custom/p1" + + def test_diacritic_name_still_resolves(self) -> None: + """Names with diacritics (á, é, ü) are unaffected by the NFC fix.""" + r = Resolver.from_records( + [{"id": "d1", "label": "Café Résumé"}], + domain="custom", + name="label", + id="id", + cache=False, + ) + result = r.resolve("Café Résumé") + assert result.entity_id == "custom/d1" + + def test_case_insensitive_resolve_still_works(self) -> None: + """Case-insensitive resolution is preserved after the NFC normalizer change.""" + r = Resolver.from_records( + [{"id": "c1", "label": "Widget"}], + domain="custom", + name="label", + id="id", + cache=False, + ) + result = r.resolve("widget") + assert result.entity_id == "custom/c1" diff --git a/tests/core/test_output_and_configure_validation.py b/tests/core/test_output_and_configure_validation.py new file mode 100644 index 0000000..d1e640a --- /dev/null +++ b/tests/core/test_output_and_configure_validation.py @@ -0,0 +1,334 @@ +"""Regression tests for output-grammar, configure(), and ResolutionContext validation. + +#9 ResolutionContext.country validates ISO alpha-2 shape with actionable errors. +#10 parse_name_grammar rejects invalid lang shapes (whitespace, locale tags). +#16 dispatch_pivot lets UnknownCodeSystemError's built-in did-you-mean run. +#17 configure() omitting default_to leaves previous value unchanged. +#31 configure() rejects non-str/list/None default_to with a clear ValueError. +#35 configure(cache_dir=None) resets to platform default. +""" + +from __future__ import annotations + +import pytest + +import resolvekit +from resolvekit.core.config import _reset_config, get_default_to +from resolvekit.core.errors import UnknownCodeSystemError, UnknownOutputError +from resolvekit.core.model.name_grammar import parse_name_grammar + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _reset_after_each() -> None: # type: ignore[return] + """Reset config + singleton after every test to prevent bleed.""" + yield + _reset_config() + resolvekit.reset() + + +# --------------------------------------------------------------------------- +# #9 — ResolutionContext.country validation +# --------------------------------------------------------------------------- + + +class TestResolutionContextCountry: + def test_iso2_accepted(self) -> None: + from resolvekit.core.model.query import ResolutionContext + + ctx = ResolutionContext(country="US") + assert ctx.country == "US" + + def test_iso2_lowercase_normalised(self) -> None: + from resolvekit.core.model.query import ResolutionContext + + ctx = ResolutionContext(country="us") + assert ctx.country == "US" + + def test_none_accepted(self) -> None: + from resolvekit.core.model.query import ResolutionContext + + ctx = ResolutionContext(country=None) + assert ctx.country is None + + def test_iso3_rejected_with_alpha2_guidance(self) -> None: + from pydantic import ValidationError + + from resolvekit.core.model.query import ResolutionContext + + with pytest.raises(ValidationError) as exc_info: + ResolutionContext(country="USA") + msg = str(exc_info.value) + # Must mention alpha-2 guidance, not just a generic max_length error. + assert "alpha-2" in msg + assert "USA" in msg + + def test_single_char_rejected(self) -> None: + from pydantic import ValidationError + + from resolvekit.core.model.query import ResolutionContext + + with pytest.raises(ValidationError): + ResolutionContext(country="U") + + def test_empty_string_rejected(self) -> None: + from pydantic import ValidationError + + from resolvekit.core.model.query import ResolutionContext + + with pytest.raises(ValidationError): + ResolutionContext(country="") + + def test_non_alpha_rejected(self) -> None: + from pydantic import ValidationError + + from resolvekit.core.model.query import ResolutionContext + + with pytest.raises(ValidationError): + ResolutionContext(country="1!") + + def test_four_char_alpha_rejected(self) -> None: + from pydantic import ValidationError + + from resolvekit.core.model.query import ResolutionContext + + with pytest.raises(ValidationError): + ResolutionContext(country="USAA") + + def test_iso3_hint_mentions_alpha2_not_usa(self) -> None: + """USA hint message should say 'alpha-2 code' and suggest the pattern.""" + from pydantic import ValidationError + + from resolvekit.core.model.query import ResolutionContext + + with pytest.raises(ValidationError) as exc_info: + ResolutionContext(country="USA") + msg = str(exc_info.value) + assert "alpha-2" in msg + + +# --------------------------------------------------------------------------- +# #10 — parse_name_grammar lang shape validation +# --------------------------------------------------------------------------- + + +class TestParseNameGrammarLangShape: + def test_valid_two_letter_lang_accepted(self) -> None: + target = parse_name_grammar("name:en") + assert target.name_lang == "en" + assert target.kind == "name" + + def test_valid_three_letter_lang_accepted(self) -> None: + target = parse_name_grammar("name:zho") + assert target.name_lang == "zho" + assert target.kind == "name" + + def test_whitespace_lang_raises(self) -> None: + # 'name: en' has a leading space — must raise, not silently return None. + with pytest.raises(UnknownOutputError) as exc_info: + parse_name_grammar("name: en") + err = exc_info.value + assert "en" in err.hint or " en" in err.hint + + def test_locale_tag_raises(self) -> None: + # 'name:en-US' is a BCP-47 locale tag, not ISO 639-1 — must raise. + with pytest.raises(UnknownOutputError): + parse_name_grammar("name:en-US") + + def test_long_invalid_lang_raises(self) -> None: + # 'name:invalidkind' is not a kind and not a 2-3 letter code - must raise. + with pytest.raises(UnknownOutputError): + parse_name_grammar("name:invalidkind") + + def test_uppercase_lang_raises(self) -> None: + # 'name:EN' — uppercase, should raise since _LANG_RE requires lowercase. + with pytest.raises(UnknownOutputError): + parse_name_grammar("name:EN") + + def test_hint_mentions_iso639(self) -> None: + """Hint for invalid lang should reference ISO 639.""" + with pytest.raises(UnknownOutputError) as exc_info: + parse_name_grammar("name:en-US") + err = exc_info.value + assert err.hint is not None + assert "639" in err.hint + + def test_valid_lang_with_script_accepted(self) -> None: + target = parse_name_grammar("name:zh:Hant") + assert target.name_lang == "zh" + assert target.name_script == "Hant" + + def test_known_kind_with_invalid_lang_shape_not_triggered(self) -> None: + # 'name:canonical' is a valid kind — lang validation must not fire. + target = parse_name_grammar("name:canonical") + assert target.name_kind == "canonical" + assert target.name_lang is None + + +# --------------------------------------------------------------------------- +# #16 — dispatch_pivot lets UnknownCodeSystemError's did-you-mean run +# --------------------------------------------------------------------------- + + +class TestDispatchPivotDidYouMean: + def _make_entity(self) -> EntityRecord: # type: ignore[name-defined] # noqa: F821 + from resolvekit.core.model.entity import CodeRecord, EntityRecord + + return EntityRecord( + entity_id="country/FRA", + entity_type="geo.country", + canonical_name="France", + canonical_name_norm="france", + names=[], + codes=[ + CodeRecord(system="iso3", value="FRA", value_norm="fra"), + CodeRecord(system="iso2", value="FR", value_norm="fr"), + ], + ) + + def test_typo_raises_unknown_code_system_error(self) -> None: + from resolvekit.core.model.entity_attributes import dispatch_pivot + + entity = self._make_entity() + with pytest.raises(UnknownCodeSystemError): + dispatch_pivot(entity, "iso33") + + def test_typo_hint_contains_did_you_mean(self) -> None: + from resolvekit.core.model.entity_attributes import dispatch_pivot + + entity = self._make_entity() + with pytest.raises(UnknownCodeSystemError) as exc_info: + dispatch_pivot(entity, "iso33") + err = exc_info.value + assert err.hint is not None + assert "did you mean" in err.hint.lower() + + def test_typo_hint_suggests_iso3(self) -> None: + from resolvekit.core.model.entity_attributes import dispatch_pivot + + entity = self._make_entity() + with pytest.raises(UnknownCodeSystemError) as exc_info: + dispatch_pivot(entity, "iso33") + err = exc_info.value + assert "iso3" in err.hint + + def test_available_list_no_duplicates(self) -> None: + """The available list passed to the error must not contain duplicates.""" + from resolvekit.core.model.entity_attributes import dispatch_pivot + + entity = self._make_entity() + with pytest.raises(UnknownCodeSystemError) as exc_info: + dispatch_pivot(entity, "iso99") + err = exc_info.value + assert len(err.available) == len(set(err.available)) + + +# --------------------------------------------------------------------------- +# #17 — configure() incremental update: omitting default_to leaves it unchanged +# --------------------------------------------------------------------------- + + +class TestConfigureIncremental: + def test_omitting_default_to_preserves_previous_value(self) -> None: + """configure(on_missing='null') must not wipe a previously set default_to.""" + resolvekit.configure(default_to="iso3") + assert get_default_to() == "iso3" + resolvekit.configure(on_missing="null") + assert get_default_to() == "iso3" + + def test_omitting_on_missing_preserves_previous_value(self) -> None: + """configure(default_to=None) must not wipe a previously set on_missing.""" + from resolvekit.core.config import get_on_missing + + resolvekit.configure(on_missing="raise") + assert get_on_missing() == "raise" + resolvekit.configure(default_to=None) + assert get_on_missing() == "raise" + + def test_configure_chain_preserves_all_settings(self) -> None: + """Multiple incremental configure() calls leave all settings intact.""" + from resolvekit.core.config import get_cache_dir, get_on_missing + + resolvekit.configure(default_to="iso3") + resolvekit.configure(on_missing="null") + resolvekit.configure(cache_dir="/tmp/test-rk-cache") + assert get_default_to() == "iso3" + assert get_on_missing() == "null" + assert str(get_cache_dir()) == "/tmp/test-rk-cache" + + def test_explicit_none_clears_default_to(self) -> None: + """configure(default_to=None) explicitly clears the default output.""" + resolvekit.configure(default_to="iso3") + assert get_default_to() == "iso3" + resolvekit.configure(default_to=None) + assert get_default_to() is None + + +# --------------------------------------------------------------------------- +# #31 — configure() type validation on default_to +# --------------------------------------------------------------------------- + + +class TestConfigureDefaultToTypeValidation: + def test_str_accepted(self) -> None: + resolvekit.configure(default_to="iso3") # must not raise + + def test_list_of_str_accepted(self) -> None: + resolvekit.configure(default_to=["iso3", "name"]) # must not raise + + def test_none_accepted(self) -> None: + resolvekit.configure(default_to=None) # must not raise + + def test_ellipsis_raises_clear_value_error(self) -> None: + with pytest.raises(ValueError, match="default_to"): + resolvekit.configure(default_to=...) + + def test_int_raises_clear_value_error(self) -> None: + with pytest.raises(ValueError, match="default_to"): + resolvekit.configure(default_to=42) # type: ignore[arg-type] + + def test_bytes_raises_clear_value_error(self) -> None: + with pytest.raises(ValueError, match="default_to"): + resolvekit.configure(default_to=b"iso3") # type: ignore[arg-type] + + def test_dict_raises_clear_value_error(self) -> None: + with pytest.raises(ValueError, match="default_to"): + resolvekit.configure(default_to={"a": 1}) # type: ignore[arg-type] + + def test_list_of_non_str_raises_clear_value_error(self) -> None: + with pytest.raises(ValueError, match="default_to"): + resolvekit.configure(default_to=[1, 2]) # type: ignore[arg-type] + + def test_error_message_names_parameter(self) -> None: + """ValueError message must name the parameter.""" + with pytest.raises(ValueError) as exc_info: + resolvekit.configure(default_to=42) # type: ignore[arg-type] + assert "default_to" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# #35 — configure(cache_dir=None) resets to platform default +# --------------------------------------------------------------------------- + + +class TestConfigureCacheDirReset: + def test_cache_dir_none_resets_to_default(self) -> None: + from resolvekit.core.config import _default_cache_dir, get_cache_dir + + resolvekit.configure(cache_dir="/tmp/rk-test-custom") + assert str(get_cache_dir()) == "/tmp/rk-test-custom" + + resolvekit.configure(cache_dir=None) + assert get_cache_dir() == _default_cache_dir() + + def test_omitting_cache_dir_preserves_custom(self) -> None: + from resolvekit.core.config import get_cache_dir + + resolvekit.configure(cache_dir="/tmp/rk-test-preserve") + assert str(get_cache_dir()) == "/tmp/rk-test-preserve" + + resolvekit.configure(on_missing="null") # omit cache_dir + assert str(get_cache_dir()) == "/tmp/rk-test-preserve" diff --git a/tests/core/test_resolver_api.py b/tests/core/test_resolver_api.py index 204b9c7..e490d0c 100644 --- a/tests/core/test_resolver_api.py +++ b/tests/core/test_resolver_api.py @@ -469,7 +469,7 @@ def test_auto_routing_with_domains_raises(self, geo_test_datapack): routing_mode=RoutingMode.AUTO, ) - with pytest.raises(ValueError, match="Cannot specify domains with AUTO"): + with pytest.raises(ValueError, match="not supported under the default AUTO"): resolver.resolve("US", domain="geo") def test_auto_routing_with_domains_raises_with_explanation(self, geo_test_datapack): @@ -484,7 +484,7 @@ def test_auto_routing_with_domains_raises_with_explanation(self, geo_test_datapa routing_mode=RoutingMode.AUTO, ) - with pytest.raises(ValueError, match="Cannot specify domains with AUTO"): + with pytest.raises(ValueError, match="not supported under the default AUTO"): resolver.resolve_explained("US", domain="geo") def test_query_length_guardrail_applied_to_raw_text(self, geo_test_datapack): diff --git a/tests/packs/geo/test_dotted_and_casing_integration.py b/tests/packs/geo/test_dotted_and_casing_integration.py new file mode 100644 index 0000000..bea7050 --- /dev/null +++ b/tests/packs/geo/test_dotted_and_casing_integration.py @@ -0,0 +1,85 @@ +"""Integration regression tests for dotted-abbreviation and mixed-case resolution. + +Finding #1: 'U.S.A.' resolved to politicalParty/SocialistPartyUSA (geo +suppressed by the punctuation-noise gate); 'U.K.' returned None. + +Finding #11: 'fRaNcE' / 'CHIna' went ambiguous and 'SUDan' returned None +because the AutoRouter dropped the geo pack for 50-74%-uppercase names. + +These exercise the full Resolver so the gate, routing, and scoring fixes are +validated together. Acronym-admin suppression (NASA -> "Nasa") must still hold. +""" + +from __future__ import annotations + +import pytest + +from resolvekit import Resolver + + +@pytest.fixture(scope="module") +def resolver() -> Resolver: + return Resolver.auto() + + +# Finding #1: dotted abbreviations resolve to the correct geo entity. +@pytest.mark.parametrize( + "query,expected", + [ + ("U.S.A.", "country/USA"), + ("U.S.A", "country/USA"), + ("U.K.", "country/GBR"), + ], +) +def test_dotted_abbreviation_resolves_to_country(resolver, query, expected): + result = resolver.resolve(query) + assert result.status.value == "resolved", f"{query!r} -> {result.status.value}" + assert result.candidates[0].entity_id == expected + + +def test_dotted_dc_resolves_to_geo_not_org(resolver): + """'D.C.' must resolve to a geo entity, never an org pack entity.""" + result = resolver.resolve("D.C.") + assert result.candidates, "D.C. produced no candidates" + top = result.candidates[0].entity_id + assert not top.startswith(("org/", "politicalParty/")), top + assert top == "geoId/11001" + + +# Finding #1: the null-marker gate must remain intact. +@pytest.mark.parametrize("query", ["#N/A", "N/A", "--", "?", "N.A.", "NA", "NULL"]) +def test_null_markers_still_blocked(resolver, query): + result = resolver.resolve(query) + assert result.status.value != "resolved", ( + f"{query!r} resolved to " + f"{result.candidates[0].entity_id if result.candidates else None}" + ) + + +# Finding #11: mixed-case country names resolve like their standard casings. +@pytest.mark.parametrize( + "query,expected", + [ + ("fRaNcE", "country/FRA"), + ("CHIna", "country/CHN"), + ("SUDan", "country/SDN"), + ("France", "country/FRA"), + ("FRANCE", "country/FRA"), + ("france", "country/FRA"), + ], +) +def test_mixed_case_name_resolves(resolver, query, expected): + result = resolver.resolve(query) + assert result.status.value == "resolved", f"{query!r} -> {result.status.value}" + assert result.candidates[0].entity_id == expected + + +# Acronym-admin suppression must survive the dotted-form carve-out: a bare +# uppercase acronym must not leak a same-spelled admin/city geo entity. +@pytest.mark.parametrize("query", ["NASA", "SWIFT", "EMEA"]) +def test_bare_acronym_does_not_leak_geo_admin(resolver, query): + result = resolver.resolve(query) + top = result.candidates[0].entity_id if result.candidates else None + if top is not None and top.startswith(("geoId/", "nuts/", "wikidataId/")): + # geo candidate must not be an accepted resolution at admin/city tier + assert result.status.value != "resolved", f"{query!r} leaked geo {top}" diff --git a/tests/packs/geo/test_routing_casing.py b/tests/packs/geo/test_routing_casing.py new file mode 100644 index 0000000..71505b5 --- /dev/null +++ b/tests/packs/geo/test_routing_casing.py @@ -0,0 +1,61 @@ +"""Regression tests for geo routing of dotted and mixed-case inputs. + +Finding #1: dotted abbreviations ("U.S.A.", "U.K.") read as acronyms to the +org pack but earned no geo routing boost, so geo was dropped from routing. + +Finding #11: 50-74%-uppercase country names ("fRaNcE", "CHIna") triggered the +org acronym boost (>= 0.5 uppercase) but not geo's (which required >= 0.75), +so geo was dropped and the country pack was never consulted. + +These tests pin ``geo_scoring_fn`` so geo stays within the AutoRouter's +``SCORE_PREFERENCE_THRESHOLD`` of the org acronym score for both shapes. +""" + +from __future__ import annotations + +import pytest + +from resolvekit.core.engine.router import SCORE_PREFERENCE_THRESHOLD +from resolvekit.packs.geo.routing import geo_scoring_fn +from resolvekit.packs.org.routing import org_scoring_fn + + +def _geo_stays_in_routing(text: str) -> bool: + """True when geo survives the AutoRouter threshold against org.""" + geo = geo_scoring_fn(text, text.lower()) + org = org_scoring_fn(text, text.lower()) + max_score = max(geo, org) + return geo >= max_score - SCORE_PREFERENCE_THRESHOLD + + +# Finding #1: dotted initialisms. +@pytest.mark.parametrize("text", ["U.S.A.", "U.S.A", "U.K.", "D.C.", "E.U."]) +def test_dotted_initialism_keeps_geo_in_routing(text: str) -> None: + assert _geo_stays_in_routing(text), ( + f"{text!r} dropped geo: geo={geo_scoring_fn(text, text.lower())}, " + f"org={org_scoring_fn(text, text.lower())}" + ) + + +# Finding #11: mixed-case (50-74% uppercase) country names. +@pytest.mark.parametrize("text", ["fRaNcE", "CHIna", "SUDan", "FRANce", "KENya"]) +def test_mixed_case_name_keeps_geo_in_routing(text: str) -> None: + assert _geo_stays_in_routing(text), ( + f"{text!r} dropped geo: geo={geo_scoring_fn(text, text.lower())}, " + f"org={org_scoring_fn(text, text.lower())}" + ) + + +# Standard casings must be unchanged (already routed correctly pre-fix). +@pytest.mark.parametrize("text", ["France", "FRANCE", "france", "USA", "United States"]) +def test_standard_casing_keeps_geo_in_routing(text: str) -> None: + assert _geo_stays_in_routing(text) + + +def test_lowercase_acronym_not_over_boosted() -> None: + """A fully lowercase token is not acronym-shaped; geo stays at base score. + + The fix mirrors org's >= 0.5 uppercase acronym threshold, so all-lowercase + input ("dprk") must not pick up the acronym boost. + """ + assert geo_scoring_fn("dprk", "dprk") == pytest.approx(0.5) diff --git a/tests/packs/geo/test_short_input_initialisms.py b/tests/packs/geo/test_short_input_initialisms.py new file mode 100644 index 0000000..324c428 --- /dev/null +++ b/tests/packs/geo/test_short_input_initialisms.py @@ -0,0 +1,59 @@ +"""Regression tests for the short-input gate (dotted-abbreviation handling). + +The punctuation-noise gate used to classify dotted abbreviations ("U.S.A.", +"U.K.", "D.C.") as missing-value noise and suppress every geo source. These +unit tests pin the gate-level predicates so a dotted initialism passes the +gate while genuine null markers stay blocked. +""" + +from __future__ import annotations + +import pytest + +from resolvekit.packs.geo.sources._short_input import ( + is_degenerate_token, + is_dotted_initialism, + is_punctuation_noise, +) + +# Dotted letter initialisms must pass the punctuation-noise gate. +_DOTTED_INITIALISMS = ["u.s.a.", "u.s.a", "u.k.", "d.c.", "e.u.", "n.z."] + +# Genuine null markers / punctuation noise must stay blocked. +_NULL_MARKERS = ["#n/a", "n/a", "--", "---", ".", "?", "-", ""] + + +@pytest.mark.parametrize("text", _DOTTED_INITIALISMS) +def test_dotted_initialism_recognized(text: str) -> None: + assert is_dotted_initialism(text) is True + + +@pytest.mark.parametrize("text", _NULL_MARKERS) +def test_null_marker_not_a_dotted_initialism(text: str) -> None: + assert is_dotted_initialism(text) is False + + +@pytest.mark.parametrize("text", _DOTTED_INITIALISMS) +def test_dotted_initialism_passes_punctuation_gate(text: str) -> None: + assert is_punctuation_noise(text) is False + + +@pytest.mark.parametrize("text", _NULL_MARKERS) +def test_null_marker_stays_blocked_by_punctuation_gate(text: str) -> None: + assert is_punctuation_noise(text) is True + + +@pytest.mark.parametrize("text", ["n.a.", "n.a", "N.A.", "N.A"]) +def test_dotted_na_still_caught_by_degenerate_token(text: str) -> None: + """'N.A.' reads as a dotted initialism but is an explicit null marker. + + ``is_degenerate_token`` runs ahead of the punctuation gate in + ``short_input_blocked``, so the null-marker classification still wins. + """ + assert is_degenerate_token(text) is True + + +@pytest.mark.parametrize("text", ["usa.", ".usa", "u.sa", "a.b.cd"]) +def test_non_initialism_dotted_shapes_not_treated_as_initialism(text: str) -> None: + """Multi-letter runs around periods are not single-letter initialisms.""" + assert is_dotted_initialism(text) is False diff --git a/tests/parse/test_automaton.py b/tests/parse/test_automaton.py index a149e8f..73cf7f3 100644 --- a/tests/parse/test_automaton.py +++ b/tests/parse/test_automaton.py @@ -831,3 +831,75 @@ def test_resolver_close_evicts_via_resolver(tmp_path: Path) -> None: assert not any(k[0] == id(store) for k in _AUTOMATON_CACHE), ( "No cache entries should remain for the closed resolver's store" ) + + +# --------------------------------------------------------------------------- +# Casefold-expansion: ß → ss splits +# --------------------------------------------------------------------------- + + +class _WeisStore: + """Minimal fake store yielding one name: 'Weis' (value_norm='weis'). + + Simulates a city gazetteer whose entry normalizes to 'weis', which is + a prefix of 'weiss' (the casefold of 'Weiß'). + """ + + def iter_names( + self, + entity_type_prefixes=None, + with_name_meta: bool = False, + ): + if with_name_meta: + yield ("weis", "city/WEIS1", "name", "Weis") + else: + yield ("weis", "city/WEIS1") + + +def test_casefold_split_raw_surface_is_clean() -> None: + """Automaton hit on 'weis' pattern fired via 'Weiß' carries the full raw token. + + 'Weiß' casefolds to 'weiss'; the pattern 'weis' matches normalized span [ns, + ns+4) which ends between the two 's' chars both mapped to raw ß. The raw + surface recovery raw[start:end] must equal 'Weiß' — never a split fragment. + + The round-trip invariant is documented to break for such mid-expansion spans + (normalize_aligned('Weiß')[0]='weiss' ≠ matched pattern 'weis'), but offset + corruption must NOT occur in the public output. + """ + automaton = PackAutomaton( + store=_WeisStore(), + profile=GEO_NORMALIZATION_PROFILE, + pack_id="geo", + small_prefixes=None, + ) + raw = "I visited Weiß yesterday" + hits = automaton.find(raw) + + assert len(hits) == 1, f"Expected exactly 1 hit, got {hits}" + hit = hits[0] + + # The surface recovered from raw offsets must be the full raw token. + recovered = raw[hit.start : hit.end] + assert recovered == "Weiß", ( + f"Hit surface sliced from raw must be 'Weiß', got {recovered!r}; " + f"start={hit.start}, end={hit.end}" + ) + assert hit.surface == "Weiß", f"_RawHit.surface must be 'Weiß', got {hit.surface!r}" + assert hit.start == raw.index("Weiß"), "start offset must point to W" + assert hit.end == hit.start + len("Weiß"), "end offset must be one-past ß" + + +def test_casefold_split_no_spurious_hit_mid_word() -> None: + """'weis' pattern does NOT fire inside 'Weißwurst' (word boundary blocks it).""" + automaton = PackAutomaton( + store=_WeisStore(), + profile=GEO_NORMALIZATION_PROFILE, + pack_id="geo", + small_prefixes=None, + ) + # 'Weißwurst': the raw span for 'weis' expansion ends inside the word. + hits = automaton.find("Weißwurst is a sausage") + assert not hits, ( + f"Pattern 'weis' must not fire inside 'Weißwurst' (word-boundary), got {hits}" + ) diff --git a/tests/parse/test_offsets.py b/tests/parse/test_offsets.py index 059f0e7..98b9d65 100644 --- a/tests/parse/test_offsets.py +++ b/tests/parse/test_offsets.py @@ -383,3 +383,74 @@ def test_org_punctuation_strip() -> None: spans=[(0, 3, "AT&T")], profile=ORG_NORMALIZATION_PROFILE, ) + + +# --------------------------------------------------------------------------- +# Casefold-expansion mid-split: raw surface recovery is clean even when +# the round-trip invariant breaks (ß→ss, span ends between the two s chars). +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("profile", PROFILES) +def test_casefold_expansion_raw_surface_always_clean( + profile: NormalizationProfile, +) -> None: + """raw[starts[ns]:ends[ne-1]] never splits a raw codepoint for ß expansions. + + Pattern 'weis' (4 normalized chars) fires on 'Weiß' (raw length 4). + After casefold: normalized='weiss', starts=[0,1,2,3,3], ends=[1,2,3,4,4]. + Span [0,4) ends between the two 's' chars that both map to raw ß (index 3). + + Raw surface recovery: raw[starts[0]:ends[3]] = raw[0:4] = 'Weiß' — clean, + because both expansion chars share ends[*]=4 (one-past ß). + + Round-trip: normalize_aligned('Weiß')[0] = 'weiss', not 'weis' — the + invariant does NOT hold for this mid-expansion split, which is expected + and documented in the module docstring. + """ + raw = "Weiß" + normalized, starts, ends = normalize_aligned(raw, profile) + assert normalized == "weiss", f"Expected 'weiss', got {normalized!r}" + assert len(starts) == len(ends) == 5 + + # Both 's' chars from ß share the same raw position (index 3, ends=4). + assert starts[3] == 3 + assert starts[4] == 3 + assert ends[3] == 4 + assert ends[4] == 4 + + # Span [0,4) = 'weis' — raw surface recovery is always clean. + ns, ne = 0, 4 + raw_surface = raw[starts[ns] : ends[ne - 1]] + assert raw_surface == "Weiß", ( + f"Surface recovery must not split ß: got {raw_surface!r}" + ) + + # Documented exception: round-trip does not hold for mid-expansion spans. + renorm = normalize_aligned(raw_surface, profile)[0] + assert renorm == "weiss" # re-normalizes the full ß to ss + assert normalized[ns:ne] == "weis" # matched pattern was only 4 chars + # These differ: invariant violation is expected for this span boundary. + assert renorm != normalized[ns:ne], ( + "Round-trip invariant should NOT hold for mid-casefold-expansion span — " + "if this assertion fails the invariant is now fixed, update this test." + ) + + +@pytest.mark.parametrize("profile", PROFILES) +def test_casefold_expansion_whole_token_round_trips( + profile: NormalizationProfile, +) -> None: + """Whole-token spans that include the full casefold expansion do round-trip.""" + raw = "Weiß" + normalized, starts, ends = normalize_aligned(raw, profile) + assert normalized == "weiss" + + # Span [0,5) covers all of 'weiss' — round-trip holds. + ns, ne = 0, 5 + raw_surface = raw[starts[ns] : ends[ne - 1]] + assert raw_surface == "Weiß" + renorm = normalize_aligned(raw_surface, profile)[0] + assert renorm == normalized[ns:ne], ( + f"Whole-token round-trip failed: {renorm!r} != {normalized[ns:ne]!r}" + ) diff --git a/tests/parse/test_parse_api.py b/tests/parse/test_parse_api.py index 2128f49..b472b82 100644 --- a/tests/parse/test_parse_api.py +++ b/tests/parse/test_parse_api.py @@ -192,3 +192,42 @@ def test_parse_closed_resolver_raises(parse_geo_datapack: Any) -> None: r.close() with pytest.raises(RuntimeError, match="closed"): r.parse("Kenya") + + +# --------------------------------------------------------------------------- +# Casefold-expansion offset correctness (regression for ß/Weiß edge case) +# --------------------------------------------------------------------------- + + +def test_parse_offsets_correct_after_casefold_expansion_token( + parse_geo_resolver: Resolver, +) -> None: + """Entities after a ß-containing token carry correct raw offsets. + + When 'Weiß' precedes a recognised entity, casefold expands ß→ss (changing + the normalized length relative to raw). The automaton must map spans back + to raw correctly so that text[start:end] == surface for every detected entity. + + Regression: normalize_aligned round-trip invariant + can break for mid-casefold-expansion spans, but raw surface recovery and all + public offsets must still be exact. + """ + text = "Weiß lives in Kenya" + result = parse_geo_resolver.parse(text) + + kenya_entities = [e for e in result if e.surface == "Kenya"] + assert kenya_entities, ( + f"Expected 'Kenya' in parse result; got {[e.surface for e in result]}" + ) + + for e in kenya_entities: + recovered = text[e.start : e.end] + assert recovered == e.surface, ( + f"Offset mismatch after Weiß: text[{e.start}:{e.end}]={recovered!r} " + f"!= surface={e.surface!r}" + ) + # Confirm 'Kenya' starts after the ß token, not before it. + kenya_hit = kenya_entities[0] + assert kenya_hit.start > text.index("Weiß"), ( + "Kenya entity must start after the Weiß token" + ) diff --git a/tests/test_ambiguous_error_hints.py b/tests/test_ambiguous_error_hints.py new file mode 100644 index 0000000..d373823 --- /dev/null +++ b/tests/test_ambiguous_error_hints.py @@ -0,0 +1,105 @@ +"""Regression tests for AmbiguousResolutionError hint/message. + +Covers: +- the hint must be candidate-aware — suggesting ``entity_types`` only + when the near-tied finalists span more than one entity type, never when they + share a type (e.g. ``country/COD`` vs ``country/COG`` for "Congo"). +- ``str(e)`` must preview the top candidates and reference ``.candidates``. +""" + +from __future__ import annotations + +from resolvekit.core.errors import ( + AmbiguousResolutionError, + entity_types_would_disambiguate, +) +from resolvekit.core.model import CandidateSummary + + +def _cand(entity_id: str, entity_type: str, confidence: float) -> CandidateSummary: + return CandidateSummary( + entity_id=entity_id, entity_type=entity_type, confidence=confidence + ) + + +class TestEntityTypesWouldDisambiguate: + def test_same_type_finalists_returns_false(self) -> None: + """Congo-shape: both near-tied finalists are geo.country.""" + candidates = [ + _cand("country/COD", "geo.country", 0.922), + _cand("country/COG", "geo.country", 0.918), + _cand("wikidataId/Q1", "geo.admin2", 0.876), + ] + assert entity_types_would_disambiguate(candidates) is False + + def test_distinct_top_two_types_returns_true(self) -> None: + """A genuine cross-type ambiguity: top two carry different types.""" + candidates = [ + _cand("city/X", "geo.city", 0.90), + _cand("admin1/Y", "geo.admin1", 0.89), + ] + assert entity_types_would_disambiguate(candidates) is True + + def test_empty_or_single_returns_false(self) -> None: + assert entity_types_would_disambiguate(None) is False + assert entity_types_would_disambiguate([]) is False + assert entity_types_would_disambiguate([_cand("a/X", "t", 0.9)]) is False + + def test_missing_type_returns_false(self) -> None: + candidates = [ + CandidateSummary(entity_id="a/X", confidence=0.9), + _cand("b/Y", "geo.city", 0.89), + ] + assert entity_types_would_disambiguate(candidates) is False + + +class TestAmbiguousResolutionErrorHint: + def test_same_type_hint_omits_entity_types(self) -> None: + candidates = [ + _cand("country/COD", "geo.country", 0.922), + _cand("country/COG", "geo.country", 0.918), + ] + err = AmbiguousResolutionError(candidates=candidates) + assert err.hint is not None + assert "entity_types=" not in err.hint + assert ".candidates" in err.hint + assert "on_ambiguous='best'" in err.hint + + def test_cross_type_hint_suggests_entity_types(self) -> None: + candidates = [ + _cand("city/X", "geo.city", 0.90), + _cand("admin1/Y", "geo.admin1", 0.89), + ] + err = AmbiguousResolutionError(candidates=candidates) + assert err.hint is not None + assert "entity_types=" in err.hint + + def test_no_candidates_hint_omits_entity_types(self) -> None: + err = AmbiguousResolutionError(candidates=None) + assert err.hint is not None + assert "entity_types=" not in err.hint + assert ".candidates" in err.hint + + def test_caller_supplied_hint_wins(self) -> None: + err = AmbiguousResolutionError(candidates=None, hint="custom hint") + assert err.hint == "custom hint" + + +class TestAmbiguousResolutionErrorMessage: + def test_message_previews_candidates_and_references_attribute(self) -> None: + candidates = [ + _cand("country/COD", "geo.country", 0.922), + _cand("country/COG", "geo.country", 0.918), + _cand("wikidataId/Q1", "geo.admin2", 0.876), + _cand("wikidataId/Q2", "geo.city", 0.873), + ] + msg = str(AmbiguousResolutionError(candidates=candidates)) + assert "country/COD" in msg + assert "0.92" in msg + assert ".candidates" in msg + # Preview is capped, so a "..." marker appears for >3 candidates. + assert "..." in msg + + def test_message_without_candidates_is_bare_count(self) -> None: + msg = str(AmbiguousResolutionError(candidates=None)) + assert "0 candidates" in msg diff --git a/tests/test_bulk_param_validation.py b/tests/test_bulk_param_validation.py new file mode 100644 index 0000000..3b135e8 --- /dev/null +++ b/tests/test_bulk_param_validation.py @@ -0,0 +1,378 @@ +"""Regression tests for bulk() parameter validation, output building, and result pickling. + +Each test class corresponds to one finding and runs with minimal mocks to avoid +flaky data-dependency. Integration probes using the real resolver are grouped at +the bottom. +""" + +from __future__ import annotations + +import pickle +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from resolvekit.core.api.bulk import _bulk_dispatch, _detect_input_kind +from resolvekit.core.model.bulk_result import BulkResult +from resolvekit.core.model.result import ReasonCode, ResolutionResult, ResolutionStatus + +# --------------------------------------------------------------------------- +# Shared mock helpers +# --------------------------------------------------------------------------- + + +def _make_resolved( + entity_id: str = "country/FRA", query: str = "France" +) -> ResolutionResult: + return ResolutionResult( + status=ResolutionStatus.RESOLVED, + entity_id=entity_id, + reasons=[ReasonCode.EXACT_NAME_MATCH], + query_text=query, + ) + + +def _make_no_match(query: str = "n/a") -> ResolutionResult: + return ResolutionResult( + status=ResolutionStatus.NO_MATCH, + reasons=[ReasonCode.NO_CANDIDATES], + query_text=query, + ) + + +def _mock_resolver(results_by_text: dict[str, ResolutionResult]) -> MagicMock: + from resolvekit.core.model.result import ResolutionResultList + + resolver = MagicMock() + resolver._routing_mode = None + resolver._runner.available_packs = frozenset({"geo"}) + + def _resolve_many_internal( + texts, *, domain=None, context=None, include_entity=False, timeout=None + ): + return ResolutionResultList( + [results_by_text.get(t, _make_no_match(t)) for t in texts] + ) + + resolver._resolve_many_internal.side_effect = _resolve_many_internal + return resolver + + +def _dispatch( + resolver: Any, + values: Any, + *, + to: Any = None, + output: str = "series", + on_error: str = "raise", + on_ambiguous: str = "null", + on_missing: str = "auto", + not_found: str = "null", +) -> Any: + """Thin wrapper so callers don't have to repeat all kwargs.""" + return _bulk_dispatch( + resolver=resolver, + values=values, + to=to, + output=output, + domain=None, + context=None, + from_system=None, + not_found=not_found, + on_error=on_error, + on_ambiguous=on_ambiguous, + on_missing=on_missing, + ) + + +# --------------------------------------------------------------------------- +# #3 — polars Series path: no NameError from TYPE_CHECKING Explainer forward ref +# --------------------------------------------------------------------------- + + +class TestFinding3PolarsNameError: + def test_polars_bulk_result_no_crash(self) -> None: + """bulk() on pl.Series must return BulkResult without NameError.""" + pl = pytest.importorskip("polars") + resolver = _mock_resolver({"France": _make_resolved()}) + result = _dispatch(resolver, pl.Series(["France", "Spain"])) + assert isinstance(result, BulkResult) + assert result.kind == "polars" + + def test_polars_series_stored_as_object_dtype(self) -> None: + """Values series uses pl.Object so polars never introspects pydantic models.""" + pl = pytest.importorskip("polars") + resolver = _mock_resolver({"France": _make_resolved()}) + result = _dispatch(resolver, pl.Series(["France"])) + assert isinstance(result, BulkResult) + # The inner pl.Series must be pl.Object, not inferred from pydantic. + assert result.values.dtype == pl.Object + + def test_polars_output_record_no_crash(self) -> None: + """output='record' with polars input must not crash with 'nested objects'.""" + pl = pytest.importorskip("polars") + resolver = _mock_resolver({"France": _make_resolved()}) + result = _dispatch(resolver, pl.Series(["France"]), output="record") + assert isinstance(result, BulkResult) + # The records must be Struct-typed (polars can handle dicts of primitives). + assert isinstance(result.values[0], dict) + # value field must be a primitive (entity_id string), not a ResolutionResult. + assert isinstance(result.values[0]["value"], str | type(None)) + + +# --------------------------------------------------------------------------- +# #22 — pandas None not coerced to NaN +# --------------------------------------------------------------------------- + + +class TestFinding22PandasNone: + def test_none_preserved_not_nan(self) -> None: + """Unresolved rows in pandas output must be None, not NaN.""" + pd = pytest.importorskip("pandas") + resolver = _mock_resolver({"France": _make_resolved(entity_id="country/FRA")}) + # Patch dispatch_pivot to return the entity_id string for "iso3" + from unittest.mock import patch + + def fake_pivot(entity: Any, to: str) -> str | None: + if to == "iso3": + return "FRA" + return None + + from resolvekit.core.api import bulk as bulk_mod + + with patch.object(bulk_mod, "dispatch_pivot", side_effect=fake_pivot): + series = _dispatch( + resolver, + pd.Series(["France", "n/a"]), + to="iso3", + ) + + assert isinstance(series, pd.Series) + assert series.dtype == object + assert series.iloc[0] == "FRA" + assert series.iloc[1] is None + + def test_pandas_series_dtype_object_when_no_pivot(self) -> None: + """Even without a pivot, pandas series must use dtype=object.""" + pd = pytest.importorskip("pandas") + resolver = _mock_resolver({"France": _make_resolved()}) + result = _dispatch(resolver, pd.Series(["France"])) + # BulkResult.values is the underlying pandas Series. + assert isinstance(result, BulkResult) + assert result.values.dtype == object + + +# --------------------------------------------------------------------------- +# #24 — ResolutionResult pickle round-trip +# --------------------------------------------------------------------------- + + +class TestFinding24Pickle: + def test_pickle_round_trip(self) -> None: + """ResolutionResult must survive pickle.dumps / pickle.loads.""" + r = _make_resolved() + unpickled = pickle.loads(pickle.dumps(r)) + assert unpickled.status == ResolutionStatus.RESOLVED + assert unpickled.entity_id == "country/FRA" + + def test_pickle_with_private_attrs(self) -> None: + """Pickle must work even when _explainer / _resolve_context are set.""" + import weakref + + class _Sentinel: + """Weakref-able sentinel.""" + + r = ResolutionResult( + status=ResolutionStatus.RESOLVED, + entity_id="country/FRA", + reasons=[ReasonCode.EXACT_NAME_MATCH], + ) + # Simulate a live explainer backref using a weakref-able object. + sentinel = _Sentinel() + priv = r.__pydantic_private__ + assert priv is not None + priv["_explainer"] = weakref.ref(sentinel) + + unpickled = pickle.loads(pickle.dumps(r)) + assert unpickled.entity_id == "country/FRA" + # The explainer must be dropped — no weakref in the unpickled object. + unpriv = unpickled.__pydantic_private__ + assert unpriv is not None + assert unpriv["_explainer"] is None + + def test_explain_on_unpickled_raises_gracefully(self) -> None: + """explain() on an unpickled result must raise ExplainNotAvailableError.""" + from resolvekit.core.errors_base import ExplainNotAvailableError + + r = _make_resolved(query="France") + unpickled = pickle.loads(pickle.dumps(r)) + with pytest.raises(ExplainNotAvailableError): + unpickled.explain() + + +# --------------------------------------------------------------------------- +# #25 — output='record' without pivot: primitives only, to_polars safe +# --------------------------------------------------------------------------- + + +class TestFinding25RecordPrimitives: + def test_record_value_is_entity_id_when_no_pivot(self) -> None: + """When no pivot is set, 'value' in output='record' must be entity_id string.""" + resolver = _mock_resolver({"France": _make_resolved()}) + result = _dispatch(resolver, ["France"], output="record") + assert isinstance(result, BulkResult) + records = result.to_list() + assert records[0]["value"] == "country/FRA" + + def test_record_value_none_when_no_match(self) -> None: + """For no-match rows, 'value' must be None.""" + resolver = _mock_resolver({}) + result = _dispatch(resolver, ["unknown"], output="record") + records = result.to_list() + assert records[0]["value"] is None + + def test_to_polars_on_record_result_no_crash(self) -> None: + """BulkResult from output='record' with no pivot must convert to polars.""" + pl = pytest.importorskip("polars") + resolver = _mock_resolver({"France": _make_resolved()}) + result = _dispatch(resolver, ["France"], output="record") + # This must not raise 'nested objects are not allowed'. + polars_series = result.to_polars() + assert isinstance(polars_series, pl.Series) + + def test_pandas_record_to_polars_no_crash(self) -> None: + """BulkResult from pandas input, output='record', to_polars() must work.""" + pl = pytest.importorskip("polars") + pd = pytest.importorskip("pandas") + resolver = _mock_resolver({"France": _make_resolved()}) + result = _dispatch(resolver, pd.Series(["France"]), output="record") + polars_series = result.to_polars() + assert isinstance(polars_series, pl.Series) + + +# --------------------------------------------------------------------------- +# #30 — enum-ish param validation: on_error, on_ambiguous, on_missing +# --------------------------------------------------------------------------- + + +class TestFinding30ParamValidation: + def _base_dispatch(self, **kwargs: Any) -> Any: + resolver = _mock_resolver({"France": _make_resolved()}) + return _dispatch(resolver, ["France"], **kwargs) + + def test_on_error_invalid_raises(self) -> None: + with pytest.raises(ValueError, match="on_error="): + self._base_dispatch(on_error="raise2") + + def test_on_error_did_you_mean(self) -> None: + with pytest.raises(ValueError, match="did you mean 'raise'"): + self._base_dispatch(on_error="raise2") + + def test_on_ambiguous_invalid_raises(self) -> None: + with pytest.raises(ValueError, match="on_ambiguous="): + self._base_dispatch(on_ambiguous="Null") + + def test_on_ambiguous_did_you_mean(self) -> None: + with pytest.raises(ValueError, match="did you mean 'null'"): + self._base_dispatch(on_ambiguous="Null") + + def test_on_missing_invalid_raises(self) -> None: + with pytest.raises(ValueError, match="on_missing="): + self._base_dispatch(on_missing="Raise") + + def test_on_missing_did_you_mean(self) -> None: + with pytest.raises(ValueError, match="did you mean 'raise'"): + self._base_dispatch(on_missing="Raise") + + def test_valid_values_do_not_raise(self) -> None: + """Valid parameter strings must not raise.""" + resolver = _mock_resolver({"France": _make_resolved()}) + for on_error in ("raise", "null", "keep"): + _dispatch(resolver, ["France"], on_error=on_error) + for on_ambiguous in ("raise", "null", "best"): + _dispatch(resolver, ["France"], on_ambiguous=on_ambiguous) + for on_missing in ("raise", "null", "auto"): + _dispatch(resolver, ["France"], on_missing=on_missing) + + def test_output_still_validated(self) -> None: + """The existing output= validation must still work after adding new checks.""" + resolver = _mock_resolver({"France": _make_resolved()}) + with pytest.raises(ValueError, match="output="): + _dispatch(resolver, ["France"]) # type: ignore[call-arg] + # call via _bulk_dispatch directly + _bulk_dispatch( + resolver=resolver, + values=["France"], + to=None, + output="badvalue", + domain=None, + context=None, + from_system=None, + not_found="null", + on_error="raise", + on_ambiguous="null", + on_missing="auto", + ) + + +# --------------------------------------------------------------------------- +# #34 — BulkResult repr: angle-bracket style +# --------------------------------------------------------------------------- + + +class TestFinding34BulkResultRepr: + def test_repr_starts_with_angle_bracket(self) -> None: + resolver = _mock_resolver({"France": _make_resolved()}) + result = _dispatch(resolver, ["France"]) + assert isinstance(result, BulkResult) + r = repr(result) + assert r.startswith(""), f"Expected repr to end with >, got: {r}" + + def test_repr_contains_counts(self) -> None: + resolver = _mock_resolver({"France": _make_resolved()}) + result = _dispatch(resolver, ["France"]) + r = repr(result) + assert "total=1" in r + assert "resolved=1" in r + + def test_repr_not_eval_able_as_constructor(self) -> None: + """The repr must not be parseable as BulkResult(...) constructor syntax.""" + resolver = _mock_resolver({"France": _make_resolved()}) + result = _dispatch(resolver, ["France"]) + r = repr(result) + # angle-bracket form cannot be eval'd as a constructor + assert "BulkResult(" not in r + + +# --------------------------------------------------------------------------- +# #36 — TypeError message includes dict and DataFrame hint +# --------------------------------------------------------------------------- + + +class TestFinding36TypeErrorMessage: + def test_message_includes_dict(self) -> None: + """TypeError message must list 'dict' as an accepted type.""" + + with pytest.raises(TypeError, match="dict"): + _detect_input_kind(42) + + def test_polars_dataframe_hint(self) -> None: + """Passing a polars DataFrame must mention column extraction.""" + pl = pytest.importorskip("polars") + df = pl.DataFrame({"name": ["France"]}) + with pytest.raises(TypeError, match="col_name"): + _detect_input_kind(df) + + def test_pandas_dataframe_hint(self) -> None: + """Passing a pandas DataFrame must mention column extraction.""" + pd = pytest.importorskip("pandas") + df = pd.DataFrame({"name": ["France"]}) + with pytest.raises(TypeError, match="col_name"): + _detect_input_kind(df) + + def test_dict_accepted(self) -> None: + """dict input must be accepted (not raise TypeError).""" + kind, _ = _detect_input_kind({"a": "France"}) + assert kind == "dict" diff --git a/tests/test_entity_attributes.py b/tests/test_entity_attributes.py index 18019cf..7552f31 100644 --- a/tests/test_entity_attributes.py +++ b/tests/test_entity_attributes.py @@ -33,7 +33,7 @@ def _make_entity( ) -> EntityRecord: """Build a minimal ``EntityRecord`` for testing.""" codes: list[CodeRecord] = [] - for system, value in (("iso2", iso2), ("iso3", iso3), ("numeric", numeric)): + for system, value in (("iso2", iso2), ("iso3", iso3), ("iso_numeric", numeric)): if value is not None: codes.append( CodeRecord(system=system, value=value, value_norm=value.lower()) diff --git a/tests/test_error_ux.py b/tests/test_error_ux.py index 31abe14..68708b6 100644 --- a/tests/test_error_ux.py +++ b/tests/test_error_ux.py @@ -82,10 +82,16 @@ def test_existing_errors_inherit_resolver_error() -> None: def test_ambiguous_resolution_error_inherits_and_has_hint() -> None: - """AmbiguousResolutionError has a useful default hint.""" + """AmbiguousResolutionError has a useful default hint. + + With no candidate type signal, the hint must not steer callers to an + entity_types filter (which provably cannot help); it points at the + candidate set and on_ambiguous='best' instead. + """ err = AmbiguousResolutionError(candidates=None) assert err.hint is not None - assert "disambiguate" in err.hint + assert ".candidates" in err.hint + assert "entity_types=" not in err.hint def test_explain_not_available_error_default_hint() -> None: diff --git a/tests/test_iso_numeric_regressions.py b/tests/test_iso_numeric_regressions.py new file mode 100644 index 0000000..6c6c665 --- /dev/null +++ b/tests/test_iso_numeric_regressions.py @@ -0,0 +1,371 @@ +"""Regression tests for iso_numeric zero-padding bugs (#2, #4) and aliases dedup (#12). + +#2: iso_numeric codes stored unpadded — lookups and emission must handle padding. +#4: EntityRecord.numeric looked up 'numeric' key instead of 'iso_numeric'. +#12: EntityRecord.aliases leaked canonical name and duplicates. +""" + +from __future__ import annotations + +import pytest + +from resolvekit.core.api.code_lookup import ( + _CODE_SYSTEM_PRIORITY, + _iso_numeric_lookup_value, +) +from resolvekit.core.model.entity import CodeRecord, EntityRecord, NameRecord + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_country( + *, + entity_id: str, + canonical_name: str, + iso_numeric: str | None = None, + extra_names: list[tuple[str, bool]] | None = None, +) -> EntityRecord: + """Build a minimal EntityRecord with iso_numeric stored unpadded (as bundled data does).""" + codes: list[CodeRecord] = [] + if iso_numeric is not None: + codes.append( + CodeRecord( + system="iso_numeric", + value=iso_numeric, + value_norm=iso_numeric, + ) + ) + names: list[NameRecord] = [] + for value, is_preferred in extra_names or []: + names.append( + NameRecord( + value=value, + value_norm=value.lower(), + kind="alias", + is_preferred=is_preferred, + ) + ) + return EntityRecord( + entity_id=entity_id, + entity_type="geo.country", + canonical_name=canonical_name, + canonical_name_norm=canonical_name.lower(), + codes=codes, + names=names, + ) + + +# --------------------------------------------------------------------------- +# Finding #2 — iso_numeric zero-padding: _iso_numeric_lookup_value helper +# --------------------------------------------------------------------------- + + +class TestIsoNumericLookupValue: + def test_padded_input_strips_leading_zeros(self) -> None: + assert _iso_numeric_lookup_value("004") == "4" + + def test_unpadded_input_unchanged(self) -> None: + assert _iso_numeric_lookup_value("4") == "4" + + def test_three_digit_no_leading_zeros_unchanged(self) -> None: + assert _iso_numeric_lookup_value("840") == "840" + + def test_two_leading_zeros_stripped(self) -> None: + assert _iso_numeric_lookup_value("004") == "4" + assert _iso_numeric_lookup_value("040") == "40" + + def test_all_zeros_returns_zero(self) -> None: + assert _iso_numeric_lookup_value("000") == "0" + + def test_already_unpadded_250(self) -> None: + assert _iso_numeric_lookup_value("250") == "250" + + +# --------------------------------------------------------------------------- +# Finding #2 — iso_numeric zero-padding: codes_dict emission +# --------------------------------------------------------------------------- + + +class TestCodesDictIsoNumericPadding: + def test_unpadded_4_emits_padded_004(self) -> None: + """Afghanistan stored as '4'; codes_dict must return '004'.""" + entity = _make_country( + entity_id="country/AFG", + canonical_name="Afghanistan", + iso_numeric="4", + ) + assert entity.codes_dict["iso_numeric"] == "004" + + def test_unpadded_40_emits_padded_040(self) -> None: + entity = _make_country( + entity_id="country/TEST", + canonical_name="Test", + iso_numeric="40", + ) + assert entity.codes_dict["iso_numeric"] == "040" + + def test_already_padded_250_unchanged(self) -> None: + """France stored as '250' (no leading zeros needed).""" + entity = _make_country( + entity_id="country/FRA", + canonical_name="France", + iso_numeric="250", + ) + assert entity.codes_dict["iso_numeric"] == "250" + + def test_code_method_returns_padded(self) -> None: + entity = _make_country( + entity_id="country/AFG", + canonical_name="Afghanistan", + iso_numeric="4", + ) + assert entity.code("iso_numeric") == "004" + + def test_non_iso_numeric_systems_unaffected(self) -> None: + """iso2, iso3, wikidata etc. are NOT zero-padded.""" + from resolvekit.core.model.entity import CodeRecord + + entity = EntityRecord( + entity_id="country/USA", + entity_type="geo.country", + canonical_name="United States", + canonical_name_norm="united states", + codes=[ + CodeRecord(system="iso2", value="US", value_norm="us"), + CodeRecord(system="iso3", value="USA", value_norm="usa"), + CodeRecord(system="iso_numeric", value="840", value_norm="840"), + ], + ) + assert entity.codes_dict["iso2"] == "US" + assert entity.codes_dict["iso3"] == "USA" + assert entity.codes_dict["iso_numeric"] == "840" + + +# --------------------------------------------------------------------------- +# Finding #4 — EntityRecord.numeric must read 'iso_numeric', not 'numeric' +# --------------------------------------------------------------------------- + + +class TestEntityNumericProperty: + def test_numeric_returns_padded_value_from_iso_numeric_system(self) -> None: + entity = _make_country( + entity_id="country/FRA", + canonical_name="France", + iso_numeric="250", + ) + assert entity.numeric == "250" + + def test_numeric_returns_padded_for_single_digit_code(self) -> None: + entity = _make_country( + entity_id="country/AFG", + canonical_name="Afghanistan", + iso_numeric="4", + ) + assert entity.numeric == "004" + + def test_numeric_returns_none_when_absent(self) -> None: + entity = _make_country( + entity_id="country/XXX", + canonical_name="No Numeric", + ) + assert entity.numeric is None + + def test_numeric_is_not_read_from_system_named_numeric(self) -> None: + """Old bug: .numeric looked for system='numeric'; must not return that.""" + from resolvekit.core.model.entity import CodeRecord + + entity = EntityRecord( + entity_id="country/XXX", + entity_type="geo.country", + canonical_name="Old Bug", + canonical_name_norm="old bug", + codes=[ + CodeRecord(system="numeric", value="999", value_norm="999"), + ], + ) + # system='numeric' is NOT 'iso_numeric', so .numeric must return None + assert entity.numeric is None + + def test_numeric_reads_iso_numeric_system(self) -> None: + """Correct system name is 'iso_numeric'.""" + from resolvekit.core.model.entity import CodeRecord + + entity = EntityRecord( + entity_id="country/FRA", + entity_type="geo.country", + canonical_name="France", + canonical_name_norm="france", + codes=[ + CodeRecord(system="iso_numeric", value="250", value_norm="250"), + ], + ) + assert entity.numeric == "250" + + +# --------------------------------------------------------------------------- +# Finding #2 — _CODE_SYSTEM_PRIORITY: 'iso_numeric' not 'numeric' +# --------------------------------------------------------------------------- + + +class TestCodeSystemPriority: + def test_iso_numeric_in_priority_list(self) -> None: + assert "iso_numeric" in _CODE_SYSTEM_PRIORITY + + def test_dead_numeric_not_in_priority_list(self) -> None: + assert "numeric" not in _CODE_SYSTEM_PRIORITY + + def test_iso_numeric_before_dcid_and_wikidata(self) -> None: + idx_iso_numeric = _CODE_SYSTEM_PRIORITY.index("iso_numeric") + idx_dcid = _CODE_SYSTEM_PRIORITY.index("dcid") + idx_wikidata = _CODE_SYSTEM_PRIORITY.index("wikidata") + assert idx_iso_numeric < idx_dcid + assert idx_iso_numeric < idx_wikidata + + +# --------------------------------------------------------------------------- +# Finding #12 — EntityRecord.aliases deduplication +# --------------------------------------------------------------------------- + + +class TestEntityAliasesDedup: + def test_canonical_name_excluded_from_aliases(self) -> None: + entity = _make_country( + entity_id="country/FRA", + canonical_name="France", + extra_names=[ + ("France", False), # canonical value as non-preferred record + ("République française", False), + ], + ) + assert "France" not in entity.aliases + + def test_duplicate_alias_values_deduplicated(self) -> None: + entity = _make_country( + entity_id="country/CUB", + canonical_name="Cuba", + extra_names=[ + ("Cuba", False), + ("Cuba", False), + ("Cuba", False), + ("Cuba", False), + ("Cuba", False), + ], + ) + assert entity.aliases.count("Cuba") == 0 # also excluded as canonical + + def test_genuine_aliases_preserved(self) -> None: + entity = _make_country( + entity_id="country/DEU", + canonical_name="Germany", + extra_names=[ + ("Deutschland", False), + ("Allemagne", False), + ("Germany", False), # same as canonical — must be excluded + ], + ) + assert "Deutschland" in entity.aliases + assert "Allemagne" in entity.aliases + assert "Germany" not in entity.aliases + + def test_preferred_names_excluded(self) -> None: + entity = _make_country( + entity_id="country/XXX", + canonical_name="Test", + extra_names=[ + ("Preferred Alt", True), # is_preferred=True + ("Real Alias", False), + ], + ) + assert "Preferred Alt" not in entity.aliases + assert "Real Alias" in entity.aliases + + def test_duplicate_non_canonical_values_deduplicated(self) -> None: + entity = _make_country( + entity_id="country/IND", + canonical_name="India", + extra_names=[ + ("Bharat", False), + ("Bharat", False), # duplicate non-canonical + ("भारत", False), + ], + ) + assert entity.aliases.count("Bharat") == 1 + assert "भारत" in entity.aliases + + def test_order_preserved_first_occurrence_wins(self) -> None: + entity = _make_country( + entity_id="country/XXX", + canonical_name="Test", + extra_names=[ + ("Alpha", False), + ("Beta", False), + ("Alpha", False), # duplicate, should be dropped + ("Gamma", False), + ], + ) + assert entity.aliases == ["Alpha", "Beta", "Gamma"] + + def test_empty_names_list(self) -> None: + entity = _make_country( + entity_id="country/XXX", + canonical_name="Test", + ) + assert entity.aliases == [] + + +# --------------------------------------------------------------------------- +# Integration: entity().numeric and entity().aliases against live bundled data +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestLiveBundledData: + """Probes against the actual bundled geo.countries pack.""" + + @pytest.fixture(scope="class") + def resolver(self): + from resolvekit.core.api.resolver import Resolver + + return Resolver.from_modules(module_ids=["geo.countries"]) + + def test_france_numeric_is_padded(self, resolver) -> None: + e = resolver.entity("France") + assert e is not None + assert e.numeric == "250" + + def test_afghanistan_numeric_is_zero_padded(self, resolver) -> None: + e = resolver.entity("Afghanistan") + assert e is not None + assert e.numeric == "004" + + def test_resolve_to_iso_numeric_emits_padded(self, resolver) -> None: + assert resolver.resolve("Afghanistan", to="iso_numeric") == "004" + assert resolver.resolve("France", to="iso_numeric") == "250" + + def test_resolve_id_from_system_iso_numeric_padded(self, resolver) -> None: + assert resolver.resolve_id("004", from_system="iso_numeric") == "country/AFG" + assert resolver.resolve_id("4", from_system="iso_numeric") == "country/AFG" + assert resolver.resolve_id("250", from_system="iso_numeric") == "country/FRA" + + def test_germany_canonical_not_in_aliases(self, resolver) -> None: + e = resolver.entity("Germany") + assert e is not None + assert e.canonical_name not in e.aliases + + def test_germany_aliases_no_duplicates(self, resolver) -> None: + e = resolver.entity("Germany") + assert e is not None + assert len(e.aliases) == len(set(e.aliases)) + + def test_cuba_canonical_not_in_aliases(self, resolver) -> None: + e = resolver.entity("Cuba") + assert e is not None + assert e.canonical_name not in e.aliases + assert e.aliases.count(e.canonical_name) == 0 + + def test_france_canonical_not_in_aliases(self, resolver) -> None: + e = resolver.entity("France") + assert e is not None + assert e.canonical_name not in e.aliases diff --git a/tests/test_resolver_param_validation.py b/tests/test_resolver_param_validation.py new file mode 100644 index 0000000..e11841a --- /dev/null +++ b/tests/test_resolver_param_validation.py @@ -0,0 +1,236 @@ +"""Regression tests for resolver entry-point validation. + +Covers: +- #8: resolve_id(on_ambiguous=) raises a named ValueError eagerly. +- #14: domain= under AUTO routing raises an actionable, public-API message + (no internal RoutingMode enum reference). +- #15: as_of= on members_of/is_member/related/within coerces ISO date strings + and raises a clear ValueError on bad strings. +- #23: available_entity_types() returns fine-grained types accepted by + ResolutionContext(entity_types=...). +- #32: parse(confidence_threshold=) raises eagerly. +- #33: parse(domain=) raises UnknownDomainError, like resolve(). +""" + +from __future__ import annotations + +from datetime import date +from typing import Any + +import pytest + +from resolvekit.core.api.query_prep import _auto_routing_domain_error +from resolvekit.core.api.resolver import ( + _coerce_as_of, + _on_ambiguous_error, + _validate_confidence_threshold, +) +from resolvekit.core.errors import UnknownDomainError + + +class TestAutoRoutingDomainMessage: + def test_message_is_actionable_for_public_api(self) -> None: + msg = _auto_routing_domain_error() + # Must NOT reference the internal, non-public RoutingMode enum. + assert "RoutingMode" not in msg + # Must name the public string spelling and a real constructor. + assert 'routing_mode="explicit"' in msg + assert "Resolver" in msg + + +# --------------------------------------------------------------------------- +# Pure-helper unit tests (no data needed) +# --------------------------------------------------------------------------- + + +class TestOnAmbiguousValidation: + def test_error_message_lists_valid_options(self) -> None: + msg = _on_ambiguous_error("Raise") + assert "'raise'" in msg + assert "'null'" in msg + assert "'best'" in msg + + def test_error_message_suggests_close_match(self) -> None: + assert "did you mean 'raise'" in _on_ambiguous_error("Raise") + + +class TestCoerceAsOf: + def test_none_passes_through(self) -> None: + assert _coerce_as_of(None) is None + + def test_date_passes_through(self) -> None: + d = date(2020, 1, 1) + assert _coerce_as_of(d) is d + + def test_iso_string_coerced(self) -> None: + assert _coerce_as_of("2020-01-01") == date(2020, 1, 1) + + def test_bad_string_raises_value_error(self) -> None: + with pytest.raises(ValueError, match="not a valid ISO date"): + _coerce_as_of("not-a-date") + + def test_non_string_non_date_raises_type_error(self) -> None: + with pytest.raises(TypeError, match="as_of must be"): + _coerce_as_of(20200101) # type: ignore[arg-type] + + +class TestConfidenceThresholdValidation: + def test_none_ok(self) -> None: + _validate_confidence_threshold(None) + + def test_in_range_float_ok(self) -> None: + _validate_confidence_threshold(0.9) + + def test_string_raises(self) -> None: + with pytest.raises(ValueError, match=r"confidence_threshold='0\.9'"): + _validate_confidence_threshold("0.9") + + def test_bool_raises(self) -> None: + with pytest.raises(ValueError, match="confidence_threshold"): + _validate_confidence_threshold(True) + + def test_out_of_range_raises(self) -> None: + with pytest.raises(ValueError, match="out of range"): + _validate_confidence_threshold(5.0) + + +# --------------------------------------------------------------------------- +# Integration tests against the synthetic geo fixture +# --------------------------------------------------------------------------- + + +class TestResolveIdOnAmbiguousValidation: + @pytest.fixture + def resolver(self, geo_test_datapack: Any) -> Any: + from resolvekit.core.api.resolver import Resolver + + r = Resolver.from_datapacks(datapack_paths=[geo_test_datapack]) + yield r + r.close() + + def test_typo_raises_before_resolution(self, resolver: Any) -> None: + with pytest.raises(ValueError, match="on_ambiguous='Raise'"): + resolver.resolve_id("United States", on_ambiguous="Raise") + + def test_valid_values_do_not_raise_validation(self, resolver: Any) -> None: + # "null" / "best" never raise the validation error. + assert resolver.resolve_id("United States", on_ambiguous="null") is not None + assert resolver.resolve_id("United States", on_ambiguous="best") is not None + + +class TestParseValidation: + @pytest.fixture + def resolver(self, geo_test_datapack: Any) -> Any: + from resolvekit.core.api.resolver import Resolver + + r = Resolver.from_datapacks(datapack_paths=[geo_test_datapack]) + yield r + r.close() + + def test_parse_unknown_domain_raises(self, resolver: Any) -> None: + with pytest.raises(UnknownDomainError): + resolver.parse("United States", domain="xyz") + + def test_parse_bad_confidence_threshold_raises_eagerly(self, resolver: Any) -> None: + # Eager: raises even when no span would resolve. + with pytest.raises(ValueError, match="confidence_threshold"): + resolver.parse("zzzz qqqq", confidence_threshold="0.9") + + def test_parse_bulk_unknown_domain_raises(self, resolver: Any) -> None: + with pytest.raises(UnknownDomainError): + resolver.parse_bulk(values=["United States"], domain="xyz") + + +# --------------------------------------------------------------------------- +# Integration tests requiring bundled module data +# --------------------------------------------------------------------------- + + +def _bundled_geo_available() -> bool: + try: + from resolvekit import Resolver + + r = Resolver.from_modules(module_ids=["geo.countries"]) + r.close() + return True + except Exception: + return False + + +_BUNDLED = _bundled_geo_available() +bundled = pytest.mark.skipif( + not _BUNDLED, reason="bundled geo module data not available" +) + + +@bundled +class TestAvailableEntityTypes: + def test_returns_full_dotted_types(self) -> None: + from resolvekit import Resolver + + r = Resolver.from_modules(module_ids=["geo.countries"]) + try: + types = r.available_entity_types() + assert "geo.country" in types + assert "geo" not in types + finally: + r.close() + + def test_values_accepted_by_entity_types_filter(self) -> None: + from resolvekit import ResolutionContext, Resolver + + r = Resolver.lite() + try: + types = r.available_entity_types() + ctx = ResolutionContext(entity_types=list(types)) + result = r.resolve("France", context=ctx, as_result=True) + assert result.status.value == "resolved" + finally: + r.close() + + +def _geo_unions_have_members() -> bool: + try: + from resolvekit import Resolver + + r = Resolver.from_modules( + module_ids=["geo.countries", "geo.continental_unions"] + ) + try: + return len(r.members_of("European Union")) > 0 + finally: + r.close() + except Exception: + return False + + +_UNIONS = _geo_unions_have_members() +unions = pytest.mark.skipif( + not _UNIONS, reason="bundled continental-union membership data not available" +) + + +@unions +class TestRelationshipAsOfCoercion: + @pytest.fixture(scope="class") + def resolver(self): # type: ignore[no-untyped-def] + from resolvekit import Resolver + + r = Resolver.from_modules( + module_ids=["geo.countries", "geo.continental_unions"] + ) + yield r + r.close() + + def test_members_of_accepts_iso_string(self, resolver: Any) -> None: + from_str = resolver.members_of("European Union", as_of="2020-01-01") + from_date = resolver.members_of("European Union", as_of=date(2020, 1, 1)) + assert from_str == from_date + + def test_members_of_bad_date_raises(self, resolver: Any) -> None: + with pytest.raises(ValueError, match="not a valid ISO date"): + resolver.members_of("European Union", as_of="not-a-date") + + def test_is_member_accepts_iso_string(self, resolver: Any) -> None: + # Should not raise an internals-deep AttributeError. + resolver.is_member("France", "European Union", as_of="2020-01-01") diff --git a/tests/test_snap_label_candidates.py b/tests/test_snap_label_candidates.py new file mode 100644 index 0000000..209d56a --- /dev/null +++ b/tests/test_snap_label_candidates.py @@ -0,0 +1,272 @@ +"""Regression tests for snap() label-candidate support (regression) and +_apply_to error propagation (regression). + +Finding #5: snap() must resolve free-text candidate labels to entity IDs before +filtering, so candidates=['France','Spain'] works as documented. + +Finding #13: _apply_to must propagate TypeError (list to=) and +UnknownCodeSystemError instead of swallowing them as None. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from resolvekit.core.api.snap import _apply_to, _snap_dispatch +from resolvekit.core.errors import UnknownCodeSystemError +from resolvekit.core.model.entity import CodeRecord, EntityRecord +from resolvekit.core.model.result import CandidateSummary + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_entity( + entity_id: str = "country/FRA", *, canonical_name: str = "France" +) -> EntityRecord: + return EntityRecord( + entity_id=entity_id, + entity_type="geo.country", + canonical_name=canonical_name, + canonical_name_norm=canonical_name.lower(), + codes=[ + CodeRecord( + system="iso3", + value=entity_id.split("/")[1], + value_norm=entity_id.split("/")[1].lower(), + ), + CodeRecord(system="iso2", value="FR", value_norm="fr"), + ], + ) + + +def _make_resolver( + search_results: list[CandidateSummary], + entity: EntityRecord | None = None, + *, + resolve_id_map: dict[str, str | None] | None = None, +) -> MagicMock: + """Build a mock Resolver. + + ``resolve_id_map`` maps label strings to entity IDs (or None for + unresolvable labels). Strings containing '/' are never passed to + resolve_id; they pass through _resolve_candidate_to_id unchanged. + """ + resolver = MagicMock() + resolver._search_internal.return_value = search_results + resolver._runner.get_entity.return_value = entity + + if resolve_id_map: + + def _resolve_id(text: str, *, on_ambiguous: str = "raise") -> str | None: + return resolve_id_map.get(text) + + resolver.resolve_id.side_effect = _resolve_id + else: + resolver.resolve_id.return_value = None + + return resolver + + +# --------------------------------------------------------------------------- +# Finding #5 — label candidates are resolved to entity IDs before filtering +# --------------------------------------------------------------------------- + + +def test_label_candidates_resolve_to_entity_ids(): + """snap() with free-text labels in candidates matches correctly.""" + search_results = [ + CandidateSummary(entity_id="country/FRA", confidence=0.9), + CandidateSummary(entity_id="country/ESP", confidence=0.5), + ] + resolver = _make_resolver( + search_results, + entity=_make_entity("country/FRA"), + resolve_id_map={"France": "country/FRA", "Spain": "country/ESP"}, + ) + + result = _snap_dispatch( + resolver=resolver, + query="Frnace", # typo of France + candidates=["France", "Spain"], + max_distance=0.5, + domain=None, + context=None, + ) + assert result == "country/FRA" + + +def test_label_candidates_unresolvable_label_skipped(): + """A label that cannot be resolved is silently skipped (does not prevent other matches).""" + search_results = [ + CandidateSummary(entity_id="country/FRA", confidence=0.9), + ] + resolver = _make_resolver( + search_results, + entity=_make_entity("country/FRA"), + resolve_id_map={"France": "country/FRA", "NotACountry": None}, + ) + + result = _snap_dispatch( + resolver=resolver, + query="France", + candidates=["France", "NotACountry"], + max_distance=0.5, + domain=None, + context=None, + ) + assert result == "country/FRA" + + +def test_label_candidates_all_unresolvable_returns_none(): + """When every label candidate fails resolution, snap() returns None.""" + resolver = _make_resolver( + [], + resolve_id_map={"NotACountry": None, "AlsoNothing": None}, + ) + + result = _snap_dispatch( + resolver=resolver, + query="Something", + candidates=["NotACountry", "AlsoNothing"], + max_distance=0.5, + domain=None, + context=None, + ) + assert result is None + + +def test_entity_id_candidates_unchanged(): + """Entity-ID candidates still work after the label-resolution refactor.""" + search_results = [ + CandidateSummary(entity_id="country/FRA", confidence=0.9), + ] + resolver = _make_resolver( + search_results, + entity=_make_entity("country/FRA"), + ) + + result = _snap_dispatch( + resolver=resolver, + query="France", + candidates=["country/FRA", "country/ESP"], + max_distance=0.5, + domain=None, + context=None, + ) + # resolve_id should NOT be called for entity IDs (strings with '/') + resolver.resolve_id.assert_not_called() + assert result == "country/FRA" + + +def test_mixed_candidates_label_and_entity_id(): + """Mixed list: one entity ID and one free-text label both work.""" + search_results = [ + CandidateSummary(entity_id="country/FRA", confidence=0.9), + CandidateSummary(entity_id="country/ESP", confidence=0.7), + ] + resolver = _make_resolver( + search_results, + entity=_make_entity("country/FRA"), + resolve_id_map={"Spain": "country/ESP"}, + ) + + result = _snap_dispatch( + resolver=resolver, + query="Frnace", + candidates=["country/FRA", "Spain"], # one ID, one label + max_distance=0.5, + domain=None, + context=None, + ) + assert result == "country/FRA" + # resolve_id called only for the label, not the entity ID + resolver.resolve_id.assert_called_once_with("Spain", on_ambiguous="null") + + +def test_label_candidates_with_to_pivot(): + """Label candidates + to= pivot returns the pivoted value.""" + search_results = [ + CandidateSummary(entity_id="country/FRA", confidence=0.9), + ] + entity = _make_entity("country/FRA") + resolver = _make_resolver( + search_results, + entity=entity, + resolve_id_map={"France": "country/FRA", "Spain": "country/ESP"}, + ) + + result = _snap_dispatch( + resolver=resolver, + query="France", + candidates=["France", "Spain"], + max_distance=0.5, + to="iso3", + domain=None, + context=None, + ) + assert result == "FRA" + + +# --------------------------------------------------------------------------- +# Finding #13 — _apply_to propagates TypeError and UnknownCodeSystemError +# --------------------------------------------------------------------------- + + +def test_apply_to_list_raises_type_error(): + """snap() with to=['iso3','name'] raises TypeError, not silently returns None.""" + search_results = [ + CandidateSummary(entity_id="country/FRA", confidence=0.9), + ] + entity = _make_entity("country/FRA") + resolver = _make_resolver(search_results, entity=entity) + + with pytest.raises(TypeError, match="to= takes a single target string"): + _snap_dispatch( + resolver=resolver, + query="France", + candidates=["country/FRA"], + max_distance=0.5, + to=["iso3", "name"], + domain=None, + context=None, + ) + + +def test_apply_to_unknown_code_system_raises(): + """snap() with an unknown to= system raises UnknownCodeSystemError.""" + search_results = [ + CandidateSummary(entity_id="country/FRA", confidence=0.9), + ] + entity = _make_entity("country/FRA") + resolver = _make_resolver(search_results, entity=entity) + + with pytest.raises(UnknownCodeSystemError): + _snap_dispatch( + resolver=resolver, + query="France", + candidates=["country/FRA"], + max_distance=0.5, + to="not_a_real_system", + domain=None, + context=None, + ) + + +def test_apply_to_returns_none_when_entity_missing(): + """_apply_to still returns None when the entity cannot be fetched (legitimate miss).""" + resolver = _make_resolver([], entity=None) + + result = _apply_to(resolver, "country/FRA", "iso3") + assert result is None + + +def test_apply_to_returns_entity_id_when_to_is_none(): + """_apply_to(to=None) returns the entity_id directly.""" + resolver = _make_resolver([]) + result = _apply_to(resolver, "country/FRA", None) + assert result == "country/FRA" + resolver._runner.get_entity.assert_not_called() diff --git a/uv.lock b/uv.lock index 9a3deb6..aa5baa7 100644 --- a/uv.lock +++ b/uv.lock @@ -1700,7 +1700,7 @@ wheels = [ [[package]] name = "resolvekit" -version = "0.1.2" +version = "0.1.3" source = { editable = "." } dependencies = [ { name = "packaging" }, From c3ecda21603bacf6a371c29b2da7d5963db902a4 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 11 Jun 2026 20:42:31 +0200 Subject: [PATCH 2/9] test: gate D.C. assertion on remote data; Windows-safe cache_dir comparisons Clean CI has no admin tiers, so 'D.C.' cannot reach geoId/11001 there; the org-suppression half of the assertion stays ungated. Cache-dir tests now compare Path objects, not POSIX string literals. Refresh committed benchmark results for the new matching behavior. --- benchmarks/results/latest.json | 1364 ++++++++--------- benchmarks/results/latest.md | 208 +-- .../test_output_and_configure_validation.py | 10 +- .../geo/test_dotted_and_casing_integration.py | 23 +- 4 files changed, 807 insertions(+), 798 deletions(-) diff --git a/benchmarks/results/latest.json b/benchmarks/results/latest.json index cf1e3e7..798509c 100644 --- a/benchmarks/results/latest.json +++ b/benchmarks/results/latest.json @@ -1,6 +1,6 @@ { "benchmark_version": "1", - "generated_at": "2026-06-10T16:27:57Z", + "generated_at": "2026-06-11T18:03:40Z", "hardware": { "cpu": "arm", "cores": 18, @@ -90,18 +90,18 @@ }, "ambiguity_recall": 0.9130434782608695, "latency_ms": { - "p50": 0.10395800927653909, - "p95": 0.12778718955814838, - "p99": 0.13321772566996515, - "mean": 0.10581143736920279, - "min": 0.06108300294727087, - "max": 0.13466598466038704, + "p50": 0.10708300396800041, + "p95": 0.13656639494001865, + "p99": 0.14553271583281457, + "mean": 0.10662672929870694, + "min": 0.05770899588242173, + "max": 0.14791596913710237, "sample_count": 23 }, - "throughput_qps": 9410.482201266535, + "throughput_qps": 9342.78758643959, "effective_warmup": 5, - "cold_start_ms": 25.720665988046676, - "peak_rss_mb": 115.53125, + "cold_start_ms": 24.00350000243634, + "peak_rss_mb": 115.625, "wheel_size_mb": 0.14886188507080078, "data_size_mb": null, "calibration": null @@ -146,18 +146,18 @@ }, "ambiguity_recall": 0.9565217391304348, "latency_ms": { - "p50": 0.013125012628734112, - "p95": 0.10034579900093367, - "p99": 0.23132221307605524, - "mean": 0.02950547597087596, - "min": 0.003374996595084667, - "max": 0.2664579660631716, + "p50": 0.013666984159499407, + "p95": 0.08801657822914416, + "p99": 0.23774747038260122, + "mean": 0.030322473637921656, + "min": 0.0038329744711518288, + "max": 0.2787499688565731, "sample_count": 23 }, - "throughput_qps": 33550.095786872655, + "throughput_qps": 32641.47413898927, "effective_warmup": 5, - "cold_start_ms": 5.944499978795648, - "peak_rss_mb": 113.734375, + "cold_start_ms": 5.1318330224603415, + "peak_rss_mb": 113.34375, "wheel_size_mb": 0.33511829376220703, "data_size_mb": null, "calibration": null @@ -211,18 +211,18 @@ }, "ambiguity_recall": 0.9090909090909091, "latency_ms": { - "p50": 0.20414547179825604, - "p95": 0.376266971579753, - "p99": 0.46925171918701386, - "mean": 0.22390917795498602, - "min": 0.1366250216960907, - "max": 0.5259579629637301, + "p50": 0.03735398058779538, + "p95": 0.1531876012450084, + "p99": 0.1634258753620088, + "mean": 0.07902168065563521, + "min": 0.024625041987746954, + "max": 0.1659159897826612, "sample_count": 44 }, - "throughput_qps": 4438.074678279115, + "throughput_qps": 12578.317965899174, "effective_warmup": 10, - "cold_start_ms": 469.9447499588132, - "peak_rss_mb": 125.828125, + "cold_start_ms": 547.5679590017535, + "peak_rss_mb": 125.9375, "wheel_size_mb": 0.28513336181640625, "data_size_mb": null, "calibration": null @@ -270,18 +270,18 @@ }, "ambiguity_recall": 0.5357142857142857, "latency_ms": { - "p50": 0.001291482476517558, - "p95": 73.90208924480248, - "p99": 76.11763432330918, - "mean": 15.861318496588085, - "min": 0.0007919734343886375, - "max": 76.91091700689867, + "p50": 0.0012085074558854103, + "p95": 68.3986875024857, + "p99": 69.32519747351762, + "mean": 14.630337748842846, + "min": 0.0007080379873514175, + "max": 69.65412496356294, "sample_count": 28 }, - "throughput_qps": 63.043320705477264, + "throughput_qps": 68.34801414483665, "effective_warmup": 7, - "cold_start_ms": 52.61470895493403, - "peak_rss_mb": 190.75, + "cold_start_ms": 51.88416701275855, + "peak_rss_mb": 190.78125, "wheel_size_mb": 164.73299312591553, "data_size_mb": null, "calibration": null @@ -326,18 +326,18 @@ }, "ambiguity_recall": 1.0, "latency_ms": { - "p50": 0.000792031642049551, - "p95": 4.920121049508444, - "p99": 5.585671728476882, - "mean": 0.493525390756195, - "min": 0.000500003807246685, - "max": 5.62237499980256, + "p50": 0.0007910421118140221, + "p95": 4.8546631063800225, + "p99": 5.488935486646369, + "mean": 0.485683044762877, + "min": 0.0005830079317092896, + "max": 5.5187910329550505, "sample_count": 23 }, - "throughput_qps": 2024.7740749015618, + "throughput_qps": 2057.536408720884, "effective_warmup": 5, - "cold_start_ms": 134.52441699337214, - "peak_rss_mb": 177.234375, + "cold_start_ms": 150.46075003920123, + "peak_rss_mb": 177.265625, "wheel_size_mb": 0.19714641571044922, "data_size_mb": null, "calibration": null @@ -382,18 +382,18 @@ }, "ambiguity_recall": 0.6521739130434783, "latency_ms": { - "p50": 0.00200001522898674, - "p95": 0.002725020749494433, - "p99": 0.002814764156937599, - "mean": 0.0020887869734155097, - "min": 0.0017500133253633976, - "max": 0.002833025064319372, + "p50": 0.0022919848561286926, + "p95": 0.003342021955177187, + "p99": 0.007901965873315937, + "mean": 0.002626820629381615, + "min": 0.00204198295250535, + "max": 0.009166949894279242, "sample_count": 23 }, - "throughput_qps": 422988.6809276833, + "throughput_qps": 342216.02127066226, "effective_warmup": 5, - "cold_start_ms": 1.9325839821249247, - "peak_rss_mb": 112.65625, + "cold_start_ms": 1.6964579699561, + "peak_rss_mb": 112.609375, "wheel_size_mb": 20.141146659851074, "data_size_mb": null, "calibration": null @@ -438,18 +438,18 @@ }, "ambiguity_recall": 0.782608695652174, "latency_ms": { - "p50": 0.17712503904476762, - "p95": 0.3258089127484709, - "p99": 0.33920071669854224, - "mean": 0.19163956723945297, - "min": 0.06724998820573092, - "max": 0.34258299274370074, + "p50": 0.17149996710941195, + "p95": 0.3581377735827118, + "p99": 0.3794967278372497, + "mean": 0.21032603589170004, + "min": 0.07408397505059838, + "max": 0.385124993044883, "sample_count": 23 }, - "throughput_qps": 5206.319693732097, + "throughput_qps": 4747.570648039316, "effective_warmup": 5, - "cold_start_ms": 2.9767919913865626, - "peak_rss_mb": 113.015625, + "cold_start_ms": 2.521958958823234, + "peak_rss_mb": 112.890625, "wheel_size_mb": 4.124938011169434, "data_size_mb": null, "calibration": null @@ -459,7 +459,7 @@ }, { "name": "resolvekit", - "version": "0.1.0", + "version": "0.1.2", "offline": true, "dataset": "ambiguous", "metrics": { @@ -509,22 +509,22 @@ }, "ambiguity_recall": 0.8723404255319149, "latency_ms": { - "p50": 2.0549999899230897, - "p95": 3.357741690706461, - "p99": 3.478158952202648, - "mean": 1.9616985339374142, - "min": 0.2382500097155571, - "max": 3.516166005283594, + "p50": 2.287292038090527, + "p95": 3.2900125661399215, + "p99": 4.0266661427449435, + "mean": 2.1573839823101113, + "min": 0.23866700939834118, + "max": 4.1049999999813735, "sample_count": 47 }, - "throughput_qps": 509.60201885830503, + "throughput_qps": 463.3560534761993, "effective_warmup": 11, - "cold_start_ms": 6157.660084019881, - "peak_rss_mb": 1140.15625, - "wheel_size_mb": 9.482624053955078, + "cold_start_ms": 5808.8847500039265, + "peak_rss_mb": 1139.515625, + "wheel_size_mb": 9.61152458190918, "data_size_mb": 807.2899999999998, "calibration": { - "n_with_confidence": 14, + "n_with_confidence": 17, "ece": null, "brier": null, "reliability_bins": [ @@ -594,8 +594,8 @@ { "lower": 0.9, "upper": 1.0, - "count": 14, - "mean_confidence": 0.9077808217167468, + "count": 17, + "mean_confidence": 0.9212489431983777, "observed_accuracy": 1.0 } ] @@ -606,7 +606,7 @@ }, { "name": "resolvekit_typed", - "version": "0.1.0", + "version": "0.1.2", "offline": true, "dataset": "ambiguous", "metrics": { @@ -656,24 +656,24 @@ }, "ambiguity_recall": 0.9148936170212766, "latency_ms": { - "p50": 1.8937920103780925, - "p95": 2.9739581281319256, - "p99": 3.542752341600135, - "mean": 1.775741132540985, - "min": 0.25170802837237716, - "max": 3.705458017066121, + "p50": 2.1875830134376884, + "p95": 3.5609543090686193, + "p99": 4.02777363662608, + "mean": 2.0925798490544425, + "min": 0.30495895771309733, + "max": 4.149749991483986, "sample_count": 47 }, - "throughput_qps": 562.9444810222017, + "throughput_qps": 477.69021549500565, "effective_warmup": 11, - "cold_start_ms": 195.68216596962884, - "peak_rss_mb": 174.1875, - "wheel_size_mb": 9.482624053955078, + "cold_start_ms": 176.1454590014182, + "peak_rss_mb": 173.984375, + "wheel_size_mb": 9.61152458190918, "data_size_mb": 807.2899999999998, "calibration": { "n_with_confidence": 31, - "ece": 0.09498034898317101, - "brier": 0.00924510457141006, + "ece": 0.0879792617947372, + "brier": 0.00807487727155894, "reliability_bins": [ { "lower": 0.0, @@ -734,15 +734,15 @@ { "lower": 0.8, "upper": 0.9, - "count": 7, - "mean_confidence": 0.8817229305905961, + "count": 8, + "mean_confidence": 0.8834896216880805, "observed_accuracy": 1.0 }, { "lower": 0.9, "upper": 1.0, - "count": 24, - "mean_confidence": 0.9118145278078136, + "count": 23, + "mean_confidence": 0.9219446048199349, "observed_accuracy": 1.0 } ] @@ -793,18 +793,18 @@ }, "ambiguity_recall": 1.0, "latency_ms": { - "p50": 0.090666493633762, + "p50": 0.10399948223493993, "p95": null, "p99": null, - "mean": 0.08038770174607635, - "min": 0.03512500552460551, - "max": 0.1300839940086007, + "mean": 0.08946238667704165, + "min": 0.03825000021606684, + "max": 0.14224997721612453, "sample_count": 10 }, - "throughput_qps": 12365.40227954112, + "throughput_qps": 11116.768947974871, "effective_warmup": 100, - "cold_start_ms": 25.720665988046676, - "peak_rss_mb": 115.53125, + "cold_start_ms": 24.00350000243634, + "peak_rss_mb": 115.625, "wheel_size_mb": 0.14886188507080078, "data_size_mb": null, "calibration": null @@ -854,18 +854,18 @@ }, "ambiguity_recall": 1.0, "latency_ms": { - "p50": 0.032542040571570396, + "p50": 0.03383299917913973, "p95": null, "p99": null, - "mean": 0.08872951730154455, - "min": 0.003208988346159458, - "max": 0.29799999902024865, + "mean": 0.08929999312385917, + "min": 0.003374996595084667, + "max": 0.2962090075016022, "sample_count": 10 }, - "throughput_qps": 11227.023837740953, + "throughput_qps": 11155.012404039704, "effective_warmup": 100, - "cold_start_ms": 5.944499978795648, - "peak_rss_mb": 113.734375, + "cold_start_ms": 5.1318330224603415, + "peak_rss_mb": 113.34375, "wheel_size_mb": 0.33511829376220703, "data_size_mb": null, "calibration": null @@ -925,18 +925,18 @@ }, "ambiguity_recall": 0.9154929577464789, "latency_ms": { - "p50": 0.13574998592957854, - "p95": 0.23669539368711412, - "p99": 0.2893864049110561, - "mean": 0.13794584406746757, - "min": 0.028958020266145468, - "max": 0.4853749996982515, + "p50": 0.13637501979246736, + "p95": 0.22905447403900317, + "p99": 0.28743003262206906, + "mean": 0.1163099836760565, + "min": 0.028332986403256655, + "max": 0.41799998143687844, "sample_count": 207 }, - "throughput_qps": 7214.1459980815525, + "throughput_qps": 8552.305419069993, "effective_warmup": 100, - "cold_start_ms": 469.9447499588132, - "peak_rss_mb": 125.828125, + "cold_start_ms": 547.5679590017535, + "peak_rss_mb": 125.9375, "wheel_size_mb": 0.28513336181640625, "data_size_mb": null, "calibration": null @@ -990,18 +990,18 @@ }, "ambiguity_recall": 0.375, "latency_ms": { - "p50": 0.0036040146369487047, - "p95": 73.20841426262632, - "p99": 79.3705508578569, - "mean": 31.593807435128838, - "min": 0.0004169996827840805, - "max": 86.14470798056573, + "p50": 0.0034789845813065767, + "p95": 69.97832738852594, + "p99": 70.95342157757841, + "mean": 30.16347130003851, + "min": 0.00037497375160455704, + "max": 71.57262496184558, "sample_count": 100 }, - "throughput_qps": 31.650512087838248, + "throughput_qps": 33.15153171429597, "effective_warmup": 100, - "cold_start_ms": 52.61470895493403, - "peak_rss_mb": 190.75, + "cold_start_ms": 51.88416701275855, + "peak_rss_mb": 190.78125, "wheel_size_mb": 164.73299312591553, "data_size_mb": null, "calibration": null @@ -1051,18 +1051,18 @@ }, "ambiguity_recall": 1.0, "latency_ms": { - "p50": 0.053270981879904866, + "p50": 0.050666509196162224, "p95": null, "p99": null, - "mean": 2.3696249874774367, + "mean": 2.213991596363485, "min": 0.0007919734343886375, - "max": 6.26462500076741, + "max": 5.877374962437898, "sample_count": 10 }, - "throughput_qps": 421.9253509053138, + "throughput_qps": 451.58619001274474, "effective_warmup": 100, - "cold_start_ms": 134.52441699337214, - "peak_rss_mb": 177.234375, + "cold_start_ms": 150.46075003920123, + "peak_rss_mb": 177.265625, "wheel_size_mb": 0.19714641571044922, "data_size_mb": null, "calibration": null @@ -1112,18 +1112,18 @@ }, "ambiguity_recall": 0.75, "latency_ms": { - "p50": 0.0020415172912180424, + "p50": 0.0021249870769679546, "p95": null, "p99": null, - "mean": 0.0020709005184471607, - "min": 0.001834006980061531, - "max": 0.002374988980591297, + "mean": 0.0029833056032657623, + "min": 0.0019579892978072166, + "max": 0.010791001841425896, "sample_count": 10 }, - "throughput_qps": 423279.80742839543, + "throughput_qps": 305343.3572029056, "effective_warmup": 100, - "cold_start_ms": 1.9325839821249247, - "peak_rss_mb": 112.65625, + "cold_start_ms": 1.6964579699561, + "peak_rss_mb": 112.609375, "wheel_size_mb": 20.141146659851074, "data_size_mb": null, "calibration": null @@ -1173,18 +1173,18 @@ }, "ambiguity_recall": 0.75, "latency_ms": { - "p50": 0.24158350424841046, + "p50": 0.22183300461620092, "p95": null, "p99": null, - "mean": 0.22920840419828892, - "min": 0.046874978579580784, - "max": 0.437250011600554, + "mean": 0.22403339971788228, + "min": 0.04850002005696297, + "max": 0.4534999607130885, "sample_count": 10 }, - "throughput_qps": 4347.669198566372, + "throughput_qps": 4457.321209465699, "effective_warmup": 100, - "cold_start_ms": 2.9767919913865626, - "peak_rss_mb": 113.015625, + "cold_start_ms": 2.521958958823234, + "peak_rss_mb": 112.890625, "wheel_size_mb": 4.124938011169434, "data_size_mb": null, "calibration": null @@ -1194,12 +1194,12 @@ }, { "name": "resolvekit", - "version": "0.1.0", + "version": "0.1.2", "offline": true, "dataset": "eval_geo", "metrics": { "accuracy": { - "overall": 0.888283378746594, + "overall": 0.885558583106267, "by_capability": { "multilingual": 0.875, "transliteration": 0.8333333333333334, @@ -1207,13 +1207,13 @@ "iso_code": 1.0 }, "by_language": { - "en": 0.8885793871866295, + "en": 0.8857938718662952, "de": 1.0, "fr": 1.0, "es": 0.6666666666666666 }, "by_difficulty": { - "easy": 0.9224806201550387, + "easy": 0.9186046511627907, "medium": 0.8701298701298701, "hard": 0.65625 }, @@ -1225,17 +1225,17 @@ "admin2": 0.96875, "continental_union": 1.0, "city": 0.8888888888888888, - "admin4": 0.90625, + "admin4": 0.875, "continent": 1.0, "world_region": 0.5555555555555556 }, - "wrong_match_rate": 0.04904632152588556, + "wrong_match_rate": 0.051771117166212535, "abstention_precision": 0.5384615384615384, "abstention_recall": 0.8235294117647058, "error_rate": 0.0, "row_count": 367, - "accuracy_ci_low": 0.8519473453836499, - "accuracy_ci_high": 0.9165748484586806, + "accuracy_ci_low": 0.8489180096995185, + "accuracy_ci_high": 0.9142110459404097, "by_entity_type_n": { "admin1": 55, "admin3": 33, @@ -1256,31 +1256,31 @@ "admin2": 0.0, "continental_union": 0.0, "city": 0.08333333333333333, - "admin4": 0.03125, + "admin4": 0.0625, "continent": 0.0, "world_region": 0.4444444444444444 } }, - "ambiguity_recall": 0.9058823529411765, + "ambiguity_recall": 0.8941176470588236, "latency_ms": { - "p50": 0.5385000258684158, - "p95": 2.6294081297237426, - "p99": 3.3702109102159676, - "mean": 0.8744766156326178, - "min": 0.005125009920448065, - "max": 4.238917026668787, + "p50": 0.4820830072276294, + "p95": 2.454450010554865, + "p99": 3.2821616320870763, + "mean": 0.8395072174250917, + "min": 0.004832982085645199, + "max": 5.1496250089257956, "sample_count": 367 }, - "throughput_qps": 1142.833860984252, + "throughput_qps": 1190.3762781393418, "effective_warmup": 100, - "cold_start_ms": 6157.660084019881, - "peak_rss_mb": 1140.15625, - "wheel_size_mb": 9.482624053955078, + "cold_start_ms": 5808.8847500039265, + "peak_rss_mb": 1139.515625, + "wheel_size_mb": 9.61152458190918, "data_size_mb": 807.2899999999998, "calibration": { - "n_with_confidence": 244, - "ece": 0.04319519049943959, - "brier": 0.06849376742170193, + "n_with_confidence": 247, + "ece": 0.02998936843963204, + "brier": 0.07066714542601314, "reliability_bins": [ { "lower": 0.0, @@ -1334,23 +1334,23 @@ { "lower": 0.7, "upper": 0.8, - "count": 4, - "mean_confidence": 0.7779670792497737, - "observed_accuracy": 0.5 + "count": 3, + "mean_confidence": 0.7800108870404773, + "observed_accuracy": 0.6666666666666666 }, { "lower": 0.8, "upper": 0.9, - "count": 110, - "mean_confidence": 0.8730397257784943, - "observed_accuracy": 0.9454545454545454 + "count": 118, + "mean_confidence": 0.873116884264454, + "observed_accuracy": 0.9152542372881356 }, { "lower": 0.9, "upper": 1.0, - "count": 130, - "mean_confidence": 0.9118297846115497, - "observed_accuracy": 0.9230769230769231 + "count": 126, + "mean_confidence": 0.9198798913756091, + "observed_accuracy": 0.9365079365079365 } ] } @@ -1360,7 +1360,7 @@ }, { "name": "resolvekit_typed", - "version": "0.1.0", + "version": "0.1.2", "offline": true, "dataset": "eval_geo", "metrics": { @@ -1429,24 +1429,24 @@ }, "ambiguity_recall": 0.9294117647058824, "latency_ms": { - "p50": 0.293209042865783, - "p95": 2.286637295037508, - "p99": 3.7076120020356003, - "mean": 1.6605140762187907, - "min": 0.00658305361866951, - "max": 370.6715420121327, + "p50": 0.29087503207847476, + "p95": 2.4170999706257135, + "p99": 3.586995628429573, + "mean": 0.7024642266586869, + "min": 0.0070829992182552814, + "max": 8.74579098308459, "sample_count": 367 }, - "throughput_qps": 602.029553197973, + "throughput_qps": 1422.437665791873, "effective_warmup": 100, - "cold_start_ms": 195.68216596962884, - "peak_rss_mb": 174.1875, - "wheel_size_mb": 9.482624053955078, + "cold_start_ms": 176.1454590014182, + "peak_rss_mb": 173.984375, + "wheel_size_mb": 9.61152458190918, "data_size_mb": 807.2899999999998, "calibration": { - "n_with_confidence": 276, - "ece": 0.07292954470462497, - "brier": 0.037901303347634606, + "n_with_confidence": 277, + "ece": 0.07022480631081299, + "brier": 0.03764011854201866, "reliability_bins": [ { "lower": 0.0, @@ -1501,22 +1501,22 @@ "lower": 0.7, "upper": 0.8, "count": 5, - "mean_confidence": 0.7760491156782644, + "mean_confidence": 0.781223196075759, "observed_accuracy": 0.8 }, { "lower": 0.8, "upper": 0.9, - "count": 116, - "mean_confidence": 0.873357062967526, - "observed_accuracy": 0.9741379310344828 + "count": 125, + "mean_confidence": 0.8736450914409711, + "observed_accuracy": 0.976 }, { "lower": 0.9, "upper": 1.0, - "count": 155, - "mean_confidence": 0.914076005025156, - "observed_accuracy": 0.967741935483871 + "count": 147, + "mean_confidence": 0.9213331717102354, + "observed_accuracy": 0.9659863945578231 } ] } @@ -1580,7 +1580,7 @@ }, { "name": "resolvekit", - "version": "0.1.0", + "version": "0.1.2", "offline": true, "dataset": "eval_org", "metrics": { @@ -1616,19 +1616,19 @@ }, "ambiguity_recall": 1.0, "latency_ms": { - "p50": 0.8049374737311155, - "p95": 3.6685726197902144, - "p99": 7.048514505149791, - "mean": 1.480708346934989, - "min": 0.006167043466120958, - "max": 7.893499976489693, + "p50": 0.8636039856355637, + "p95": 3.4459110291209103, + "p99": 6.526982187060634, + "mean": 1.4385436050361022, + "min": 0.006415997631847858, + "max": 7.297249976545572, "sample_count": 20 }, - "throughput_qps": 675.0314313510626, + "throughput_qps": 694.774124987808, "effective_warmup": 5, - "cold_start_ms": 6157.660084019881, - "peak_rss_mb": 1140.15625, - "wheel_size_mb": 9.482624053955078, + "cold_start_ms": 5808.8847500039265, + "peak_rss_mb": 1139.515625, + "wheel_size_mb": 9.61152458190918, "data_size_mb": 807.2899999999998, "calibration": { "n_with_confidence": 4, @@ -1694,16 +1694,16 @@ { "lower": 0.8, "upper": 0.9, - "count": 3, - "mean_confidence": 0.8592597389200617, - "observed_accuracy": 0.3333333333333333 + "count": 4, + "mean_confidence": 0.8627862031382588, + "observed_accuracy": 0.5 }, { "lower": 0.9, "upper": 1.0, - "count": 1, - "mean_confidence": 0.9067406096718764, - "observed_accuracy": 1.0 + "count": 0, + "mean_confidence": 0.0, + "observed_accuracy": 0.0 } ] } @@ -1713,7 +1713,7 @@ }, { "name": "resolvekit_typed", - "version": "0.1.0", + "version": "0.1.2", "offline": true, "dataset": "eval_org", "metrics": { @@ -1749,19 +1749,19 @@ }, "ambiguity_recall": 1.0, "latency_ms": { - "p50": 0.7721869915258139, - "p95": 2.708489567157814, - "p99": 4.566530702286397, - "mean": 1.1623874452197924, - "min": 0.008083006832748652, - "max": 5.031040986068547, + "p50": 0.870500021846965, + "p95": 3.1969169998774327, + "p99": 5.596617002156559, + "mean": 1.3592020521173254, + "min": 0.0076249707490205765, + "max": 6.196542002726346, "sample_count": 20 }, - "throughput_qps": 859.8190553073584, + "throughput_qps": 735.3729544354547, "effective_warmup": 5, - "cold_start_ms": 195.68216596962884, - "peak_rss_mb": 174.1875, - "wheel_size_mb": 9.482624053955078, + "cold_start_ms": 176.1454590014182, + "peak_rss_mb": 173.984375, + "wheel_size_mb": 9.61152458190918, "data_size_mb": 807.2899999999998, "calibration": { "n_with_confidence": 4, @@ -1827,16 +1827,16 @@ { "lower": 0.8, "upper": 0.9, - "count": 3, - "mean_confidence": 0.8592597389200617, - "observed_accuracy": 0.3333333333333333 + "count": 4, + "mean_confidence": 0.8627862031382588, + "observed_accuracy": 0.5 }, { "lower": 0.9, "upper": 1.0, - "count": 1, - "mean_confidence": 0.9067406096718764, - "observed_accuracy": 1.0 + "count": 0, + "mean_confidence": 0.0, + "observed_accuracy": 0.0 } ] } @@ -1903,18 +1903,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 522.9110419750214, - "p95": 882.013883977197, - "p99": 1350.4199929907916, - "mean": 520.3987830495269, - "min": 0.24087500059977174, - "max": 6719.806667009834, + "p50": 0.10295800166204572, + "p95": 0.18740842351689935, + "p99": 0.24237801553681487, + "mean": 0.11103353033315909, + "min": 0.017332960851490498, + "max": 0.48274995060637593, "sample_count": 2045 }, - "throughput_qps": 1.921574384286339, + "throughput_qps": 8970.775316352365, "effective_warmup": 100, - "cold_start_ms": 469.9447499588132, - "peak_rss_mb": 125.828125, + "cold_start_ms": 547.5679590017535, + "peak_rss_mb": 125.9375, "wheel_size_mb": 0.28513336181640625, "data_size_mb": null, "calibration": null @@ -1960,25 +1960,25 @@ }, { "name": "resolvekit", - "version": "0.1.0", + "version": "0.1.2", "offline": true, "dataset": "geo_admin", "metrics": { "accuracy": { - "overall": 0.9346890887759093, + "overall": 0.9339069221744232, "by_capability": { - "typo": 0.926605504587156, + "typo": 0.9257714762301918, "alias": 0.9344262295081968 }, "by_language": { - "en": 0.9346890887759093 + "en": 0.9339069221744232 }, "by_difficulty": { - "easy": 0.9425566343042071, - "medium": 0.927327781983346 + "easy": 0.941747572815534, + "medium": 0.9265707797123391 }, "by_entity_type": { - "admin1": 0.9595448798988622, + "admin1": 0.9570164348925411, "admin2": 0.9209702660406885, "admin3": 0.930327868852459 }, @@ -1987,8 +1987,8 @@ "abstention_recall": 0.0, "error_rate": 0.0, "row_count": 2557, - "accuracy_ci_low": 0.9244452636633439, - "accuracy_ci_high": 0.9436287320105784, + "accuracy_ci_low": 0.9236113684980024, + "accuracy_ci_high": 0.9429006406789807, "by_entity_type_n": { "admin1": 791, "admin2": 1278, @@ -2002,24 +2002,24 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.5692919949069619, - "p95": 3.289067209698257, - "p99": 10.439953575842091, - "mean": 1.1436656999955734, - "min": 0.01008401159197092, - "max": 56.2088749720715, + "p50": 0.4985000123269856, + "p95": 2.694774803239852, + "p99": 10.30254853190855, + "mean": 1.0659289564066856, + "min": 0.012542004697024822, + "max": 62.83512501977384, "sample_count": 2557 }, - "throughput_qps": 873.9594350876952, + "throughput_qps": 937.6241586539993, "effective_warmup": 100, - "cold_start_ms": 6157.660084019881, - "peak_rss_mb": 1140.15625, - "wheel_size_mb": 9.482624053955078, + "cold_start_ms": 5808.8847500039265, + "peak_rss_mb": 1139.515625, + "wheel_size_mb": 9.61152458190918, "data_size_mb": 807.2899999999998, "calibration": { "n_with_confidence": 2076, - "ece": 0.09780883538569152, - "brier": 0.05605244071052304, + "ece": 0.09787790969721948, + "brier": 0.05618076623232687, "reliability_bins": [ { "lower": 0.0, @@ -2088,7 +2088,7 @@ "lower": 0.9, "upper": 1.0, "count": 95, - "mean_confidence": 0.9120766800000057, + "mean_confidence": 0.9135861354813954, "observed_accuracy": 0.6 } ] @@ -2099,7 +2099,7 @@ }, { "name": "resolvekit_typed", - "version": "0.1.0", + "version": "0.1.2", "offline": true, "dataset": "geo_admin", "metrics": { @@ -2141,19 +2141,19 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.23791700368747115, - "p95": 1.3966909842565651, - "p99": 2.455623031128199, - "mean": 0.5934858750626605, - "min": 0.05345902172848582, - "max": 373.037250014022, + "p50": 0.24925003526732326, + "p95": 1.4904499636031665, + "p99": 2.485555082093926, + "mean": 0.4664215534562329, + "min": 0.05520798731595278, + "max": 24.331375025212765, "sample_count": 2557 }, - "throughput_qps": 1683.4925846734452, + "throughput_qps": 2141.5126392437533, "effective_warmup": 100, - "cold_start_ms": 195.68216596962884, - "peak_rss_mb": 174.1875, - "wheel_size_mb": 9.482624053955078, + "cold_start_ms": 176.1454590014182, + "peak_rss_mb": 173.984375, + "wheel_size_mb": 9.61152458190918, "data_size_mb": 807.2899999999998, "calibration": { "n_with_confidence": 2168, @@ -2292,18 +2292,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 493.20072898990475, - "p95": 998.1148312537694, - "p99": 1336.8711437517773, - "mean": 503.5482367967745, - "min": 0.11520902626216412, - "max": 2715.4043330228887, + "p50": 0.0982089841272682, + "p95": 0.18124792259186506, + "p99": 0.2374545292695984, + "mean": 0.10387829144065108, + "min": 0.01850002445280552, + "max": 0.45650004176422954, "sample_count": 2048 }, - "throughput_qps": 1.9858753567219316, + "throughput_qps": 9588.703293343735, "effective_warmup": 100, - "cold_start_ms": 469.9447499588132, - "peak_rss_mb": 125.828125, + "cold_start_ms": 547.5679590017535, + "peak_rss_mb": 125.9375, "wheel_size_mb": 0.28513336181640625, "data_size_mb": null, "calibration": null @@ -2349,18 +2349,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 69.68120852252468, - "p95": 73.19713169708848, - "p99": 86.86435475829052, - "mean": 70.18523681458078, - "min": 66.96395797189325, - "max": 103.51612500380725, + "p50": 68.81568749668077, + "p95": 73.22504200856201, + "p99": 79.61777351738417, + "mean": 69.7751113987124, + "min": 66.20041595306247, + "max": 991.1713750334457, "sample_count": 2048 }, - "throughput_qps": 14.247611387357749, + "throughput_qps": 14.331355816134646, "effective_warmup": 100, - "cold_start_ms": 52.61470895493403, - "peak_rss_mb": 190.75, + "cold_start_ms": 51.88416701275855, + "peak_rss_mb": 190.78125, "wheel_size_mb": 164.73299312591553, "data_size_mb": null, "calibration": null @@ -2397,27 +2397,27 @@ }, { "name": "resolvekit", - "version": "0.1.0", + "version": "0.1.2", "offline": true, "dataset": "geo_cities", "metrics": { "accuracy": { "overall": 0.85791015625, "by_capability": { - "typo": 0.832258064516129, + "typo": 0.8333333333333334, "alias": 0.9852941176470589 }, "by_language": { "en": 0.85791015625 }, "by_difficulty": { - "medium": 0.851782363977486, - "easy": 0.8645621181262729 + "medium": 0.8527204502814258, + "easy": 0.8635437881873728 }, "by_entity_type": { "city": 0.85791015625 }, - "wrong_match_rate": 0.01025390625, + "wrong_match_rate": 0.0107421875, "abstention_precision": 0.0, "abstention_recall": 0.0, "error_rate": 0.0, @@ -2428,29 +2428,29 @@ "city": 2048 }, "by_entity_type_wrong_match": { - "city": 0.01025390625 + "city": 0.0107421875 } }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.6341459811665118, - "p95": 2.805662513128481, - "p99": 4.991217242786661, - "mean": 0.9398447181183656, - "min": 0.009874987881630659, - "max": 11.300958984065801, + "p50": 0.6114165007602423, + "p95": 2.4082206044113255, + "p99": 3.966243496397509, + "mean": 0.8845630542850813, + "min": 0.01270900247618556, + "max": 9.39195801038295, "sample_count": 2048 }, - "throughput_qps": 1063.3574048331855, + "throughput_qps": 1129.7571036259465, "effective_warmup": 100, - "cold_start_ms": 6157.660084019881, - "peak_rss_mb": 1140.15625, - "wheel_size_mb": 9.482624053955078, + "cold_start_ms": 5808.8847500039265, + "peak_rss_mb": 1139.515625, + "wheel_size_mb": 9.61152458190918, "data_size_mb": 807.2899999999998, "calibration": { - "n_with_confidence": 1042, - "ece": 0.10152807381513898, - "brier": 0.029912430580734412, + "n_with_confidence": 1043, + "ece": 0.10049286992190337, + "brier": 0.030797223277077325, "reliability_bins": [ { "lower": 0.0, @@ -2512,15 +2512,15 @@ "lower": 0.8, "upper": 0.9, "count": 944, - "mean_confidence": 0.877169642004851, + "mean_confidence": 0.877175441473085, "observed_accuracy": 0.9872881355932204 }, { "lower": 0.9, "upper": 1.0, - "count": 97, - "mean_confidence": 0.9057691240417097, - "observed_accuracy": 0.9175257731958762 + "count": 98, + "mean_confidence": 0.9064522440904342, + "observed_accuracy": 0.9081632653061225 } ] } @@ -2530,7 +2530,7 @@ }, { "name": "resolvekit_typed", - "version": "0.1.0", + "version": "0.1.2", "offline": true, "dataset": "geo_cities", "metrics": { @@ -2566,19 +2566,19 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.498958514072001, - "p95": 2.4522585357772186, - "p99": 3.9379512617597348, - "mean": 0.9781855417543284, - "min": 0.11433399049565196, - "max": 368.14649996813387, + "p50": 0.5121459835208952, + "p95": 2.55935403110925, + "p99": 3.9847877464489994, + "mean": 0.8234369047102064, + "min": 0.11687498772516847, + "max": 8.570917008910328, "sample_count": 2048 }, - "throughput_qps": 1021.7218157701666, + "throughput_qps": 1213.5641870611796, "effective_warmup": 100, - "cold_start_ms": 195.68216596962884, - "peak_rss_mb": 174.1875, - "wheel_size_mb": 9.482624053955078, + "cold_start_ms": 176.1454590014182, + "peak_rss_mb": 173.984375, + "wheel_size_mb": 9.61152458190918, "data_size_mb": 807.2899999999998, "calibration": { "n_with_confidence": 1033, @@ -2703,18 +2703,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.1212499919347465, - "p95": 0.22180369705893088, - "p99": 0.3445029805880041, - "mean": 0.12092995188921304, - "min": 0.023875036276876926, - "max": 1.1464169947430491, + "p50": 0.13283296721056104, + "p95": 0.24343319237232197, + "p99": 0.3957998135592792, + "mean": 0.13239579499181264, + "min": 0.027292000595480204, + "max": 1.1515420046634972, "sample_count": 4055 }, - "throughput_qps": 8239.83576713875, + "throughput_qps": 7522.001962117365, "effective_warmup": 100, - "cold_start_ms": 25.720665988046676, - "peak_rss_mb": 115.53125, + "cold_start_ms": 24.00350000243634, + "peak_rss_mb": 115.625, "wheel_size_mb": 0.14886188507080078, "data_size_mb": null, "calibration": null @@ -2764,18 +2764,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.05720899207517505, - "p95": 0.32890382572077204, - "p99": 0.3740949963685125, - "mean": 0.10873045985909631, - "min": 0.00104197533801198, - "max": 0.6723339902237058, + "p50": 0.05887501174584031, + "p95": 0.3259212069679049, + "p99": 0.36991912056691945, + "mean": 0.10900656351332728, + "min": 0.0010830117389559746, + "max": 0.461542047560215, "sample_count": 4055 }, - "throughput_qps": 9165.307221648398, + "throughput_qps": 9143.127716036095, "effective_warmup": 100, - "cold_start_ms": 5.944499978795648, - "peak_rss_mb": 113.734375, + "cold_start_ms": 5.1318330224603415, + "peak_rss_mb": 113.34375, "wheel_size_mb": 0.33511829376220703, "data_size_mb": null, "calibration": null @@ -2825,18 +2825,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.49341697013005614, - "p95": 855.4525586252566, - "p99": 1143.6420364829246, - "mean": 227.88282915167213, - "min": 0.027959002181887627, - "max": 5351.015084015671, + "p50": 0.10950001887977123, + "p95": 0.1995963102672249, + "p99": 0.2585153747349978, + "mean": 0.12268732091166198, + "min": 0.020666979253292084, + "max": 0.8494590292684734, "sample_count": 4055 }, - "throughput_qps": 4.388144231709499, + "throughput_qps": 8117.957627601737, "effective_warmup": 100, - "cold_start_ms": 469.9447499588132, - "peak_rss_mb": 125.828125, + "cold_start_ms": 547.5679590017535, + "peak_rss_mb": 125.9375, "wheel_size_mb": 0.28513336181640625, "data_size_mb": null, "calibration": null @@ -2886,18 +2886,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.0003750319592654705, - "p95": 0.0005839974619448185, - "p99": 0.0012500095181167126, - "mean": 0.0005016366936238862, + "p50": 0.0004169996827840805, + "p95": 0.000625033862888813, + "p99": 0.0013108854182064546, + "mean": 0.0005082388060528018, "min": 0.0002919696271419525, - "max": 0.20633399253711104, + "max": 0.11350004933774471, "sample_count": 4055 }, - "throughput_qps": 1320596.1277970248, + "throughput_qps": 1290117.3274060548, "effective_warmup": 100, - "cold_start_ms": 52.61470895493403, - "peak_rss_mb": 190.75, + "cold_start_ms": 51.88416701275855, + "peak_rss_mb": 190.78125, "wheel_size_mb": 164.73299312591553, "data_size_mb": null, "calibration": null @@ -2947,18 +2947,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.08666602661833167, - "p95": 5.665520590264349, - "p99": 6.053272995632142, - "mean": 1.9885520039082638, - "min": 0.0007919734343886375, - "max": 19.632583018392324, + "p50": 0.09058299474418163, + "p95": 5.780353693990037, + "p99": 6.234529975336045, + "mean": 2.026445543595605, + "min": 0.0007500057108700275, + "max": 20.27991699287668, "sample_count": 4055 }, - "throughput_qps": 502.5604296966172, + "throughput_qps": 493.34352747645767, "effective_warmup": 100, - "cold_start_ms": 134.52441699337214, - "peak_rss_mb": 177.234375, + "cold_start_ms": 150.46075003920123, + "peak_rss_mb": 177.265625, "wheel_size_mb": 0.19714641571044922, "data_size_mb": null, "calibration": null @@ -3008,18 +3008,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.002167013008147478, - "p95": 0.003500026650726795, - "p99": 0.004602343542501332, - "mean": 0.0025047548574598736, - "min": 0.0008750357665121555, - "max": 0.29020896181464195, + "p50": 0.002416956704109907, + "p95": 0.004332978278398514, + "p99": 0.008240002207458028, + "mean": 0.0029135139001035, + "min": 0.0012919772416353226, + "max": 0.4570410237647593, "sample_count": 4055 }, - "throughput_qps": 347380.3496914383, + "throughput_qps": 296705.50810861774, "effective_warmup": 100, - "cold_start_ms": 1.9325839821249247, - "peak_rss_mb": 112.65625, + "cold_start_ms": 1.6964579699561, + "peak_rss_mb": 112.609375, "wheel_size_mb": 20.141146659851074, "data_size_mb": null, "calibration": null @@ -3069,18 +3069,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.29850000282749534, - "p95": 0.540674926014617, - "p99": 0.64337098156102, - "mean": 0.3105933982827247, - "min": 0.00300002284348011, - "max": 26.904208003543317, + "p50": 0.32758305314928293, + "p95": 0.5704411829356104, + "p99": 0.6911318155471237, + "mean": 0.3295122584501458, + "min": 0.001125037670135498, + "max": 0.7675830274820328, "sample_count": 4055 }, - "throughput_qps": 3212.3047238376494, + "throughput_qps": 2989.586947160201, "effective_warmup": 100, - "cold_start_ms": 2.9767919913865626, - "peak_rss_mb": 113.015625, + "cold_start_ms": 2.521958958823234, + "peak_rss_mb": 112.890625, "wheel_size_mb": 4.124938011169434, "data_size_mb": null, "calibration": null @@ -3090,64 +3090,64 @@ }, { "name": "resolvekit", - "version": "0.1.0", + "version": "0.1.2", "offline": true, "dataset": "geo_countries_en", "metrics": { "accuracy": { - "overall": 0.7928483353884094, + "overall": 0.8034525277435265, "by_capability": { - "typo": 0.7266569908079342, - "case_noise": 0.6394822006472491, - "alias": 0.9111617312072893, + "typo": 0.7406869859700048, + "case_noise": 0.6595469255663431, + "alias": 0.9179954441913439, "unicode_normalization": 0.9962406015037594, - "prefix_truncation": 0.4012345679012346 + "prefix_truncation": 0.42592592592592593 }, "by_language": { - "en": 0.7928483353884094 + "en": 0.8034525277435265 }, "by_difficulty": { "easy": 0.9959349593495935, - "hard": 0.6398442569759896, - "medium": 0.8747795414462081 + "hard": 0.6586632057105776, + "medium": 0.8809523809523809 }, "by_entity_type": { - "country": 0.7928483353884094 + "country": 0.8034525277435265 }, - "wrong_match_rate": 0.038717632552404437, + "wrong_match_rate": 0.037731196054254006, "abstention_precision": 0.0, "abstention_recall": 0.0, "error_rate": 0.0, "row_count": 4055, - "accuracy_ci_low": 0.7801001524329666, - "accuracy_ci_high": 0.8050421699478821, + "accuracy_ci_low": 0.7909363808832427, + "accuracy_ci_high": 0.81539425296066, "by_entity_type_n": { "country": 4055 }, "by_entity_type_wrong_match": { - "country": 0.038717632552404437 + "country": 0.037731196054254006 } }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.8399580256082118, - "p95": 5.137700098566695, - "p99": 12.542851023608822, - "mean": 1.4315701540962606, - "min": 0.011000025551766157, - "max": 43.24566700961441, + "p50": 0.7899579941295087, + "p95": 2.9350000666454434, + "p99": 5.799745023250582, + "mean": 1.0607762251746338, + "min": 0.012625008821487427, + "max": 15.430832980200648, "sample_count": 4055 }, - "throughput_qps": 698.2524946240824, + "throughput_qps": 942.1837354912639, "effective_warmup": 100, - "cold_start_ms": 6157.660084019881, - "peak_rss_mb": 1140.15625, - "wheel_size_mb": 9.482624053955078, + "cold_start_ms": 5808.8847500039265, + "peak_rss_mb": 1139.515625, + "wheel_size_mb": 9.61152458190918, "data_size_mb": 807.2899999999998, "calibration": { - "n_with_confidence": 3247, - "ece": 0.07524526280825689, - "brier": 0.050059856628847335, + "n_with_confidence": 3283, + "ece": 0.0629066693962569, + "brier": 0.047136470231138046, "reliability_bins": [ { "lower": 0.0, @@ -3201,23 +3201,23 @@ { "lower": 0.7, "upper": 0.8, - "count": 295, - "mean_confidence": 0.765659659300004, - "observed_accuracy": 0.9288135593220339 + "count": 245, + "mean_confidence": 0.7620265727768, + "observed_accuracy": 0.9346938775510204 }, { "lower": 0.8, "upper": 0.9, - "count": 819, - "mean_confidence": 0.865352366807335, - "observed_accuracy": 0.8498168498168498 + "count": 803, + "mean_confidence": 0.8629243241884428, + "observed_accuracy": 0.8567870485678705 }, { "lower": 0.9, "upper": 1.0, - "count": 2133, - "mean_confidence": 0.907891523949037, - "observed_accuracy": 0.993905297702766 + "count": 2235, + "mean_confidence": 0.9188855151521665, + "observed_accuracy": 0.9901565995525727 } ] } @@ -3227,64 +3227,64 @@ }, { "name": "resolvekit_typed", - "version": "0.1.0", + "version": "0.1.2", "offline": true, "dataset": "geo_countries_en", "metrics": { "accuracy": { - "overall": 0.7938347718865598, + "overall": 0.8014796547472256, "by_capability": { - "typo": 0.7358490566037735, - "case_noise": 0.6563106796116505, + "typo": 0.7464925012094823, + "case_noise": 0.6711974110032363, "alias": 0.9009111617312073, - "unicode_normalization": 0.9868421052631579, - "prefix_truncation": 0.4351851851851852 + "unicode_normalization": 0.9849624060150376, + "prefix_truncation": 0.4660493827160494 }, "by_language": { - "en": 0.7938347718865598 + "en": 0.8014796547472256 }, "by_difficulty": { "easy": 0.9634146341463414, - "hard": 0.6554185593770279, - "medium": 0.8694885361552028 + "hard": 0.6703439325113563, + "medium": 0.873015873015873 }, "by_entity_type": { - "country": 0.7938347718865598 + "country": 0.8014796547472256 }, - "wrong_match_rate": 0.009864364981504316, + "wrong_match_rate": 0.01183723797780518, "abstention_precision": 0.0, "abstention_recall": 0.0, "error_rate": 0.0, "row_count": 4055, - "accuracy_ci_low": 0.7811076147924882, - "accuracy_ci_high": 0.806005713305854, + "accuracy_ci_low": 0.7889193209981676, + "accuracy_ci_high": 0.8134693014107482, "by_entity_type_n": { "country": 4055 }, "by_entity_type_wrong_match": { - "country": 0.009864364981504316 + "country": 0.01183723797780518 } }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.3547919914126396, - "p95": 1.2606457748916, - "p99": 2.3747085139621076, - "mean": 0.48772502131013684, - "min": 0.08045899448916316, - "max": 8.599208027590066, + "p50": 0.38587499875575304, + "p95": 1.3847580179572057, + "p99": 2.6231593138072644, + "mean": 0.5274225702324398, + "min": 0.08654198609292507, + "max": 9.922042023390532, "sample_count": 4055 }, - "throughput_qps": 2048.1053479140355, + "throughput_qps": 1893.8850295894076, "effective_warmup": 100, - "cold_start_ms": 195.68216596962884, - "peak_rss_mb": 174.1875, - "wheel_size_mb": 9.482624053955078, + "cold_start_ms": 176.1454590014182, + "peak_rss_mb": 173.984375, + "wheel_size_mb": 9.61152458190918, "data_size_mb": 807.2899999999998, "calibration": { - "n_with_confidence": 3202, - "ece": 0.1034683892934489, - "brier": 0.024836224865001852, + "n_with_confidence": 3243, + "ece": 0.09204341557699694, + "brier": 0.02457832691784888, "reliability_bins": [ { "lower": 0.0, @@ -3338,23 +3338,23 @@ { "lower": 0.7, "upper": 0.8, - "count": 324, - "mean_confidence": 0.7632116794003667, - "observed_accuracy": 0.9598765432098766 + "count": 276, + "mean_confidence": 0.7592597237930101, + "observed_accuracy": 0.9528985507246377 }, { "lower": 0.8, "upper": 0.9, - "count": 721, - "mean_confidence": 0.8649520036152014, - "observed_accuracy": 0.9833564493758669 + "count": 722, + "mean_confidence": 0.8619029486283539, + "observed_accuracy": 0.9847645429362881 }, { "lower": 0.9, "upper": 1.0, - "count": 2157, - "mean_confidence": 0.9085689563050986, - "observed_accuracy": 0.9930458970792768 + "count": 2245, + "mean_confidence": 0.9196675236557936, + "observed_accuracy": 0.9893095768374165 } ] } @@ -3403,18 +3403,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.09620797936804593, - "p95": 0.1785708678653463, - "p99": 0.28779425076209053, - "mean": 0.09909854126358666, - "min": 0.02412503818050027, - "max": 1.0927909752354026, + "p50": 0.10364549234509468, + "p95": 0.18883508746512234, + "p99": 0.29498325951863164, + "mean": 0.10699268743813595, + "min": 0.028749986086040735, + "max": 1.039499999023974, "sample_count": 2140 }, - "throughput_qps": 10052.737743123773, + "throughput_qps": 9307.53634539201, "effective_warmup": 100, - "cold_start_ms": 25.720665988046676, - "peak_rss_mb": 115.53125, + "cold_start_ms": 24.00350000243634, + "peak_rss_mb": 115.625, "wheel_size_mb": 0.14886188507080078, "data_size_mb": null, "calibration": null @@ -3463,18 +3463,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.13143799151293933, - "p95": 0.3540207602782175, - "p99": 0.37298261362593643, - "mean": 0.14734440999949855, - "min": 0.001292035449296236, - "max": 0.49775000661611557, + "p50": 0.13270799536257982, + "p95": 0.34771635255310684, + "p99": 0.3696342371404171, + "mean": 0.1464578160411182, + "min": 0.001374981366097927, + "max": 0.44645898742601275, "sample_count": 2140 }, - "throughput_qps": 6769.616848337858, + "throughput_qps": 6813.435886933442, "effective_warmup": 100, - "cold_start_ms": 5.944499978795648, - "peak_rss_mb": 113.734375, + "cold_start_ms": 5.1318330224603415, + "peak_rss_mb": 113.34375, "wheel_size_mb": 0.33511829376220703, "data_size_mb": null, "calibration": null @@ -3523,18 +3523,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.10868749814108014, - "p95": 0.16075210296548903, - "p99": 0.2987681305967277, - "mean": 0.12303746243390455, - "min": 0.022957974579185247, - "max": 2.141250006388873, + "p50": 0.1048955018632114, + "p95": 0.18615481094457204, + "p99": 0.2429281454533339, + "mean": 0.11474187417874095, + "min": 0.020042003598064184, + "max": 0.3750419709831476, "sample_count": 2140 }, - "throughput_qps": 8095.940679152647, + "throughput_qps": 8684.083993546279, "effective_warmup": 100, - "cold_start_ms": 469.9447499588132, - "peak_rss_mb": 125.828125, + "cold_start_ms": 547.5679590017535, + "peak_rss_mb": 125.9375, "wheel_size_mb": 0.28513336181640625, "data_size_mb": null, "calibration": null @@ -3583,18 +3583,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.00045797787606716156, - "p95": 0.000667001586407423, - "p99": 0.0011089979670941882, - "mean": 0.0005109641193978001, - "min": 0.00033294782042503357, - "max": 0.03404100425541401, + "p50": 0.0004169996827840805, + "p95": 0.0006660120561718941, + "p99": 0.001175611978396784, + "mean": 0.0005136362590313515, + "min": 0.00029098009690642357, + "max": 0.06804201984778047, "sample_count": 2140 }, - "throughput_qps": 1294420.069281639, + "throughput_qps": 1296806.2466994685, "effective_warmup": 100, - "cold_start_ms": 52.61470895493403, - "peak_rss_mb": 190.75, + "cold_start_ms": 51.88416701275855, + "peak_rss_mb": 190.78125, "wheel_size_mb": 164.73299312591553, "data_size_mb": null, "calibration": null @@ -3643,18 +3643,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.09122901246882975, - "p95": 5.85957319708541, - "p99": 6.310189702780919, - "mean": 2.4287686087561022, - "min": 0.000667001586407423, - "max": 18.47491698572412, + "p50": 0.09764547576196492, + "p95": 5.931359724490903, + "p99": 6.313897648942659, + "mean": 2.4748171647851764, + "min": 0.0007080379873514175, + "max": 19.047666050028056, "sample_count": 2140 }, - "throughput_qps": 411.6511411405857, + "throughput_qps": 403.98682767673745, "effective_warmup": 100, - "cold_start_ms": 134.52441699337214, - "peak_rss_mb": 177.234375, + "cold_start_ms": 150.46075003920123, + "peak_rss_mb": 177.265625, "wheel_size_mb": 0.19714641571044922, "data_size_mb": null, "calibration": null @@ -3703,18 +3703,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.0021249870769679546, - "p95": 0.0025020679458975775, - "p99": 0.003083607880398631, - "mean": 0.0022085558973409442, - "min": 0.00100000761449337, - "max": 0.03791600465774536, + "p50": 0.002374988980591297, + "p95": 0.0028339592972770333, + "p99": 0.005483593558892613, + "mean": 0.002518326324785006, + "min": 0.0013750395737588406, + "max": 0.062167004216462374, "sample_count": 2140 }, - "throughput_qps": 406483.522513708, + "throughput_qps": 353970.1207915434, "effective_warmup": 100, - "cold_start_ms": 1.9325839821249247, - "peak_rss_mb": 112.65625, + "cold_start_ms": 1.6964579699561, + "peak_rss_mb": 112.609375, "wheel_size_mb": 20.141146659851074, "data_size_mb": null, "calibration": null @@ -3763,18 +3763,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.3109585086349398, - "p95": 0.5923937511397526, - "p99": 0.7143073971383277, - "mean": 0.32995318091979303, - "min": 0.001166015863418579, - "max": 1.0979159851558506, + "p50": 0.3407704643905163, + "p95": 0.6525680975755677, + "p99": 0.7081916125025604, + "mean": 0.35840572487937167, + "min": 0.0010840012691915035, + "max": 0.7811670075170696, "sample_count": 2140 }, - "throughput_qps": 3024.5470626844294, + "throughput_qps": 2786.2958284365604, "effective_warmup": 100, - "cold_start_ms": 2.9767919913865626, - "peak_rss_mb": 113.015625, + "cold_start_ms": 2.521958958823234, + "peak_rss_mb": 112.890625, "wheel_size_mb": 4.124938011169434, "data_size_mb": null, "calibration": null @@ -3784,63 +3784,63 @@ }, { "name": "resolvekit", - "version": "0.1.0", + "version": "0.1.2", "offline": true, "dataset": "geo_countries_multilingual", "metrics": { "accuracy": { - "overall": 0.6317757009345795, + "overall": 0.6345794392523364, "by_capability": { - "multilingual": 0.6313817330210773, - "alias": 0.44905130007027405, + "multilingual": 0.6341920374707259, + "alias": 0.45397048489107517, "case_noise": 0.8 }, "by_language": { - "fr": 0.5613839285714286, - "de": 0.7058823529411765, - "es": 0.6610169491525424 + "fr": 0.5591517857142857, + "de": 0.7210084033613445, + "es": 0.6594761171032357 }, "by_difficulty": { - "medium": 0.4594222833562586, - "easy": 0.9970845481049563 + "medium": 0.4642365887207703, + "easy": 0.9956268221574344 }, "by_entity_type": { - "country": 0.6317757009345795 + "country": 0.6345794392523364 }, - "wrong_match_rate": 0.02663551401869159, + "wrong_match_rate": 0.024766355140186914, "abstention_precision": 0.0, "abstention_recall": 0.0, "error_rate": 0.0, "row_count": 2140, - "accuracy_ci_low": 0.6111209460663962, - "accuracy_ci_high": 0.651958191869914, + "accuracy_ci_low": 0.6139523967003898, + "accuracy_ci_high": 0.6547241697026503, "by_entity_type_n": { "country": 2140 }, "by_entity_type_wrong_match": { - "country": 0.02663551401869159 + "country": 0.024766355140186914 } }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.44656250975094736, - "p95": 2.2410461824620143, - "p99": 4.8408476181794, - "mean": 0.783043874973849, - "min": 0.005791021976619959, - "max": 11.012041999492794, + "p50": 0.4538334906101227, + "p95": 2.2137868887512013, + "p99": 3.789976217085496, + "mean": 0.7471719615283274, + "min": 0.007916998583823442, + "max": 7.112917024642229, "sample_count": 2140 }, - "throughput_qps": 1276.1918755175584, + "throughput_qps": 1337.3787649425624, "effective_warmup": 100, - "cold_start_ms": 6157.660084019881, - "peak_rss_mb": 1140.15625, - "wheel_size_mb": 9.482624053955078, + "cold_start_ms": 5808.8847500039265, + "peak_rss_mb": 1139.515625, + "wheel_size_mb": 9.61152458190918, "data_size_mb": 807.2899999999998, "calibration": { - "n_with_confidence": 1373, - "ece": 0.09654959674894516, - "brier": 0.03934458887379307, + "n_with_confidence": 1382, + "ece": 0.07181478782911134, + "brier": 0.03581593998020828, "reliability_bins": [ { "lower": 0.0, @@ -3894,23 +3894,23 @@ { "lower": 0.7, "upper": 0.8, - "count": 38, - "mean_confidence": 0.7580720979631523, - "observed_accuracy": 0.6578947368421053 + "count": 32, + "mean_confidence": 0.7519314584852467, + "observed_accuracy": 0.71875 }, { "lower": 0.8, "upper": 0.9, - "count": 87, - "mean_confidence": 0.8612424728432934, - "observed_accuracy": 0.5977011494252874 + "count": 168, + "mean_confidence": 0.8774388382157395, + "observed_accuracy": 0.7976190476190477 }, { "lower": 0.9, "upper": 1.0, - "count": 1248, - "mean_confidence": 0.9079905757401159, - "observed_accuracy": 0.9927884615384616 + "count": 1182, + "mean_confidence": 0.9198168313975806, + "observed_accuracy": 0.9915397631133672 } ] } @@ -3920,63 +3920,63 @@ }, { "name": "resolvekit_typed", - "version": "0.1.0", + "version": "0.1.2", "offline": true, "dataset": "geo_countries_multilingual", "metrics": { "accuracy": { - "overall": 0.6140186915887851, + "overall": 0.6135514018691589, "by_capability": { - "multilingual": 0.6135831381733021, + "multilingual": 0.6131147540983607, "alias": 0.44553759662684467, "case_noise": 0.8 }, "by_language": { - "fr": 0.5491071428571429, - "de": 0.6840336134453782, - "es": 0.6394453004622496 + "fr": 0.5457589285714286, + "de": 0.6907563025210084, + "es": 0.6363636363636364 }, "by_difficulty": { "medium": 0.4497936726272352, - "easy": 0.9620991253644315 + "easy": 0.9606413994169096 }, "by_entity_type": { - "country": 0.6140186915887851 + "country": 0.6135514018691589 }, - "wrong_match_rate": 0.005607476635514018, + "wrong_match_rate": 0.0065420560747663555, "abstention_precision": 0.0, "abstention_recall": 0.0, "error_rate": 0.0, "row_count": 2140, - "accuracy_ci_low": 0.5932054698804872, - "accuracy_ci_high": 0.634423287766533, + "accuracy_ci_low": 0.5927344031816617, + "accuracy_ci_high": 0.6339614497209036, "by_entity_type_n": { "country": 2140 }, "by_entity_type_wrong_match": { - "country": 0.005607476635514018 + "country": 0.0065420560747663555 } }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.2901664993260056, - "p95": 1.2878878478659317, - "p99": 2.8483263851376535, - "mean": 0.6409013145015723, - "min": 0.09587500244379044, - "max": 374.6798330103047, + "p50": 0.2998755080625415, + "p95": 1.3359601172851399, + "p99": 2.5492549955379227, + "mean": 0.4804065982238868, + "min": 0.013499986380338669, + "max": 9.24637500429526, "sample_count": 2140 }, - "throughput_qps": 1559.0560320251461, + "throughput_qps": 2079.269393903852, "effective_warmup": 100, - "cold_start_ms": 195.68216596962884, - "peak_rss_mb": 174.1875, - "wheel_size_mb": 9.482624053955078, + "cold_start_ms": 176.1454590014182, + "peak_rss_mb": 173.984375, + "wheel_size_mb": 9.61152458190918, "data_size_mb": 807.2899999999998, "calibration": { - "n_with_confidence": 1312, - "ece": 0.08851437142514469, - "brier": 0.016269641793733323, + "n_with_confidence": 1313, + "ece": 0.07655015989216392, + "brier": 0.01593430963161928, "reliability_bins": [ { "lower": 0.0, @@ -4030,23 +4030,23 @@ { "lower": 0.7, "upper": 0.8, - "count": 32, - "mean_confidence": 0.7535759128172774, - "observed_accuracy": 0.8125 + "count": 26, + "mean_confidence": 0.7522973197795066, + "observed_accuracy": 0.8846153846153846 }, { "lower": 0.8, "upper": 0.9, - "count": 49, - "mean_confidence": 0.8592278433774692, - "observed_accuracy": 0.9183673469387755 + "count": 136, + "mean_confidence": 0.8806256865219483, + "observed_accuracy": 0.9485294117647058 }, { "lower": 0.9, "upper": 1.0, - "count": 1231, - "mean_confidence": 0.9079224623513902, - "observed_accuracy": 0.9983753046303818 + "count": 1151, + "mean_confidence": 0.9202126988534636, + "observed_accuracy": 0.996524761077324 } ] } @@ -4090,18 +4090,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.0432920060120523, - "p95": 0.29141263803467155, - "p99": 1.8326692562550229, - "mean": 0.1630523441625493, - "min": 0.02600002335384488, - "max": 2.6237499550916255, + "p50": 0.05220901221036911, + "p95": 0.3039795032236725, + "p99": 0.35245678853243567, + "mean": 0.10576314359371151, + "min": 0.03037502756342292, + "max": 0.3744999994523823, "sample_count": 35 }, - "throughput_qps": 6119.372177147129, + "throughput_qps": 9409.973155159385, "effective_warmup": 8, - "cold_start_ms": 25.720665988046676, - "peak_rss_mb": 115.53125, + "cold_start_ms": 24.00350000243634, + "peak_rss_mb": 115.625, "wheel_size_mb": 0.14886188507080078, "data_size_mb": null, "calibration": null @@ -4145,18 +4145,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.1772079849615693, - "p95": 0.25250461767427623, - "p99": 0.27760834083892394, - "mean": 0.16131433990917035, - "min": 0.06587500683963299, - "max": 0.28908299282193184, + "p50": 0.17691700486466289, + "p95": 0.24743398535065353, + "p99": 0.2725512860342859, + "mean": 0.16152154255126203, + "min": 0.06837502587586641, + "max": 0.283417000900954, "sample_count": 35 }, - "throughput_qps": 6186.660738072703, + "throughput_qps": 6178.10560352383, "effective_warmup": 8, - "cold_start_ms": 5.944499978795648, - "peak_rss_mb": 113.734375, + "cold_start_ms": 5.1318330224603415, + "peak_rss_mb": 113.34375, "wheel_size_mb": 0.33511829376220703, "data_size_mb": null, "calibration": null @@ -4200,18 +4200,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.21475000539794564, - "p95": 0.36792121827602353, - "p99": 0.5091404845006762, - "mean": 0.23893222844760334, - "min": 0.1514590112492442, - "max": 0.5359579809010029, + "p50": 0.21245802054181695, + "p95": 0.3166296111885458, + "p99": 0.35292625892907364, + "mean": 0.22464776910575374, + "min": 0.15999999595806003, + "max": 0.3701669629663229, "sample_count": 35 }, - "throughput_qps": 4153.111713740137, + "throughput_qps": 4424.429158680332, "effective_warmup": 8, - "cold_start_ms": 469.9447499588132, - "peak_rss_mb": 125.828125, + "cold_start_ms": 547.5679590017535, + "peak_rss_mb": 125.9375, "wheel_size_mb": 0.28513336181640625, "data_size_mb": null, "calibration": null @@ -4255,18 +4255,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.0003750319592654705, - "p95": 0.000553589779883623, - "p99": 0.0005830079317092896, - "mean": 0.00040717589269791333, - "min": 0.00033300602808594704, - "max": 0.0005830079317092896, + "p50": 0.0004169996827840805, + "p95": 0.0005833047907799482, + "p99": 0.0006387801840901372, + "mean": 0.00044054052393351285, + "min": 0.00033294782042503357, + "max": 0.000667001586407423, "sample_count": 35 }, - "throughput_qps": 1448255.5323156368, + "throughput_qps": 1383891.7307409043, "effective_warmup": 8, - "cold_start_ms": 52.61470895493403, - "peak_rss_mb": 190.75, + "cold_start_ms": 51.88416701275855, + "peak_rss_mb": 190.78125, "wheel_size_mb": 164.73299312591553, "data_size_mb": null, "calibration": null @@ -4310,18 +4310,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 5.555042007472366, - "p95": 5.898937716847286, - "p99": 6.096676050219684, - "mean": 3.886606028702642, - "min": 0.001334003172814846, - "max": 6.159249984193593, + "p50": 5.239624995738268, + "p95": 5.4286044265609235, + "p99": 5.520252728601918, + "mean": 3.6546916867207204, + "min": 0.0013329554349184036, + "max": 5.538541998248547, "sample_count": 35 }, - "throughput_qps": 257.2637198191671, + "throughput_qps": 273.5887218672053, "effective_warmup": 8, - "cold_start_ms": 134.52441699337214, - "peak_rss_mb": 177.234375, + "cold_start_ms": 150.46075003920123, + "peak_rss_mb": 177.265625, "wheel_size_mb": 0.19714641571044922, "data_size_mb": null, "calibration": null @@ -4365,18 +4365,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.002040993422269821, - "p95": 0.002275174483656883, - "p99": 0.002801277441903946, - "mean": 0.0020593970215746333, - "min": 0.0018749851733446121, - "max": 0.00304199056699872, + "p50": 0.002208980731666088, + "p95": 0.0027330941520631287, + "p99": 0.004639570834115141, + "mean": 0.0023416842200926374, + "min": 0.0019999570213258266, + "max": 0.005291018169373274, "sample_count": 35 }, - "throughput_qps": 432985.978031537, + "throughput_qps": 381820.9316933471, "effective_warmup": 8, - "cold_start_ms": 1.9325839821249247, - "peak_rss_mb": 112.65625, + "cold_start_ms": 1.6964579699561, + "peak_rss_mb": 112.609375, "wheel_size_mb": 20.141146659851074, "data_size_mb": null, "calibration": null @@ -4420,18 +4420,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.3357910318300128, - "p95": 0.5738795851357281, - "p99": 0.6228706333786247, - "mean": 0.3681190262016441, - "min": 0.21312502212822437, - "max": 0.6407910259440541, + "p50": 0.3429169883020222, + "p95": 0.6236205867026001, + "p99": 0.7088498212397094, + "mean": 0.3881417148347412, + "min": 0.23087498266249895, + "max": 0.7512080483138561, "sample_count": 35 }, - "throughput_qps": 2712.3021577590243, + "throughput_qps": 2574.4048988065783, "effective_warmup": 8, - "cold_start_ms": 2.9767919913865626, - "peak_rss_mb": 113.015625, + "cold_start_ms": 2.521958958823234, + "peak_rss_mb": 112.890625, "wheel_size_mb": 4.124938011169434, "data_size_mb": null, "calibration": null @@ -4441,7 +4441,7 @@ }, { "name": "resolvekit", - "version": "0.1.0", + "version": "0.1.2", "offline": true, "dataset": "no_match", "metrics": { @@ -4475,19 +4475,19 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.004832982085645199, - "p95": 2.9449377034325144, - "p99": 3.9461885439231956, - "mean": 0.603719088914139, - "min": 0.0025840126909315586, - "max": 4.352374991867691, + "p50": 0.004958012141287327, + "p95": 2.3519043636042625, + "p99": 3.331632032059128, + "mean": 0.5229428793037576, + "min": 0.0022500171326100826, + "max": 3.8277909625321627, "sample_count": 35 }, - "throughput_qps": 1655.1463732807783, + "throughput_qps": 1910.6324024700366, "effective_warmup": 8, - "cold_start_ms": 6157.660084019881, - "peak_rss_mb": 1140.15625, - "wheel_size_mb": 9.482624053955078, + "cold_start_ms": 5808.8847500039265, + "peak_rss_mb": 1139.515625, + "wheel_size_mb": 9.61152458190918, "data_size_mb": 807.2899999999998, "calibration": { "n_with_confidence": 3, @@ -4547,7 +4547,7 @@ "lower": 0.7, "upper": 0.8, "count": 1, - "mean_confidence": 0.7224260818051875, + "mean_confidence": 0.7492740791589273, "observed_accuracy": 0.0 }, { @@ -4572,7 +4572,7 @@ }, { "name": "resolvekit_typed", - "version": "0.1.0", + "version": "0.1.2", "offline": true, "dataset": "no_match", "metrics": { @@ -4606,19 +4606,19 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.0069579691626131535, - "p95": 1.6570248873904347, - "p99": 2.841498225461686, - "mean": 0.2972535173674779, - "min": 0.003667024429887533, - "max": 3.4358749981038272, + "p50": 0.007125025149434805, + "p95": 1.7998413881286979, + "p99": 3.0636733619030503, + "mean": 0.3237953076937369, + "min": 0.003708992153406143, + "max": 3.688083030283451, "sample_count": 35 }, - "throughput_qps": 3359.3010535732146, + "throughput_qps": 3084.3686741684132, "effective_warmup": 8, - "cold_start_ms": 195.68216596962884, - "peak_rss_mb": 174.1875, - "wheel_size_mb": 9.482624053955078, + "cold_start_ms": 176.1454590014182, + "peak_rss_mb": 173.984375, + "wheel_size_mb": 9.61152458190918, "data_size_mb": 807.2899999999998, "calibration": { "n_with_confidence": 3, @@ -4678,7 +4678,7 @@ "lower": 0.7, "upper": 0.8, "count": 3, - "mean_confidence": 0.7451905982267782, + "mean_confidence": 0.7630579243858749, "observed_accuracy": 0.0 }, { diff --git a/benchmarks/results/latest.md b/benchmarks/results/latest.md index 4996615..05d0980 100644 --- a/benchmarks/results/latest.md +++ b/benchmarks/results/latest.md @@ -1,4 +1,4 @@ -# resolvekit benchmark — 2026-06-10 +# resolvekit benchmark — 2026-06-11 Hardware: arm, 18 cores, 49,152 MB RAM, Python 3.12.13. Warmup: 100 queries discarded. Seed: 42. @@ -24,15 +24,15 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | version | accuracy | acc CI | coverage | wrong-match | abst P | p50 ms | p95 ms | qps | mem MB | wheel MB | data MB | |---|---|---|---|---|---|---|---|---|---|---|---|---| -| country_converter | 1.3.2 | 0.913 | [0.73, 0.98] | 48.3% | 0.000 | 0.000 | 0.1 | 0.1 | 9410.5 | 115.5 | 0.1 | — | -| countryguess | 0.4.9 | 0.957 | [0.79, 0.99] | 48.3% | 0.000 | 0.000 | 0.0 | 0.1 | 33550.1 | 113.7 | 0.3 | — | -| data_commons_resolve | 2.1.6 | 0.909 | [0.79, 0.96] | 93.1% | 0.023 | 0.000 | 0.2 | 0.4 | 4438.1 | 125.8 | 0.3 | — | -| geonamescache | 3.0.1 | 0.536 | [0.36, 0.70] | 60.3% | 0.214 | 0.000 | 0.0 | 73.9 | 63.0 | 190.8 | 164.7 | — | -| hdx_python_country | 4.1.1 | 1.000 | [0.86, 1.00] | 48.3% | 0.000 | 0.000 | 0.0 | 4.9 | 2024.8 | 177.2 | 0.2 | — | -| pycountry | 26.2.16 | 0.652 | [0.45, 0.81] | 48.3% | 0.000 | 0.000 | 0.0 | 0.0 | 422988.7 | 112.7 | 20.1 | — | -| rapidfuzz_dict | 3.14.5 | 0.783 | [0.58, 0.90] | 48.3% | 0.217 | 0.000 | 0.2 | 0.3 | 5206.3 | 113.0 | 4.1 | — | -| resolvekit | 0.1.0 | 0.872 | [0.75, 0.94] | 100.0% | 0.000 | 0.000 | 2.1 | 3.4 | 509.6 | 1140.2 | 9.5 | 807.3 | -| resolvekit_typed | 0.1.0 | 0.915 | [0.80, 0.97] | 100.0% | 0.000 | 0.000 | 1.9 | 3.0 | 562.9 | 174.2 | 9.5 | 807.3 | +| country_converter | 1.3.2 | 0.913 | [0.73, 0.98] | 48.3% | 0.000 | 0.000 | 0.1 | 0.1 | 9342.8 | 115.6 | 0.1 | — | +| countryguess | 0.4.9 | 0.957 | [0.79, 0.99] | 48.3% | 0.000 | 0.000 | 0.0 | 0.1 | 32641.5 | 113.3 | 0.3 | — | +| data_commons_resolve | 2.1.6 | 0.909 | [0.79, 0.96] | 93.1% | 0.023 | 0.000 | 0.0 | 0.2 | 12578.3 | 125.9 | 0.3 | — | +| geonamescache | 3.0.1 | 0.536 | [0.36, 0.70] | 60.3% | 0.214 | 0.000 | 0.0 | 68.4 | 68.3 | 190.8 | 164.7 | — | +| hdx_python_country | 4.1.1 | 1.000 | [0.86, 1.00] | 48.3% | 0.000 | 0.000 | 0.0 | 4.9 | 2057.5 | 177.3 | 0.2 | — | +| pycountry | 26.2.16 | 0.652 | [0.45, 0.81] | 48.3% | 0.000 | 0.000 | 0.0 | 0.0 | 342216.0 | 112.6 | 20.1 | — | +| rapidfuzz_dict | 3.14.5 | 0.783 | [0.58, 0.90] | 48.3% | 0.217 | 0.000 | 0.2 | 0.4 | 4747.6 | 112.9 | 4.1 | — | +| resolvekit | 0.1.2 | 0.872 | [0.75, 0.94] | 100.0% | 0.000 | 0.000 | 2.3 | 3.3 | 463.4 | 1139.5 | 9.6 | 807.3 | +| resolvekit_typed | 0.1.2 | 0.915 | [0.80, 0.97] | 100.0% | 0.000 | 0.000 | 2.2 | 3.6 | 477.7 | 174.0 | 9.6 | 807.3 | #### recall metrics @@ -82,15 +82,15 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | version | accuracy | acc CI | coverage | wrong-match | abst P | p50 ms | p95 ms | qps | mem MB | wheel MB | data MB | |---|---|---|---|---|---|---|---|---|---|---|---|---| -| country_converter | 1.3.2 | 0.700 | [0.40, 0.89] | 23.6% | 0.000 | 0.250 | 0.1 | n/a | 12365.4 | 115.5 | 0.1 | — | -| countryguess | 0.4.9 | 0.800 | [0.49, 0.94] | 23.6% | 0.100 | 0.500 | 0.0 | n/a | 11227.0 | 113.7 | 0.3 | — | -| data_commons_resolve | 2.1.6 | 0.763 | [0.70, 0.82] | 65.7% | 0.092 | 0.281 | 0.1 | 0.2 | 7214.1 | 125.8 | 0.3 | — | -| geonamescache | 3.0.1 | 0.280 | [0.20, 0.37] | 42.8% | 0.330 | 0.188 | 0.0 | 73.2 | 31.7 | 190.8 | 164.7 | — | -| hdx_python_country | 4.1.1 | 0.800 | [0.49, 0.94] | 23.6% | 0.000 | 0.333 | 0.1 | n/a | 421.9 | 177.2 | 0.2 | — | -| pycountry | 26.2.16 | 0.500 | [0.24, 0.76] | 23.6% | 0.000 | 0.167 | 0.0 | n/a | 423279.8 | 112.7 | 20.1 | — | -| rapidfuzz_dict | 3.14.5 | 0.600 | [0.31, 0.83] | 23.6% | 0.400 | 0.000 | 0.2 | n/a | 4347.7 | 113.0 | 4.1 | — | -| resolvekit | 0.1.0 | 0.888 | [0.85, 0.92] | 100.0% | 0.049 | 0.538 | 0.5 | 2.6 | 1142.8 | 1140.2 | 9.5 | 807.3 | -| resolvekit_typed | 0.1.0 | 0.913 | [0.88, 0.94] | 100.0% | 0.025 | 0.423 | 0.3 | 2.3 | 602.0 | 174.2 | 9.5 | 807.3 | +| country_converter | 1.3.2 | 0.700 | [0.40, 0.89] | 23.6% | 0.000 | 0.250 | 0.1 | n/a | 11116.8 | 115.6 | 0.1 | — | +| countryguess | 0.4.9 | 0.800 | [0.49, 0.94] | 23.6% | 0.100 | 0.500 | 0.0 | n/a | 11155.0 | 113.3 | 0.3 | — | +| data_commons_resolve | 2.1.6 | 0.763 | [0.70, 0.82] | 65.7% | 0.092 | 0.281 | 0.1 | 0.2 | 8552.3 | 125.9 | 0.3 | — | +| geonamescache | 3.0.1 | 0.280 | [0.20, 0.37] | 42.8% | 0.330 | 0.188 | 0.0 | 70.0 | 33.2 | 190.8 | 164.7 | — | +| hdx_python_country | 4.1.1 | 0.800 | [0.49, 0.94] | 23.6% | 0.000 | 0.333 | 0.1 | n/a | 451.6 | 177.3 | 0.2 | — | +| pycountry | 26.2.16 | 0.500 | [0.24, 0.76] | 23.6% | 0.000 | 0.167 | 0.0 | n/a | 305343.4 | 112.6 | 20.1 | — | +| rapidfuzz_dict | 3.14.5 | 0.600 | [0.31, 0.83] | 23.6% | 0.400 | 0.000 | 0.2 | n/a | 4457.3 | 112.9 | 4.1 | — | +| resolvekit | 0.1.2 | 0.886 | [0.85, 0.91] | 100.0% | 0.052 | 0.538 | 0.5 | 2.5 | 1190.4 | 1139.5 | 9.6 | 807.3 | +| resolvekit_typed | 0.1.2 | 0.913 | [0.88, 0.94] | 100.0% | 0.025 | 0.423 | 0.3 | 2.4 | 1422.4 | 174.0 | 9.6 | 807.3 | #### recall metrics @@ -103,7 +103,7 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | hdx_python_country | 1.000 | 1.000 | | pycountry | 1.000 | 0.750 | | rapidfuzz_dict | 0.000 | 0.750 | -| resolvekit | 0.824 | 0.906 | +| resolvekit | 0.824 | 0.894 | | resolvekit_typed | 0.647 | 0.929 | #### per-capability accuracy @@ -131,7 +131,7 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | hdx_python_country | — | — | — | — | — | — | — | — | 0.800 (n=10) | — | | pycountry | — | — | — | — | — | — | — | — | 0.500 (n=10) | — | | rapidfuzz_dict | — | — | — | — | — | — | — | — | 0.600 (n=10) | — | -| resolvekit | 0.782 (n=55) | 0.969 (n=32) | 0.818 (n=33) | 0.906 (n=32) | 0.886 (n=35) | 0.889 (n=72) | 1.000 (n=7) | 1.000 (n=9) | 0.964 (n=83) | 0.556 (n=9) | +| resolvekit | 0.782 (n=55) | 0.969 (n=32) | 0.818 (n=33) | 0.875 (n=32) | 0.886 (n=35) | 0.889 (n=72) | 1.000 (n=7) | 1.000 (n=9) | 0.964 (n=83) | 0.556 (n=9) | | resolvekit_typed | 0.818 (n=55) | 0.938 (n=32) | 0.848 (n=33) | 0.938 (n=32) | 0.971 (n=35) | 0.889 (n=72) | 1.000 (n=7) | 1.000 (n=9) | 0.952 (n=83) | 1.000 (n=9) | ### eval_org @@ -140,8 +140,8 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | version | accuracy | acc CI | coverage | wrong-match | abst P | p50 ms | p95 ms | qps | mem MB | wheel MB | data MB | |---|---|---|---|---|---|---|---|---|---|---|---|---| -| resolvekit | 0.1.0 | 0.750 | [0.53, 0.89] | 100.0% | 0.100 | 1.000 | 0.8 | 3.7 | 675.0 | 1140.2 | 9.5 | 807.3 | -| resolvekit_typed | 0.1.0 | 0.750 | [0.53, 0.89] | 100.0% | 0.100 | 1.000 | 0.8 | 2.7 | 859.8 | 174.2 | 9.5 | 807.3 | +| resolvekit | 0.1.2 | 0.750 | [0.53, 0.89] | 100.0% | 0.100 | 1.000 | 0.9 | 3.4 | 694.8 | 1139.5 | 9.6 | 807.3 | +| resolvekit_typed | 0.1.2 | 0.750 | [0.53, 0.89] | 100.0% | 0.100 | 1.000 | 0.9 | 3.2 | 735.4 | 174.0 | 9.6 | 807.3 | | country_converter | *skipped (scope: supports ['country'], dataset has ['org'])* | — | — | — | — | — | — | — | — | — | — | — | | countryguess | *skipped (scope: supports ['country'], dataset has ['org'])* | — | — | — | — | — | — | — | — | — | — | — | | geonamescache | *skipped (scope: supports ['city', 'country'], dataset has ['org'])* | — | — | — | — | — | — | — | — | — | — | — | @@ -176,9 +176,9 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | version | accuracy | acc CI | coverage | wrong-match | abst P | p50 ms | p95 ms | qps | mem MB | wheel MB | data MB | |---|---|---|---|---|---|---|---|---|---|---|---|---| -| data_commons_resolve | 2.1.6 | 0.598 | [0.58, 0.62] | 80.7% | 0.146 | 0.000 | 522.9 | 882.0 | 1.9 | 125.8 | 0.3 | — | -| resolvekit | 0.1.0 | 0.935 | [0.92, 0.94] | 100.0% | 0.043 | 0.000 | 0.6 | 3.3 | 874.0 | 1140.2 | 9.5 | 807.3 | -| resolvekit_typed | 0.1.0 | 0.977 | [0.97, 0.98] | 100.0% | 0.001 | 0.000 | 0.2 | 1.4 | 1683.5 | 174.2 | 9.5 | 807.3 | +| data_commons_resolve | 2.1.6 | 0.598 | [0.58, 0.62] | 80.7% | 0.146 | 0.000 | 0.1 | 0.2 | 8970.8 | 125.9 | 0.3 | — | +| resolvekit | 0.1.2 | 0.934 | [0.92, 0.94] | 100.0% | 0.043 | 0.000 | 0.5 | 2.7 | 937.6 | 1139.5 | 9.6 | 807.3 | +| resolvekit_typed | 0.1.2 | 0.977 | [0.97, 0.98] | 100.0% | 0.001 | 0.000 | 0.2 | 1.5 | 2141.5 | 174.0 | 9.6 | 807.3 | | country_converter | *skipped (scope: supports ['country'], dataset has ['admin1', 'admin2', 'admin3'])* | — | — | — | — | — | — | — | — | — | — | — | | countryguess | *skipped (scope: supports ['country'], dataset has ['admin1', 'admin2', 'admin3'])* | — | — | — | — | — | — | — | — | — | — | — | | geonamescache | *skipped (scope: supports ['city', 'country'], dataset has ['admin1', 'admin2', 'admin3'])* | — | — | — | — | — | — | — | — | — | — | — | @@ -199,7 +199,7 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | alias | typo | |---|---|---| | data_commons_resolve | 0.725 | 0.419 | -| resolvekit | 0.934 | 0.927 | +| resolvekit | 0.934 | 0.926 | | resolvekit_typed | 0.934 | 0.972 | #### per-entity-type accuracy @@ -207,7 +207,7 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | admin1 | admin2 | admin3 | |---|---|---|---| | data_commons_resolve | 0.652 (n=781) | 0.564 (n=1,264) | — | -| resolvekit | 0.960 (n=791) | 0.921 (n=1,278) | 0.930 (n=488) | +| resolvekit | 0.957 (n=791) | 0.921 (n=1,278) | 0.930 (n=488) | | resolvekit_typed | 0.980 (n=791) | 0.973 (n=1,278) | 0.986 (n=488) | ### geo_cities @@ -216,10 +216,10 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | version | accuracy | acc CI | coverage | wrong-match | abst P | p50 ms | p95 ms | qps | mem MB | wheel MB | data MB | |---|---|---|---|---|---|---|---|---|---|---|---|---| -| data_commons_resolve | 2.1.6 | 0.502 | [0.48, 0.52] | 100.0% | 0.198 | 0.000 | 493.2 | 998.1 | 2.0 | 125.8 | 0.3 | — | -| geonamescache | 3.0.1 | 0.000 | [0.00, 0.00] | 100.0% | 0.154 | 0.000 | 69.7 | 73.2 | 14.2 | 190.8 | 164.7 | — | -| resolvekit | 0.1.0 | 0.858 | [0.84, 0.87] | 100.0% | 0.010 | 0.000 | 0.6 | 2.8 | 1063.4 | 1140.2 | 9.5 | 807.3 | -| resolvekit_typed | 0.1.0 | 0.862 | [0.85, 0.88] | 100.0% | 0.005 | 0.000 | 0.5 | 2.5 | 1021.7 | 174.2 | 9.5 | 807.3 | +| data_commons_resolve | 2.1.6 | 0.502 | [0.48, 0.52] | 100.0% | 0.198 | 0.000 | 0.1 | 0.2 | 9588.7 | 125.9 | 0.3 | — | +| geonamescache | 3.0.1 | 0.000 | [0.00, 0.00] | 100.0% | 0.154 | 0.000 | 68.8 | 73.2 | 14.3 | 190.8 | 164.7 | — | +| resolvekit | 0.1.2 | 0.858 | [0.84, 0.87] | 100.0% | 0.011 | 0.000 | 0.6 | 2.4 | 1129.8 | 1139.5 | 9.6 | 807.3 | +| resolvekit_typed | 0.1.2 | 0.862 | [0.85, 0.88] | 100.0% | 0.005 | 0.000 | 0.5 | 2.6 | 1213.6 | 174.0 | 9.6 | 807.3 | | country_converter | *skipped (scope: supports ['country'], dataset has ['city'])* | — | — | — | — | — | — | — | — | — | — | — | | countryguess | *skipped (scope: supports ['country'], dataset has ['city'])* | — | — | — | — | — | — | — | — | — | — | — | | hdx_python_country | *skipped (scope: supports ['country'], dataset has ['city'])* | — | — | — | — | — | — | — | — | — | — | — | @@ -241,7 +241,7 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r |---|---|---| | data_commons_resolve | 0.787 | 0.201 | | geonamescache | 0.000 | 0.000 | -| resolvekit | 0.985 | 0.832 | +| resolvekit | 0.985 | 0.833 | | resolvekit_typed | 0.985 | 0.837 | #### per-entity-type accuracy @@ -259,15 +259,15 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | version | accuracy | acc CI | coverage | wrong-match | abst P | p50 ms | p95 ms | qps | mem MB | wheel MB | data MB | |---|---|---|---|---|---|---|---|---|---|---|---|---| -| country_converter | 1.3.2 | 0.566 | [0.55, 0.58] | 100.0% | 0.025 | 0.000 | 0.1 | 0.2 | 8239.8 | 115.5 | 0.1 | — | -| countryguess | 0.4.9 | 0.675 | [0.66, 0.69] | 100.0% | 0.038 | 0.000 | 0.1 | 0.3 | 9165.3 | 113.7 | 0.3 | — | -| data_commons_resolve | 2.1.6 | 0.625 | [0.61, 0.64] | 100.0% | 0.047 | 0.000 | 0.5 | 855.5 | 4.4 | 125.8 | 0.3 | — | -| geonamescache | 3.0.1 | 0.057 | [0.05, 0.07] | 100.0% | 0.000 | 0.000 | 0.0 | 0.0 | 1320596.1 | 190.8 | 164.7 | — | -| hdx_python_country | 4.1.1 | 0.642 | [0.63, 0.66] | 100.0% | 0.042 | 0.000 | 0.1 | 5.7 | 502.6 | 177.2 | 0.2 | — | -| pycountry | 26.2.16 | 0.099 | [0.09, 0.11] | 100.0% | 0.001 | 0.000 | 0.0 | 0.0 | 347380.3 | 112.7 | 20.1 | — | -| rapidfuzz_dict | 3.14.5 | 0.469 | [0.45, 0.48] | 100.0% | 0.507 | 0.000 | 0.3 | 0.5 | 3212.3 | 113.0 | 4.1 | — | -| resolvekit | 0.1.0 | 0.793 | [0.78, 0.81] | 100.0% | 0.039 | 0.000 | 0.8 | 5.1 | 698.3 | 1140.2 | 9.5 | 807.3 | -| resolvekit_typed | 0.1.0 | 0.794 | [0.78, 0.81] | 100.0% | 0.010 | 0.000 | 0.4 | 1.3 | 2048.1 | 174.2 | 9.5 | 807.3 | +| country_converter | 1.3.2 | 0.566 | [0.55, 0.58] | 100.0% | 0.025 | 0.000 | 0.1 | 0.2 | 7522.0 | 115.6 | 0.1 | — | +| countryguess | 0.4.9 | 0.675 | [0.66, 0.69] | 100.0% | 0.038 | 0.000 | 0.1 | 0.3 | 9143.1 | 113.3 | 0.3 | — | +| data_commons_resolve | 2.1.6 | 0.625 | [0.61, 0.64] | 100.0% | 0.047 | 0.000 | 0.1 | 0.2 | 8118.0 | 125.9 | 0.3 | — | +| geonamescache | 3.0.1 | 0.057 | [0.05, 0.07] | 100.0% | 0.000 | 0.000 | 0.0 | 0.0 | 1290117.3 | 190.8 | 164.7 | — | +| hdx_python_country | 4.1.1 | 0.642 | [0.63, 0.66] | 100.0% | 0.042 | 0.000 | 0.1 | 5.8 | 493.3 | 177.3 | 0.2 | — | +| pycountry | 26.2.16 | 0.099 | [0.09, 0.11] | 100.0% | 0.001 | 0.000 | 0.0 | 0.0 | 296705.5 | 112.6 | 20.1 | — | +| rapidfuzz_dict | 3.14.5 | 0.469 | [0.45, 0.48] | 100.0% | 0.507 | 0.000 | 0.3 | 0.6 | 2989.6 | 112.9 | 4.1 | — | +| resolvekit | 0.1.2 | 0.803 | [0.79, 0.82] | 100.0% | 0.038 | 0.000 | 0.8 | 2.9 | 942.2 | 1139.5 | 9.6 | 807.3 | +| resolvekit_typed | 0.1.2 | 0.801 | [0.79, 0.81] | 100.0% | 0.012 | 0.000 | 0.4 | 1.4 | 1893.9 | 174.0 | 9.6 | 807.3 | #### recall metrics @@ -294,8 +294,8 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | hdx_python_country | 0.644 | 0.665 | 0.389 | 0.599 | 0.791 | | pycountry | 0.195 | 0.012 | 0.000 | 0.009 | 0.000 | | rapidfuzz_dict | 0.442 | 0.449 | 0.188 | 0.443 | 0.573 | -| resolvekit | 0.911 | 0.639 | 0.401 | 0.727 | 0.996 | -| resolvekit_typed | 0.901 | 0.656 | 0.435 | 0.736 | 0.987 | +| resolvekit | 0.918 | 0.660 | 0.426 | 0.741 | 0.996 | +| resolvekit_typed | 0.901 | 0.671 | 0.466 | 0.746 | 0.985 | #### per-entity-type accuracy @@ -308,8 +308,8 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | hdx_python_country | 0.642 (n=4,055) | | pycountry | 0.099 (n=4,055) | | rapidfuzz_dict | 0.469 (n=4,055) | -| resolvekit | 0.793 (n=4,055) | -| resolvekit_typed | 0.794 (n=4,055) | +| resolvekit | 0.803 (n=4,055) | +| resolvekit_typed | 0.801 (n=4,055) | ### geo_countries_multilingual @@ -317,15 +317,15 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | version | accuracy | acc CI | coverage | wrong-match | abst P | p50 ms | p95 ms | qps | mem MB | wheel MB | data MB | |---|---|---|---|---|---|---|---|---|---|---|---|---| -| country_converter | 1.3.2 | 0.419 | [0.40, 0.44] | 100.0% | 0.025 | 0.000 | 0.1 | 0.2 | 10052.7 | 115.5 | 0.1 | — | -| countryguess | 0.4.9 | 0.512 | [0.49, 0.53] | 100.0% | 0.029 | 0.000 | 0.1 | 0.4 | 6769.6 | 113.7 | 0.3 | — | -| data_commons_resolve | 2.1.6 | 0.827 | [0.81, 0.84] | 100.0% | 0.029 | 0.000 | 0.1 | 0.2 | 8095.9 | 125.8 | 0.3 | — | -| geonamescache | 3.0.1 | 0.148 | [0.13, 0.16] | 100.0% | 0.002 | 0.000 | 0.0 | 0.0 | 1294420.1 | 190.8 | 164.7 | — | -| hdx_python_country | 4.1.1 | 0.565 | [0.54, 0.59] | 100.0% | 0.084 | 0.000 | 0.1 | 5.9 | 411.7 | 177.2 | 0.2 | — | -| pycountry | 26.2.16 | 0.143 | [0.13, 0.16] | 100.0% | 0.002 | 0.000 | 0.0 | 0.0 | 406483.5 | 112.7 | 20.1 | — | -| rapidfuzz_dict | 3.14.5 | 0.370 | [0.35, 0.39] | 100.0% | 0.495 | 0.000 | 0.3 | 0.6 | 3024.5 | 113.0 | 4.1 | — | -| resolvekit | 0.1.0 | 0.632 | [0.61, 0.65] | 100.0% | 0.027 | 0.000 | 0.4 | 2.2 | 1276.2 | 1140.2 | 9.5 | 807.3 | -| resolvekit_typed | 0.1.0 | 0.614 | [0.59, 0.63] | 100.0% | 0.006 | 0.000 | 0.3 | 1.3 | 1559.1 | 174.2 | 9.5 | 807.3 | +| country_converter | 1.3.2 | 0.419 | [0.40, 0.44] | 100.0% | 0.025 | 0.000 | 0.1 | 0.2 | 9307.5 | 115.6 | 0.1 | — | +| countryguess | 0.4.9 | 0.512 | [0.49, 0.53] | 100.0% | 0.029 | 0.000 | 0.1 | 0.3 | 6813.4 | 113.3 | 0.3 | — | +| data_commons_resolve | 2.1.6 | 0.827 | [0.81, 0.84] | 100.0% | 0.029 | 0.000 | 0.1 | 0.2 | 8684.1 | 125.9 | 0.3 | — | +| geonamescache | 3.0.1 | 0.148 | [0.13, 0.16] | 100.0% | 0.002 | 0.000 | 0.0 | 0.0 | 1296806.2 | 190.8 | 164.7 | — | +| hdx_python_country | 4.1.1 | 0.565 | [0.54, 0.59] | 100.0% | 0.084 | 0.000 | 0.1 | 5.9 | 404.0 | 177.3 | 0.2 | — | +| pycountry | 26.2.16 | 0.143 | [0.13, 0.16] | 100.0% | 0.002 | 0.000 | 0.0 | 0.0 | 353970.1 | 112.6 | 20.1 | — | +| rapidfuzz_dict | 3.14.5 | 0.370 | [0.35, 0.39] | 100.0% | 0.495 | 0.000 | 0.3 | 0.7 | 2786.3 | 112.9 | 4.1 | — | +| resolvekit | 0.1.2 | 0.635 | [0.61, 0.65] | 100.0% | 0.025 | 0.000 | 0.5 | 2.2 | 1337.4 | 1139.5 | 9.6 | 807.3 | +| resolvekit_typed | 0.1.2 | 0.614 | [0.59, 0.63] | 100.0% | 0.007 | 0.000 | 0.3 | 1.3 | 2079.3 | 174.0 | 9.6 | 807.3 | #### recall metrics @@ -352,8 +352,8 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | hdx_python_country | 0.451 | 0.800 | 0.565 | | pycountry | 0.036 | 0.400 | 0.142 | | rapidfuzz_dict | 0.311 | 0.600 | 0.370 | -| resolvekit | 0.449 | 0.800 | 0.631 | -| resolvekit_typed | 0.446 | 0.800 | 0.614 | +| resolvekit | 0.454 | 0.800 | 0.634 | +| resolvekit_typed | 0.446 | 0.800 | 0.613 | #### per-entity-type accuracy @@ -366,7 +366,7 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | hdx_python_country | 0.565 (n=2,140) | | pycountry | 0.143 (n=2,140) | | rapidfuzz_dict | 0.370 (n=2,140) | -| resolvekit | 0.632 (n=2,140) | +| resolvekit | 0.635 (n=2,140) | | resolvekit_typed | 0.614 (n=2,140) | ### no_match @@ -375,15 +375,15 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | version | accuracy | acc CI | coverage | wrong-match | abst P | p50 ms | p95 ms | qps | mem MB | wheel MB | data MB | |---|---|---|---|---|---|---|---|---|---|---|---|---| -| country_converter | 1.3.2 | 1.000 | [0.90, 1.00] | 100.0% | 0.000 | 1.000 | 0.0 | 0.3 | 6119.4 | 115.5 | 0.1 | — | -| countryguess | 0.4.9 | 0.943 | [0.81, 0.98] | 100.0% | 0.057 | 1.000 | 0.2 | 0.3 | 6186.7 | 113.7 | 0.3 | — | -| data_commons_resolve | 2.1.6 | 0.971 | [0.85, 0.99] | 100.0% | 0.029 | 1.000 | 0.2 | 0.4 | 4153.1 | 125.8 | 0.3 | — | -| geonamescache | 3.0.1 | 1.000 | [0.90, 1.00] | 100.0% | 0.000 | 1.000 | 0.0 | 0.0 | 1448255.5 | 190.8 | 164.7 | — | -| hdx_python_country | 4.1.1 | 1.000 | [0.90, 1.00] | 100.0% | 0.000 | 1.000 | 5.6 | 5.9 | 257.3 | 177.2 | 0.2 | — | -| pycountry | 26.2.16 | 1.000 | [0.90, 1.00] | 100.0% | 0.000 | 1.000 | 0.0 | 0.0 | 432986.0 | 112.7 | 20.1 | — | -| rapidfuzz_dict | 3.14.5 | 0.200 | [0.10, 0.36] | 100.0% | 0.800 | 1.000 | 0.3 | 0.6 | 2712.3 | 113.0 | 4.1 | — | -| resolvekit | 0.1.0 | 0.771 | [0.61, 0.88] | 100.0% | 0.086 | 1.000 | 0.0 | 2.9 | 1655.1 | 1140.2 | 9.5 | 807.3 | -| resolvekit_typed | 0.1.0 | 0.914 | [0.78, 0.97] | 100.0% | 0.086 | 1.000 | 0.0 | 1.7 | 3359.3 | 174.2 | 9.5 | 807.3 | +| country_converter | 1.3.2 | 1.000 | [0.90, 1.00] | 100.0% | 0.000 | 1.000 | 0.1 | 0.3 | 9410.0 | 115.6 | 0.1 | — | +| countryguess | 0.4.9 | 0.943 | [0.81, 0.98] | 100.0% | 0.057 | 1.000 | 0.2 | 0.2 | 6178.1 | 113.3 | 0.3 | — | +| data_commons_resolve | 2.1.6 | 0.971 | [0.85, 0.99] | 100.0% | 0.029 | 1.000 | 0.2 | 0.3 | 4424.4 | 125.9 | 0.3 | — | +| geonamescache | 3.0.1 | 1.000 | [0.90, 1.00] | 100.0% | 0.000 | 1.000 | 0.0 | 0.0 | 1383891.7 | 190.8 | 164.7 | — | +| hdx_python_country | 4.1.1 | 1.000 | [0.90, 1.00] | 100.0% | 0.000 | 1.000 | 5.2 | 5.4 | 273.6 | 177.3 | 0.2 | — | +| pycountry | 26.2.16 | 1.000 | [0.90, 1.00] | 100.0% | 0.000 | 1.000 | 0.0 | 0.0 | 381820.9 | 112.6 | 20.1 | — | +| rapidfuzz_dict | 3.14.5 | 0.200 | [0.10, 0.36] | 100.0% | 0.800 | 1.000 | 0.3 | 0.6 | 2574.4 | 112.9 | 4.1 | — | +| resolvekit | 0.1.2 | 0.771 | [0.61, 0.88] | 100.0% | 0.086 | 1.000 | 0.0 | 2.4 | 1910.6 | 1139.5 | 9.6 | 807.3 | +| resolvekit_typed | 0.1.2 | 0.914 | [0.78, 0.97] | 100.0% | 0.086 | 1.000 | 0.0 | 1.8 | 3084.4 | 174.0 | 9.6 | 807.3 | #### recall metrics @@ -454,7 +454,7 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C | tool | accuracy | n | wrong-match | |---|---|---|---| | data_commons_resolve | 0.652 | 781 | 0.119 | -| resolvekit | 0.960 | 791 | 0.030 | +| resolvekit | 0.957 | 791 | 0.030 | | resolvekit_typed | 0.980 | 791 | 0.000 | ### admin2 @@ -519,7 +519,7 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C | tool | accuracy | n | wrong-match | |---|---|---|---| -| resolvekit | 0.906 | 32 | 0.031 | +| resolvekit | 0.875 | 32 | 0.062 | | resolvekit_typed | 0.938 | 32 | 0.000 | ### admin5 @@ -557,7 +557,7 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C |---|---|---|---| | data_commons_resolve | 0.502 | 2,048 | 0.198 | | geonamescache | 0.000 | 2,048 | 0.154 | -| resolvekit | 0.858 | 2,048 | 0.010 | +| resolvekit | 0.858 | 2,048 | 0.011 | | resolvekit_typed | 0.862 | 2,048 | 0.005 | ### continent @@ -619,8 +619,8 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C | hdx_python_country | 0.642 | 4,055 | 0.042 | | pycountry | 0.099 | 4,055 | 0.001 | | rapidfuzz_dict | 0.469 | 4,055 | 0.507 | -| resolvekit | 0.793 | 4,055 | 0.039 | -| resolvekit_typed | 0.794 | 4,055 | 0.010 | +| resolvekit | 0.803 | 4,055 | 0.038 | +| resolvekit_typed | 0.801 | 4,055 | 0.012 | **geo_countries_multilingual** @@ -633,8 +633,8 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C | hdx_python_country | 0.565 | 2,140 | 0.084 | | pycountry | 0.143 | 2,140 | 0.002 | | rapidfuzz_dict | 0.370 | 2,140 | 0.495 | -| resolvekit | 0.632 | 2,140 | 0.027 | -| resolvekit_typed | 0.614 | 2,140 | 0.006 | +| resolvekit | 0.635 | 2,140 | 0.025 | +| resolvekit_typed | 0.614 | 2,140 | 0.007 | **no_match** @@ -672,7 +672,7 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C ### resolvekit on eval_geo -ECE: 0.043. Brier: 0.068. Reliability diagram data: +ECE: 0.030. Brier: 0.071. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -683,9 +683,9 @@ ECE: 0.043. Brier: 0.068. Reliability diagram data: | [0.4, 0.5) | 0 | 0.000 | 0.000 | | [0.5, 0.6) | 0 | 0.000 | 0.000 | | [0.6, 0.7) | 0 | 0.000 | 0.000 | -| [0.7, 0.8) | 4 | 0.778 | 0.500 | -| [0.8, 0.9) | 110 | 0.873 | 0.945 | -| [0.9, 1.0) | 130 | 0.912 | 0.923 | +| [0.7, 0.8) | 3 | 0.780 | 0.667 | +| [0.8, 0.9) | 118 | 0.873 | 0.915 | +| [0.9, 1.0) | 126 | 0.920 | 0.937 | ### resolvekit on geo_admin @@ -702,11 +702,11 @@ ECE: 0.098. Brier: 0.056. Reliability diagram data: | [0.6, 0.7) | 0 | 0.000 | 0.000 | | [0.7, 0.8) | 0 | 0.000 | 0.000 | | [0.8, 0.9) | 1,981 | 0.877 | 0.964 | -| [0.9, 1.0) | 95 | 0.912 | 0.600 | +| [0.9, 1.0) | 95 | 0.914 | 0.600 | ### resolvekit on geo_cities -ECE: 0.102. Brier: 0.030. Reliability diagram data: +ECE: 0.100. Brier: 0.031. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -719,11 +719,11 @@ ECE: 0.102. Brier: 0.030. Reliability diagram data: | [0.6, 0.7) | 0 | 0.000 | 0.000 | | [0.7, 0.8) | 1 | 0.700 | 0.000 | | [0.8, 0.9) | 944 | 0.877 | 0.987 | -| [0.9, 1.0) | 97 | 0.906 | 0.918 | +| [0.9, 1.0) | 98 | 0.906 | 0.908 | ### resolvekit on geo_countries_en -ECE: 0.075. Brier: 0.050. Reliability diagram data: +ECE: 0.063. Brier: 0.047. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -734,13 +734,13 @@ ECE: 0.075. Brier: 0.050. Reliability diagram data: | [0.4, 0.5) | 0 | 0.000 | 0.000 | | [0.5, 0.6) | 0 | 0.000 | 0.000 | | [0.6, 0.7) | 0 | 0.000 | 0.000 | -| [0.7, 0.8) | 295 | 0.766 | 0.929 | -| [0.8, 0.9) | 819 | 0.865 | 0.850 | -| [0.9, 1.0) | 2,133 | 0.908 | 0.994 | +| [0.7, 0.8) | 245 | 0.762 | 0.935 | +| [0.8, 0.9) | 803 | 0.863 | 0.857 | +| [0.9, 1.0) | 2,235 | 0.919 | 0.990 | ### resolvekit on geo_countries_multilingual -ECE: 0.097. Brier: 0.039. Reliability diagram data: +ECE: 0.072. Brier: 0.036. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -751,13 +751,13 @@ ECE: 0.097. Brier: 0.039. Reliability diagram data: | [0.4, 0.5) | 0 | 0.000 | 0.000 | | [0.5, 0.6) | 0 | 0.000 | 0.000 | | [0.6, 0.7) | 0 | 0.000 | 0.000 | -| [0.7, 0.8) | 38 | 0.758 | 0.658 | -| [0.8, 0.9) | 87 | 0.861 | 0.598 | -| [0.9, 1.0) | 1,248 | 0.908 | 0.993 | +| [0.7, 0.8) | 32 | 0.752 | 0.719 | +| [0.8, 0.9) | 168 | 0.877 | 0.798 | +| [0.9, 1.0) | 1,182 | 0.920 | 0.992 | ### resolvekit_typed on ambiguous -ECE: 0.095. Brier: 0.009. Reliability diagram data: +ECE: 0.088. Brier: 0.008. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -769,12 +769,12 @@ ECE: 0.095. Brier: 0.009. Reliability diagram data: | [0.5, 0.6) | 0 | 0.000 | 0.000 | | [0.6, 0.7) | 0 | 0.000 | 0.000 | | [0.7, 0.8) | 0 | 0.000 | 0.000 | -| [0.8, 0.9) | 7 | 0.882 | 1.000 | -| [0.9, 1.0) | 24 | 0.912 | 1.000 | +| [0.8, 0.9) | 8 | 0.883 | 1.000 | +| [0.9, 1.0) | 23 | 0.922 | 1.000 | ### resolvekit_typed on eval_geo -ECE: 0.073. Brier: 0.038. Reliability diagram data: +ECE: 0.070. Brier: 0.038. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -785,9 +785,9 @@ ECE: 0.073. Brier: 0.038. Reliability diagram data: | [0.4, 0.5) | 0 | 0.000 | 0.000 | | [0.5, 0.6) | 0 | 0.000 | 0.000 | | [0.6, 0.7) | 0 | 0.000 | 0.000 | -| [0.7, 0.8) | 5 | 0.776 | 0.800 | -| [0.8, 0.9) | 116 | 0.873 | 0.974 | -| [0.9, 1.0) | 155 | 0.914 | 0.968 | +| [0.7, 0.8) | 5 | 0.781 | 0.800 | +| [0.8, 0.9) | 125 | 0.874 | 0.976 | +| [0.9, 1.0) | 147 | 0.921 | 0.966 | ### resolvekit_typed on geo_admin @@ -825,7 +825,7 @@ ECE: 0.110. Brier: 0.023. Reliability diagram data: ### resolvekit_typed on geo_countries_en -ECE: 0.103. Brier: 0.025. Reliability diagram data: +ECE: 0.092. Brier: 0.025. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -836,13 +836,13 @@ ECE: 0.103. Brier: 0.025. Reliability diagram data: | [0.4, 0.5) | 0 | 0.000 | 0.000 | | [0.5, 0.6) | 0 | 0.000 | 0.000 | | [0.6, 0.7) | 0 | 0.000 | 0.000 | -| [0.7, 0.8) | 324 | 0.763 | 0.960 | -| [0.8, 0.9) | 721 | 0.865 | 0.983 | -| [0.9, 1.0) | 2,157 | 0.909 | 0.993 | +| [0.7, 0.8) | 276 | 0.759 | 0.953 | +| [0.8, 0.9) | 722 | 0.862 | 0.985 | +| [0.9, 1.0) | 2,245 | 0.920 | 0.989 | ### resolvekit_typed on geo_countries_multilingual -ECE: 0.089. Brier: 0.016. Reliability diagram data: +ECE: 0.077. Brier: 0.016. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -853,9 +853,9 @@ ECE: 0.089. Brier: 0.016. Reliability diagram data: | [0.4, 0.5) | 0 | 0.000 | 0.000 | | [0.5, 0.6) | 0 | 0.000 | 0.000 | | [0.6, 0.7) | 0 | 0.000 | 0.000 | -| [0.7, 0.8) | 32 | 0.754 | 0.812 | -| [0.8, 0.9) | 49 | 0.859 | 0.918 | -| [0.9, 1.0) | 1,231 | 0.908 | 0.998 | +| [0.7, 0.8) | 26 | 0.752 | 0.885 | +| [0.8, 0.9) | 136 | 0.881 | 0.949 | +| [0.9, 1.0) | 1,151 | 0.920 | 0.997 | ## Caveats diff --git a/tests/core/test_output_and_configure_validation.py b/tests/core/test_output_and_configure_validation.py index d1e640a..957e550 100644 --- a/tests/core/test_output_and_configure_validation.py +++ b/tests/core/test_output_and_configure_validation.py @@ -10,6 +10,8 @@ from __future__ import annotations +from pathlib import Path + import pytest import resolvekit @@ -257,7 +259,7 @@ def test_configure_chain_preserves_all_settings(self) -> None: resolvekit.configure(cache_dir="/tmp/test-rk-cache") assert get_default_to() == "iso3" assert get_on_missing() == "null" - assert str(get_cache_dir()) == "/tmp/test-rk-cache" + assert get_cache_dir() == Path("/tmp/test-rk-cache") def test_explicit_none_clears_default_to(self) -> None: """configure(default_to=None) explicitly clears the default output.""" @@ -319,7 +321,7 @@ def test_cache_dir_none_resets_to_default(self) -> None: from resolvekit.core.config import _default_cache_dir, get_cache_dir resolvekit.configure(cache_dir="/tmp/rk-test-custom") - assert str(get_cache_dir()) == "/tmp/rk-test-custom" + assert get_cache_dir() == Path("/tmp/rk-test-custom") resolvekit.configure(cache_dir=None) assert get_cache_dir() == _default_cache_dir() @@ -328,7 +330,7 @@ def test_omitting_cache_dir_preserves_custom(self) -> None: from resolvekit.core.config import get_cache_dir resolvekit.configure(cache_dir="/tmp/rk-test-preserve") - assert str(get_cache_dir()) == "/tmp/rk-test-preserve" + assert get_cache_dir() == Path("/tmp/rk-test-preserve") resolvekit.configure(on_missing="null") # omit cache_dir - assert str(get_cache_dir()) == "/tmp/rk-test-preserve" + assert get_cache_dir() == Path("/tmp/rk-test-preserve") diff --git a/tests/packs/geo/test_dotted_and_casing_integration.py b/tests/packs/geo/test_dotted_and_casing_integration.py index bea7050..28ef7d5 100644 --- a/tests/packs/geo/test_dotted_and_casing_integration.py +++ b/tests/packs/geo/test_dotted_and_casing_integration.py @@ -1,9 +1,9 @@ """Integration regression tests for dotted-abbreviation and mixed-case resolution. -Finding #1: 'U.S.A.' resolved to politicalParty/SocialistPartyUSA (geo +'U.S.A.' resolved to politicalParty/SocialistPartyUSA (geo suppressed by the punctuation-noise gate); 'U.K.' returned None. -Finding #11: 'fRaNcE' / 'CHIna' went ambiguous and 'SUDan' returned None +'fRaNcE' / 'CHIna' went ambiguous and 'SUDan' returned None because the AutoRouter dropped the geo pack for 50-74%-uppercase names. These exercise the full Resolver so the gate, routing, and scoring fixes are @@ -22,7 +22,7 @@ def resolver() -> Resolver: return Resolver.auto() -# Finding #1: dotted abbreviations resolve to the correct geo entity. +# Dotted abbreviations resolve to the correct geo entity. @pytest.mark.parametrize( "query,expected", [ @@ -37,16 +37,23 @@ def test_dotted_abbreviation_resolves_to_country(resolver, query, expected): assert result.candidates[0].entity_id == expected -def test_dotted_dc_resolves_to_geo_not_org(resolver): - """'D.C.' must resolve to a geo entity, never an org pack entity.""" +def test_dotted_dc_never_surfaces_an_org_entity(resolver): + """'D.C.' must never rank an org pack entity first.""" result = resolver.resolve("D.C.") assert result.candidates, "D.C. produced no candidates" top = result.candidates[0].entity_id assert not top.startswith(("org/", "politicalParty/")), top - assert top == "geoId/11001" -# Finding #1: the null-marker gate must remain intact. +@pytest.mark.requires_remote_data +def test_dotted_dc_resolves_to_washington_dc(resolver): + """With the admin tiers cached, 'D.C.' resolves to Washington, D.C.""" + result = resolver.resolve("D.C.") + assert result.candidates + assert result.candidates[0].entity_id == "geoId/11001" + + +# The null-marker gate must remain intact. @pytest.mark.parametrize("query", ["#N/A", "N/A", "--", "?", "N.A.", "NA", "NULL"]) def test_null_markers_still_blocked(resolver, query): result = resolver.resolve(query) @@ -56,7 +63,7 @@ def test_null_markers_still_blocked(resolver, query): ) -# Finding #11: mixed-case country names resolve like their standard casings. +# mixed-case country names resolve like their standard casings. @pytest.mark.parametrize( "query,expected", [ From 832f23543f6e313f5245a5f53fbc2cdce9cd4514 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 11 Jun 2026 21:31:02 +0200 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20approved=20API-consistency=20batch?= =?UTF-8?q?=20=E2=80=94=20numeric=20coercion,=20tuple=20results,=20accesso?= =?UTF-8?q?r=20on=5Ferror,=20alpha-3=20hints,=20error=20exports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scalar resolve()/resolve_id() coerce int/float through the shared bulk helper and raise the documented TypeError otherwise. ResolutionResult sequence fields are tuples (frozen contract now real). pandas/polars accessors expose on_error (default 'raise') and propagate validation errors un-garbled. ResolutionContext.country accepts alpha-3 via a static ISO table (no runtime pycountry). Eight commonly raised errors promoted to top-level __all__. configure() sentinel semantics unified across all parameters (omitted = unchanged, None = reset). Two Opus review passes: pycountry-at-query-time blocker fixed, auto_download None inconsistency fixed, exception-path split documented. Suite: 3787 passed. --- CHANGELOG.md | 21 +- docs/how-to/clean-a-dataframe-column.md | 39 +++ docs/how-to/convert-between-code-systems.md | 9 +- docs/reference/api.md | 36 +-- src/resolvekit/__init__.py | 18 +- src/resolvekit/_convenience.py | 21 +- src/resolvekit/_pandas_integration.py | 15 +- src/resolvekit/_polars_integration.py | 46 ++- src/resolvekit/core/api/bulk.py | 60 +++- src/resolvekit/core/api/cache.py | 22 +- src/resolvekit/core/api/code_lookup.py | 4 +- src/resolvekit/core/api/group_api.py | 2 +- src/resolvekit/core/api/query_prep.py | 2 +- src/resolvekit/core/api/resolver.py | 44 ++- src/resolvekit/core/byod/cache.py | 15 +- src/resolvekit/core/config.py | 12 +- src/resolvekit/core/engine/_stages.py | 10 +- src/resolvekit/core/engine/decision.py | 26 +- src/resolvekit/core/engine/enrichment.py | 12 +- src/resolvekit/core/engine/interfaces.py | 2 +- src/resolvekit/core/engine/multi_runner.py | 10 +- src/resolvekit/core/engine/runner.py | 6 +- src/resolvekit/core/engine/tier_utils.py | 7 +- src/resolvekit/core/errors.py | 17 +- src/resolvekit/core/explain/result_html.py | 7 +- src/resolvekit/core/explain/scorecard.py | 3 +- .../core/model/entity_attributes.py | 18 +- src/resolvekit/core/model/name_grammar.py | 3 +- src/resolvekit/core/model/query.py | 23 +- src/resolvekit/core/model/result.py | 14 +- src/resolvekit/core/parse/link.py | 2 +- src/resolvekit/core/util/iso_codes.py | 265 ++++++++++++++++++ .../packs/geo/constraints/containment.py | 9 +- .../org/constraints/country_relevance.py | 6 +- src/resolvekit/packs/org/decision.py | 4 +- tests/api/test_cache_no_mutation_leak.py | 89 ++++-- tests/api/test_group_preference_tiebreak.py | 4 +- tests/core/test_multi_runner.py | 2 +- .../test_output_and_configure_validation.py | 22 +- tests/core/test_query.py | 44 +++ tests/core/test_resolver_api.py | 47 ++-- tests/core/test_result.py | 2 +- tests/packs/geo/test_geo_decision.py | 4 +- tests/packs/org/test_country_relevance.py | 96 +++++++ tests/packs/test_geo_constraints.py | 78 ++++++ tests/test_numeric_coercion.py | 257 +++++++++++++++++ tests/test_pandas_accessor.py | 64 +++++ tests/test_polars_accessor.py | 74 +++++ tests/test_public_api.py | 10 +- tests/test_resolve_id.py | 91 +++++- 50 files changed, 1442 insertions(+), 252 deletions(-) create mode 100644 src/resolvekit/core/util/iso_codes.py create mode 100644 tests/test_numeric_coercion.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a58ef..0749819 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,9 +43,28 @@ types that `entity_types=` accepts. BYOD labels containing NFKC-compatibility characters (`™`, `№`) now round-trip; existing BYOD disk caches are rebuilt automatically. +**API consistency.** Scalar `resolve()`/`resolve_id()` now coerce numeric +input the same way `bulk()` does (`resolve_id(840)` → `country/USA`; integral +floats like `840.0` from numeric dataframe columns coerce cleanly in both +paths), and non-string types raise the `TypeError` the docstring always +promised — `bool` included. `ResolutionContext(country=...)` accepts ISO +alpha-3 alongside alpha-2 (`"USA"` and `"US"` behave identically). The pandas +and polars accessors no longer convert caller mistakes into all-`None` +columns: parameter-validation errors propagate (polars previously garbled +them through `map_batches`), and `on_error` is exposed with the same +`"raise"` default as `bulk()`. `ResolutionResult.reasons`, `.candidates`, +and `.refinement_hints` are tuples now — the documented frozen contract is +real, not advisory. Commonly raised errors (`UnknownCodeSystemError`, +`UnknownOutputError`, `UnknownDomainError`, `OutputMissingError`, +`DataPackNotAvailableError`, `CrosswalkError`, `ExplainNotAvailableError`, +`NoModulesInstalledError`) are importable from top-level `resolvekit`; +`resolvekit.errors` remains the canonical home. + **Docs.** Corrected the `snap()` candidate guidance and the `UnknownOutputError`/`UnknownCodeSystemError` reference entries; refreshed -stale confidence figures in the tutorials. +stale confidence figures in the tutorials; documented that code +auto-detection is case-sensitive by design while `from_system` is +case-insensitive. ## 0.1.2 (2026-06-11) diff --git a/docs/how-to/clean-a-dataframe-column.md b/docs/how-to/clean-a-dataframe-column.md index ac86a9c..82deeab 100644 --- a/docs/how-to/clean-a-dataframe-column.md +++ b/docs/how-to/clean-a-dataframe-column.md @@ -101,6 +101,45 @@ detail = result.unnest() `result.failures` gives a sub-result containing only the non-resolved rows, so you can inspect or re-attempt just the misses. +## Use the Series accessor + +Install the extras (`resolvekit[pandas]` or `resolvekit[polars]`) to get a `.resolvekit` accessor directly on Series and Expr objects: + +```python +import pandas as pd +import resolvekit.pandas # registers the accessor + +df["iso3"] = df["country"].resolvekit.resolve(to="iso3") +``` + +```python +import polars as pl +import resolvekit.polars # registers the namespace + +df = df.with_columns( + pl.col("country").resolvekit.resolve(to="iso3").alias("iso3") +) +``` + +The accessor accepts the same parameters as `rk.bulk()`. By default, parameter mistakes (unknown `to=`, bad `domain=`, unknown `from_system=`) raise immediately rather than silently producing all-`None` output. + +### Control per-row errors with `on_error` + +`on_error` governs what happens when an individual row's resolution raises an unexpected error at runtime: + +| value | behaviour | +|-------|-----------| +| `"raise"` (default) | propagate the exception | +| `"null"` | return `None` for that row | +| `"keep"` | return the original input string | + +```python +# Silently drop failed rows instead of raising +df["iso3"] = df["country"].resolvekit.resolve(to="iso3", on_error="null") +``` + +Note: `not_found` (for rows that simply don't match any entity) is independent of `on_error` (for rows that hit an unexpected runtime error). The defaults — `not_found="null"`, `on_error="raise"` — match `rk.bulk()`. + ## Input code systems If your column already contains ISO 2-letter codes, skip fuzzy matching entirely: diff --git a/docs/how-to/convert-between-code-systems.md b/docs/how-to/convert-between-code-systems.md index ebbca81..a7adbc3 100644 --- a/docs/how-to/convert-between-code-systems.md +++ b/docs/how-to/convert-between-code-systems.md @@ -32,7 +32,7 @@ rk.resolve("Germany", to="flag") # "🇩🇪" rk.resolve("Tanzania", to="dcid") # "country/TZA" ``` -The `to` parameter accepts any code system the loaded packs carry — the same open-ended set as `from_system`. Common values: `"iso3"`, `"iso2"`, `"name"`, `"flag"`, `"aliases"`, `"dcid"`, `"iso_numeric"`, `"wikidata"`. Pass an unknown one and it raises `UnknownCodeSystemError`. +The `to` parameter accepts any code system the loaded packs carry — the same open-ended set as `from_system`. Common values: `"iso3"`, `"iso2"`, `"name"`, `"flag"`, `"aliases"`, `"dcid"`, `"iso_numeric"`, `"wikidata"`. Pass an unknown one and it raises `UnknownCodeSystemError`. (The same typo in `configure(default_to=...)` or `rk.to(...)` raises the sibling `UnknownOutputError` instead — those paths validate the whole output spec, not just a code system. Catch `ResolverError` to cover both.) `"aliases"` returns a list of multilingual names: @@ -48,6 +48,13 @@ By default resolvekit auto-detects whether the input is a name, ISO code, or other identifier. When your input column contains codes that could be ambiguous (two-letter codes especially), use `from_system` to pin the interpretation. +Auto-detection is deliberately case-sensitive for codes: `"US"` resolves as an +ISO code, but `"us"` does not — lowercase two- and three-letter strings are +indistinguishable from ordinary words (`"in"`, `"it"`, `"no"`), so treating +them as codes would mis-resolve real text. Once you declare intent with +`from_system`, case no longer matters: `from_system="iso2"` accepts `"us"`, +`"Us"`, and `"US"` alike. + ```python rk.resolve("DE", from_system="iso2", to="iso3") # "DEU" rk.resolve("DE", from_system="iso2", to="name") # "Germany" diff --git a/docs/reference/api.md b/docs/reference/api.md index a2c620b..269ea47 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -633,9 +633,9 @@ Frozen Pydantic model. Returned by `resolve()` (when `to` is not set) and by the | `entity` | [`EntityRecord`](#entityrecord) `| None` | Populated when `include_entity=True`. | | `pack_id` | `str | None` | Domain pack that produced the result (e.g. `"geo"`). | | `match_tier` | `str | None` | How the match was found: `"exact_code"`, `"exact_name"`, `"acronym"`, `"fts"`, `"fuzzy"`, or `"fallback"`. | -| `candidates` | `list[CandidateSummary]` | Top candidates (up to 10), including the winner on a resolved result. | -| `reasons` | `list[str]` | Reason codes explaining the outcome (e.g. `["exact_code_match"]`). Currently always a single-element list. | -| `refinement_hints` | `list[str]` | Suggestions for a retry that would likely succeed (e.g. `["entity_types"]`). | +| `candidates` | `tuple[CandidateSummary, ...]` | Top candidates (up to 10), including the winner on a resolved result. | +| `reasons` | `tuple[str, ...]` | Reason codes explaining the outcome (e.g. `("exact_code_match",)`). Currently always a single-element tuple. | +| `refinement_hints` | `tuple[str, ...]` | Suggestions for a retry that would likely succeed (e.g. `("entity_types",)`). | | `query_text` | `str | None` | The original input text as seen by the resolver. | **Convenience properties** (delegate to `entity`; return `None` when `entity` is not populated) @@ -654,7 +654,7 @@ Frozen Pydantic model. Returned by `resolve()` (when `to` is not set) and by the | Method | Returns | Meaning | |---|---|---| -| `.top_candidates(n=3)` | `list[CandidateSummary]` | Top *n* candidates by confidence. | +| `.top_candidates(n=3)` | `tuple[CandidateSummary, ...]` | Top *n* candidates by confidence. | | `.explain(verbosity="standard")` | `Scorecard` | Re-run with full tracing. Verbosity: `"minimal"`, `"standard"`, `"full"`. Raises `ExplainNotAvailableError` on detached results. | | `.to_dict()` | `dict` | JSON-serializable dict (delegates to `model_dump()`). | | `.to_json(indent=None)` | `str` | JSON string. | @@ -672,7 +672,7 @@ Frozen Pydantic model. Returned by `resolve()` (when `to` is not set) and by the >>> r.is_resolved True >>> r.reasons -[] +(,) ``` **Explain** @@ -820,7 +820,7 @@ from resolvekit import ResolutionContext | `as_of` | `date | None` | Resolve against entities valid at this date. | | `entity_types` | `frozenset[str] | None` | Restrict to specific entity types (e.g. `{"geo.country"}`). Must be a collection — a bare string raises `ValueError`. | | `parent_ids` | `list[str] | None` | Restrict to entities contained within these parent IDs. | -| `country` | `str | None` | ISO 3166-1 alpha-2 country code hint. Max 2 characters. | +| `country` | `str | None` | ISO 3166-1 country code hint — alpha-2 (`"US"`) or alpha-3 (`"USA"`). | | `languages` | `list[str] | None` | Preferred language codes for name matching. | | `attributes` | `dict` | Escape hatch for domain-specific hints. | @@ -1184,7 +1184,7 @@ Pass a custom blocklist to `Resolver.auto(sentinel_blocklist=...)`, or disable b ## Errors { #errors } -All public errors are importable from `resolvekit` or the dedicated `resolvekit.errors` namespace. The resolution errors most callers need: +All public errors are importable from `resolvekit` or the dedicated `resolvekit.errors` namespace. The errors most callers need: ```python from resolvekit import ( @@ -1193,14 +1193,18 @@ from resolvekit import ( AmbiguousResolutionError, EntityNotFoundError, GroupNotFoundError, + CrosswalkError, + DataPackNotAvailableError, + ExplainNotAvailableError, + NoModulesInstalledError, + OutputMissingError, + UnknownCodeSystemError, + UnknownDomainError, + UnknownOutputError, ) ``` -Output-related errors introduced with configurable default output: - -```python -from resolvekit.errors import UnknownOutputError, OutputMissingError -``` +All of the above are also available via `resolvekit.errors`. The full catalogue of datapack and registry errors (e.g. `DataPackRuntimeVersionError`, `ModuleConflictError`) lives exclusively in `resolvekit.errors`. ### `ResolverError` { #resolvererror } @@ -1258,7 +1262,7 @@ except EntityNotFoundError as e: Raised at configuration or compile time when `default_to` contains a malformed token (including a malformed `name:` grammar segment in a per-call `to=`) or names a code system that no loaded pack carries. A per-call `to=` naming an unknown code system raises [`UnknownCodeSystemError`](#unknowncodesystemerror) instead. ```python -from resolvekit.errors import UnknownOutputError +from resolvekit import UnknownOutputError # or: from resolvekit.errors import UnknownOutputError ``` | Attribute | Type | Meaning | @@ -1273,7 +1277,7 @@ Carries `.hint` with difflib did-you-mean suggestions. Raised when a per-call `to=` (or `EntityRecord.to(system)`) names a code system that no loaded pack carries, and by `Resolver.members_of` when the requested `as_codes` is not loaded. ```python -from resolvekit.errors import UnknownCodeSystemError +from resolvekit import UnknownCodeSystemError # or: from resolvekit.errors import UnknownCodeSystemError ``` | Attribute | Type | Meaning | @@ -1288,7 +1292,7 @@ Carries `.hint` with difflib did-you-mean suggestions. Raised at runtime when a resolved entity (and the full fallback chain) has no value for the requested output, under `on_missing="raise"` (or `on_missing="auto"` for scalar `resolve()`/`snap()`). ```python -from resolvekit.errors import OutputMissingError +from resolvekit import OutputMissingError # or: from resolvekit.errors import OutputMissingError ``` | Attribute | Type | Meaning | @@ -1306,7 +1310,7 @@ Carries `.hint` listing the available codes. Raised by [`bulk`](#bulk) when a [`Crosswalk`](#crosswalk) built with `strict=True` (the default) maps one or more values to entity IDs that no loaded pack carries — typically a crosswalk saved before a data rebuild that changed IDs. ```python -from resolvekit.errors import CrosswalkError +from resolvekit import CrosswalkError # or: from resolvekit.errors import CrosswalkError ``` | Attribute | Type | Meaning | diff --git a/src/resolvekit/__init__.py b/src/resolvekit/__init__.py index aa727bd..1333155 100644 --- a/src/resolvekit/__init__.py +++ b/src/resolvekit/__init__.py @@ -68,11 +68,18 @@ from resolvekit.core.byod.result import AugmentResult from resolvekit.core.errors import ( AmbiguousResolutionError, + CrosswalkError, + DataPackNotAvailableError, EntityNotFoundError, GroupNotFoundError, + NoModulesInstalledError, + OutputMissingError, ResolutionError, + UnknownCodeSystemError, + UnknownDomainError, + UnknownOutputError, ) -from resolvekit.core.errors_base import ResolverError +from resolvekit.core.errors_base import ExplainNotAvailableError, ResolverError from resolvekit.core.model import ( BulkResult, EntityRecord, @@ -86,7 +93,6 @@ from resolvekit.core.parse import ParseResult as ParseResult from resolvekit.core.util.sentinel import SentinelBlocklist as SentinelBlocklist -# Exactly 28 public names (not counting __version__). # ``default``, ``download_all``, ``clear_cache``, and ``reset`` are importable # but excluded from star-imports. __all__ = [ @@ -95,10 +101,15 @@ "AugmentResult", "BulkResult", "Crosswalk", + "CrosswalkError", + "DataPackNotAvailableError", "DroppedSpan", "EntityNotFoundError", "EntityRecord", + "ExplainNotAvailableError", "GroupNotFoundError", + "NoModulesInstalledError", + "OutputMissingError", "ParseResult", "ParsedEntity", "ResolutionContext", @@ -107,6 +118,9 @@ "ResolutionStatus", "Resolver", "ResolverError", + "UnknownCodeSystemError", + "UnknownDomainError", + "UnknownOutputError", "bulk", "configure", "download", diff --git a/src/resolvekit/_convenience.py b/src/resolvekit/_convenience.py index 6b13149..122b6a2 100644 --- a/src/resolvekit/_convenience.py +++ b/src/resolvekit/_convenience.py @@ -93,7 +93,7 @@ def default() -> Resolver: def configure( *, - auto_download: bool | None = None, + auto_download: bool | None | object = _CONFIG_UNSET, cache_dir: str | Path | None | object = _CONFIG_UNSET, default_to: str | list[str] | None | object = _CONFIG_UNSET, on_missing: Literal["raise", "null", "auto"] | object = _CONFIG_UNSET, @@ -108,7 +108,8 @@ def configure( Args: auto_download: If True, remote packs are downloaded automatically - when needed. ``None`` leaves the current setting unchanged. + when needed. ``None`` resets to the default (disabled). + Omitting leaves the current setting unchanged. cache_dir: Custom cache directory for remote data packs. ``None`` resets to the platform default (removes any custom path). Omitting leaves the current setting unchanged. @@ -171,7 +172,7 @@ def configure( ) _configure_core( - auto_download=auto_download, + auto_download=auto_download, # type: ignore[arg-type] # ty: ignore[invalid-argument-type] cache_dir=cache_dir, # type: ignore[arg-type] # ty: ignore[invalid-argument-type] default_to=default_to, # type: ignore[arg-type] # ty: ignore[invalid-argument-type] on_missing=on_missing, @@ -237,7 +238,11 @@ def resolve( ``Resolver.resolve()`` (``include_entity=False`` default) for pipeline use. Args: - text: The text or code to resolve. + text: The text or code to resolve. ``str`` is used as-is. + ``int`` and ``float`` are coerced to string (``840`` → ``"840"``; + ``840.0`` → ``"840"``), matching the behaviour of :func:`bulk` + on numeric columns. ``None`` returns a NO_MATCH result silently. + ``bool`` and all other types raise ``TypeError``. to: Target representation pivot (e.g. ``"iso3"``, ``"name"``). Omit (default) to use the configured ``default_to`` spec, or returns a raw :class:`ResolutionResult` when no default is set. @@ -283,7 +288,11 @@ def resolve_id( """Resolve text and return entity_id or None. Args: - text: Text to resolve. + text: Text to resolve. ``str`` is used as-is. ``int`` and + ``float`` are coerced to string (``840`` → ``"840"``; + ``840.0`` → ``"840"``), matching the behaviour of :func:`bulk` + on numeric columns. ``None`` returns ``None`` silently. + ``bool`` and all other types raise ``TypeError``. on_ambiguous: Behavior when multiple entities match. - ``"raise"`` (default): raises :class:`AmbiguousResolutionError`. - ``"null"``: returns ``None`` on ambiguity. @@ -298,6 +307,8 @@ def resolve_id( Entity ID string, or None if no match. Raises: + TypeError: If *text* is a ``bool``, ``bytes``, ``list``, or any + other unsupported type. AmbiguousResolutionError: If ``on_ambiguous="raise"`` and the resolution is ambiguous. """ diff --git a/src/resolvekit/_pandas_integration.py b/src/resolvekit/_pandas_integration.py index 8f60de2..8e55337 100644 --- a/src/resolvekit/_pandas_integration.py +++ b/src/resolvekit/_pandas_integration.py @@ -52,6 +52,7 @@ def resolve( domain: str | list[str] | None = None, from_system: str | None = None, not_found: str = "null", + on_error: str = "raise", on_ambiguous: str = "null", ) -> pd.Series: """Resolve the Series and pivot to a code or attribute. @@ -64,6 +65,11 @@ def resolve( domain: Optional domain filter. from_system: Force code-system for lookup. not_found: ``"null"`` (default), ``"raise"``, or sentinel string. + on_error: ``"raise"`` (default), ``"null"``, or ``"keep"``. + Controls what happens when a per-row resolution raises an + unexpected error. ``"raise"`` propagates the exception; + ``"null"`` silently returns ``None``; ``"keep"`` returns the + original input string. on_ambiguous: ``"null"`` (default), ``"raise"``, or ``"best"``. Returns: @@ -74,6 +80,7 @@ def resolve( domain=domain, from_system=from_system, not_found=not_found, + on_error=on_error, on_ambiguous=on_ambiguous, ) @@ -85,6 +92,7 @@ def bulk( domain: str | list[str] | None = None, from_system: str | None = None, not_found: str = "null", + on_error: str = "raise", on_ambiguous: str = "null", ) -> object: """Run bulk resolution on the Series. @@ -99,6 +107,11 @@ def bulk( domain: Optional domain filter. from_system: Force code-system for lookup. not_found: ``"null"`` (default), ``"raise"``, or sentinel. + on_error: ``"raise"`` (default), ``"null"``, or ``"keep"``. + Controls what happens when a per-row resolution raises an + unexpected error. ``"raise"`` propagates the exception; + ``"null"`` silently returns ``None``; ``"keep"`` returns the + original input string. on_ambiguous: ``"null"`` (default), ``"raise"``, or ``"best"``. Returns: @@ -118,7 +131,7 @@ def bulk( context=None, from_system=from_system, not_found=not_found, - on_error="null", + on_error=on_error, on_ambiguous=on_ambiguous, ) diff --git a/src/resolvekit/_polars_integration.py b/src/resolvekit/_polars_integration.py index d4174ea..07287ae 100644 --- a/src/resolvekit/_polars_integration.py +++ b/src/resolvekit/_polars_integration.py @@ -55,6 +55,7 @@ def resolve( domain: str | list[str] | None = None, from_system: str | None = None, not_found: str = "null", + on_error: str = "raise", on_ambiguous: str = "null", ) -> pl.Expr: """Resolve column values and pivot to a code or attribute. @@ -68,16 +69,52 @@ def resolve( domain: Optional domain filter. from_system: Force code-system for lookup. not_found: ``"null"`` (default), ``"raise"``, or sentinel. + on_error: ``"raise"`` (default), ``"null"``, or ``"keep"``. + Controls what happens when a per-row resolution raises an + unexpected error. ``"raise"`` propagates the exception; + ``"null"`` silently returns ``None``; ``"keep"`` returns the + original input string. on_ambiguous: ``"null"`` (default), ``"raise"``, or ``"best"``. Returns: Polars ``Expr`` of pivot values. """ from resolvekit._convenience import _get_default + from resolvekit.core.api._pivot import validate_scalar_pivot from resolvekit.core.api.bulk import _bulk_dispatch + from resolvekit.core.api.loading.paths import _normalize_domain + from resolvekit.core.errors import UnknownDomainError + + # Validate on_error / on_ambiguous eagerly — these are caller mistakes + # that must raise directly, not be swallowed inside map_batches. + if on_error not in {"raise", "null", "keep"}: + raise ValueError( + f"on_error={on_error!r} is not valid; " + "expected one of 'raise', 'null', 'keep'" + ) + if on_ambiguous not in {"raise", "null", "best"}: + raise ValueError( + f"on_ambiguous={on_ambiguous!r} is not valid; " + "expected one of 'raise', 'null', 'best'" + ) resolver = _get_default() + # Validate to= eagerly so UnknownCodeSystemError propagates directly + # rather than being mangled by polars's map_batches exception reconstruction. + validate_scalar_pivot(to, available_code_systems=resolver.code_systems()) + + # Validate domain eagerly so UnknownDomainError propagates directly + # rather than being mangled by polars's map_batches exception reconstruction. + if domain is not None: + norm_domain = _normalize_domain(domain) + if norm_domain is not None: + available = resolver._runner.available_packs + if available: + unknown = sorted(norm_domain - available) + if unknown: + raise UnknownDomainError(unknown, sorted(available)) + def _apply(series: pl.Series) -> pl.Series: result = _bulk_dispatch( resolver=resolver, @@ -88,12 +125,13 @@ def _apply(series: pl.Series) -> pl.Series: context=None, from_system=from_system, not_found=not_found, - on_error="null", + on_error=on_error, on_ambiguous=on_ambiguous, ) - if isinstance(result, pl.Series): - return result - return pl.Series(values=list(result)) + vals = ( + result.to_list() if isinstance(result, pl.Series) else list(result) + ) + return pl.Series(values=vals, dtype=pl.String) return self._expr.map_batches(_apply, return_dtype=pl.String) diff --git a/src/resolvekit/core/api/bulk.py b/src/resolvekit/core/api/bulk.py index c7ac835..8a56054 100644 --- a/src/resolvekit/core/api/bulk.py +++ b/src/resolvekit/core/api/bulk.py @@ -11,6 +11,7 @@ from __future__ import annotations import difflib +import math import warnings import weakref from typing import Any, Literal @@ -49,6 +50,35 @@ def _closest_match(value: str, choices: tuple[str, ...]) -> str | None: return matches[0] if matches else None +def _numeric_to_str(v: int | float) -> str: + """Coerce an ``int`` or ``float`` to its canonical string form. + + Integral floats (``840.0``) produce the same string as the equivalent + integer (``"840"``), matching the numeric codes stored in data packs. + Non-integral floats (``840.5``) are left to plain ``str()`` — they will + not match any code, but the string is well-defined. + + This is the shared coercion kernel used by both the scalar resolve path + and ``_flatten_input`` so the two can never diverge. + """ + if isinstance(v, float) and math.isfinite(v) and v == int(v): + # Integral float — strip the decimal part. + return str(int(v)) + return str(v) + + +def _coerce_item_to_str(v: object) -> str: + """Coerce a non-null collection element to ``str``. + + ``int`` and ``float`` values are passed through ``_numeric_to_str`` so + that integral floats (``840.0``) map to ``"840"`` rather than + ``"840.0"``. All other types fall back to ``str()``. + """ + if isinstance(v, (int, float)) and not isinstance(v, bool): + return _numeric_to_str(v) # type: ignore[arg-type] + return str(v) + + # --------------------------------------------------------------------------- # Module-level sentinels for the crosswalk short-circuit # --------------------------------------------------------------------------- @@ -57,7 +87,7 @@ def _closest_match(value: str, choices: tuple[str, ...]) -> str | None: # _assemble_output can bypass _apply_not_found unconditionally. _IGNORE_RESULT: ResolutionResult = ResolutionResult( status=ResolutionStatus.NO_MATCH, - reasons=[ReasonCode.SENTINEL_BLOCKED], + reasons=(ReasonCode.SENTINEL_BLOCKED,), ) # --------------------------------------------------------------------------- @@ -206,7 +236,7 @@ def _apply_not_found( if not_found == "raise": raise ResolutionError( status=result.status, - candidates=[], + candidates=(), message=f"no match for {original!r}", ) if not_found == "null": @@ -283,25 +313,27 @@ def _flatten_input( orig_name = raw.name # Coerce to object before map so typed Series (Int64, categorical) don't reject "" items: list[str | None] = [ - None if pd.isna(v) else str(v) for v in raw.astype(object) + None if pd.isna(v) else _coerce_item_to_str(v) for v in raw.astype(object) ] elif kind == "polars": orig_polars_name = raw.name - items = [None if v is None else str(v) for v in raw.to_list()] + items = [None if v is None else _coerce_item_to_str(v) for v in raw.to_list()] elif kind == "numpy": import numpy as np items = [ - None if (v is None or (isinstance(v, float) and np.isnan(v))) else str(v) + None + if (v is None or (isinstance(v, float) and np.isnan(v))) + else _coerce_item_to_str(v) for v in raw.tolist() ] elif kind == "dict": orig_keys = list(raw.keys()) - items = [None if v is None else str(v) for v in raw.values()] + items = [None if v is None else _coerce_item_to_str(v) for v in raw.values()] elif kind == "tuple": - items = [None if v is None else str(v) for v in raw] + items = [None if v is None else _coerce_item_to_str(v) for v in raw] else: # list - items = [None if v is None else str(v) for v in raw] + items = [None if v is None else _coerce_item_to_str(v) for v in raw] return items, orig_index, orig_name, orig_polars_name, orig_keys @@ -359,7 +391,7 @@ def _resolve_uniques( unique_results[i] = ResolutionResult( status=ResolutionStatus.NO_MATCH, query_text=u, - reasons=[ReasonCode.SENTINEL_BLOCKED], + reasons=(ReasonCode.SENTINEL_BLOCKED,), ) continue # Entity found — synthetic RESOLVED result with entity attached. @@ -368,7 +400,7 @@ def _resolve_uniques( entity_id=eid, entity=entity, query_text=u, - reasons=[ReasonCode.EXACT_CODE_MATCH], + reasons=(ReasonCode.EXACT_CODE_MATCH,), ) # Fail fast on unknown ids when strict mode is active. @@ -405,12 +437,12 @@ def _resolve_uniques( if on_error == "null": r = ResolutionResult( status=ResolutionStatus.ERROR, - reasons=[ReasonCode.INTERNAL_ERROR], + reasons=(ReasonCode.INTERNAL_ERROR,), ) else: # keep — pass through original input string as query_text r = ResolutionResult( status=ResolutionStatus.NO_MATCH, - reasons=[ReasonCode.INTERNAL_ERROR], + reasons=(ReasonCode.INTERNAL_ERROR,), query_text=u, ) resolved_subset.append(r) @@ -439,7 +471,7 @@ def _resolve_uniques( raise _batch_sentinel = ResolutionResult( status=ResolutionStatus.ERROR, - reasons=[ReasonCode.INTERNAL_ERROR], + reasons=(ReasonCode.INTERNAL_ERROR,), ) resolved_subset = [_batch_sentinel] * len(to_resolve) else: @@ -485,7 +517,7 @@ def _assemble_output( # Shared sentinel for null-input rows — allocated once, reused for all nulls. _null_sentinel = ResolutionResult( status=ResolutionStatus.NO_MATCH, - reasons=[ReasonCode.INVALID_QUERY], + reasons=(ReasonCode.INVALID_QUERY,), ) # Whether pivoting is active on this call (either explicit to= or spec path). diff --git a/src/resolvekit/core/api/cache.py b/src/resolvekit/core/api/cache.py index f32aac4..d3a2a68 100644 --- a/src/resolvekit/core/api/cache.py +++ b/src/resolvekit/core/api/cache.py @@ -48,25 +48,15 @@ class CacheInfo(NamedTuple): currsize: int -# ResolutionResult fields that are mutable containers and therefore must not -# be shared between a cached entry and the result handed back to a caller. -# In-place mutation (e.g. ``result.reasons.append(...)``) would otherwise -# poison every subsequent cache hit for the same query. -_MUTABLE_LIST_FIELDS = ("candidates", "reasons", "refinement_hints") - - def _detach_mutables(result: ResolutionResult) -> ResolutionResult: - """Return a shallow copy of *result* with fresh mutable list fields. + """Return a shallow copy of *result*, preserving the ``_explainer`` back-reference. - The query cache returns the same instance on every hit, and pydantic's - ``model_copy`` is shallow, so list fields would otherwise be shared with - the cached entry. We rebuild those lists (the elements are frozen models / - enums, so copying the containers alone is sufficient) and preserve the - ``_explainer`` back-reference that ``model_copy`` may drop. + ``candidates``, ``reasons``, and ``refinement_hints`` are now tuple-typed + (immutable), so no container copying is required. ``model_copy`` is still + called to give each caller a distinct identity, and the ``_explainer`` + weakref is re-attached because ``model_copy`` does not carry private attrs. """ - copy = result.model_copy( - update={field: list(getattr(result, field)) for field in _MUTABLE_LIST_FIELDS} - ) + copy = result.model_copy() copy._explainer = result._explainer return copy diff --git a/src/resolvekit/core/api/code_lookup.py b/src/resolvekit/core/api/code_lookup.py index c6f89ef..a3a0c5d 100644 --- a/src/resolvekit/core/api/code_lookup.py +++ b/src/resolvekit/core/api/code_lookup.py @@ -119,7 +119,7 @@ def make_code_resolved_result( status=ResolutionStatus.RESOLVED, entity_id=entity_id, entity=entity, - reasons=[ReasonCode.EXACT_CODE_MATCH], + reasons=(ReasonCode.EXACT_CODE_MATCH,), query_text=value, ) result._explainer = explainer_ref @@ -187,7 +187,7 @@ def resolve_or_lookup( if not entity_ids: return ResolutionResult( status=ResolutionStatus.NO_MATCH, - reasons=[ReasonCode.NO_CANDIDATES], + reasons=(ReasonCode.NO_CANDIDATES,), query_text=value, ) if len(entity_ids) > 1: diff --git a/src/resolvekit/core/api/group_api.py b/src/resolvekit/core/api/group_api.py index 20eb76b..4094d0b 100644 --- a/src/resolvekit/core/api/group_api.py +++ b/src/resolvekit/core/api/group_api.py @@ -210,7 +210,7 @@ def apply_group_preference_tiebreak( # match_tier omitted: GROUP_PREFERENCE_TIEBREAK is a decision-level # signal, not a match-level signal (e.g. FUZZY). "match_tier": None, - "reasons": [ReasonCode.GROUP_PREFERENCE_TIEBREAK], + "reasons": (ReasonCode.GROUP_PREFERENCE_TIEBREAK,), } ) diff --git a/src/resolvekit/core/api/query_prep.py b/src/resolvekit/core/api/query_prep.py index 2b2b9d3..cc001ef 100644 --- a/src/resolvekit/core/api/query_prep.py +++ b/src/resolvekit/core/api/query_prep.py @@ -119,5 +119,5 @@ def invalid_query_result( """Return a stable result for empty / whitespace-only / non-string queries.""" return ResolutionResult( status=ResolutionStatus.NO_MATCH, - reasons=[reason], + reasons=(reason,), ) diff --git a/src/resolvekit/core/api/resolver.py b/src/resolvekit/core/api/resolver.py index 4d33804..c8e4fb1 100644 --- a/src/resolvekit/core/api/resolver.py +++ b/src/resolvekit/core/api/resolver.py @@ -1545,7 +1545,7 @@ def _resolve_inner( ): return ResolutionResult( status=ResolutionStatus.NO_MATCH, - reasons=[ReasonCode.SENTINEL_BLOCKED], + reasons=(ReasonCode.SENTINEL_BLOCKED,), query_text=text, ) ref: weakref.ref[Explainer] = ( @@ -1641,7 +1641,12 @@ def resolve( """Resolve a single text string, optionally pivoting to a code or attribute. Args: - text: The text or code to resolve. + text: The text or code to resolve. ``str`` is used as-is. + ``int`` and ``float`` are coerced to string (``840`` → + ``"840"``; ``840.0`` → ``"840"``), matching the behaviour of + :meth:`bulk` on numeric columns. ``None`` returns a + NO_MATCH result silently. ``bool`` and all other types raise + ``TypeError``. to: Target representation. ``UNSET`` (default) uses the resolver's configured ``default_to`` spec when set, or returns a raw :class:`ResolutionResult` when no spec is configured. @@ -1679,7 +1684,9 @@ def resolve( Raises: ValueError: If ``as_result=True`` combined with an explicit ``to=`` (other than ``UNSET``/``None``), or if ``timeout <= 0``. - TypeError: If *text* is not a ``str`` (with ``.hint`` to ``bulk()``). + TypeError: If *text* is a ``bool``, ``bytes``, ``list``, ``tuple``, + or any other unsupported type. ``int`` and ``float`` are + accepted (coerced); ``None`` is accepted (soft NO_MATCH). AmbiguousResolutionError: When pivoting and the result is ambiguous. OutputMissingError: When a spec is active and the entity lacks the requested output and ``on_missing="raise"`` (or ``"auto"`` scalar). @@ -1702,9 +1709,26 @@ def resolve( ) setattr(err, "hint", "rk.bulk(values=[...])") # noqa: B010 raise err - # All other non-strings (None, bytes, int, float, empty collections) - # soft-return NO_MATCH — resolve_id() and bulk() depend on this. - return self._invalid_query_result(ReasonCode.INVALID_INPUT_TYPE) + # None → silent NO_MATCH (unchanged). + if text is None: + return self._invalid_query_result(ReasonCode.INVALID_INPUT_TYPE) + # bool is an int subclass — reject before the int check to avoid + # True→"1" / False→"0" mapping to real entities. + if isinstance(text, bool): + raise TypeError( + f"resolve() text must be a str, int, or float; got {type(text).__name__!r}" + ) + # int / float → coerce to canonical string via the shared helper so + # scalar and bulk can never diverge. + if isinstance(text, (int, float)): + from resolvekit.core.api.bulk import _numeric_to_str + + text = _numeric_to_str(text) # type: ignore[assignment] + else: + # bytes, empty list/tuple, arbitrary objects → TypeError. + raise TypeError( + f"resolve() text must be a str, int, or float; got {type(text).__name__!r}" + ) effective_timeout = timeout if timeout is not None else self._default_timeout if effective_timeout is not None and effective_timeout <= 0: raise ValueError("timeout must be positive") @@ -1792,7 +1816,11 @@ def resolve_id( and handles AMBIGUOUS per ``on_ambiguous``. Args: - text: Text to resolve. + text: Text to resolve. ``str`` is used as-is. ``int`` and + ``float`` are coerced to string (``840`` → ``"840"``; + ``840.0`` → ``"840"``), matching the behaviour of + :meth:`bulk` on numeric columns. ``None`` returns ``None`` + silently. ``bool`` and all other types raise ``TypeError``. on_ambiguous: Behavior when multiple entities match. - ``"raise"`` (default): preserves the existing contract; raises :class:`AmbiguousResolutionError` with candidates. @@ -1810,6 +1838,8 @@ def resolve_id( Entity ID string, or None if no match. Raises: + TypeError: If *text* is a ``bool``, ``bytes``, ``list``, or any + other unsupported type. AmbiguousResolutionError: If ``on_ambiguous="raise"`` and the resolution is ambiguous. ResolutionError: If the resolution pipeline errored. diff --git a/src/resolvekit/core/byod/cache.py b/src/resolvekit/core/byod/cache.py index f933e72..640d164 100644 --- a/src/resolvekit/core/byod/cache.py +++ b/src/resolvekit/core/byod/cache.py @@ -171,11 +171,7 @@ def commit_build(build_dir: Path, final_dir: Path) -> None: """Atomically promote *build_dir* to *final_dir* and clean up. Uses ``os.replace(build_dir, final_dir)`` for an atomic last-writer-wins - swap. On success removes the now-superseded temp dir with - ``shutil.rmtree(build_dir, ignore_errors=True)`` (mirrors - ``composed_sqlite.py:326``). - - If the parent of *final_dir* does not exist it is created first. + swap. If the parent of *final_dir* does not exist it is created first. Args: build_dir: Temp directory where the build was written. @@ -183,12 +179,9 @@ def commit_build(build_dir: Path, final_dir: Path) -> None: """ final_dir.parent.mkdir(parents=True, exist_ok=True) os.replace(build_dir, final_dir) - # build_dir is now the old location of the directory that was replaced; - # on success os.replace moves it entirely, so nothing remains there to clean. - # However, if this is a second concurrent write (final_dir already existed - # before replace), the "old" final_dir was atomically overwritten — nothing - # to clean in either case. The rmtree below is a belt-and-suspenders guard - # matching composed_sqlite.py:326's pattern. + # Belt-and-suspenders cleanup: ``shutil.rmtree`` with ignore_errors=True + # handles both cases (build_dir doesn't exist after replace, or final_dir + # was clobbered by a concurrent write) without raising. shutil.rmtree(build_dir, ignore_errors=True) diff --git a/src/resolvekit/core/config.py b/src/resolvekit/core/config.py index 8a91670..bb209b3 100644 --- a/src/resolvekit/core/config.py +++ b/src/resolvekit/core/config.py @@ -31,7 +31,7 @@ class _Config: def configure( *, - auto_download: bool | None = None, + auto_download: bool | None | _Unset = _UNSET, cache_dir: str | Path | None | _Unset = _UNSET, default_to: str | list[str] | None | _Unset = _UNSET, on_missing: Literal["raise", "null", "auto"] | object = _UNSET, @@ -42,7 +42,8 @@ def configure( Args: auto_download: If True, remote packs are downloaded automatically - when needed. ``None`` leaves the current setting unchanged. + when needed. ``None`` resets to the default (disabled). + Omitting leaves the current setting unchanged. cache_dir: Custom cache directory for remote data packs. ``None`` resets to the platform default (removes any custom path). Omitting leaves the current setting unchanged. @@ -58,8 +59,11 @@ def configure( Omitting this argument leaves any previously configured policy unchanged. """ - if auto_download is not None: - _config.auto_download = auto_download + if auto_download is not _UNSET: + # None resets to the dataclass default (disabled). + _config.auto_download = ( + bool(auto_download) if auto_download is not None else False + ) if cache_dir is not _UNSET: # None resets to platform default (removes any custom path). _config.cache_dir = ( diff --git a/src/resolvekit/core/engine/_stages.py b/src/resolvekit/core/engine/_stages.py index 1e1d334..234b388 100644 --- a/src/resolvekit/core/engine/_stages.py +++ b/src/resolvekit/core/engine/_stages.py @@ -335,15 +335,12 @@ def merge_candidates( candidates = [] for entity_id, evidence_list in evidence_by_entity.items(): - # Find best evidence best = max(evidence_list, key=lambda e: e.raw_score or 0) - # Aggregate signals from all evidence aggregated_signals: dict[str, float] = {} for ev in evidence_list: aggregated_signals.update(ev.signals) - # Create retrieval summary retrieval = RetrievalSummary( best_source=best.source_name, best_rank=best.rank, @@ -351,7 +348,6 @@ def merge_candidates( signals=aggregated_signals, ) - # Placeholder scores - will be overwritten during scoring step placeholder_score = best.raw_score or DEFAULT_FALLBACK_SCORE candidates.append( @@ -467,9 +463,9 @@ def check_post_scoring_stop( status=ResolutionStatus.RESOLVED, entity_id=top.entity_id, confidence=top.scores.calibrated_score, - candidates=[ + candidates=tuple( build_candidate_summary(c) for c in candidates[:DEFAULT_TOP_K_RESULTS] - ], - reasons=[reason], + ), + reasons=(reason,), ) return None diff --git a/src/resolvekit/core/engine/decision.py b/src/resolvekit/core/engine/decision.py index 0c5c2a1..30647cb 100644 --- a/src/resolvekit/core/engine/decision.py +++ b/src/resolvekit/core/engine/decision.py @@ -99,7 +99,7 @@ def decide( if not candidates: return ResolutionResult( status=ResolutionStatus.NO_MATCH, - reasons=[ReasonCode.NO_CANDIDATES], + reasons=(ReasonCode.NO_CANDIDATES,), ) candidates = sorted( @@ -108,7 +108,6 @@ def decide( top = candidates[0] top_score = top.scores.calibrated_score - # Step 3: early-accept hook early_reason = self._early_accept(top, candidates) if early_reason is not None: result = self._make_resolved(top, candidates, early_reason) @@ -120,15 +119,15 @@ def decide( ) return result - # Step 4: threshold check — attach calibrated score so callers can - # distinguish a near-miss ("NO_MATCH, confidence=0.66") from a true - # no-candidate ("NO_MATCH, confidence=None"). + # Attach calibrated score on near-miss so callers can distinguish + # a near-miss ("NO_MATCH, confidence=0.66") from a true no-candidate + # ("NO_MATCH, confidence=None"). if top_score < self._confidence_threshold: result = ResolutionResult( status=ResolutionStatus.NO_MATCH, confidence=top_score, candidates=self._make_summaries(candidates), - reasons=[ReasonCode.BELOW_CONFIDENCE_THRESHOLD], + reasons=(ReasonCode.BELOW_CONFIDENCE_THRESHOLD,), ) trace.emit( TraceEvent( @@ -138,7 +137,6 @@ def decide( ) return result - # Step 5: gap check — single candidate ≥ threshold is always a clear winner effective_gap = self._effective_gap(query) if len(candidates) == 1: has_clear_winner = True @@ -150,7 +148,6 @@ def decide( has_clear_winner = gap > effective_gap if not has_clear_winner: - # Step 6: tiebreak hook winner = self._tiebreak(candidates, context, effective_gap) if winner is not None: result = self._make_resolved( @@ -168,7 +165,7 @@ def decide( result = ResolutionResult( status=ResolutionStatus.AMBIGUOUS, candidates=self._make_summaries(candidates), - reasons=[ReasonCode.AMBIGUOUS_LOW_GAP], + reasons=(ReasonCode.AMBIGUOUS_LOW_GAP,), ) trace.emit( TraceEvent( @@ -178,7 +175,6 @@ def decide( ) return result - # Step 7: resolved result = self._make_resolved(top, candidates, self._resolved_reason(top)) trace.emit( TraceEvent( @@ -286,8 +282,12 @@ def _make_resolved( entity_id=top.entity_id, confidence=top.scores.calibrated_score, candidates=self._make_summaries(all_candidates), - reasons=[reason], + reasons=(reason,), ) - def _make_summaries(self, candidates: list[Candidate]) -> list[CandidateSummary]: - return [build_candidate_summary(c) for c in candidates[: self._max_candidates]] + def _make_summaries( + self, candidates: list[Candidate] + ) -> tuple[CandidateSummary, ...]: + return tuple( + build_candidate_summary(c) for c in candidates[: self._max_candidates] + ) diff --git a/src/resolvekit/core/engine/enrichment.py b/src/resolvekit/core/engine/enrichment.py index fd876e1..fb18d4d 100644 --- a/src/resolvekit/core/engine/enrichment.py +++ b/src/resolvekit/core/engine/enrichment.py @@ -130,7 +130,7 @@ def finalize_result( did_you_mean_candidates = self._spelling_suggestions(query_text) if did_you_mean_candidates: result = result.model_copy( - update={"candidates": did_you_mean_candidates} + update={"candidates": tuple(did_you_mean_candidates)} ) did_you_mean_active = True refinement_hints = first_pass_hints @@ -153,8 +153,8 @@ def finalize_result( update={ "pack_id": result.pack_id or self._pack_id, "match_tier": result.match_tier or match_tier, - "reasons": reasons, - "refinement_hints": list( + "reasons": tuple(reasons), + "refinement_hints": tuple( dict.fromkeys(result.refinement_hints or refinement_hints) ), } @@ -179,10 +179,10 @@ def _attach_recovery_candidates( }: return result - summaries = [ + summaries = tuple( build_candidate_summary(candidate) for candidate in final_candidates[:DEFAULT_TOP_K_RESULTS] - ] + ) return result.model_copy(update={"candidates": summaries}) def _load_result_entities( @@ -259,7 +259,7 @@ def rank_of(c: CandidateSummary) -> int: key=lambda c: (-round(c.confidence or 0.0, 3), ranks[c.entity_id]), ) - return result.model_copy(update={"candidates": enriched}) + return result.model_copy(update={"candidates": tuple(enriched)}) def _derive_result_match_tier( self, diff --git a/src/resolvekit/core/engine/interfaces.py b/src/resolvekit/core/engine/interfaces.py index 2ab1ad3..2d8f63e 100644 --- a/src/resolvekit/core/engine/interfaces.py +++ b/src/resolvekit/core/engine/interfaces.py @@ -57,7 +57,7 @@ class PipelineResult: _TIMEOUT_RESULT = ResolutionResult( status=ResolutionStatus.ERROR, - reasons=[ReasonCode.TIMEOUT], + reasons=(ReasonCode.TIMEOUT,), ) diff --git a/src/resolvekit/core/engine/multi_runner.py b/src/resolvekit/core/engine/multi_runner.py index a40702c..31dd6a6 100644 --- a/src/resolvekit/core/engine/multi_runner.py +++ b/src/resolvekit/core/engine/multi_runner.py @@ -404,7 +404,7 @@ def _run( ) error_result = ResolutionResult( status=ResolutionStatus.ERROR, - reasons=[ReasonCode.INTERNAL_ERROR], + reasons=(ReasonCode.INTERNAL_ERROR,), ) return PipelineResult(result=error_result, candidates=None) @@ -539,7 +539,7 @@ def _merge_results( return ( ResolutionResult( status=ResolutionStatus.NO_MATCH, - reasons=[ReasonCode.NO_CANDIDATES], + reasons=(ReasonCode.NO_CANDIDATES,), ), None, ) @@ -792,10 +792,10 @@ def _build_ambiguous_result( return ResolutionResult( status=ResolutionStatus.AMBIGUOUS, - candidates=interleaved, + candidates=tuple(interleaved), match_tier=top_tier, - reasons=[ReasonCode.AMBIGUOUS_DOMAIN_COLLISION], - refinement_hints=hints, + reasons=(ReasonCode.AMBIGUOUS_DOMAIN_COLLISION,), + refinement_hints=tuple(hints), ) def _any_candidate_matches_country_scoped_prefix( diff --git a/src/resolvekit/core/engine/runner.py b/src/resolvekit/core/engine/runner.py index 6900995..634d258 100644 --- a/src/resolvekit/core/engine/runner.py +++ b/src/resolvekit/core/engine/runner.py @@ -703,7 +703,7 @@ def _make_result( return _make_result( ResolutionResult( status=ResolutionStatus.NO_MATCH, - reasons=[ReasonCode.NO_CANDIDATES], + reasons=(ReasonCode.NO_CANDIDATES,), ) ) @@ -741,7 +741,7 @@ def _make_result( return _make_result( ResolutionResult( status=ResolutionStatus.NO_MATCH, - reasons=[ReasonCode.FILTERED_BY_CONSTRAINT], + reasons=(ReasonCode.FILTERED_BY_CONSTRAINT,), ) ) @@ -813,7 +813,7 @@ def _make_result( return _make_result( ResolutionResult( status=ResolutionStatus.ERROR, - reasons=[ReasonCode.INTERNAL_ERROR], + reasons=(ReasonCode.INTERNAL_ERROR,), ) ) diff --git a/src/resolvekit/core/engine/tier_utils.py b/src/resolvekit/core/engine/tier_utils.py index ff55135..db9546e 100644 --- a/src/resolvekit/core/engine/tier_utils.py +++ b/src/resolvekit/core/engine/tier_utils.py @@ -59,9 +59,8 @@ def reason_to_match_tier(reason: ReasonCode | None) -> MatchTier | None: def _source_name_tier_fallback(source_name: str) -> MatchTier | None: """Infer tier from source_name substring — fallback for evidence without match_tier. - Used only when ``CandidateEvidence.match_tier`` is None (e.g. legacy mocks or - sources not yet migrated to stamp match_tier at emission). New sources should - stamp ``match_tier`` directly; this fallback is not part of the public API. + Used only when ``CandidateEvidence.match_tier`` is None (e.g. for backward + compatibility with sources that predate explicit tier stamping). """ for token, tier in _SOURCE_TIER_TOKENS: if token in source_name: @@ -121,7 +120,7 @@ def build_candidate_summary( entity_id=candidate.entity_id, confidence=candidate.scores.calibrated_score, match_tier=derive_candidate_match_tier(candidate), - top_evidence=top_evidence, + top_evidence=tuple(top_evidence), key_features=key_features, ) diff --git a/src/resolvekit/core/errors.py b/src/resolvekit/core/errors.py index 873573d..92114ed 100644 --- a/src/resolvekit/core/errors.py +++ b/src/resolvekit/core/errors.py @@ -3,6 +3,7 @@ from __future__ import annotations import difflib +from collections.abc import Sequence from typing import Any from resolvekit.core.errors_base import ExplainNotAvailableError, ResolverError @@ -359,7 +360,7 @@ class ResolutionError(ResolverError): def __init__( self, status: ResolutionStatus, - candidates: list[CandidateSummary] | None = None, + candidates: Sequence[CandidateSummary] | None = None, message: str | None = None, *, hint: str | None = None, @@ -374,7 +375,7 @@ def __init__( def _single_candidate_type( - candidates: list[CandidateSummary] | None, + candidates: Sequence[CandidateSummary] | None, ) -> str | None: """Return an entity type carried by exactly one candidate, else ``None``. @@ -393,7 +394,7 @@ def _single_candidate_type( def entity_types_would_disambiguate( - candidates: list[CandidateSummary] | None, + candidates: Sequence[CandidateSummary] | None, ) -> bool: """True when an ``entity_types`` filter could break the *contended* tie. @@ -414,7 +415,9 @@ def entity_types_would_disambiguate( return first.entity_type != second.entity_type -def _ambiguous_resolution_hint(candidates: list[CandidateSummary] | None) -> str: +def _ambiguous_resolution_hint( + candidates: Sequence[CandidateSummary] | None, +) -> str: """Candidate-aware disambiguation advice for ``AmbiguousResolutionError``. Suggests ``entity_types`` only when a single-type filter would actually @@ -434,7 +437,9 @@ def _ambiguous_resolution_hint(candidates: list[CandidateSummary] | None) -> str ) -def _candidate_preview(candidates: list[CandidateSummary], *, limit: int = 3) -> str: +def _candidate_preview( + candidates: Sequence[CandidateSummary], *, limit: int = 3 +) -> str: """Render ``entity_id (conf)`` for the top *limit* candidates.""" parts: list[str] = [] for c in candidates[:limit]: @@ -453,7 +458,7 @@ class AmbiguousResolutionError(ResolutionError): def __init__( self, - candidates: list[CandidateSummary] | None = None, + candidates: Sequence[CandidateSummary] | None = None, *, hint: str | None = None, ) -> None: diff --git a/src/resolvekit/core/explain/result_html.py b/src/resolvekit/core/explain/result_html.py index 4b7c24a..8ad132d 100644 --- a/src/resolvekit/core/explain/result_html.py +++ b/src/resolvekit/core/explain/result_html.py @@ -43,8 +43,7 @@ def did_you_mean_lines(result: ResolutionResult) -> str | None: """Build ``resolvekit.resolve(text=...)`` lines from candidate names. Returns one line per unique candidate canonical_name (excluding the - original query_text), joined with ``"\\n "``. Used by both the AMBIGUOUS - disambiguation path and the NO_MATCH DID_YOU_MEAN refinement path. + original query_text), joined with ``"\\n "``. """ names = [c.canonical_name for c in result.candidates if c.canonical_name] unique_names = [n for n in dict.fromkeys(names) if n != result.query_text] @@ -54,7 +53,7 @@ def did_you_mean_lines(result: ResolutionResult) -> str | None: return "\n ".join(lines) -def render_refinement_hint( # noqa: PLR0911 (per-hint dispatch is naturally branchy) +def render_refinement_hint( # noqa: PLR0911 result: ResolutionResult, hint: RefinementHint ) -> str | None: """Map one RefinementHint value to a runnable resolve() argument string. @@ -90,7 +89,7 @@ def render_refinement_hint( # noqa: PLR0911 (per-hint dispatch is naturally bra ), None, ) - placeholder = country or "" + placeholder = country or "" return f'{prefix}context=ResolutionContext(country="{placeholder}"))' if hint == RefinementHint.PARENT_IDS: diff --git a/src/resolvekit/core/explain/scorecard.py b/src/resolvekit/core/explain/scorecard.py index 0d2c844..ffa39d4 100644 --- a/src/resolvekit/core/explain/scorecard.py +++ b/src/resolvekit/core/explain/scorecard.py @@ -5,6 +5,7 @@ from __future__ import annotations +from collections.abc import Sequence from datetime import datetime from enum import StrEnum from typing import TYPE_CHECKING, Any @@ -419,7 +420,7 @@ def _find_candidate( return next((c for c in candidates if c.entity_id == entity_id), None) def _find_summary( - self, summaries: list[CandidateSummary] | None, entity_id: str + self, summaries: Sequence[CandidateSummary] | None, entity_id: str ) -> CandidateSummary | None: """Find a candidate summary by entity_id.""" if not summaries: diff --git a/src/resolvekit/core/model/entity_attributes.py b/src/resolvekit/core/model/entity_attributes.py index 9520b80..9c54828 100644 --- a/src/resolvekit/core/model/entity_attributes.py +++ b/src/resolvekit/core/model/entity_attributes.py @@ -71,23 +71,16 @@ def dispatch_pivot( (bare ``"name"`` → ``entity.canonical_name``). 3. ``target.startswith("name:")`` → name-grammar branch via ``parse_name_grammar`` + ``apply_name``. Malformed grammar raises - ``UnknownOutputError`` (loud — programming error). A valid token that - the entity simply lacks returns ``None`` (quiet — per-entity miss). + ``UnknownOutputError`` (programming error). Valid token the entity + lacks returns ``None`` (per-entity miss). 4. ``target`` in ``entity.codes_dict`` → that code value. - Note: a *known-system* miss here raises ``UnknownCodeSystemError`` - because ``dispatch_pivot`` has no ``known_systems`` set to distinguish - "unknown system" from "entity lacks a valid system". The spec path - (``_resolve_target`` in ``output_spec.py``) uses ``codes_dict.get`` - directly and never raises; that asymmetry is intentional and deferred. 5. ``target`` in ``entity.attributes`` → that attribute value. - 6. Raise ``UnknownCodeSystemError`` with did-you-mean suggestion built by - the error class itself from the available list. + 6. Raise ``UnknownCodeSystemError`` with did-you-mean suggestion. Args: entity: The resolved ``EntityRecord``. - target: A known pivot name, a code system name, an attribute key, - a name-grammar token (e.g. ``"name:fr"``), - or the ``EntityRecord`` type itself. + target: A known pivot name, code system name, attribute key, + name-grammar token (e.g. ``"name:fr"``), or ``EntityRecord`` type. Returns: The requested value, or the entity itself when ``target is EntityRecord``. @@ -96,7 +89,6 @@ def dispatch_pivot( UnknownOutputError: When ``target`` is a malformed name-grammar token. UnknownCodeSystemError: When ``target`` doesn't match any routing branch. TypeError: When ``target`` is a list or other unsupported type. - Hint directs callers to ``rk.to([...])`` or ``default_to=[...]``. """ from resolvekit.core.model.entity import EntityRecord as _EntityRecord diff --git a/src/resolvekit/core/model/name_grammar.py b/src/resolvekit/core/model/name_grammar.py index 19f58ed..b712688 100644 --- a/src/resolvekit/core/model/name_grammar.py +++ b/src/resolvekit/core/model/name_grammar.py @@ -75,8 +75,7 @@ def parse_name_grammar(token: str) -> OutputTarget: Middle-token disambiguation (kind wins): if the middle segment is in ``KNOWN_KINDS`` (after folding ``abbr``→``acronym``), it is treated as a kind selector; otherwise it is validated as a language code (must match - ``^[a-z]{2,3}$``). The kind-set is closed (5 names) and collision-free - with the ISO-639-1 langs present in the data (en/fr/es/de/ru/ja/it/pt/zh/ar). + ``^[a-z]{2,3}$``). Args: token: A raw token starting with ``"name"`` (e.g. ``"name"``, diff --git a/src/resolvekit/core/model/query.py b/src/resolvekit/core/model/query.py index 28bec20..4f5bcfe 100644 --- a/src/resolvekit/core/model/query.py +++ b/src/resolvekit/core/model/query.py @@ -55,7 +55,7 @@ class ResolutionContext(BaseModel): as_of: Point-in-time for temporal validity checks entity_types: Entity type hints (e.g., {"geo.country", "geo.state"}) parent_ids: Parent/container entity hints - country: ISO 3166-1 alpha-2 country code hint (useful for geo + org) + country: ISO 3166-1 country code hint — alpha-2 or alpha-3 (useful for geo + org) languages: Preferred languages for name matching attributes: Escape hatch for domain-specific attributes (use sparingly) """ @@ -71,7 +71,10 @@ class ResolutionContext(BaseModel): ) country: str | None = Field( default=None, - description="ISO 3166-1 alpha-2 country code hint (e.g. 'US')", + description=( + "ISO 3166-1 country code hint — alpha-2 (e.g. 'US') or" + " alpha-3 (e.g. 'USA'). Stored uppercased; length disambiguates the form." + ), ) languages: list[str] | None = Field(default=None, description="Preferred languages") attributes: dict[str, str | int | float | bool] = Field( @@ -98,20 +101,14 @@ def _validate_country(cls, value: Any) -> Any: raise ValueError("country must be a string") if not value.isalpha(): raise ValueError( - f"country must be an ISO 3166-1 alpha-2 code" - f" (two uppercase letters), got {value!r}" + f"country must be an ISO 3166-1 alpha-2 or alpha-3 code" + f" (two or three letters), got {value!r}" ) - if len(value) == 2: + if len(value) in (2, 3): return value.upper() - if len(value) == 3: - raise ValueError( - f"country must be an ISO 3166-1 alpha-2 code (two letters)," - f" got {value!r} — pass the alpha-2 code instead" - f" (e.g. 'US' not 'USA')" - ) raise ValueError( - f"country must be an ISO 3166-1 alpha-2 code" - f" (two uppercase letters), got {value!r}" + f"country must be an ISO 3166-1 alpha-2 or alpha-3 code" + f" (two or three letters), got {value!r}" ) def replace(self, **updates: Any) -> "ResolutionContext": diff --git a/src/resolvekit/core/model/result.py b/src/resolvekit/core/model/result.py index bf022b3..9e60771 100644 --- a/src/resolvekit/core/model/result.py +++ b/src/resolvekit/core/model/result.py @@ -107,7 +107,7 @@ class ReasonCode(StrEnum): They're invaluable for debugging and monitoring resolution quality. Note: ``ResolutionResult.reasons`` is currently always a single-element - list. Callers should treat the field as ``[reason]`` and avoid logic that + tuple. Callers should treat the field as ``(reason,)`` and avoid logic that assumes multiple codes per result; this invariant may relax in a future minor version, with notice. """ @@ -192,9 +192,7 @@ class CandidateSummary(BaseModel): entity_type: str | None = Field(default=None) pack_id: str | None = Field(default=None) match_tier: MatchTier | None = Field(default=None) - top_evidence: list[CandidateEvidenceSummary] = Field( - default_factory=list, max_length=3 - ) + top_evidence: tuple[CandidateEvidenceSummary, ...] = Field(default=(), max_length=3) key_features: dict[str, float | bool | None] = Field(default_factory=dict) def __repr__(self) -> str: # explicit by design @@ -241,9 +239,9 @@ class ResolutionResult(BaseModel): confidence: float | None = Field(default=None, ge=0.0, le=1.0) pack_id: str | None = Field(default=None) match_tier: MatchTier | None = Field(default=None) - candidates: list[CandidateSummary] = Field(default_factory=list, max_length=10) - reasons: list[ReasonCode] = Field(default_factory=list) - refinement_hints: list[RefinementHint] = Field(default_factory=list, max_length=4) + candidates: tuple[CandidateSummary, ...] = Field(default=(), max_length=10) + reasons: tuple[ReasonCode, ...] = Field(default=()) + refinement_hints: tuple[RefinementHint, ...] = Field(default=(), max_length=4) query_text: str | None = Field(default=None) trace: Trace | None = Field(default=None) @@ -406,7 +404,7 @@ def best_candidate(self) -> CandidateSummary | None: """Return the highest-confidence candidate, or None.""" return self.candidates[0] if self.candidates else None - def top_candidates(self, n: int = 3) -> list[CandidateSummary]: + def top_candidates(self, n: int = 3) -> tuple[CandidateSummary, ...]: """Return the top *n* candidates by confidence.""" return self.candidates[:n] diff --git a/src/resolvekit/core/parse/link.py b/src/resolvekit/core/parse/link.py index abc56df..f9f6d76 100644 --- a/src/resolvekit/core/parse/link.py +++ b/src/resolvekit/core/parse/link.py @@ -227,7 +227,7 @@ def _dropped(reason: str) -> DroppedSpan: status=ResolutionStatus.NO_MATCH, confidence=result.confidence, candidates=result.candidates, - reasons=[ReasonCode.BELOW_CONFIDENCE_THRESHOLD], + reasons=(ReasonCode.BELOW_CONFIDENCE_THRESHOLD,), query_text=result.query_text, ) # Constructor drops PrivateAttr; re-attach None to signal demoted result. diff --git a/src/resolvekit/core/util/iso_codes.py b/src/resolvekit/core/util/iso_codes.py new file mode 100644 index 0000000..98191d3 --- /dev/null +++ b/src/resolvekit/core/util/iso_codes.py @@ -0,0 +1,265 @@ +"""Static ISO 3166-1 alpha-3 to alpha-2 mapping. + +Standards data for converting alpha-3 country hints where no entity store +with code lookups is available (e.g. the org pack's country-relevance +constraint). The geo pack converts via its own store instead. +""" + +from __future__ import annotations + +ISO3_TO_ISO2: dict[str, str] = { + "ABW": "AW", + "AFG": "AF", + "AGO": "AO", + "AIA": "AI", + "ALA": "AX", + "ALB": "AL", + "AND": "AD", + "ARE": "AE", + "ARG": "AR", + "ARM": "AM", + "ASM": "AS", + "ATA": "AQ", + "ATF": "TF", + "ATG": "AG", + "AUS": "AU", + "AUT": "AT", + "AZE": "AZ", + "BDI": "BI", + "BEL": "BE", + "BEN": "BJ", + "BES": "BQ", + "BFA": "BF", + "BGD": "BD", + "BGR": "BG", + "BHR": "BH", + "BHS": "BS", + "BIH": "BA", + "BLM": "BL", + "BLR": "BY", + "BLZ": "BZ", + "BMU": "BM", + "BOL": "BO", + "BRA": "BR", + "BRB": "BB", + "BRN": "BN", + "BTN": "BT", + "BVT": "BV", + "BWA": "BW", + "CAF": "CF", + "CAN": "CA", + "CCK": "CC", + "CHE": "CH", + "CHL": "CL", + "CHN": "CN", + "CIV": "CI", + "CMR": "CM", + "COD": "CD", + "COG": "CG", + "COK": "CK", + "COL": "CO", + "COM": "KM", + "CPV": "CV", + "CRI": "CR", + "CUB": "CU", + "CUW": "CW", + "CXR": "CX", + "CYM": "KY", + "CYP": "CY", + "CZE": "CZ", + "DEU": "DE", + "DJI": "DJ", + "DMA": "DM", + "DNK": "DK", + "DOM": "DO", + "DZA": "DZ", + "ECU": "EC", + "EGY": "EG", + "ERI": "ER", + "ESH": "EH", + "ESP": "ES", + "EST": "EE", + "ETH": "ET", + "FIN": "FI", + "FJI": "FJ", + "FLK": "FK", + "FRA": "FR", + "FRO": "FO", + "FSM": "FM", + "GAB": "GA", + "GBR": "GB", + "GEO": "GE", + "GGY": "GG", + "GHA": "GH", + "GIB": "GI", + "GIN": "GN", + "GLP": "GP", + "GMB": "GM", + "GNB": "GW", + "GNQ": "GQ", + "GRC": "GR", + "GRD": "GD", + "GRL": "GL", + "GTM": "GT", + "GUF": "GF", + "GUM": "GU", + "GUY": "GY", + "HKG": "HK", + "HMD": "HM", + "HND": "HN", + "HRV": "HR", + "HTI": "HT", + "HUN": "HU", + "IDN": "ID", + "IMN": "IM", + "IND": "IN", + "IOT": "IO", + "IRL": "IE", + "IRN": "IR", + "IRQ": "IQ", + "ISL": "IS", + "ISR": "IL", + "ITA": "IT", + "JAM": "JM", + "JEY": "JE", + "JOR": "JO", + "JPN": "JP", + "KAZ": "KZ", + "KEN": "KE", + "KGZ": "KG", + "KHM": "KH", + "KIR": "KI", + "KNA": "KN", + "KOR": "KR", + "KWT": "KW", + "LAO": "LA", + "LBN": "LB", + "LBR": "LR", + "LBY": "LY", + "LCA": "LC", + "LIE": "LI", + "LKA": "LK", + "LSO": "LS", + "LTU": "LT", + "LUX": "LU", + "LVA": "LV", + "MAC": "MO", + "MAF": "MF", + "MAR": "MA", + "MCO": "MC", + "MDA": "MD", + "MDG": "MG", + "MDV": "MV", + "MEX": "MX", + "MHL": "MH", + "MKD": "MK", + "MLI": "ML", + "MLT": "MT", + "MMR": "MM", + "MNE": "ME", + "MNG": "MN", + "MNP": "MP", + "MOZ": "MZ", + "MRT": "MR", + "MSR": "MS", + "MTQ": "MQ", + "MUS": "MU", + "MWI": "MW", + "MYS": "MY", + "MYT": "YT", + "NAM": "NA", + "NCL": "NC", + "NER": "NE", + "NFK": "NF", + "NGA": "NG", + "NIC": "NI", + "NIU": "NU", + "NLD": "NL", + "NOR": "NO", + "NPL": "NP", + "NRU": "NR", + "NZL": "NZ", + "OMN": "OM", + "PAK": "PK", + "PAN": "PA", + "PCN": "PN", + "PER": "PE", + "PHL": "PH", + "PLW": "PW", + "PNG": "PG", + "POL": "PL", + "PRI": "PR", + "PRK": "KP", + "PRT": "PT", + "PRY": "PY", + "PSE": "PS", + "PYF": "PF", + "QAT": "QA", + "REU": "RE", + "ROU": "RO", + "RUS": "RU", + "RWA": "RW", + "SAU": "SA", + "SDN": "SD", + "SEN": "SN", + "SGP": "SG", + "SGS": "GS", + "SHN": "SH", + "SJM": "SJ", + "SLB": "SB", + "SLE": "SL", + "SLV": "SV", + "SMR": "SM", + "SOM": "SO", + "SPM": "PM", + "SRB": "RS", + "SSD": "SS", + "STP": "ST", + "SUR": "SR", + "SVK": "SK", + "SVN": "SI", + "SWE": "SE", + "SWZ": "SZ", + "SXM": "SX", + "SYC": "SC", + "SYR": "SY", + "TCA": "TC", + "TCD": "TD", + "TGO": "TG", + "THA": "TH", + "TJK": "TJ", + "TKL": "TK", + "TKM": "TM", + "TLS": "TL", + "TON": "TO", + "TTO": "TT", + "TUN": "TN", + "TUR": "TR", + "TUV": "TV", + "TWN": "TW", + "TZA": "TZ", + "UGA": "UG", + "UKR": "UA", + "UMI": "UM", + "URY": "UY", + "USA": "US", + "UZB": "UZ", + "VAT": "VA", + "VCT": "VC", + "VEN": "VE", + "VGB": "VG", + "VIR": "VI", + "VNM": "VN", + "VUT": "VU", + "WLF": "WF", + "WSM": "WS", + "YEM": "YE", + "ZAF": "ZA", + "ZMB": "ZM", + "ZWE": "ZW", +} + + +def iso3_to_iso2(code: str) -> str | None: + """Return the alpha-2 code for an alpha-3 code, or None if unknown.""" + return ISO3_TO_ISO2.get(code.upper()) diff --git a/src/resolvekit/packs/geo/constraints/containment.py b/src/resolvekit/packs/geo/constraints/containment.py index 885fef0..9020189 100644 --- a/src/resolvekit/packs/geo/constraints/containment.py +++ b/src/resolvekit/packs/geo/constraints/containment.py @@ -75,11 +75,12 @@ def _resolve_parent_ids( parent_ids = set(context.parent_ids or []) # Treat country hint as a shorthand containment parent. - # Example: country="GT" -> add country entity IDs for iso2=gt. + # Length distinguishes alpha-2 ("GT") from alpha-3 ("GTM"). if context.country: - iso2 = context.country.strip().lower() - if iso2: - parent_ids.update(store.lookup_code("iso2", iso2)) + code = context.country.strip().lower() + if code: + system = "iso3" if len(code) == 3 else "iso2" + parent_ids.update(store.lookup_code(system, code)) return parent_ids diff --git a/src/resolvekit/packs/org/constraints/country_relevance.py b/src/resolvekit/packs/org/constraints/country_relevance.py index 1462e4f..c276173 100644 --- a/src/resolvekit/packs/org/constraints/country_relevance.py +++ b/src/resolvekit/packs/org/constraints/country_relevance.py @@ -11,6 +11,7 @@ Severity, ) from resolvekit.core.store import EntityStore +from resolvekit.core.util.iso_codes import iso3_to_iso2 class CountryRelevanceConstraint(Constraint): @@ -38,7 +39,10 @@ def apply( if not context.country: return candidates - hint_countries = {context.country.upper()} + hint_code = context.country.upper() + if len(hint_code) == 3: + hint_code = iso3_to_iso2(hint_code) or hint_code + hint_countries = {hint_code} entity_ids = [c.entity_id for c in candidates] entities = store.bulk_get_entities(entity_ids) diff --git a/src/resolvekit/packs/org/decision.py b/src/resolvekit/packs/org/decision.py index f2e36d0..99b85d6 100644 --- a/src/resolvekit/packs/org/decision.py +++ b/src/resolvekit/packs/org/decision.py @@ -113,14 +113,14 @@ def decide( if ( result.status == ResolutionStatus.AMBIGUOUS and self._is_acronym_like(query.normalized.original) - and result.reasons == [ReasonCode.AMBIGUOUS_LOW_GAP] + and result.reasons == (ReasonCode.AMBIGUOUS_LOW_GAP,) ): return ResolutionResult( status=result.status, entity_id=result.entity_id, confidence=result.confidence, candidates=result.candidates, - reasons=[ReasonCode.ACRONYM_MATCH_AMBIGUOUS], + reasons=(ReasonCode.ACRONYM_MATCH_AMBIGUOUS,), ) return result diff --git a/tests/api/test_cache_no_mutation_leak.py b/tests/api/test_cache_no_mutation_leak.py index 60ac013..4eb9275 100644 --- a/tests/api/test_cache_no_mutation_leak.py +++ b/tests/api/test_cache_no_mutation_leak.py @@ -1,8 +1,9 @@ -"""Regression tests for query-cache mutation leakage . +"""Regression tests for query-cache mutation leakage. -In-place mutation of a returned result's list fields must not poison the -query cache: a cache hit must return a result whose mutable containers are not -shared with the cached entry. +``ResolutionResult.reasons``, ``.candidates``, and ``.refinement_hints`` are +now tuple-typed (immutable), so in-place mutation raises ``AttributeError``. +The cache-integrity invariant is preserved as a structural property rather than +a defensive-copy concern. """ from __future__ import annotations @@ -32,41 +33,63 @@ def _make_result() -> ResolutionResult: class TestDetachMutables: - def test_lists_are_fresh_objects(self) -> None: + def test_copy_has_equal_values(self) -> None: original = _make_result() detached = _detach_mutables(original) assert detached.reasons == original.reasons - assert detached.reasons is not original.reasons - assert detached.candidates is not original.candidates - assert detached.refinement_hints is not original.refinement_hints + assert detached.candidates == original.candidates + assert detached.refinement_hints == original.refinement_hints - def test_mutating_detached_does_not_touch_original(self) -> None: - original = _make_result() - detached = _detach_mutables(original) - detached.reasons.append(ReasonCode.INTERNAL_ERROR) - assert ReasonCode.INTERNAL_ERROR not in original.reasons + def test_fields_are_tuples(self) -> None: + result = _make_result() + assert isinstance(result.reasons, tuple) + assert isinstance(result.candidates, tuple) + assert isinstance(result.refinement_hints, tuple) + + def test_mutation_raises(self) -> None: + result = _make_result() + with pytest.raises(AttributeError): + result.reasons.append(ReasonCode.INTERNAL_ERROR) # type: ignore[attr-defined] + with pytest.raises(AttributeError): + result.candidates.clear() # type: ignore[attr-defined] class TestQueryCacheNoLeak: - def test_cache_hit_returns_detached_lists(self) -> None: + def test_cache_hit_returns_detached_copy(self) -> None: + cache = _QueryCache(maxsize=8) + result = _make_result() + + first = cache.get_or_call( + raw_text="France", context=None, domains=None, inner=lambda: result + ) + assert first.reasons == (ReasonCode.EXACT_NAME_MATCH,) + + second = cache.get_or_call( + raw_text="France", + context=None, + domains=None, + inner=lambda: pytest.fail("inner should not run on a cache hit"), + ) + assert second.reasons == first.reasons + + def test_tuple_fields_prevent_cache_poisoning(self) -> None: cache = _QueryCache(maxsize=8) result = _make_result() first = cache.get_or_call( raw_text="France", context=None, domains=None, inner=lambda: result ) - # Poison the FIRST returned result's reasons in place. - first.reasons.append(ReasonCode.INTERNAL_ERROR) + # Tuple fields are immutable — mutation is impossible, cache is safe by type. + with pytest.raises(AttributeError): + first.reasons.append(ReasonCode.INTERNAL_ERROR) # type: ignore[attr-defined] - # A subsequent hit must not see the poison. second = cache.get_or_call( raw_text="France", context=None, domains=None, inner=lambda: pytest.fail("inner should not run on a cache hit"), ) - assert ReasonCode.INTERNAL_ERROR not in second.reasons - assert second.reasons == [ReasonCode.EXACT_NAME_MATCH] + assert second.reasons == (ReasonCode.EXACT_NAME_MATCH,) class TestResolverEndToEnd: @@ -78,17 +101,25 @@ def resolver(self, geo_test_datapack: Any) -> Any: yield r r.close() - def test_reasons_mutation_does_not_poison_cache(self, resolver: Any) -> None: + def test_reasons_is_tuple(self, resolver: Any) -> None: first = resolver.resolve("United States") - if not first.reasons: - pytest.skip("fixture resolution carries no reasons to mutate") - first.reasons.append("CORRUPTED") - second = resolver.resolve("United States") - assert "CORRUPTED" not in second.reasons + assert isinstance(first.reasons, tuple) + + def test_candidates_is_tuple(self, resolver: Any) -> None: + first = resolver.resolve("United States") + assert isinstance(first.candidates, tuple) + + def test_reasons_mutation_raises(self, resolver: Any) -> None: + first = resolver.resolve("United States") + with pytest.raises(AttributeError): + first.reasons.append("CORRUPTED") # type: ignore[attr-defined] + + def test_candidates_mutation_raises(self, resolver: Any) -> None: + first = resolver.resolve("United States") + with pytest.raises(AttributeError): + first.candidates.clear() # type: ignore[attr-defined] - def test_candidates_mutation_does_not_poison_cache(self, resolver: Any) -> None: + def test_cache_returns_consistent_reasons(self, resolver: Any) -> None: first = resolver.resolve("United States") - before = len(first.candidates) - first.candidates.clear() second = resolver.resolve("United States") - assert len(second.candidates) == before + assert first.reasons == second.reasons diff --git a/tests/api/test_group_preference_tiebreak.py b/tests/api/test_group_preference_tiebreak.py index 7c4793a..49127d7 100644 --- a/tests/api/test_group_preference_tiebreak.py +++ b/tests/api/test_group_preference_tiebreak.py @@ -275,7 +275,7 @@ def test_rule_fires_when_unique_group_in_top_two() -> None: assert out.is_resolved assert out.entity_id == group.entity_id assert out.confidence == group.confidence - assert out.reasons == [ReasonCode.GROUP_PREFERENCE_TIEBREAK] + assert out.reasons == (ReasonCode.GROUP_PREFERENCE_TIEBREAK,) assert out.match_tier is None assert out.candidates == result.candidates # original candidates preserved @@ -293,7 +293,7 @@ def test_rule_fires_when_group_at_rank_two() -> None: assert out.entity_id == group.entity_id # Confidence must be the group's, NOT the rank-1 non-group's. assert out.confidence == group.confidence - assert out.reasons == [ReasonCode.GROUP_PREFERENCE_TIEBREAK] + assert out.reasons == (ReasonCode.GROUP_PREFERENCE_TIEBREAK,) assert out.match_tier is None diff --git a/tests/core/test_multi_runner.py b/tests/core/test_multi_runner.py index b7581ee..4ec68fc 100644 --- a/tests/core/test_multi_runner.py +++ b/tests/core/test_multi_runner.py @@ -450,7 +450,7 @@ def bulk_get_entities(self, entity_ids): assert result.status == ResolutionStatus.AMBIGUOUS assert result.match_tier == MatchTier.EXACT_NAME - assert result.reasons == [ReasonCode.AMBIGUOUS_DOMAIN_COLLISION] + assert result.reasons == (ReasonCode.AMBIGUOUS_DOMAIN_COLLISION,) assert RefinementHint.ENTITY_TYPES in result.refinement_hints assert {candidate.pack_id for candidate in result.candidates} == {"geo", "org"} diff --git a/tests/core/test_output_and_configure_validation.py b/tests/core/test_output_and_configure_validation.py index 957e550..ba4d160 100644 --- a/tests/core/test_output_and_configure_validation.py +++ b/tests/core/test_output_and_configure_validation.py @@ -56,17 +56,11 @@ def test_none_accepted(self) -> None: ctx = ResolutionContext(country=None) assert ctx.country is None - def test_iso3_rejected_with_alpha2_guidance(self) -> None: - from pydantic import ValidationError - + def test_iso3_accepted_and_uppercased(self) -> None: from resolvekit.core.model.query import ResolutionContext - with pytest.raises(ValidationError) as exc_info: - ResolutionContext(country="USA") - msg = str(exc_info.value) - # Must mention alpha-2 guidance, not just a generic max_length error. - assert "alpha-2" in msg - assert "USA" in msg + assert ResolutionContext(country="USA").country == "USA" + assert ResolutionContext(country="usa").country == "USA" def test_single_char_rejected(self) -> None: from pydantic import ValidationError @@ -100,16 +94,10 @@ def test_four_char_alpha_rejected(self) -> None: with pytest.raises(ValidationError): ResolutionContext(country="USAA") - def test_iso3_hint_mentions_alpha2_not_usa(self) -> None: - """USA hint message should say 'alpha-2 code' and suggest the pattern.""" - from pydantic import ValidationError - + def test_alpha2_accepted_and_uppercased(self) -> None: from resolvekit.core.model.query import ResolutionContext - with pytest.raises(ValidationError) as exc_info: - ResolutionContext(country="USA") - msg = str(exc_info.value) - assert "alpha-2" in msg + assert ResolutionContext(country="us").country == "US" # --------------------------------------------------------------------------- diff --git a/tests/core/test_query.py b/tests/core/test_query.py index e8e9066..a57b58a 100644 --- a/tests/core/test_query.py +++ b/tests/core/test_query.py @@ -183,3 +183,47 @@ def test_replace_deep_copies_mutable_fields(self): assert ctx.parent_ids == ["country/USA"] assert ctx.languages == ["en"] assert ctx.attributes == {"source": "crm"} + + def test_country_alpha2_accepted(self): + from resolvekit.core.model.query import ResolutionContext + + ctx = ResolutionContext(country="US") + assert ctx.country == "US" + + def test_country_alpha3_accepted(self): + from resolvekit.core.model.query import ResolutionContext + + ctx = ResolutionContext(country="USA") + assert ctx.country == "USA" + + def test_country_lowercase_normalised_to_upper(self): + from resolvekit.core.model.query import ResolutionContext + + ctx2 = ResolutionContext(country="us") + assert ctx2.country == "US" + ctx3 = ResolutionContext(country="usa") + assert ctx3.country == "USA" + + def test_country_single_char_rejected(self): + from resolvekit.core.model.query import ResolutionContext + + with pytest.raises(ValidationError): + ResolutionContext(country="U") + + def test_country_empty_string_rejected(self): + from resolvekit.core.model.query import ResolutionContext + + with pytest.raises(ValidationError): + ResolutionContext(country="") + + def test_country_nonalpha_rejected(self): + from resolvekit.core.model.query import ResolutionContext + + with pytest.raises(ValidationError): + ResolutionContext(country="1!") + + def test_country_four_char_rejected(self): + from resolvekit.core.model.query import ResolutionContext + + with pytest.raises(ValidationError): + ResolutionContext(country="USAA") diff --git a/tests/core/test_resolver_api.py b/tests/core/test_resolver_api.py index e490d0c..bacbd47 100644 --- a/tests/core/test_resolver_api.py +++ b/tests/core/test_resolver_api.py @@ -160,7 +160,7 @@ def test_resolved_result_includes_pack_and_match_tier(): assert result.status == ResolutionStatus.RESOLVED assert result.pack_id == "geo" assert result.match_tier == MatchTier.EXACT_CODE - assert result.refinement_hints == [] + assert result.refinement_hints == () def test_blank_query_returns_explicit_no_match(): @@ -177,7 +177,7 @@ def test_blank_query_returns_explicit_no_match(): result = resolver.resolve(" ") assert result.status == ResolutionStatus.NO_MATCH - assert result.reasons == [ReasonCode.INVALID_QUERY] + assert result.reasons == (ReasonCode.INVALID_QUERY,) def test_blank_query_with_explanation_returns_scorecard(): @@ -195,7 +195,7 @@ def test_blank_query_with_explanation_returns_scorecard(): result, scorecard = explained.result, explained.scorecard assert result.status == ResolutionStatus.NO_MATCH - assert result.reasons == [ReasonCode.INVALID_QUERY] + assert result.reasons == (ReasonCode.INVALID_QUERY,) assert scorecard.status == ResolutionStatus.NO_MATCH assert scorecard.normalized_text == "" @@ -284,7 +284,7 @@ def test_no_match_keeps_recovery_candidates_and_hints(): assert result.status == ResolutionStatus.NO_MATCH assert result.pack_id == "geo" assert result.match_tier == MatchTier.FTS - assert result.reasons == [ReasonCode.BELOW_CONFIDENCE_THRESHOLD] + assert result.reasons == (ReasonCode.BELOW_CONFIDENCE_THRESHOLD,) assert [candidate.entity_id for candidate in result.candidates] == [ "city/Springfield_US" ] @@ -968,48 +968,59 @@ def test_resolve_none_returns_no_match(self): resolver = _make_simple_resolver() result = resolver.resolve(None) # type: ignore[arg-type] assert result.status == ResolutionStatus.NO_MATCH - assert result.reasons == [ReasonCode.INVALID_INPUT_TYPE] + assert result.reasons == (ReasonCode.INVALID_INPUT_TYPE,) def test_resolve_empty_string_returns_no_match(self): resolver = _make_simple_resolver() result = resolver.resolve("") assert result.status == ResolutionStatus.NO_MATCH - assert result.reasons == [ReasonCode.INVALID_QUERY] + assert result.reasons == (ReasonCode.INVALID_QUERY,) def test_resolve_whitespace_returns_no_match(self): resolver = _make_simple_resolver() result = resolver.resolve(" \t\n") assert result.status == ResolutionStatus.NO_MATCH - assert result.reasons == [ReasonCode.INVALID_QUERY] + assert result.reasons == (ReasonCode.INVALID_QUERY,) def test_resolve_explained_none_returns_no_match(self): resolver = _make_simple_resolver() explained = resolver.resolve_explained(None) # type: ignore[arg-type] assert explained.result.status == ResolutionStatus.NO_MATCH - assert explained.result.reasons == [ReasonCode.INVALID_INPUT_TYPE] + assert explained.result.reasons == (ReasonCode.INVALID_INPUT_TYPE,) def test_resolve_explained_empty_returns_no_match(self): resolver = _make_simple_resolver() explained = resolver.resolve_explained("") assert explained.result.status == ResolutionStatus.NO_MATCH - assert explained.result.reasons == [ReasonCode.INVALID_QUERY] + assert explained.result.reasons == (ReasonCode.INVALID_QUERY,) - @pytest.mark.parametrize( - "value", - [float("nan"), b"United States", 840, 3.14, [], {}, object()], - ) - def test_resolve_non_string_returns_no_match(self, value): - """bytes / int / float / NaN / arbitrary objects must not crash.""" + @pytest.mark.parametrize("value", [float("nan"), 840, 3.14]) + def test_resolve_numeric_coerces_to_no_match(self, value): + """int / float / NaN are coerced to string and resolved (not INVALID_INPUT_TYPE).""" resolver = _make_simple_resolver() result = resolver.resolve(value) # type: ignore[arg-type] assert result.status == ResolutionStatus.NO_MATCH - assert result.reasons == [ReasonCode.INVALID_INPUT_TYPE] + assert ReasonCode.INVALID_INPUT_TYPE not in result.reasons + + @pytest.mark.parametrize("value", [b"United States", [], {}, object()]) + def test_resolve_unsupported_types_raise(self, value): + """bytes / list / dict / arbitrary objects raise TypeError.""" + resolver = _make_simple_resolver() + with pytest.raises(TypeError): + resolver.resolve(value) # type: ignore[arg-type] - @pytest.mark.parametrize("value", [float("nan"), b"X", 840]) - def test_resolve_id_non_string_returns_none(self, value): + @pytest.mark.parametrize("value", [float("nan"), 840]) + def test_resolve_id_numeric_coerces_to_none(self, value): + """int / float coerce to string; mock store has no match → returns None.""" resolver = _make_simple_resolver() assert resolver.resolve_id(value) is None # type: ignore[arg-type] + def test_resolve_id_bytes_raises(self): + """bytes raises TypeError (not silently None).""" + resolver = _make_simple_resolver() + with pytest.raises(TypeError): + resolver.resolve_id(b"X") # type: ignore[arg-type] + class TestResolveIdErrorRaise: def test_resolve_id_raises_on_error_status(self): diff --git a/tests/core/test_result.py b/tests/core/test_result.py index fd1bc02..8edd886 100644 --- a/tests/core/test_result.py +++ b/tests/core/test_result.py @@ -133,7 +133,7 @@ def test_create_resolved_result(self): assert result.pack_id == "geo" assert result.match_tier == MatchTier.EXACT_CODE assert ReasonCode.EXACT_CODE_MATCH in result.reasons - assert result.refinement_hints == [RefinementHint.COUNTRY] + assert result.refinement_hints == (RefinementHint.COUNTRY,) def test_create_no_match_result(self): from resolvekit.core.model.result import ( diff --git a/tests/packs/geo/test_geo_decision.py b/tests/packs/geo/test_geo_decision.py index 1d06df5..8298337 100644 --- a/tests/packs/geo/test_geo_decision.py +++ b/tests/packs/geo/test_geo_decision.py @@ -52,7 +52,7 @@ def test_fuzzy_tier_win_returns_fuzzy_match(self) -> None: ) assert result.status == ResolutionStatus.RESOLVED - assert result.reasons == [ReasonCode.FUZZY_MATCH] + assert result.reasons == (ReasonCode.FUZZY_MATCH,) def test_geo_fuzzy_reranker_source_returns_fuzzy_match(self) -> None: """Both geo fuzzy sources (geo_fuzzy and geo_fuzzy_retrieval) report FUZZY_MATCH.""" @@ -94,7 +94,7 @@ def test_geo_fuzzy_reranker_source_returns_fuzzy_match(self) -> None: result = policy.decide(query, ResolutionContext(), [candidate], NullTraceSink()) assert result.status == ResolutionStatus.RESOLVED - assert result.reasons == [ReasonCode.FUZZY_MATCH] + assert result.reasons == (ReasonCode.FUZZY_MATCH,) def test_fuzzy_tier_win_is_resolved(self) -> None: """Sanity: FUZZY-tier single candidate reaches RESOLVED with the expected entity_id.""" diff --git a/tests/packs/org/test_country_relevance.py b/tests/packs/org/test_country_relevance.py index a660ba9..a9b8bc2 100644 --- a/tests/packs/org/test_country_relevance.py +++ b/tests/packs/org/test_country_relevance.py @@ -102,6 +102,102 @@ def bulk_get_entities(self, entity_ids): assert de_country.passed is False assert de_country.severity == Severity.SOFT + def test_alpha3_hint_matches_alpha2_country_code(self): + from resolvekit.core.explain import NullTraceSink + from resolvekit.core.model import ( + Candidate, + CandidateEvidence, + NormalizedText, + Query, + ResolutionContext, + RetrievalSummary, + ScoreSummary, + ) + from resolvekit.core.store import EntityStore + from resolvekit.packs.org.constraints.country_relevance import ( + CountryRelevanceConstraint, + ) + + class MockStore(EntityStore): + def get_entity(self, entity_id): + from resolvekit.core.model import EntityRecord + + return EntityRecord( + entity_id=entity_id, + entity_type="org.ngo", + canonical_name="Test Org", + canonical_name_norm="test org", + attributes={"country_code": "US" if "US" in entity_id else "DE"}, + ) + + def lookup_code(self, system, value_norm): + return [] + + def lookup_name_exact(self, value_norm, name_kinds=None): + return [] + + def search_fulltext(self, query_norm, fields=None, limit=10): + return [] + + def bulk_get_entities(self, entity_ids): + return {eid: self.get_entity(eid) for eid in entity_ids} + + constraint = CountryRelevanceConstraint() + query = Query( + raw_text="Test Org", + normalized=NormalizedText(original="Test Org", normalized="test org"), + ) + context = ResolutionContext(country="USA") # alpha-3 for United States + + candidates = [ + Candidate( + entity_id="org/TestOrg_US", + sources=[ + CandidateEvidence( + entity_id="org/TestOrg_US", + source_name="org_exact_name", + raw_score=1.0, + ) + ], + retrieval=RetrievalSummary(best_source="org_exact_name"), + scores=ScoreSummary(raw_score=0.85, calibrated_score=0.85), + ), + Candidate( + entity_id="org/TestOrg_DE", + sources=[ + CandidateEvidence( + entity_id="org/TestOrg_DE", + source_name="org_exact_name", + raw_score=1.0, + ) + ], + retrieval=RetrievalSummary(best_source="org_exact_name"), + scores=ScoreSummary(raw_score=0.85, calibrated_score=0.85), + ), + ] + + result = constraint.apply( + query, context, candidates, MockStore(), NullTraceSink() + ) + + # Both should remain (soft constraint) + assert len(result) == 2 + + us_country = next( + co + for co in result[0].constraint_outcomes + if co.constraint_name == "org_country_relevance" + ) + de_country = next( + co + for co in result[1].constraint_outcomes + if co.constraint_name == "org_country_relevance" + ) + + # "USA" (alpha-3) should resolve to "US" (alpha-2) and match the US org + assert us_country.passed is True + assert de_country.passed is False + def test_no_filter_without_context(self): from resolvekit.core.explain import NullTraceSink from resolvekit.core.model import ( diff --git a/tests/packs/test_geo_constraints.py b/tests/packs/test_geo_constraints.py index f6b3a24..605f3e3 100644 --- a/tests/packs/test_geo_constraints.py +++ b/tests/packs/test_geo_constraints.py @@ -597,6 +597,84 @@ def get_relations(self, entity_id, relation_type=None): for co in cl_candidate.constraint_outcomes ) + def test_uses_alpha3_country_as_parent_filter(self): + from resolvekit.core.explain import NullTraceSink + from resolvekit.core.model import ( + Candidate, + CandidateEvidence, + NormalizedText, + Query, + ResolutionContext, + RetrievalSummary, + ScoreSummary, + ) + from resolvekit.core.store import EntityStore + from resolvekit.packs.geo.constraints.containment import ( + GeoContainmentConstraint, + ) + + class MockStore(EntityStore): + def get_entity(self, entity_id): + return None + + def lookup_code(self, system, value_norm): + if system == "iso3" and value_norm == "gtm": + return ["country/GTM"] + return [] + + def lookup_name_exact(self, value_norm, name_kinds=None): + return [] + + def search_fulltext(self, query_norm, fields=None, limit=10): + return [] + + def bulk_get_entities(self, entity_ids): + return {} + + def get_relations(self, entity_id, relation_type=None): + if relation_type != "contained_in": + return [] + if entity_id == "city/ConcepcionGT": + return ["country/GTM"] + if entity_id == "city/ConcepcionCL": + return ["country/CHL"] + return [] + + constraint = GeoContainmentConstraint() + query = Query( + raw_text="concepcion", + normalized=NormalizedText(original="concepcion", normalized="concepcion"), + ) + context = ResolutionContext(country="GTM") + + gt_candidate = Candidate( + entity_id="city/ConcepcionGT", + sources=[ + CandidateEvidence( + entity_id="city/ConcepcionGT", source_name="test", raw_score=0.9 + ) + ], + retrieval=RetrievalSummary(best_source="test"), + scores=ScoreSummary(raw_score=0.9, calibrated_score=0.9), + ) + cl_candidate = Candidate( + entity_id="city/ConcepcionCL", + sources=[ + CandidateEvidence( + entity_id="city/ConcepcionCL", source_name="test", raw_score=0.9 + ) + ], + retrieval=RetrievalSummary(best_source="test"), + scores=ScoreSummary(raw_score=0.9, calibrated_score=0.9), + ) + + result = constraint.apply( + query, context, [gt_candidate, cl_candidate], MockStore(), NullTraceSink() + ) + + assert len(result) == 1 + assert result[0].entity_id == "city/ConcepcionGT" + def test_country_without_match_does_not_filter(self): from resolvekit.core.explain import NullTraceSink from resolvekit.core.model import ( diff --git a/tests/test_numeric_coercion.py b/tests/test_numeric_coercion.py new file mode 100644 index 0000000..89e6f07 --- /dev/null +++ b/tests/test_numeric_coercion.py @@ -0,0 +1,257 @@ +"""Unit tests for numeric-input coercion helpers and bulk integration. + +Covers: +- _numeric_to_str: int / float canonical string conversion +- _coerce_item_to_str: collection-element coercion (used in _flatten_input) +- bulk() _flatten_input paths: list, tuple, dict, pandas, polars, numpy +- resolve() / resolve_id() TypeError surface for bool and unsupported types +""" + +from __future__ import annotations + +import math +from typing import Any + +import pytest + +from resolvekit.core.api.bulk import _coerce_item_to_str, _numeric_to_str + +# --------------------------------------------------------------------------- +# _numeric_to_str unit tests +# --------------------------------------------------------------------------- + + +class TestNumericToStr: + def test_int_unchanged(self) -> None: + assert _numeric_to_str(840) == "840" + + def test_zero_int(self) -> None: + assert _numeric_to_str(0) == "0" + + def test_negative_int(self) -> None: + assert _numeric_to_str(-1) == "-1" + + def test_integral_float_strips_decimal(self) -> None: + assert _numeric_to_str(840.0) == "840" + + def test_integral_float_zero(self) -> None: + assert _numeric_to_str(0.0) == "0" + + def test_non_integral_float_unchanged(self) -> None: + assert _numeric_to_str(840.5) == "840.5" + + def test_nan_falls_back_to_str(self) -> None: + # NaN is not equal to its int cast — falls back to str(). + result = _numeric_to_str(float("nan")) + assert result == "nan" + + def test_inf_falls_back_to_str(self) -> None: + result = _numeric_to_str(math.inf) + assert result == "inf" + + def test_large_integral_float(self) -> None: + assert _numeric_to_str(1_000_000.0) == "1000000" + + +# --------------------------------------------------------------------------- +# _coerce_item_to_str unit tests +# --------------------------------------------------------------------------- + + +class TestCoerceItemToStr: + def test_str_passthrough(self) -> None: + assert _coerce_item_to_str("France") == "France" + + def test_int_coerced(self) -> None: + assert _coerce_item_to_str(840) == "840" + + def test_integral_float_coerced(self) -> None: + assert _coerce_item_to_str(840.0) == "840" + + def test_non_integral_float_str(self) -> None: + assert _coerce_item_to_str(840.5) == "840.5" + + def test_bool_true_not_coerced_numerically(self) -> None: + # bool is an int subclass, but _coerce_item_to_str must NOT + # call _numeric_to_str for bool — it falls back to str(). + assert _coerce_item_to_str(True) == "True" + assert _coerce_item_to_str(False) == "False" + + def test_arbitrary_object_fallback(self) -> None: + assert _coerce_item_to_str(None) == "None" # caller should filter None first + + +# --------------------------------------------------------------------------- +# _flatten_input list/tuple/dict paths +# --------------------------------------------------------------------------- + + +class TestFlattenInputListPath: + """_flatten_input uses _coerce_item_to_str for list/tuple/dict values.""" + + def _flatten(self, kind: str, raw: Any) -> list[str | None]: + from resolvekit.core.api.bulk import _flatten_input + + items, *_ = _flatten_input(kind, raw) # type: ignore[arg-type] + return items + + def test_list_int_coerced(self) -> None: + assert self._flatten("list", [840, None, "France"]) == ["840", None, "France"] + + def test_list_integral_float_coerced(self) -> None: + assert self._flatten("list", [840.0, None]) == ["840", None] + + def test_tuple_int_coerced(self) -> None: + assert self._flatten("tuple", (840,)) == ["840"] + + def test_dict_values_coerced(self) -> None: + result = self._flatten("dict", {"a": 840, "b": None, "c": "France"}) + assert result == ["840", None, "France"] + + def test_bool_values_unchanged_in_bulk(self) -> None: + # bulk() does NOT reject bools — they become "True"/"False" (existing behaviour). + assert self._flatten("list", [True, False]) == ["True", "False"] + + +# --------------------------------------------------------------------------- +# Pandas/numpy paths (conditional on library presence) +# --------------------------------------------------------------------------- + + +import importlib.util + +_HAS_NUMPY = importlib.util.find_spec("numpy") is not None +_HAS_PANDAS = importlib.util.find_spec("pandas") is not None +_HAS_POLARS = importlib.util.find_spec("polars") is not None + + +@pytest.mark.skipif(not _HAS_NUMPY, reason="numpy not installed") +class TestFlattenInputNumpyPath: + def _flatten(self, raw: Any) -> list[str | None]: + from resolvekit.core.api.bulk import _flatten_input + + items, *_ = _flatten_input("numpy", raw) + return items + + def test_float64_integral_coerced(self) -> None: + import numpy as np + + arr = np.array([840.0, 250.0], dtype=np.float64) + result = self._flatten(arr) + assert result == ["840", "250"] + + def test_int_array_coerced(self) -> None: + import numpy as np + + arr = np.array([840, 250], dtype=np.int64) + result = self._flatten(arr) + assert result == ["840", "250"] + + def test_nan_becomes_none(self) -> None: + import numpy as np + + arr = np.array([840.0, float("nan")], dtype=np.float64) + result = self._flatten(arr) + assert result == ["840", None] + + +@pytest.mark.skipif(not _HAS_PANDAS, reason="pandas not installed") +class TestFlattenInputPandasPath: + def _flatten(self, raw: Any) -> list[str | None]: + from resolvekit.core.api.bulk import _flatten_input + + items, *_ = _flatten_input("pandas", raw) + return items + + def test_float64_integral_coerced(self) -> None: + import pandas as pd + + s = pd.Series([840.0, 250.0]) + result = self._flatten(s) + assert result == ["840", "250"] + + def test_int_series_coerced(self) -> None: + import pandas as pd + + s = pd.Series([840, 250], dtype=int) + result = self._flatten(s) + assert result == ["840", "250"] + + def test_na_becomes_none(self) -> None: + import pandas as pd + + s = pd.Series([840.0, float("nan")]) + result = self._flatten(s) + assert result == ["840", None] + + +@pytest.mark.skipif(not _HAS_POLARS, reason="polars not installed") +class TestFlattenInputPolarsPath: + def _flatten(self, raw: Any) -> list[str | None]: + from resolvekit.core.api.bulk import _flatten_input + + items, *_ = _flatten_input("polars", raw) + return items + + def test_float_integral_coerced(self) -> None: + import polars as pl + + s = pl.Series([840.0, 250.0]) + result = self._flatten(s) + assert result == ["840", "250"] + + def test_int_series_coerced(self) -> None: + import polars as pl + + s = pl.Series([840, 250]) + result = self._flatten(s) + assert result == ["840", "250"] + + def test_null_becomes_none(self) -> None: + import polars as pl + + s = pl.Series([840.0, None]) + result = self._flatten(s) + assert result == ["840", None] + + +# --------------------------------------------------------------------------- +# resolve() / resolve_id() TypeError surface (no data needed — uses mock) +# --------------------------------------------------------------------------- + + +class TestResolveTypeErrors: + """TypeError is raised for bool and other unsupported scalar types.""" + + @pytest.fixture + def resolver(self, geo_test_datapack: Any) -> Any: + from resolvekit.core.api.resolver import Resolver + + r = Resolver.from_datapacks(datapack_paths=[geo_test_datapack]) + yield r + r.close() + + def test_bool_true_raises(self, resolver: Any) -> None: + with pytest.raises(TypeError, match="bool"): + resolver.resolve(True) + + def test_bool_false_raises(self, resolver: Any) -> None: + with pytest.raises(TypeError, match="bool"): + resolver.resolve(False) + + def test_bytes_raises(self, resolver: Any) -> None: + with pytest.raises(TypeError): + resolver.resolve(b"US") + + def test_none_returns_no_match_result(self, resolver: Any) -> None: + from resolvekit.core.model import ResolutionStatus + + result = resolver.resolve(None, to=None) + assert result.status == ResolutionStatus.NO_MATCH + + def test_none_resolve_id_returns_none(self, resolver: Any) -> None: + assert resolver.resolve_id(None) is None + + def test_bool_resolve_id_raises(self, resolver: Any) -> None: + with pytest.raises(TypeError, match="bool"): + resolver.resolve_id(True) diff --git a/tests/test_pandas_accessor.py b/tests/test_pandas_accessor.py index 90a8f91..36c2f6d 100644 --- a/tests/test_pandas_accessor.py +++ b/tests/test_pandas_accessor.py @@ -104,3 +104,67 @@ def test_accessor_bulk_method_exists(): s = pd.Series(["US"]) acc = s.resolvekit assert hasattr(acc, "bulk") + + +# --------------------------------------------------------------------------- +# on_error propagation: caller mistakes must raise, not silently produce Nones +# --------------------------------------------------------------------------- + + +def test_resolve_bad_to_raises_unknown_code_system(): + """resolve(to='iso33') must raise UnknownCodeSystemError, not all-None.""" + import resolvekit.pandas # noqa: F401 + from resolvekit.core.errors import UnknownCodeSystemError + + s = pd.Series(["France"]) + with pytest.raises(UnknownCodeSystemError): + s.resolvekit.resolve(to="iso33") + + +def test_resolve_bad_domain_raises_unknown_domain(): + """resolve(domain='bad_xyz') must raise UnknownDomainError, not all-None.""" + import resolvekit.pandas # noqa: F401 + from resolvekit.core.errors import UnknownDomainError + + s = pd.Series(["France"]) + with pytest.raises(UnknownDomainError): + s.resolvekit.resolve(to="iso3", domain="bad_xyz") + + +def test_resolve_bad_on_ambiguous_raises_value_error(): + """resolve(on_ambiguous='typo') must raise ValueError for invalid param.""" + import resolvekit.pandas # noqa: F401 + + s = pd.Series(["France"]) + with pytest.raises(ValueError, match="on_ambiguous="): + s.resolvekit.resolve(to="iso3", on_ambiguous="typo") + + +def test_resolve_valid_call_still_works(): + """resolve(to='iso3') with valid args must return correct codes.""" + import resolvekit.pandas # noqa: F401 + + s = pd.Series(["France", "Germany"]) + result = s.resolvekit.resolve(to="iso3") + assert isinstance(result, pd.Series) + assert result.tolist() == ["FRA", "DEU"] + + +def test_resolve_on_error_null_returns_none_rows(): + """resolve(on_error='null') must suppress per-row errors and return None.""" + import resolvekit.pandas # noqa: F401 + + s = pd.Series(["France"]) + result = s.resolvekit.resolve(to="iso3", from_system="bad_sys", on_error="null") + assert isinstance(result, pd.Series) + assert result.tolist() == [None] + + +def test_bulk_on_error_default_is_raise(): + """bulk() must default on_error='raise', not 'null'.""" + import resolvekit.pandas # noqa: F401 + from resolvekit.core.errors import UnknownCodeSystemError + + s = pd.Series(["France"]) + with pytest.raises(UnknownCodeSystemError): + s.resolvekit.bulk(to="iso33") diff --git a/tests/test_polars_accessor.py b/tests/test_polars_accessor.py index e3198dc..4d2ef98 100644 --- a/tests/test_polars_accessor.py +++ b/tests/test_polars_accessor.py @@ -88,3 +88,77 @@ def test_resolve_returns_expr(): expr = pl.col("country").resolvekit.resolve(to="iso3") assert isinstance(expr, pl.Expr) + + +# --------------------------------------------------------------------------- +# on_error propagation: caller mistakes must raise, not silently produce Nones +# --------------------------------------------------------------------------- + + +def test_resolve_valid_call_returns_string_column(): + """resolve(to='iso3') with valid args must return correct codes as strings.""" + import resolvekit.polars # noqa: F401 + + df = pl.DataFrame({"country": ["France", "Germany", None]}) + result = df.with_columns( + pl.col("country").resolvekit.resolve(to="iso3").alias("iso3") + ) + assert result["iso3"].to_list() == ["FRA", "DEU", None] + + +def test_resolve_bad_to_raises_unknown_code_system(): + """resolve(to='iso33') must raise UnknownCodeSystemError, not all-None.""" + import resolvekit.polars # noqa: F401 + from resolvekit.core.errors import UnknownCodeSystemError + + df = pl.DataFrame({"country": ["France"]}) + with pytest.raises(UnknownCodeSystemError): + df.with_columns(pl.col("country").resolvekit.resolve(to="iso33").alias("out")) + + +def test_resolve_bad_domain_raises_unknown_domain(): + """resolve(domain='bad_xyz') must raise UnknownDomainError, not all-None.""" + import resolvekit.polars # noqa: F401 + from resolvekit.core.errors import UnknownDomainError + + df = pl.DataFrame({"country": ["France"]}) + with pytest.raises(UnknownDomainError): + df.with_columns( + pl.col("country").resolvekit.resolve(to="iso3", domain="bad_xyz").alias("out") + ) + + +def test_resolve_bad_on_ambiguous_raises_value_error(): + """resolve(on_ambiguous='typo') must raise ValueError for invalid param.""" + import resolvekit.polars # noqa: F401 + + df = pl.DataFrame({"country": ["France"]}) + with pytest.raises(ValueError, match="on_ambiguous="): + df.with_columns( + pl.col("country").resolvekit.resolve(to="iso3", on_ambiguous="typo").alias("out") + ) + + +def test_resolve_on_error_null_returns_none_rows(): + """resolve(on_error='null') must suppress per-row errors and return None.""" + import resolvekit.polars # noqa: F401 + + df = pl.DataFrame({"country": ["France"]}) + result = df.with_columns( + pl.col("country") + .resolvekit.resolve(to="iso3", from_system="bad_sys", on_error="null") + .alias("out") + ) + assert result["out"].to_list() == [None] + + +def test_resolve_on_error_default_is_raise(): + """resolve() must default on_error='raise', not 'null'.""" + import resolvekit.polars # noqa: F401 + from resolvekit.core.errors import UnknownCodeSystemError + + df = pl.DataFrame({"country": ["France"]}) + with pytest.raises(UnknownCodeSystemError): + df.with_columns( + pl.col("country").resolvekit.resolve(to="iso33").alias("out") + ) diff --git a/tests/test_public_api.py b/tests/test_public_api.py index f106ac7..39cf64c 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -21,11 +21,16 @@ def _all(module_name: str) -> tuple[str, ...]: "AugmentResult", "BulkResult", "Crosswalk", + "CrosswalkError", + "DataPackNotAvailableError", "DroppedSpan", "EntityNotFoundError", "EntityRecord", + "ExplainNotAvailableError", "GroupNotFoundError", "IGNORE", + "NoModulesInstalledError", + "OutputMissingError", "ParseResult", "ParsedEntity", "ResolutionContext", @@ -34,6 +39,9 @@ def _all(module_name: str) -> tuple[str, ...]: "ResolutionStatus", "Resolver", "ResolverError", + "UnknownCodeSystemError", + "UnknownDomainError", + "UnknownOutputError", "bulk", "configure", "download", @@ -240,7 +248,7 @@ def test_resolvekit_all() -> None: def test_resolvekit_all_count() -> None: - assert len(resolvekit.__all__) == 28 + assert len(resolvekit.__all__) == 36 def test_resolvekit_all_excludes_removed_names() -> None: diff --git a/tests/test_resolve_id.py b/tests/test_resolve_id.py index 677279c..eecda53 100644 --- a/tests/test_resolve_id.py +++ b/tests/test_resolve_id.py @@ -1,9 +1,15 @@ -"""resolve_id on_ambiguous kwarg tests. +"""resolve_id on_ambiguous kwarg and numeric coercion tests. Tests for the Resolver.resolve_id() on_ambiguous parameter: - "raise" (default): raises AmbiguousResolutionError - "null": returns None on AMBIGUOUS - "best": returns the top candidate's entity_id + +Tests for numeric input coercion: +- int / float coerced to canonical string form (840 → "840", 840.0 → "840") +- None returns None silently +- bool raises TypeError +- bytes / arbitrary types raise TypeError """ from __future__ import annotations @@ -167,3 +173,86 @@ def test_resolve_id_lowercase_iso2_with_from_system(self, resolver: Any) -> None def test_resolve_id_lowercase_iso3_with_from_system(self, resolver: Any) -> None: assert resolver.resolve_id("usa", from_system="iso3") == "country/USA" + + +# --------------------------------------------------------------------------- +# Bundled-data tests for numeric input coercion +# --------------------------------------------------------------------------- + + +def _bundled_geo_available() -> bool: + try: + from resolvekit import Resolver + + r = Resolver.from_modules(module_ids=["geo.countries"]) + r.close() + return True + except Exception: + return False + + +_BUNDLED = _bundled_geo_available() +bundled = pytest.mark.skipif( + not _BUNDLED, reason="bundled geo.countries data not available" +) + + +@bundled +class TestNumericCoercionResolveId: + """resolve_id / resolve numeric-input coercion against bundled geo data.""" + + @pytest.fixture(scope="class") + def resolver(self) -> Any: + from resolvekit.core.api.resolver import Resolver + + r = Resolver.from_modules(module_ids=["geo.countries"]) + yield r + r.close() + + def test_int_resolves_usa(self, resolver: Any) -> None: + """840 (int) should resolve to country/USA via iso_numeric coercion.""" + assert resolver.resolve_id(840) == "country/USA" + + def test_integral_float_resolves_usa(self, resolver: Any) -> None: + """840.0 (float) should resolve identically to 840 (int).""" + assert resolver.resolve_id(840.0) == "country/USA" + + def test_none_returns_none(self, resolver: Any) -> None: + """None input returns None (unchanged NO_MATCH behaviour).""" + assert resolver.resolve_id(None) is None + + def test_bool_raises_type_error(self, resolver: Any) -> None: + """bool is an int subclass but must raise TypeError to avoid True→'1'.""" + with pytest.raises(TypeError, match="bool"): + resolver.resolve_id(True) + with pytest.raises(TypeError, match="bool"): + resolver.resolve_id(False) + + def test_bytes_raises_type_error(self, resolver: Any) -> None: + """Unsupported types raise TypeError.""" + with pytest.raises(TypeError): + resolver.resolve_id(b"840") + + def test_list_raises_type_error_with_bulk_hint(self, resolver: Any) -> None: + """Non-empty list raises TypeError with a hint to bulk().""" + with pytest.raises(TypeError, match="bulk"): + resolver.resolve_id(["840"]) + + def test_module_level_resolve_id_int(self) -> None: + """Module-level resolve_id() also coerces int input.""" + import resolvekit as rk + + assert rk.resolve_id(840) == "country/USA" + + def test_module_level_resolve_id_integral_float(self) -> None: + """Module-level resolve_id() also coerces integral float input.""" + import resolvekit as rk + + assert rk.resolve_id(840.0) == "country/USA" + + def test_resolver_int_coercion_consistent_with_string(self, resolver: Any) -> None: + """resolve(840) produces the same resolution as resolve('840').""" + # Both should land on country/USA via iso_numeric. + r_str = resolver.resolve("840", to=None) + r_int = resolver.resolve(840, to=None) + assert r_int.entity_id == r_str.entity_id From 0cf822aa3e36ff39afb012e53d6e4bd857777a56 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 11 Jun 2026 21:41:19 +0200 Subject: [PATCH 4/9] style: format test_polars_accessor.py --- tests/test_polars_accessor.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/test_polars_accessor.py b/tests/test_polars_accessor.py index 4d2ef98..22fe948 100644 --- a/tests/test_polars_accessor.py +++ b/tests/test_polars_accessor.py @@ -124,7 +124,9 @@ def test_resolve_bad_domain_raises_unknown_domain(): df = pl.DataFrame({"country": ["France"]}) with pytest.raises(UnknownDomainError): df.with_columns( - pl.col("country").resolvekit.resolve(to="iso3", domain="bad_xyz").alias("out") + pl.col("country") + .resolvekit.resolve(to="iso3", domain="bad_xyz") + .alias("out") ) @@ -135,7 +137,9 @@ def test_resolve_bad_on_ambiguous_raises_value_error(): df = pl.DataFrame({"country": ["France"]}) with pytest.raises(ValueError, match="on_ambiguous="): df.with_columns( - pl.col("country").resolvekit.resolve(to="iso3", on_ambiguous="typo").alias("out") + pl.col("country") + .resolvekit.resolve(to="iso3", on_ambiguous="typo") + .alias("out") ) @@ -159,6 +163,4 @@ def test_resolve_on_error_default_is_raise(): df = pl.DataFrame({"country": ["France"]}) with pytest.raises(UnknownCodeSystemError): - df.with_columns( - pl.col("country").resolvekit.resolve(to="iso33").alias("out") - ) + df.with_columns(pl.col("country").resolvekit.resolve(to="iso33").alias("out")) From fe6d8a7780cc625d374b3a653e62b91cf0d972f6 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 11 Jun 2026 21:51:24 +0200 Subject: [PATCH 5/9] style: move importlib import to top of test_numeric_coercion --- tests/test_numeric_coercion.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_numeric_coercion.py b/tests/test_numeric_coercion.py index 89e6f07..c7b6c0d 100644 --- a/tests/test_numeric_coercion.py +++ b/tests/test_numeric_coercion.py @@ -9,6 +9,7 @@ from __future__ import annotations +import importlib.util import math from typing import Any @@ -117,9 +118,6 @@ def test_bool_values_unchanged_in_bulk(self) -> None: # Pandas/numpy paths (conditional on library presence) # --------------------------------------------------------------------------- - -import importlib.util - _HAS_NUMPY = importlib.util.find_spec("numpy") is not None _HAS_PANDAS = importlib.util.find_spec("pandas") is not None _HAS_POLARS = importlib.util.find_spec("polars") is not None From 78f876b61faa8e413435f8bdec63606da92f1d5a Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 11 Jun 2026 23:02:56 +0200 Subject: [PATCH 6/9] =?UTF-8?q?perf:=20hide=20SymSpell=20cold=20start=20?= =?UTF-8?q?=E2=80=94=20background=20warm-up,=20warm()=20API,=20compiled=20?= =?UTF-8?q?index=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first query past the exact-match tiers paid the lazy SymSpell build inline (~6s for the geo large tier on remote-data installs). Now: - Resolver construction starts a daemon thread that pre-builds lazy indexes (warm=True default on all constructors; warm=False opts out) - rk.warm() / Resolver.warm(): synchronous, idempotent readiness API - The built large-tier index is cached as a locally-generated pickle under /compiled/, keyed by dict files + symspellpy version; next process loads in ~1.4s instead of rebuilding for ~6s - After-fork hook resets build locks so a fork during warm-up cannot deadlock the child; unique temp names + age-gated reaping make concurrent same-process cache writes safe Opus-reviewed (two findings, both fixed and re-confirmed). --- CHANGELOG.md | 14 + docs/explanation/how-resolution-works.md | 2 + docs/reference/api.md | 40 +++ docs/reference/resolver.md | 23 ++ src/resolvekit/__init__.py | 2 + src/resolvekit/_convenience.py | 13 + src/resolvekit/core/api/loading/paths.py | 2 + src/resolvekit/core/api/resolver.py | 72 ++++ src/resolvekit/core/engine/interfaces.py | 8 + src/resolvekit/core/engine/multi_runner.py | 5 + src/resolvekit/core/engine/runner.py | 18 + src/resolvekit/packs/geo/pack.py | 6 + src/resolvekit/packs/geo/sources/symspell.py | 4 +- .../shared/sources/symspell_base.py | 197 ++++++++++- tests/core/test_warm.py | 210 ++++++++++++ tests/packs/test_symspell_lazy.py | 74 +++- tests/shared/test_symspell_compiled_cache.py | 315 ++++++++++++++++++ tests/test_public_api.py | 3 +- 18 files changed, 1001 insertions(+), 7 deletions(-) create mode 100644 tests/core/test_warm.py create mode 100644 tests/shared/test_symspell_compiled_cache.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0749819..998f278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,20 @@ stale confidence figures in the tutorials; documented that code auto-detection is case-sensitive by design while `from_system` is case-insensitive. +**Performance.** The SymSpell typo index is now built in a background daemon +thread during `Resolver` construction (default on), so the build cost no longer +lands on the first query that passes the exact-match tiers. Opt out with +`warm=False` on any constructor (`Resolver.auto(warm=False)`, +`Resolver.from_modules(warm=False)`, etc.) to keep construction fully lazy. +`resolvekit.warm()` and `Resolver.warm()` are new synchronous, idempotent, +thread-safe functions that build all lazy indexes and return when they're ready +— for servers or batch jobs that want deterministic readiness. The large-tier +SymSpell index (706k terms, ~6 s to build on remote-data installs) is now +cached as a locally-generated pickle under `/compiled/` after its +first build (~150 MB, loads in ~1.4 s on subsequent processes), keyed by the +dictionary files and symspellpy version; existing bundled-only installs are +unaffected. + ## 0.1.2 (2026-06-11) **Fixed.** `download()` crashed on a clean install with "Missing package diff --git a/docs/explanation/how-resolution-works.md b/docs/explanation/how-resolution-works.md index 26b0c4e..fca608d 100644 --- a/docs/explanation/how-resolution-works.md +++ b/docs/explanation/how-resolution-works.md @@ -66,6 +66,8 @@ The pipeline runs several sources in order and merges the results before scoring **SymSpell typo correction** — generates spelling corrections for the input before re-running exact and name lookups. `"Germny"` → corrected to `"germany"` → hits exact_name for `country/DEU`. The corrected form still shows `exact_name` as the match tier because the corrected string matched the canonical name exactly; the SymSpell step is the source, not the tier. +The SymSpell index is built lazily: it is constructed the first time a query reaches the fuzzy tier, not during `Resolver` construction. On installs with the remote data tiers (admin2–admin5, cities), the 706k-term dictionary takes ~6 seconds to build. By default, `Resolver` construction starts a background thread that pre-builds the index so this cost does not land on a query. Call [`rk.warm()`](../reference/api.md#warm) or [`Resolver.warm()`](../reference/resolver.md#resolverwarm) to build all indexes synchronously before processing begins. Pass `warm=False` to any constructor to keep the original fully-lazy behavior. + When an exact-code or exact-name hit is found, the pipeline stops generating candidates — there's no point running fuzzy after a certain match. ## Point-in-time filter (as_of) diff --git a/docs/reference/api.md b/docs/reference/api.md index 269ea47..1848701 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -497,6 +497,46 @@ Close and discard the singleton resolver. The next call to any resolution functi --- +### `warm` { #warm } + +```python +rk.warm() -> None +``` + +Build all lazily-constructed indexes in the singleton resolver now and block until they are ready. Idempotent — calling it again when indexes are already built returns immediately. Thread-safe. + +By default, `Resolver` construction starts a background daemon thread that pre-builds the SymSpell typo index so the cost does not land on the first query. `warm()` is for servers and batch jobs that need the resolver to be fully ready before processing begins, rather than relying on background preparation. + +`Resolver.warm()` is the same operation on an explicit resolver instance: + +```python +from resolvekit import Resolver + +r = Resolver.auto() +r.warm() # block here until all indexes are ready +r.resolve("France") # no index-build latency +``` + +The module-level call warms the singleton resolver, constructing it first if it hasn't been created yet: + +```python +import resolvekit as rk + +rk.warm() # build the singleton resolver and all its indexes +rk.resolve("France") # ready immediately +``` + +To skip background warm-up entirely and keep construction fully lazy, pass `warm=False` to any constructor: + +```python +r = Resolver.auto(warm=False) +r = Resolver.from_modules(module_ids=["geo.countries", "geo.admin1"], warm=False) +``` + +The compiled index cache reduces the SymSpell build cost from ~6 s to ~1.4 s on installs with remote data tiers (admin2–admin5, cities). The cache file is stored under `/compiled/` (the same directory as the remote data tiers; see [`configure(cache_dir=...)`](#configure)), keyed by the dictionary files and symspellpy version. It is generated locally and never downloaded. If the cache directory is read-only, the library silently skips writing it. + +--- + ### `default` { #default } ```python diff --git a/docs/reference/resolver.md b/docs/reference/resolver.md index 8356ffe..be7fea5 100644 --- a/docs/reference/resolver.md +++ b/docs/reference/resolver.md @@ -98,6 +98,7 @@ Every constructor accepts these keyword arguments: | `sentinel_blocklist` | `SentinelBlocklist | None` | `DEFAULT_BLOCKLIST` | Junk-input blocklist. `None` disables it. | | `default_to` | `str | list[str] | None` | `None` | Default output code system or name variant applied to every `resolve()`, `bulk()`, and `snap()` call. A string (`"iso3"`) or a list for a fallback chain (`["iso3", "name"]`). `None` = return raw `ResolutionResult` (default behavior). | | `on_missing` | `Literal["raise", "null", "auto"]` | `"auto"` | Miss policy when the default output chain has no value for a resolved entity. `"auto"` = raise for scalar `resolve()`/`snap()`, null + `UserWarning` for `bulk()`; `"raise"` = always raise `OutputMissingError`; `"null"` = always return `None`. | +| `warm` | `bool` | `True` | Start a background daemon thread during construction that pre-builds all lazily-constructed indexes (the SymSpell typo index). `False` keeps construction fully lazy; the index builds on the first query that reaches the fuzzy tier. See [`Resolver.warm()`](#resolverwarm). | !!! warning "Heads up" The LRU cache is not thread-safe. For concurrent workloads, either build one `Resolver` per worker thread or pass `cache_size=0`. @@ -329,6 +330,28 @@ r.snap( --- +### `warm()` { #resolverwarm } + +```python +r.warm() -> None +``` + +Build all lazily-constructed indexes now and block until they are ready. Idempotent — calling it again when indexes are already built returns immediately. Thread-safe. + +By default, `Resolver` construction starts a background daemon thread that pre-builds the SymSpell typo index (see the `warm=` [shared option](#shared-options)). Call `warm()` when you need the resolver to be fully ready before processing begins — for example, in server startup or before a batch pipeline runs. + +```python +from resolvekit import Resolver + +r = Resolver.auto() +r.warm() # block here until all indexes are ready +r.resolve("France") # no index-build latency +``` + +To warm the module-level singleton, use [`rk.warm()`](api.md#warm). + +--- + ### `suggest(prefix, *, top_k=10, domain=None, entity_type=None, context=None, to=None, fuzzy="auto", timeout=None)` { #resolversuggest } Returns a ranked typeahead suggestion list for `prefix`. Built for per-keystroke autocomplete: it bypasses the resolve pipeline and the query cache, never raises a thresholded verdict, and returns `[]` for empty, whitespace-only, or below-floor prefixes. This method exists only on `Resolver` — there is no module-level `rk.suggest()`. diff --git a/src/resolvekit/__init__.py b/src/resolvekit/__init__.py index 1333155..5fa8e1c 100644 --- a/src/resolvekit/__init__.py +++ b/src/resolvekit/__init__.py @@ -56,6 +56,7 @@ resolve_id, snap, to, + warm, ) # Re-exports excluded from __all__ — importable but not surfaced via star-import. @@ -132,4 +133,5 @@ "resolve_id", "snap", "to", + "warm", ] diff --git a/src/resolvekit/_convenience.py b/src/resolvekit/_convenience.py index 122b6a2..da60867 100644 --- a/src/resolvekit/_convenience.py +++ b/src/resolvekit/_convenience.py @@ -581,6 +581,19 @@ def parse( ) +def warm() -> None: + """Pre-build all lazily-constructed indexes, blocking until complete. + + Useful for servers and batch jobs that want to ensure full query + performance before handling the first real request. + + Note: constructing the default resolver already starts a background + warm-up (``warm=True`` is the default); call this function when you + need to block until that warm-up is complete. + """ + _get_default().warm() + + def parse_bulk( *, values: Any, diff --git a/src/resolvekit/core/api/loading/paths.py b/src/resolvekit/core/api/loading/paths.py index 85ffe97..84d3bfa 100644 --- a/src/resolvekit/core/api/loading/paths.py +++ b/src/resolvekit/core/api/loading/paths.py @@ -113,6 +113,7 @@ def _build_resolver_from_paths( sentinel_blocklist: SentinelBlocklist | None = None, default_to: str | list[str] | None = None, on_missing: Literal["raise", "null", "auto"] = "auto", + warm: bool = True, ) -> Resolver: from resolvekit.core.api.loading.module_catalog import ( _ensure_remote_data_available, @@ -210,4 +211,5 @@ def _build_resolver_from_paths( sentinel_blocklist=sentinel_blocklist, default_to=default_to, on_missing=on_missing, + warm=warm, ) diff --git a/src/resolvekit/core/api/resolver.py b/src/resolvekit/core/api/resolver.py index c8e4fb1..469ad60 100644 --- a/src/resolvekit/core/api/resolver.py +++ b/src/resolvekit/core/api/resolver.py @@ -1,6 +1,7 @@ """Public API facade for resolution.""" import logging +import threading import weakref from collections.abc import Sequence from datetime import date, datetime @@ -210,6 +211,7 @@ def __init__( sentinel_blocklist: SentinelBlocklist | None = DEFAULT_BLOCKLIST, default_to: str | list[str] | None = None, on_missing: "Literal['raise','null','auto']" = "auto", + warm: bool = True, ) -> None: """Initialize Resolver. @@ -277,6 +279,14 @@ def __init__( null + ``UserWarning`` for ``bulk()``. ``"raise"`` = always raise ``OutputMissingError`` on miss. ``"null"`` = always return ``None`` on miss. + warm: When ``True`` (default), start a background daemon thread + immediately after construction that calls the runner's + ``warm()`` to pre-build any lazily-constructed indexes (e.g. + the geo large-tier SymSpell index on remote-data installs, + which can take ~6 s). Queries that arrive mid-build simply + block on the per-source build lock for the remainder of the + build. Pass ``False`` to restore the previous fully-lazy + behaviour. """ self._runner = runner self._normalizer = normalizer or TextNormalizer() @@ -351,6 +361,29 @@ def __init__( default_output_spec=self._output_spec, ) + # Background warm-up: pre-build lazily-constructed source indexes so + # the first fuzzy/symspell query does not pay the build cost inline. + if warm: + runner_warm = getattr(self._runner, "warm", None) + if callable(runner_warm): + + def _warm_runner() -> None: + try: + runner_warm() + except Exception: + logger.debug( + "Background warm-up raised an exception; " + "sources will build lazily on first use.", + exc_info=True, + ) + + t = threading.Thread( + target=_warm_runner, + name="resolvekit-warm", + daemon=True, + ) + t.start() + def _apply_confidence_threshold_override(self, value: float) -> None: """Set confidence_threshold on every pack's decision policy. @@ -394,6 +427,24 @@ def close(self) -> None: _invalidate_automaton(self._runner.store_for_domain(pack_id)) self._runner.close() + def warm(self) -> None: + """Build all lazily-constructed indexes now, synchronously. + + Blocks until every source's internal index (e.g. the SymSpell + dictionary) has been built. Safe to call concurrently with the + background warm-up thread started by ``__init__`` — per-source build + locks make the operation idempotent. Useful for servers and batch + jobs that want to ensure full performance before handling the first + real request. + + Note: constructing a Resolver with the default ``warm=True`` already + starts a background warm-up; call this method when you need to block + until that warm-up is complete. + """ + runner_warm = getattr(self._runner, "warm", None) + if callable(runner_warm): + runner_warm() + def __enter__(self) -> "Resolver": return self @@ -757,6 +808,7 @@ def from_datapacks( sentinel_blocklist: SentinelBlocklist | None = DEFAULT_BLOCKLIST, default_to: str | list[str] | None = None, on_missing: "Literal['raise','null','auto']" = "auto", + warm: bool = True, ) -> "Resolver": """Create resolver from one or more explicit datapack filesystem paths. @@ -782,6 +834,8 @@ def from_datapacks( ``Resolver.__init__`` for full docs). on_missing: Miss policy for the default output chain (see ``Resolver.__init__`` for full docs). + warm: Start a background index warm-up on construction (see + ``Resolver.__init__`` for full docs). Returns: Configured Resolver instance @@ -801,6 +855,7 @@ def from_datapacks( sentinel_blocklist=sentinel_blocklist, default_to=default_to, on_missing=on_missing, + warm=warm, ) @classmethod @@ -820,6 +875,7 @@ def from_modules( sentinel_blocklist: SentinelBlocklist | None = DEFAULT_BLOCKLIST, default_to: str | list[str] | None = None, on_missing: "Literal['raise','null','auto']" = "auto", + warm: bool = True, ) -> "Resolver": """Create resolver from installed or explicitly registered modules. @@ -845,6 +901,8 @@ def from_modules( ``Resolver.__init__`` for full docs). on_missing: Miss policy for the default output chain (see ``Resolver.__init__`` for full docs). + warm: Start a background index warm-up on construction (see + ``Resolver.__init__`` for full docs). Returns: Configured Resolver instance @@ -865,6 +923,7 @@ def from_modules( sentinel_blocklist=sentinel_blocklist, default_to=default_to, on_missing=on_missing, + warm=warm, ) @classmethod @@ -883,6 +942,7 @@ def auto( sentinel_blocklist: SentinelBlocklist | None = DEFAULT_BLOCKLIST, default_to: str | list[str] | None = None, on_missing: "Literal['raise','null','auto']" = "auto", + warm: bool = True, ) -> "Resolver": """Create resolver from all installed modules. @@ -911,6 +971,8 @@ def auto( ``Resolver.__init__`` for full docs). on_missing: Miss policy for the default output chain (see ``Resolver.__init__`` for full docs). + warm: Start a background index warm-up on construction (see + ``Resolver.__init__`` for full docs). Returns: Configured Resolver instance @@ -951,6 +1013,7 @@ def auto( sentinel_blocklist=sentinel_blocklist, default_to=default_to, on_missing=on_missing, + warm=warm, ) return cls.from_modules( routing_mode=routing_mode, @@ -964,6 +1027,7 @@ def auto( sentinel_blocklist=sentinel_blocklist, default_to=default_to, on_missing=on_missing, + warm=warm, ) # -- Country-level geo module IDs for the lite preset -- @@ -993,6 +1057,7 @@ def lite( sentinel_blocklist: SentinelBlocklist | None = DEFAULT_BLOCKLIST, default_to: str | list[str] | None = None, on_missing: "Literal['raise','null','auto']" = "auto", + warm: bool = True, ) -> "Resolver": """Create a footprint-optimised resolver from a curated small module set. @@ -1039,6 +1104,8 @@ def lite( ``Resolver.__init__`` for full docs). on_missing: Miss policy for the default output chain (see ``Resolver.__init__`` for full docs). + warm: Start a background index warm-up on construction (see + ``Resolver.__init__`` for full docs). Returns: Configured Resolver instance @@ -1061,6 +1128,7 @@ def lite( sentinel_blocklist=sentinel_blocklist, default_to=default_to, on_missing=on_missing, + warm=warm, ) def entity( @@ -2628,6 +2696,7 @@ def from_records( attrs: "list[str] | Literal['rest'] | None" = None, entity_type: str | None = None, cache: bool = True, + warm: bool = True, **resolver_kwargs: Any, ) -> "Resolver": """Stand up a standalone resolver from user-supplied records. @@ -2667,6 +2736,8 @@ def from_records( cache: Cache the built pack on disk under the configured cache directory. ``True`` (default) reuses an identical-input build on subsequent calls; ``False`` always rebuilds to a fresh temp dir. + warm: Start a background index warm-up on construction (see + ``Resolver.__init__`` for full docs). **resolver_kwargs: Forwarded verbatim to :meth:`from_datapacks` (e.g. ``routing_mode``, ``confidence_threshold``). @@ -2704,6 +2775,7 @@ def from_records( return cls.from_datapacks( datapack_paths=[outcome.pack_dir], domains=[domain], + warm=warm, **resolver_kwargs, ) diff --git a/src/resolvekit/core/engine/interfaces.py b/src/resolvekit/core/engine/interfaces.py index 2d8f63e..770bb03 100644 --- a/src/resolvekit/core/engine/interfaces.py +++ b/src/resolvekit/core/engine/interfaces.py @@ -340,6 +340,14 @@ def requires_existing_candidates(self) -> bool: """Whether this source needs existing candidates (e.g., fuzzy rerankers).""" return False + def warm(self) -> None: # noqa: B027 — intentional no-op default; subclasses override to build indexes + """Eagerly build any lazily-constructed internal index. + + Default is a no-op. Sources with expensive lazy initialization + (e.g. SymSpell dictionary indexes) override this to build now, + idempotently and thread-safely. + """ + @abstractmethod def generate(self, ctx: GenerationContext) -> list[CandidateEvidence]: """Generate candidate evidence. diff --git a/src/resolvekit/core/engine/multi_runner.py b/src/resolvekit/core/engine/multi_runner.py index 31dd6a6..7665e0e 100644 --- a/src/resolvekit/core/engine/multi_runner.py +++ b/src/resolvekit/core/engine/multi_runner.py @@ -152,6 +152,11 @@ def __init__( ) self._view = StoreView(list(self._stores.items())) + def warm(self) -> None: + """Eagerly build all lazily-constructed source indexes across every pack runner.""" + for runner in self._runners.values(): + runner.warm() + def close(self) -> None: """Close all stores owned by this runner.""" for store in self._stores.values(): diff --git a/src/resolvekit/core/engine/runner.py b/src/resolvekit/core/engine/runner.py index 634d258..86af73d 100644 --- a/src/resolvekit/core/engine/runner.py +++ b/src/resolvekit/core/engine/runner.py @@ -120,6 +120,24 @@ def __init__( frozenset[str] | None, list[tuple[str, str, str, bool, str]] ] = {} + def warm(self) -> None: + """Eagerly build all lazily-constructed source indexes. + + Calls ``warm()`` on every candidate source. A source that raises during + warm-up is silently skipped (debug-logged); it degrades to its normal + lazy-build path. Safe to call concurrently — per-source build locks + make the operation idempotent. + """ + for source in self._sources: + try: + source.warm() + except Exception: + logger.debug( + "warm() failed for source %r; source will build lazily", + source.name, + exc_info=True, + ) + def close(self) -> None: """Close the underlying store.""" if self._store is not None: diff --git a/src/resolvekit/packs/geo/pack.py b/src/resolvekit/packs/geo/pack.py index e4e915d..d519da2 100644 --- a/src/resolvekit/packs/geo/pack.py +++ b/src/resolvekit/packs/geo/pack.py @@ -92,6 +92,11 @@ def _build_symspell_pair( seeds the index; any remaining paths are loaded as additional dictionaries. The fuzzy-retrieval source shares the SymSpell index rather than loading its own copy. + + ``use_compiled_cache`` is enabled only for the LARGE tier (admin2-5 / cities, + ~706k terms, ~6s text build). The SMALL tier builds in ~0.15s and does not + justify the ~156MB pickle overhead. The fuzzy-retrieval source shares the + provider's SymSpell instance and needs no separate flag. """ symspell = GeoSymSpellSource( name=symspell_name, @@ -99,6 +104,7 @@ def _build_symspell_pair( max_edit_distance=2, prefix_length=7, large_tier=large_tier, + use_compiled_cache=large_tier, ) for extra_path in paths[1:]: symspell.load_additional_dictionary(extra_path) diff --git a/src/resolvekit/packs/geo/sources/symspell.py b/src/resolvekit/packs/geo/sources/symspell.py index ff159d4..ec053cf 100644 --- a/src/resolvekit/packs/geo/sources/symspell.py +++ b/src/resolvekit/packs/geo/sources/symspell.py @@ -34,7 +34,7 @@ # so a single-process LRU eliminates most repeat work for hot terms. _LOOKUP_CACHE_MAX = 8192 -# Main symspell evidence tier: FTS-level, not exact — loop-invariant constant. +# Main symspell evidence tier: FTS-level, not exact. _SYMSPELL_TIER = REASON_TO_MATCH_TIER.get(ReasonCode.FTS_MATCH) # Entity-type prefixes that belong to the SMALL index group (countries, admin1, @@ -119,6 +119,7 @@ def __init__( discount_factor: float = 0.8, name: str = "geo_symspell", large_tier: bool = False, + use_compiled_cache: bool = False, ): super().__init__( name=name, @@ -126,6 +127,7 @@ def __init__( dictionary_path=dictionary_path, max_edit_distance=max_edit_distance, prefix_length=prefix_length, + use_compiled_cache=use_compiled_cache, ) self._discount = discount_factor self._large_tier = large_tier diff --git a/src/resolvekit/shared/sources/symspell_base.py b/src/resolvekit/shared/sources/symspell_base.py index ecfefc3..9a69372 100644 --- a/src/resolvekit/shared/sources/symspell_base.py +++ b/src/resolvekit/shared/sources/symspell_base.py @@ -5,13 +5,20 @@ most queries hit exact-code/exact-name/FTS tiers and never need SymSpell. """ +import contextlib +import hashlib import logging +import os import threading import time +import uuid +import weakref from importlib import import_module +from importlib.metadata import version as _pkg_version from pathlib import Path from typing import Any +from resolvekit.core.config import get_cache_dir from resolvekit.core.engine import CandidateSource from resolvekit.core.explain import emit_candidates_generated from resolvekit.core.model import ( @@ -27,6 +34,34 @@ SYMSPELL_DISTANCE_PENALTY = 0.15 SYMSPELL_MIN_SCORE = 0.5 +# Temp cache files older than this are presumed leaked by a crashed writer and +# safe to reap; live builds finish in seconds. +_STALE_TMP_AGE_SECONDS = 3600.0 + +# Live sources needing their build lock re-initialized in a forked child. +_LIVE_SOURCES: "weakref.WeakSet[SymSpellSource]" = weakref.WeakSet() + + +def _reset_sources_after_fork() -> None: + """Re-initialize build locks (and in-flight build state) in a forked child. + + A fork can happen while another thread — typically the resolver's + background warm-up — holds a source's build lock. The child would inherit + a permanently-held lock and deadlock on its first query that reaches the + source. Fresh locks fix that. Sources whose build was in flight at fork + time are reset to unbuilt so the child rebuilds from scratch instead of + reading a half-built index; fully built indexes are immutable and kept. + """ + for source in list(_LIVE_SOURCES): + source._build_lock = threading.Lock() + if not source._built: + source._build_attempted = False + source._sym_spell = None + + +if hasattr(os, "register_at_fork"): + os.register_at_fork(after_in_child=_reset_sources_after_fork) + def _load_symspell_class() -> Any | None: """Load the optional SymSpell class at runtime.""" @@ -65,6 +100,10 @@ class SymSpellSource(CandidateSource): - min_query_length: Minimum query length to process (default: 3) - matched_field: Field name for evidence (default: "symspell") - name_kinds: Set of name kinds to search (default: None = all) + - use_compiled_cache: Cache the built index as a locally-generated pickle + under the resolvekit cache dir; intended for large dictionaries where the + text build is expensive. Keyed on symspellpy version, build params, and + source-file fingerprints so stale entries are evicted automatically. Subclasses can override: - _generate_fallback: Called when SymSpell is unavailable @@ -80,6 +119,8 @@ def __init__( min_query_length: int = 3, matched_field: str = "symspell", name_kinds: set[str] | None = None, + *, + use_compiled_cache: bool = False, ) -> None: """Create a SymSpell source. @@ -94,18 +135,23 @@ def __init__( min_query_length: Minimum query length to process matched_field: Field name for evidence name_kinds: Set of name kinds to search when looking up corrected terms + use_compiled_cache: Cache the built index as a locally-generated + pickle under the resolvekit cache dir. Intended for large + dictionaries where the text build is expensive. """ self._name = name self._domain = domain self._dict_path = dictionary_path # Additional dictionary paths queued via load_additional_dictionary() - # before the index has been built. Drained on first build. + # before the index has been built. Kept after the build so a rebuild + # (load_dictionary() reset, after-fork reset) covers the full set. self._extra_dict_paths: list[str] = [] self._max_edit = max_edit_distance self._prefix_len = prefix_length self._min_query_length = min_query_length self._matched_field = matched_field self._name_kinds = name_kinds + self._use_compiled_cache = use_compiled_cache # _sym_spell is None until the first query that needs it. self._sym_spell: Any | None = None # Guards the one-time lazy build. @@ -119,11 +165,21 @@ def __init__( # guarding the lock-free fast-path so no thread ever observes a half-built # index. self._built = False + # Registered for the after-fork lock reset (see _reset_sources_after_fork). + _LIVE_SOURCES.add(self) # ------------------------------------------------------------------ # Lazy build # ------------------------------------------------------------------ + def warm(self) -> None: + """Build the SymSpell index now instead of on first query. + + Idempotent and thread-safe. Overrides a no-op ``warm()`` on + ``CandidateSource`` so callers can pre-warm expensive indexes. + """ + self._ensure_built() + def share_symspell_from(self, provider: "SymSpellSource") -> None: """Share the SymSpell instance from *provider* instead of building one. @@ -180,8 +236,9 @@ def _do_build(self) -> None: all_paths: list[str] = [] if self._dict_path: all_paths.append(self._dict_path) + # Keep _extra_dict_paths intact (not drained): an after-fork reset or a + # load_dictionary() reset must rebuild with the full path set. all_paths.extend(self._extra_dict_paths) - self._extra_dict_paths = [] # consumed if not all_paths: return @@ -190,6 +247,19 @@ def _do_build(self) -> None: if symspell_class is None: return + # Attempt to load from the compiled-index cache when requested. + if self._use_compiled_cache: + cache_path = self._compiled_cache_path(all_paths) + if cache_path is not None and self._try_load_from_cache( + symspell_class, cache_path + ): + logger.debug( + "SymSpell index for '%s' loaded from cache: %s", + self._name, + cache_path, + ) + return + t0 = time.perf_counter() self._sym_spell = symspell_class( max_dictionary_edit_distance=self._max_edit, @@ -201,12 +271,133 @@ def _do_build(self) -> None: self._load_dictionary_from_path(p) elapsed = time.perf_counter() - t0 logger.debug( - "SymSpell index built for '%s': %d paths, %.2fs", + "SymSpell index built for '%s' (text build): %d paths, %.2fs", self._name, len(all_paths), elapsed, ) + # Persist the newly built index to the compiled-index cache. + if self._use_compiled_cache and self._sym_spell is not None: + cache_path = self._compiled_cache_path(all_paths) + if cache_path is not None: + self._save_to_cache(cache_path) + + def _compiled_cache_key(self, all_paths: list[str]) -> str | None: + """Compute a deterministic hex digest for the current build parameters. + + The key covers: symspellpy distribution version, max_edit, prefix_len, + and for each path that exists: its resolved absolute path, byte size, + and mtime in nanoseconds. Returns None when symspellpy is not installed. + """ + try: + symspellpy_ver = _pkg_version("symspellpy") + except Exception: + return None + + h = hashlib.sha256() + h.update(symspellpy_ver.encode()) + h.update(f"|{self._max_edit}|{self._prefix_len}".encode()) + for raw in all_paths: + p = Path(raw).resolve() + try: + st = p.stat() + h.update(f"|{p}|{st.st_size}|{st.st_mtime_ns}".encode()) + except OSError: + # Path doesn't exist — include a stable marker so the key + # changes if the file appears later. + h.update(f"|{p}|missing".encode()) + return h.hexdigest() + + def _compiled_cache_path(self, all_paths: list[str]) -> Path | None: + """Return the Path where the compiled pickle for this build should live.""" + digest = self._compiled_cache_key(all_paths) + if digest is None: + return None + return get_cache_dir() / "compiled" / f"symspell-{self._name}-{digest[:16]}.pkl" + + def _try_load_from_cache(self, symspell_class: Any, cache_path: Path) -> bool: + """Try loading the SymSpell index from *cache_path*. + + Returns True on success, False on any failure (file absent, corrupt, + or incompatible format). On failure the bad file is removed so the + next build will regenerate and re-cache cleanly. + + Security note: we only ever load pickles that this library itself wrote + into the local cache dir — never shipped or downloaded artifacts. + """ + if not cache_path.exists(): + return False + try: + instance = symspell_class( + max_dictionary_edit_distance=self._max_edit, + prefix_length=self._prefix_len, + ) + instance.load_pickle(str(cache_path), compressed=False) + self._sym_spell = instance + return True + except Exception as exc: + logger.debug( + "SymSpell cache load failed for '%s' (will rebuild): %s", + self._name, + exc, + ) + with contextlib.suppress(OSError): + cache_path.unlink(missing_ok=True) + return False + + def _save_to_cache(self, cache_path: Path) -> None: + """Persist the current SymSpell index to *cache_path* atomically. + + Uses a uniquely-named sibling temp file + os.replace so concurrent + writers and readers never see a partial write. After a successful + save, any OTHER stale ``symspell--*.pkl`` files in the same + directory are removed (old keys from previous data versions), and + temp files past a generous age threshold are reaped. + + All save errors are caught and logged at DEBUG; a read-only or full + cache dir must never break resolution — and never discard the + successfully built in-memory index by propagating out of _do_build(). + """ + sym_spell = self._sym_spell + if sym_spell is None: + return + try: + cache_path.parent.mkdir(parents=True, exist_ok=True) + # Unique per writer: two sources with the same name and key in one + # process (each on its own warm thread) must never interleave + # writes into a shared temp file. + tmp_path = cache_path.with_suffix( + f".tmp.{os.getpid()}.{uuid.uuid4().hex[:8]}" + ) + sym_spell.save_pickle(str(tmp_path), compressed=False) + os.replace(tmp_path, cache_path) + logger.debug( + "SymSpell index for '%s' saved to cache: %s", + self._name, + cache_path, + ) + # Evict stale keys for this source name. Temp files are reaped + # only past a generous age threshold so a concurrent writer's + # in-flight temp file is never deleted under it. + prefix = f"symspell-{self._name}-" + now = time.time() + for sibling in cache_path.parent.glob(f"{prefix}*"): + if sibling == cache_path: + continue + with contextlib.suppress(OSError): + if sibling.name.endswith(".pkl") or ( + ".tmp." in sibling.name + and now - sibling.stat().st_mtime > _STALE_TMP_AGE_SECONDS + ): + sibling.unlink(missing_ok=True) + except Exception as exc: + logger.debug( + "SymSpell cache save failed for '%s' (non-fatal): %s", + self._name, + exc, + ) + def _init_symspell(self) -> None: """No-op: kept for any subclasses that call super()._init_symspell().""" diff --git a/tests/core/test_warm.py b/tests/core/test_warm.py new file mode 100644 index 0000000..5505252 --- /dev/null +++ b/tests/core/test_warm.py @@ -0,0 +1,210 @@ +"""Tests for warm-up plumbing: CandidateSource.warm(), PipelineRunner.warm(), +MultiPackRunner.warm(), Resolver warm=True/False, and the module-level warm() +convenience function. +""" + +from __future__ import annotations + +import threading +from unittest.mock import MagicMock, patch + +from resolvekit.core.api.resolver import Resolver +from resolvekit.core.engine.decision import ThresholdDecisionPolicy +from resolvekit.core.engine.interfaces import CandidateSource +from resolvekit.core.engine.multi_runner import MultiPackRunner +from resolvekit.core.engine.runner import PipelineRunner +from resolvekit.core.explain import NullTraceSink +from resolvekit.core.model import ( + CandidateEvidence, + GenerationContext, +) +from tests.conftest import MockEntityStore + +# --------------------------------------------------------------------------- +# Minimal CandidateSource helpers +# --------------------------------------------------------------------------- + + +class _MinimalSource(CandidateSource): + """Minimal concrete subclass that does not override warm().""" + + @property + def name(self) -> str: + return "minimal" + + def supports(self, domain_pack_id: str) -> bool: + return True + + def generate(self, ctx: GenerationContext) -> list[CandidateEvidence]: + return [] + + +class _EventSource(_MinimalSource): + """Source whose warm() sets a threading.Event.""" + + def __init__(self) -> None: + self.warmed = threading.Event() + + @property + def name(self) -> str: + return "event_source" + + def warm(self) -> None: + self.warmed.set() + + +class _FailingSource(_MinimalSource): + """Source whose warm() always raises.""" + + @property + def name(self) -> str: + return "failing_source" + + def warm(self) -> None: + raise RuntimeError("warm() failed intentionally") + + +def _make_runner( + sources: list[CandidateSource] | None = None, +) -> PipelineRunner: + store = MockEntityStore() + return PipelineRunner( + trace_sink=NullTraceSink(), + store=store, + sources=sources or [], + decision_policy=ThresholdDecisionPolicy( + confidence_threshold=0.8, + min_gap=0.1, + gap_inclusive=True, + ), + ) + + +def _make_resolver( + sources: list[CandidateSource] | None = None, + *, + warm: bool = False, +) -> Resolver: + from resolvekit.core.util import TextNormalizer + + runner = _make_runner(sources=sources) + return Resolver(runner=runner, normalizer=TextNormalizer(), warm=warm) + + +# --------------------------------------------------------------------------- +# (1) CandidateSource.warm() default is a no-op +# --------------------------------------------------------------------------- + + +class TestCandidateSourceWarmDefault: + def test_warm_is_callable_on_minimal_subclass(self) -> None: + source = _MinimalSource() + # Must not raise; return value is None. + result = source.warm() + assert result is None + + +# --------------------------------------------------------------------------- +# (2) PipelineRunner.warm() fires the event on a warming source +# --------------------------------------------------------------------------- + + +class TestPipelineRunnerWarm: + def test_warm_fires_event_source(self) -> None: + event_src = _EventSource() + runner = _make_runner(sources=[event_src]) + assert not event_src.warmed.is_set() + runner.warm() + assert event_src.warmed.is_set() + + def test_warm_skips_failing_source_and_continues(self) -> None: + """A source whose warm() raises must not prevent subsequent sources.""" + event_src = _EventSource() + failing_src = _FailingSource() + runner = _make_runner(sources=[failing_src, event_src]) + # Should not raise, and the event_src should still be warmed. + runner.warm() + assert event_src.warmed.is_set() + + def test_warm_no_sources_is_noop(self) -> None: + runner = _make_runner(sources=[]) + runner.warm() # must not raise + + +# --------------------------------------------------------------------------- +# (3) MultiPackRunner.warm() delegates to all pack runners +# --------------------------------------------------------------------------- + + +class TestMultiPackRunnerWarm: + def test_warm_delegates_to_all_runners(self) -> None: + runner_a = _make_runner() + runner_b = _make_runner() + runner_a.warm = MagicMock() # type: ignore[method-assign] + runner_b.warm = MagicMock() # type: ignore[method-assign] + + # Build a MultiPackRunner stub that already has runners pre-injected. + # We bypass __init__ by constructing a bare object and patching _runners. + multi = object.__new__(MultiPackRunner) + multi._runners = {"pack_a": runner_a, "pack_b": runner_b} # type: ignore[attr-defined] + + multi.warm() + + runner_a.warm.assert_called_once() + runner_b.warm.assert_called_once() + + +# --------------------------------------------------------------------------- +# (4) Resolver(warm=True) — background thread fires the event +# --------------------------------------------------------------------------- + + +class TestResolverWarmTrue: + def test_background_warm_fires_event(self) -> None: + event_src = _EventSource() + resolver = _make_resolver(sources=[event_src], warm=True) + fired = event_src.warmed.wait(timeout=10) + resolver.close() + assert fired, "Background warm-up did not fire within 10 s" + + +# --------------------------------------------------------------------------- +# (5) Resolver(warm=False) — event not set at construction; warm() sets it +# --------------------------------------------------------------------------- + + +class TestResolverWarmFalse: + def test_no_background_warm_on_construction(self) -> None: + event_src = _EventSource() + resolver = _make_resolver(sources=[event_src], warm=False) + # Give a brief window — the event must NOT be set yet. + # (No thread was started, so this is a deterministic check.) + assert not event_src.warmed.is_set() + resolver.close() + + def test_explicit_warm_call_sets_event(self) -> None: + event_src = _EventSource() + resolver = _make_resolver(sources=[event_src], warm=False) + assert not event_src.warmed.is_set() + resolver.warm() + assert event_src.warmed.is_set() + resolver.close() + + +# --------------------------------------------------------------------------- +# (6) Module-level resolvekit.warm() calls .warm() on the default resolver +# --------------------------------------------------------------------------- + + +class TestModuleLevelWarm: + def test_warm_calls_resolver_warm(self) -> None: + import resolvekit + import resolvekit._convenience as conv + + stub_resolver = MagicMock() + stub_resolver.warm = MagicMock() + + with patch.object(conv, "_get_default", return_value=stub_resolver): + resolvekit.warm() + + stub_resolver.warm.assert_called_once() diff --git a/tests/packs/test_symspell_lazy.py b/tests/packs/test_symspell_lazy.py index 0e96db4..c98b204 100644 --- a/tests/packs/test_symspell_lazy.py +++ b/tests/packs/test_symspell_lazy.py @@ -941,6 +941,72 @@ def worker() -> None: Path(dict_path).unlink(missing_ok=True) +class TestAfterForkReset: + """The after-fork hook must free inherited build locks in the child. + + A fork while another thread (e.g. the background warm-up) holds a source's + build lock would otherwise leave the child's lock permanently held. The + hook is exercised directly — a real mid-build fork cannot be orchestrated + deterministically. + """ + + def test_inflight_build_state_reset(self): + """A source forked mid-build gets a fresh lock and rebuilds from scratch.""" + from resolvekit.shared.sources.symspell_base import ( + SymSpellSource, + _reset_sources_after_fork, + ) + + source = SymSpellSource(name="fork_test", domain="x") + # Simulate the state a child inherits from a parent forked mid-build: + # lock held by a (now nonexistent) thread, build attempted, not built. + source._build_lock.acquire() + source._build_attempted = True + source._sym_spell = object() # half-built sentinel + + _reset_sources_after_fork() + + assert source._build_lock.acquire(blocking=False), ( + "Child must get a fresh, unheld build lock" + ) + source._build_lock.release() + assert source._build_attempted is False + assert source._sym_spell is None, "Half-built index must be discarded" + + def test_built_index_kept(self): + """A fully built index is immutable and survives the fork reset.""" + pytest = __import__("pytest") + pytest.importorskip("symspellpy") + + from resolvekit.shared.sources.symspell_base import ( + SymSpellSource, + _reset_sources_after_fork, + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write("france\t100\n") + dict_path = f.name + + try: + source = SymSpellSource( + name="fork_test_built", domain="x", dictionary_path=dict_path + ) + source.warm() + assert source._built is True + built_instance = source._sym_spell + + _reset_sources_after_fork() + + assert source._built is True + assert source._sym_spell is built_instance, ( + "A complete index must not be discarded by the fork reset" + ) + assert source._build_lock.acquire(blocking=False) + source._build_lock.release() + finally: + Path(dict_path).unlink(missing_ok=True) + + class TestLitePreset: """Tests for Resolver.lite() convenience constructor.""" @@ -977,10 +1043,14 @@ def test_lite_custom_module_ids(self): r.close() def test_lite_no_symspell_at_construction(self): - """Resolver.lite() must not build the SymSpell index at construction time.""" + """Resolver.lite() must not build the SymSpell index at construction time. + + warm=False opts out of the background warm-up thread, which would + otherwise build the index shortly after construction by design. + """ from resolvekit.core.api.resolver import Resolver - r = Resolver.lite() + r = Resolver.lite(warm=False) try: runner = r._runner # Look for the geo_symspell source and verify index not built yet. diff --git a/tests/shared/test_symspell_compiled_cache.py b/tests/shared/test_symspell_compiled_cache.py new file mode 100644 index 0000000..ef790d5 --- /dev/null +++ b/tests/shared/test_symspell_compiled_cache.py @@ -0,0 +1,315 @@ +"""Tests for the compiled-index (pickle) cache in SymSpellSource. + +All tests use small synthetic tab-separated dict files in tmp_path — never real data. +The cache dir is redirected via monkeypatching so tests are fully isolated. +""" + +import os + +import pytest + +symspellpy = pytest.importorskip("symspellpy") + + +def _make_dict(path, terms): + """Write a tab-separated symspell dictionary to *path*.""" + path.write_text("".join(f"{term}\t{count}\n" for term, count in terms)) + + +def _patch_cache_dir(monkeypatch, tmp_path): + """Redirect get_cache_dir as imported by symspell_base to tmp_path/cache.""" + cache_dir = tmp_path / "cache" + monkeypatch.setattr( + "resolvekit.shared.sources.symspell_base.get_cache_dir", + lambda: cache_dir, + ) + return cache_dir + + +def _make_source(dict_path, name="test_ss", use_compiled_cache=True): + from resolvekit.shared.sources.symspell_base import SymSpellSource + + return SymSpellSource( + name=name, + domain="x", + dictionary_path=str(dict_path), + use_compiled_cache=use_compiled_cache, + ) + + +class TestCompiledCacheCreate: + def test_build_creates_exactly_one_pkl_file(self, tmp_path, monkeypatch): + """Warming a source with use_compiled_cache=True creates one .pkl file.""" + cache_dir = _patch_cache_dir(monkeypatch, tmp_path) + d = tmp_path / "dict.txt" + _make_dict(d, [("france", 100), ("germany", 80)]) + + source = _make_source(d) + source.warm() + + compiled_dir = cache_dir / "compiled" + pkls = list(compiled_dir.glob("symspell-test_ss-*.pkl")) + assert len(pkls) == 1, f"Expected 1 .pkl file, found: {pkls}" + + def test_no_pkl_when_cache_disabled(self, tmp_path, monkeypatch): + """use_compiled_cache=False (default) must not create any artifacts.""" + cache_dir = _patch_cache_dir(monkeypatch, tmp_path) + d = tmp_path / "dict.txt" + _make_dict(d, [("france", 100)]) + + source = _make_source(d, use_compiled_cache=False) + source.warm() + + compiled_dir = cache_dir / "compiled" + assert not compiled_dir.exists() or not list( + compiled_dir.glob("symspell-test_ss-*.pkl") + ), "No .pkl files should be created when use_compiled_cache=False" + + +class TestCompiledCacheLoad: + def test_second_instance_loads_from_cache_not_text(self, tmp_path, monkeypatch): + """A fresh instance with identical paths loads from the pickle, not text.""" + _patch_cache_dir(monkeypatch, tmp_path) + d = tmp_path / "dict.txt" + _make_dict(d, [("france", 100), ("germany", 80)]) + + # Warm first instance to populate the cache. + source1 = _make_source(d) + source1.warm() + + # Now patch _load_dictionary_from_path to raise — it must NOT be called. + monkeypatch.setattr( + "resolvekit.shared.sources.symspell_base.SymSpellSource._load_dictionary_from_path", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("text load was invoked; should have loaded from cache") + ), + ) + + source2 = _make_source(d) + source2.warm() # Must come from cache + + # Verify it actually works: lookup a corrected term. + from symspellpy import Verbosity + + suggestions = source2._sym_spell.lookup( + "frannce", Verbosity.CLOSEST, max_edit_distance=2 + ) + terms = {s.term for s in suggestions} + assert "france" in terms, f"Expected 'france' in suggestions, got: {terms}" + + def test_cache_hit_same_lookup_results(self, tmp_path, monkeypatch): + """A cache-loaded index produces the same lookup results as a text-built one.""" + _patch_cache_dir(monkeypatch, tmp_path) + d = tmp_path / "dict.txt" + _make_dict(d, [("france", 100), ("nigeria", 60)]) + + from symspellpy import Verbosity + + # Text-built reference. + source_text = _make_source(d, name="ref_ss", use_compiled_cache=False) + source_text.warm() + + # Cached build (first build). + source_cached1 = _make_source(d, name="cache_ss") + source_cached1.warm() + + # Cache-loaded instance (second build with same params). + source_cached2 = _make_source(d, name="cache_ss") + source_cached2.warm() + + for typo in ("frannce", "nigerria"): + ref = { + s.term + for s in source_text._sym_spell.lookup( + typo, Verbosity.CLOSEST, max_edit_distance=2 + ) + } + cached = { + s.term + for s in source_cached2._sym_spell.lookup( + typo, Verbosity.CLOSEST, max_edit_distance=2 + ) + } + assert ref == cached, ( + f"Lookup mismatch for '{typo}': text={ref}, cached={cached}" + ) + + +class TestCompiledCacheInvalidation: + def test_modified_dict_triggers_rebuild_and_evicts_old(self, tmp_path, monkeypatch): + """Modifying a dict file (size/mtime change) → new key, rebuild, old file removed.""" + cache_dir = _patch_cache_dir(monkeypatch, tmp_path) + d = tmp_path / "dict.txt" + _make_dict(d, [("france", 100)]) + + # First build — creates cache entry. + source1 = _make_source(d) + source1.warm() + compiled_dir = cache_dir / "compiled" + pkls_before = list(compiled_dir.glob("symspell-test_ss-*.pkl")) + assert len(pkls_before) == 1 + first_pkl = pkls_before[0] + + # Modify the dict file (content + mtime change). + _make_dict(d, [("france", 100), ("germany", 80), ("italy", 60)]) + # Ensure mtime changes even if the filesystem has coarse resolution. + new_mtime = d.stat().st_mtime + 2 + os.utime(d, (new_mtime, new_mtime)) + + # Second build — must detect the change, rebuild, and evict the old pkl. + source2 = _make_source(d) + source2.warm() + + pkls_after = list(compiled_dir.glob("symspell-test_ss-*.pkl")) + assert len(pkls_after) == 1, f"Expected 1 .pkl after rebuild, got: {pkls_after}" + assert pkls_after[0] != first_pkl, "New cache file should have a different key" + assert not first_pkl.exists(), "Old cache file must be evicted" + + +class TestCompiledCacheCorruptFile: + def test_corrupt_cache_falls_back_to_text_build(self, tmp_path, monkeypatch): + """A corrupt .pkl file → graceful fallback to text build, query still works.""" + cache_dir = _patch_cache_dir(monkeypatch, tmp_path) + d = tmp_path / "dict.txt" + _make_dict(d, [("france", 100)]) + + # Warm once to create the cache file, then corrupt it. + source1 = _make_source(d) + source1.warm() + compiled_dir = cache_dir / "compiled" + pkl = next(compiled_dir.glob("symspell-test_ss-*.pkl")) + pkl.write_bytes(b"not a valid pickle at all!!!") + + # A new instance must survive the corrupt file and fall back to text build. + source2 = _make_source(d) + source2.warm() # Must not raise + + assert source2._sym_spell is not None, ( + "After corrupt-cache fallback, _sym_spell must be set" + ) + from symspellpy import Verbosity + + suggestions = source2._sym_spell.lookup( + "frannce", Verbosity.CLOSEST, max_edit_distance=2 + ) + assert any(s.term == "france" for s in suggestions) + + +class TestCompiledCacheSaveFailure: + def test_cache_write_failure_does_not_raise(self, tmp_path, monkeypatch): + """A failing os.replace (e.g. read-only dir) → build still succeeds, no exception.""" + _patch_cache_dir(monkeypatch, tmp_path) + d = tmp_path / "dict.txt" + _make_dict(d, [("france", 100)]) + + def _failing_replace(src, dst): + raise OSError("simulated read-only cache dir") + + monkeypatch.setattr( + "resolvekit.shared.sources.symspell_base.os.replace", _failing_replace + ) + + source = _make_source(d) + source.warm() # Must not raise + + assert source._sym_spell is not None, ( + "Build must succeed even when cache write fails" + ) + # No pkl file should exist (the temp file might linger but the target won't). + cache_dir = tmp_path / "cache" + compiled_dir = cache_dir / "compiled" + pkls = ( + list(compiled_dir.glob("symspell-test_ss-*.pkl")) + if compiled_dir.exists() + else [] + ) + assert len(pkls) == 0, f"No .pkl should exist after a failed save: {pkls}" + + def test_non_oserror_save_failure_keeps_built_index(self, tmp_path, monkeypatch): + """A non-OSError from save_pickle (e.g. PicklingError) must not escape. + + If it escaped _do_build(), _ensure_built() would mark the build failed + and discard the successfully built in-memory index for the process. + """ + import pickle + + _patch_cache_dir(monkeypatch, tmp_path) + d = tmp_path / "dict.txt" + _make_dict(d, [("france", 100)]) + + def _failing_save(path, compressed=True): + raise pickle.PicklingError("simulated unpicklable state") + + monkeypatch.setattr( + symspellpy.SymSpell, "save_pickle", _failing_save, raising=True + ) + + source = _make_source(d) + source.warm() # Must not raise + + assert source._built is True, "Build must be marked successful" + assert source._sym_spell is not None, ( + "In-memory index must survive a cache-save failure" + ) + + +class TestCompiledCacheTempEviction: + def test_aged_temp_reaped_fresh_temp_kept(self, tmp_path, monkeypatch): + """Eviction reaps leaked temp files past the age threshold only. + + A fresh temp file may belong to a concurrent writer mid-save and must + survive; an hours-old one is a leak from a crashed writer. + """ + import time as time_mod + + _patch_cache_dir(monkeypatch, tmp_path) + d = tmp_path / "dict.txt" + _make_dict(d, [("france", 100)]) + + compiled_dir = tmp_path / "cache" / "compiled" + compiled_dir.mkdir(parents=True) + old_tmp = compiled_dir / "symspell-test_ss-deadbeef.tmp.999.aaaaaaaa" + old_tmp.write_bytes(b"leaked") + os.utime(old_tmp, (time_mod.time() - 7200, time_mod.time() - 7200)) + fresh_tmp = compiled_dir / "symspell-test_ss-deadbeef.tmp.999.bbbbbbbb" + fresh_tmp.write_bytes(b"in-flight") + + source = _make_source(d) + source.warm() # save runs eviction + + assert not old_tmp.exists(), "Aged temp file must be reaped" + assert fresh_tmp.exists(), "Fresh temp file must survive eviction" + + +class TestCompiledCacheIdempotent: + def test_warm_is_idempotent(self, tmp_path, monkeypatch): + """Calling warm() twice must not trigger a second build.""" + _patch_cache_dir(monkeypatch, tmp_path) + d = tmp_path / "dict.txt" + _make_dict(d, [("france", 100)]) + + source = _make_source(d) + source.warm() + + assert source._built is True + first_instance = source._sym_spell + + # Track any invocation of _load_dictionary_from_path on the second call. + load_calls: list[int] = [] + orig_load = source._load_dictionary_from_path + + def tracked_load(path): + load_calls.append(1) + return orig_load(path) + + source._load_dictionary_from_path = tracked_load # type: ignore[method-assign] + + source.warm() # Second call — must be a no-op. + + assert source._built is True + assert source._sym_spell is first_instance, ( + "Second warm() must not rebuild the index" + ) + assert len(load_calls) == 0, ( + "Text load must not be invoked on the second warm() call" + ) diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 39cf64c..28848fa 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -53,6 +53,7 @@ def _all(module_name: str) -> tuple[str, ...]: "resolve_id", "snap", "to", + "warm", ), "resolvekit.types": ( "BulkResult", @@ -248,7 +249,7 @@ def test_resolvekit_all() -> None: def test_resolvekit_all_count() -> None: - assert len(resolvekit.__all__) == 36 + assert len(resolvekit.__all__) == 37 def test_resolvekit_all_excludes_removed_names() -> None: From 3684a37042be7701404a673f9979ce6e628fc306 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Fri, 12 Jun 2026 08:09:56 +0200 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20disambiguation=20UX=20=E2=80=94=20d?= =?UTF-8?q?ict=20context,=20per-row=20bulk=20context,=20prominence=20tiebr?= =?UTF-8?q?eak=20(0.1.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - context= accepts plain dicts on all surfaces; country names coerce to ISO codes; unknown keys raise UnknownContextKeyError - per-row context in bulk() and the pandas/polars accessors, deduplicated to unique (text, context) pairs; content-based query cache keys - AMBIGUOUS repr lists candidates with parent region and emits a dict-form country hint when candidates span countries - prominence tiebreak: sitelinks-primary enrichment with population fallback, geo calibrator retrained, decision gaps rescaled to the new fit - rk.suggest() exported at module level - builder: WDQS alias fetch batch=200 with warn-on-empty-batch; comma-form WB/IMF country aliases (Korea, Rep. etc.) - bundled countries pack rebuilt; manifest pinned to the replaced data-v2026.06 remote assets - docs: context how-to, per-row context, AMBIGUOUS repr, module-level suggest --- CHANGELOG.md | 49 +- benchmarks/results/latest.json | 1686 ++++++++--------- benchmarks/results/latest.md | 321 ++-- docs/explanation/how-resolution-works.md | 14 + docs/how-to/build-typeahead-autocomplete.md | 36 +- docs/how-to/clean-a-dataframe-column.md | 54 + docs/how-to/handle-ambiguous-matches.md | 40 +- docs/how-to/refine-resolution-with-context.md | 164 ++ docs/reference/api.md | 90 +- docs/reference/resolver.md | 6 +- scripts/build/enrich_prominence.py | 129 +- src/resolvekit/__init__.py | 12 +- src/resolvekit/_convenience.py | 63 +- src/resolvekit/_data/geo/admin1/metadata.json | 25 +- src/resolvekit/_data/geo/admin2/metadata.json | 20 +- src/resolvekit/_data/geo/admin3/metadata.json | 24 +- src/resolvekit/_data/geo/admin4/metadata.json | 18 +- src/resolvekit/_data/geo/admin5/metadata.json | 16 +- src/resolvekit/_data/geo/cities/metadata.json | 22 +- .../_data/geo/continents/metadata.json | 46 +- .../_data/geo/countries/entities.sqlite | Bin 2732032 -> 2736128 bytes .../_data/geo/countries/geo_calibrator.json | 6 +- .../_data/geo/countries/metadata.json | 10 +- .../_data/geo/countries/symspell.dict | 64 +- src/resolvekit/_data/manifest.json | 76 +- src/resolvekit/_pandas_integration.py | 26 +- src/resolvekit/_polars_integration.py | 128 +- src/resolvekit/builder/data/formal_names.yaml | 22 + src/resolvekit/builder/pipeline/stages.py | 10 +- .../builder/sources/datacommons/geo/fetch.py | 1 + .../builder/sources/wikidata/aliases.py | 24 +- src/resolvekit/core/api/batch.py | 7 +- src/resolvekit/core/api/bulk.py | 402 ++-- src/resolvekit/core/api/cache.py | 15 +- src/resolvekit/core/api/context_input.py | 159 ++ src/resolvekit/core/api/resolver.py | 62 +- src/resolvekit/core/engine/enrichment.py | 83 + src/resolvekit/core/engine/suggest_rank.py | 6 + src/resolvekit/core/errors.py | 35 +- src/resolvekit/core/explain/result_html.py | 43 +- src/resolvekit/core/model/query.py | 20 + src/resolvekit/core/model/result.py | 26 +- src/resolvekit/errors/__init__.py | 2 + src/resolvekit/packs/geo/decision.py | 58 +- src/resolvekit/packs/geo/scoring.py | 7 +- src/resolvekit/shared/scoring_base.py | 4 +- .../benchmarks/bench_bulk_per_row_context.py | 164 ++ tests/builder/test_reviewer_fixes.py | 27 + tests/building/test_wikidata_aliases.py | 65 +- tests/core/test_context_input.py | 424 +++++ tests/core/test_per_row_context.py | 549 ++++++ tests/core/test_resolver_cache.py | 28 +- tests/core/test_result.py | 19 +- tests/packs/test_geo_scoring.py | 4 +- tests/packs/test_prominence_scoring.py | 289 +++ tests/test_ambiguous_error_hints.py | 5 +- tests/test_disambiguation_repr.py | 586 ++++++ tests/test_public_api.py | 5 +- tests/test_result_repr_no_match.py | 13 +- zensical.toml | 1 + 60 files changed, 4856 insertions(+), 1454 deletions(-) create mode 100644 docs/how-to/refine-resolution-with-context.md create mode 100644 src/resolvekit/core/api/context_input.py create mode 100644 tests/benchmarks/bench_bulk_per_row_context.py create mode 100644 tests/core/test_context_input.py create mode 100644 tests/core/test_per_row_context.py create mode 100644 tests/packs/test_prominence_scoring.py create mode 100644 tests/test_disambiguation_repr.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 998f278..9cc2341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,50 @@ # Changelog -## 0.1.3 (2026-06-11) - -Bug-fix release from a systematic stress-test of the public API surface. ~30 -verified fixes, no new features. +## 0.1.3 (2026-06-12) + +Disambiguation release: context hints as plain dicts, per-row context in bulk +operations, prominence-based tiebreaking, and ambiguous results that show +their candidates — plus ~30 verified fixes from a systematic stress-test of +the public API surface. + +**Context hints as plain dicts.** Every resolution surface — `resolve()`, +`resolve_id()`, `bulk()`, `snap()`, `suggest()`, `parse()`, `parse_bulk()`, +their module-level wrappers, and the pandas/polars accessors — now accepts +`context=` as a plain dict, no import needed: +`resolve("Paris", context={"country": "FR"})`. The `country` key also takes +country names (`"France"`, `"Korea, Rep."`) and resolves them to ISO codes, +raising a did-you-mean error when the name is ambiguous (`"Korea"`) or +unknown. Unknown context keys raise `UnknownContextKeyError` listing the +valid ones (`country`, `entity_types`, `parent_ids`, `languages`, +`attributes`, `as_of`). `ResolutionContext` still works everywhere. + +**Per-row context in bulk.** Context dict values may be a column instead of +a scalar: `df["city"].resolvekit.bulk(context={"country": df["iso"]})` +resolves each row under its own country (pandas Series, polars Expr/Series, +or plain list). Work is deduplicated to unique (text, context) pairs, so a +50k-row frame with a handful of countries costs what its unique pairs cost. +Query-cache keys are content-based now, so equal contexts share cache +entries. + +**Ambiguous results teach the fix.** An AMBIGUOUS result's repr lists the +top candidates with their containing region (`Springfield, Vermont` / +`Springfield, New Jersey`) and, when the candidates span different +countries, ends with a copy-pasteable `context={'country': ...}` hint. All +refinement hints emit the dict form. + +**Prominence-based tiebreaking.** A dominant entity now beats obscure +same-named peers instead of tying: with remote geo data, bare `"Paris"` +resolves to Paris, France over Paris, Texas and Paris, Illinois, and +`"Sudan"` resolves to the country over Sudan, Texas — while genuinely +ambiguous names (`"Springfield"`) stay AMBIGUOUS. City and admin prominence +is derived from Wikidata sitelink counts, with Data Commons population as +the fallback for unlinked entities; the confidence calibrator was retrained +on the full geo tier mix and decision gaps rescaled to match. +World Bank/IMF comma-form country names (`"Korea, Rep."`, +`"Congo, Dem. Rep."`) resolve via bundled aliases. + +**`rk.suggest()`.** The typo-tolerant typeahead is now exported at module +level alongside `resolve()` and friends. **Resolution correctness.** Dotted abbreviations are no longer misclassified as missing-value markers: `"U.S.A."` resolves to `country/USA` (it previously diff --git a/benchmarks/results/latest.json b/benchmarks/results/latest.json index 798509c..eb1ea05 100644 --- a/benchmarks/results/latest.json +++ b/benchmarks/results/latest.json @@ -1,6 +1,6 @@ { "benchmark_version": "1", - "generated_at": "2026-06-11T18:03:40Z", + "generated_at": "2026-06-12T06:03:16Z", "hardware": { "cpu": "arm", "cores": 18, @@ -90,18 +90,18 @@ }, "ambiguity_recall": 0.9130434782608695, "latency_ms": { - "p50": 0.10708300396800041, - "p95": 0.13656639494001865, - "p99": 0.14553271583281457, - "mean": 0.10662672929870694, - "min": 0.05770899588242173, - "max": 0.14791596913710237, + "p50": 0.11266599176451564, + "p95": 0.14069229364395142, + "p99": 0.14792595990002155, + "mean": 0.11347455438226461, + "min": 0.08062500273808837, + "max": 0.1497499761171639, "sample_count": 23 }, - "throughput_qps": 9342.78758643959, + "throughput_qps": 8776.810244934932, "effective_warmup": 5, - "cold_start_ms": 24.00350000243634, - "peak_rss_mb": 115.625, + "cold_start_ms": 28.529625036753714, + "peak_rss_mb": 117.71875, "wheel_size_mb": 0.14886188507080078, "data_size_mb": null, "calibration": null @@ -146,18 +146,18 @@ }, "ambiguity_recall": 0.9565217391304348, "latency_ms": { - "p50": 0.013666984159499407, - "p95": 0.08801657822914416, - "p99": 0.23774747038260122, - "mean": 0.030322473637921656, - "min": 0.0038329744711518288, - "max": 0.2787499688565731, + "p50": 0.013582990504801273, + "p95": 0.10114157339557997, + "p99": 0.25143472361378394, + "mean": 0.031634089638195605, + "min": 0.003916968125849962, + "max": 0.29220798751339316, "sample_count": 23 }, - "throughput_qps": 32641.47413898927, + "throughput_qps": 31285.450455009857, "effective_warmup": 5, - "cold_start_ms": 5.1318330224603415, - "peak_rss_mb": 113.34375, + "cold_start_ms": 7.409334008116275, + "peak_rss_mb": 113.8125, "wheel_size_mb": 0.33511829376220703, "data_size_mb": null, "calibration": null @@ -211,18 +211,18 @@ }, "ambiguity_recall": 0.9090909090909091, "latency_ms": { - "p50": 0.03735398058779538, - "p95": 0.1531876012450084, - "p99": 0.1634258753620088, - "mean": 0.07902168065563521, - "min": 0.024625041987746954, - "max": 0.1659159897826612, + "p50": 0.1026035170070827, + "p95": 0.2738411334576085, + "p99": 0.4693891113856808, + "mean": 0.144946932612749, + "min": 0.04837499000132084, + "max": 0.4955829936079681, "sample_count": 44 }, - "throughput_qps": 12578.317965899174, + "throughput_qps": 6844.343750818573, "effective_warmup": 10, - "cold_start_ms": 547.5679590017535, - "peak_rss_mb": 125.9375, + "cold_start_ms": 635.2173330378719, + "peak_rss_mb": 126.25, "wheel_size_mb": 0.28513336181640625, "data_size_mb": null, "calibration": null @@ -270,18 +270,18 @@ }, "ambiguity_recall": 0.5357142857142857, "latency_ms": { - "p50": 0.0012085074558854103, - "p95": 68.3986875024857, - "p99": 69.32519747351762, - "mean": 14.630337748842846, - "min": 0.0007080379873514175, - "max": 69.65412496356294, + "p50": 0.0015005061868578196, + "p95": 73.83602258050814, + "p99": 80.74253472790588, + "mean": 15.679877889592067, + "min": 0.0007090275175869465, + "max": 82.67354097915813, "sample_count": 28 }, - "throughput_qps": 68.34801414483665, + "throughput_qps": 63.77247801332389, "effective_warmup": 7, - "cold_start_ms": 51.88416701275855, - "peak_rss_mb": 190.78125, + "cold_start_ms": 62.39195802481845, + "peak_rss_mb": 191.171875, "wheel_size_mb": 164.73299312591553, "data_size_mb": null, "calibration": null @@ -326,18 +326,18 @@ }, "ambiguity_recall": 1.0, "latency_ms": { - "p50": 0.0007910421118140221, - "p95": 4.8546631063800225, - "p99": 5.488935486646369, - "mean": 0.485683044762877, - "min": 0.0005830079317092896, - "max": 5.5187910329550505, + "p50": 0.0012080417945981026, + "p95": 5.07083752891048, + "p99": 5.716262282803655, + "mean": 0.507119569785731, + "min": 0.0006249756552278996, + "max": 5.742708046454936, "sample_count": 23 }, - "throughput_qps": 2057.536408720884, + "throughput_qps": 1970.358947850628, "effective_warmup": 5, - "cold_start_ms": 150.46075003920123, - "peak_rss_mb": 177.265625, + "cold_start_ms": 181.13599997013807, + "peak_rss_mb": 177.8125, "wheel_size_mb": 0.19714641571044922, "data_size_mb": null, "calibration": null @@ -382,18 +382,18 @@ }, "ambiguity_recall": 0.6521739130434783, "latency_ms": { - "p50": 0.0022919848561286926, - "p95": 0.003342021955177187, - "p99": 0.007901965873315937, - "mean": 0.002626820629381615, - "min": 0.00204198295250535, - "max": 0.009166949894279242, + "p50": 0.0020420411601662636, + "p95": 0.0029127637390047307, + "p99": 0.0034692301414906987, + "mean": 0.0021666966621642528, + "min": 0.0017909915186464787, + "max": 0.0036249984987080097, "sample_count": 23 }, - "throughput_qps": 342216.02127066226, + "throughput_qps": 406784.45040031214, "effective_warmup": 5, - "cold_start_ms": 1.6964579699561, - "peak_rss_mb": 112.609375, + "cold_start_ms": 1.7942499835044146, + "peak_rss_mb": 112.9375, "wheel_size_mb": 20.141146659851074, "data_size_mb": null, "calibration": null @@ -438,18 +438,18 @@ }, "ambiguity_recall": 0.782608695652174, "latency_ms": { - "p50": 0.17149996710941195, - "p95": 0.3581377735827118, - "p99": 0.3794967278372497, - "mean": 0.21032603589170004, - "min": 0.07408397505059838, - "max": 0.385124993044883, + "p50": 0.1925000105984509, + "p95": 0.3716250066645443, + "p99": 0.3800275188405067, + "mean": 0.21143300387927372, + "min": 0.07395900320261717, + "max": 0.3813750226981938, "sample_count": 23 }, - "throughput_qps": 4747.570648039316, + "throughput_qps": 4722.954559055634, "effective_warmup": 5, - "cold_start_ms": 2.521958958823234, - "peak_rss_mb": 112.890625, + "cold_start_ms": 3.0825839494355023, + "peak_rss_mb": 113.3125, "wheel_size_mb": 4.124938011169434, "data_size_mb": null, "calibration": null @@ -459,37 +459,37 @@ }, { "name": "resolvekit", - "version": "0.1.2", + "version": "0.1.3", "offline": true, "dataset": "ambiguous", "metrics": { "accuracy": { - "overall": 0.8723404255319149, + "overall": 0.851063829787234, "by_capability": { - "ambiguity_signaling": 0.8723404255319149, - "admin_hierarchy": 0.8214285714285714 + "ambiguity_signaling": 0.851063829787234, + "admin_hierarchy": 0.7857142857142857 }, "by_language": { - "en": 0.8723404255319149 + "en": 0.851063829787234 }, "by_difficulty": { - "hard": 0.8723404255319149 + "hard": 0.851063829787234 }, "by_entity_type": { "admin1": 0.6666666666666666, "admin2": 0.7, "city": 0.8333333333333334, "country": 1.0, - "admin3": 1.0, + "admin3": 0.5, "admin4": 1.0 }, - "wrong_match_rate": 0.0, + "wrong_match_rate": 0.02127659574468085, "abstention_precision": 0.0, "abstention_recall": 0.0, "error_rate": 0.0, "row_count": 47, - "accuracy_ci_low": 0.7482578662713097, - "accuracy_ci_high": 0.9401547721979753, + "accuracy_ci_low": 0.7231410342888723, + "accuracy_ci_high": 0.925933739125025, "by_entity_type_n": { "admin1": 6, "admin2": 10, @@ -503,30 +503,30 @@ "admin2": 0.0, "city": 0.0, "country": 0.0, - "admin3": 0.0, + "admin3": 0.5, "admin4": 0.0 } }, - "ambiguity_recall": 0.8723404255319149, + "ambiguity_recall": 0.851063829787234, "latency_ms": { - "p50": 2.287292038090527, - "p95": 3.2900125661399215, - "p99": 4.0266661427449435, - "mean": 2.1573839823101113, - "min": 0.23866700939834118, - "max": 4.1049999999813735, + "p50": 2.868708979804069, + "p95": 5.04354981239885, + "p99": 6.1031063192058355, + "mean": 2.868601082616109, + "min": 0.363083032425493, + "max": 6.191291962750256, "sample_count": 47 }, - "throughput_qps": 463.3560534761993, + "throughput_qps": 348.387503262102, "effective_warmup": 11, - "cold_start_ms": 5808.8847500039265, - "peak_rss_mb": 1139.515625, - "wheel_size_mb": 9.61152458190918, - "data_size_mb": 807.2899999999998, + "cold_start_ms": 33800.74145796243, + "peak_rss_mb": 1633.515625, + "wheel_size_mb": 9.546000480651855, + "data_size_mb": 806.6399999999999, "calibration": { - "n_with_confidence": 17, - "ece": null, - "brier": null, + "n_with_confidence": 21, + "ece": 0.030656806514308577, + "brier": 0.047580071747985876, "reliability_bins": [ { "lower": 0.0, @@ -594,9 +594,9 @@ { "lower": 0.9, "upper": 1.0, - "count": 17, - "mean_confidence": 0.9212489431983777, - "observed_accuracy": 1.0 + "count": 21, + "mean_confidence": 0.9217241458666438, + "observed_accuracy": 0.9523809523809523 } ] } @@ -606,7 +606,7 @@ }, { "name": "resolvekit_typed", - "version": "0.1.2", + "version": "0.1.3", "offline": true, "dataset": "ambiguous", "metrics": { @@ -656,24 +656,24 @@ }, "ambiguity_recall": 0.9148936170212766, "latency_ms": { - "p50": 2.1875830134376884, - "p95": 3.5609543090686193, - "p99": 4.02777363662608, - "mean": 2.0925798490544425, - "min": 0.30495895771309733, - "max": 4.149749991483986, + "p50": 2.1029170020483434, + "p95": 4.100300220306963, + "p99": 149.1041258757466, + "mean": 7.820167530456835, + "min": 0.338749960064888, + "max": 271.4030420174822, "sample_count": 47 }, - "throughput_qps": 477.69021549500565, + "throughput_qps": 127.8620324612901, "effective_warmup": 11, - "cold_start_ms": 176.1454590014182, - "peak_rss_mb": 173.984375, - "wheel_size_mb": 9.61152458190918, - "data_size_mb": 807.2899999999998, + "cold_start_ms": 278.9324159966782, + "peak_rss_mb": 239.71875, + "wheel_size_mb": 9.546000480651855, + "data_size_mb": 806.6399999999999, "calibration": { - "n_with_confidence": 31, - "ece": 0.0879792617947372, - "brier": 0.00807487727155894, + "n_with_confidence": 29, + "ece": 0.09199398949153335, + "brier": 0.009144965437629425, "reliability_bins": [ { "lower": 0.0, @@ -734,15 +734,15 @@ { "lower": 0.8, "upper": 0.9, - "count": 8, - "mean_confidence": 0.8834896216880805, + "count": 5, + "mean_confidence": 0.8552173377171023, "observed_accuracy": 1.0 }, { "lower": 0.9, "upper": 1.0, - "count": 23, - "mean_confidence": 0.9219446048199349, + "count": 24, + "mean_confidence": 0.9190036506733342, "observed_accuracy": 1.0 } ] @@ -793,18 +793,18 @@ }, "ambiguity_recall": 1.0, "latency_ms": { - "p50": 0.10399948223493993, + "p50": 0.10997903882525861, "p95": null, "p99": null, - "mean": 0.08946238667704165, - "min": 0.03825000021606684, - "max": 0.14224997721612453, + "mean": 0.09956230642274022, + "min": 0.040417013224214315, + "max": 0.15320797683671117, "sample_count": 10 }, - "throughput_qps": 11116.768947974871, + "throughput_qps": 9989.171832042019, "effective_warmup": 100, - "cold_start_ms": 24.00350000243634, - "peak_rss_mb": 115.625, + "cold_start_ms": 28.529625036753714, + "peak_rss_mb": 117.71875, "wheel_size_mb": 0.14886188507080078, "data_size_mb": null, "calibration": null @@ -854,18 +854,18 @@ }, "ambiguity_recall": 1.0, "latency_ms": { - "p50": 0.03383299917913973, + "p50": 0.034958997275680304, "p95": null, "p99": null, - "mean": 0.08929999312385917, - "min": 0.003374996595084667, - "max": 0.2962090075016022, + "mean": 0.09265009430237114, + "min": 0.0038750004023313522, + "max": 0.30937499832361937, "sample_count": 10 }, - "throughput_qps": 11155.012404039704, + "throughput_qps": 10736.813623182845, "effective_warmup": 100, - "cold_start_ms": 5.1318330224603415, - "peak_rss_mb": 113.34375, + "cold_start_ms": 7.409334008116275, + "peak_rss_mb": 113.8125, "wheel_size_mb": 0.33511829376220703, "data_size_mb": null, "calibration": null @@ -925,18 +925,18 @@ }, "ambiguity_recall": 0.9154929577464789, "latency_ms": { - "p50": 0.13637501979246736, - "p95": 0.22905447403900317, - "p99": 0.28743003262206906, - "mean": 0.1163099836760565, - "min": 0.028332986403256655, - "max": 0.41799998143687844, + "p50": 0.11958397226408124, + "p95": 0.23827059194445607, + "p99": 0.31867747777141625, + "mean": 0.11280774878976857, + "min": 0.025540997739881277, + "max": 0.3655829932540655, "sample_count": 207 }, - "throughput_qps": 8552.305419069993, + "throughput_qps": 8804.748202323604, "effective_warmup": 100, - "cold_start_ms": 547.5679590017535, - "peak_rss_mb": 125.9375, + "cold_start_ms": 635.2173330378719, + "peak_rss_mb": 126.25, "wheel_size_mb": 0.28513336181640625, "data_size_mb": null, "calibration": null @@ -990,18 +990,18 @@ }, "ambiguity_recall": 0.375, "latency_ms": { - "p50": 0.0034789845813065767, - "p95": 69.97832738852594, - "p99": 70.95342157757841, - "mean": 30.16347130003851, - "min": 0.00037497375160455704, - "max": 71.57262496184558, + "p50": 0.0042704923544079065, + "p95": 77.91736521758139, + "p99": 80.66156338434666, + "mean": 32.24658705003094, + "min": 0.0004159519448876381, + "max": 81.0762089677155, "sample_count": 100 }, - "throughput_qps": 33.15153171429597, + "throughput_qps": 31.009333250225666, "effective_warmup": 100, - "cold_start_ms": 51.88416701275855, - "peak_rss_mb": 190.78125, + "cold_start_ms": 62.39195802481845, + "peak_rss_mb": 191.171875, "wheel_size_mb": 164.73299312591553, "data_size_mb": null, "calibration": null @@ -1051,18 +1051,18 @@ }, "ambiguity_recall": 1.0, "latency_ms": { - "p50": 0.050666509196162224, + "p50": 0.05350049468688667, "p95": null, "p99": null, - "mean": 2.213991596363485, - "min": 0.0007919734343886375, - "max": 5.877374962437898, + "mean": 2.2790710092522204, + "min": 0.0007500057108700275, + "max": 6.027958006598055, "sample_count": 10 }, - "throughput_qps": 451.58619001274474, + "throughput_qps": 438.5676383167537, "effective_warmup": 100, - "cold_start_ms": 150.46075003920123, - "peak_rss_mb": 177.265625, + "cold_start_ms": 181.13599997013807, + "peak_rss_mb": 177.8125, "wheel_size_mb": 0.19714641571044922, "data_size_mb": null, "calibration": null @@ -1112,18 +1112,18 @@ }, "ambiguity_recall": 0.75, "latency_ms": { - "p50": 0.0021249870769679546, + "p50": 0.0020415172912180424, "p95": null, "p99": null, - "mean": 0.0029833056032657623, - "min": 0.0019579892978072166, - "max": 0.010791001841425896, + "mean": 0.0020541890989989042, + "min": 0.0017919810488820076, + "max": 0.0024999608285725117, "sample_count": 10 }, - "throughput_qps": 305343.3572029056, + "throughput_qps": 427040.18095903314, "effective_warmup": 100, - "cold_start_ms": 1.6964579699561, - "peak_rss_mb": 112.609375, + "cold_start_ms": 1.7942499835044146, + "peak_rss_mb": 112.9375, "wheel_size_mb": 20.141146659851074, "data_size_mb": null, "calibration": null @@ -1173,18 +1173,18 @@ }, "ambiguity_recall": 0.75, "latency_ms": { - "p50": 0.22183300461620092, + "p50": 0.22941702627576888, "p95": null, "p99": null, - "mean": 0.22403339971788228, - "min": 0.04850002005696297, - "max": 0.4534999607130885, + "mean": 0.23207090562209487, + "min": 0.05054200300946832, + "max": 0.4745839978568256, "sample_count": 10 }, - "throughput_qps": 4457.321209465699, + "throughput_qps": 4301.691377334196, "effective_warmup": 100, - "cold_start_ms": 2.521958958823234, - "peak_rss_mb": 112.890625, + "cold_start_ms": 3.0825839494355023, + "peak_rss_mb": 113.3125, "wheel_size_mb": 4.124938011169434, "data_size_mb": null, "calibration": null @@ -1194,48 +1194,48 @@ }, { "name": "resolvekit", - "version": "0.1.2", + "version": "0.1.3", "offline": true, "dataset": "eval_geo", "metrics": { "accuracy": { - "overall": 0.885558583106267, + "overall": 0.8610354223433242, "by_capability": { "multilingual": 0.875, "transliteration": 0.8333333333333334, - "informal_alias": 0.9375, - "iso_code": 1.0 + "informal_alias": 0.90625, + "iso_code": 0.9411764705882353 }, "by_language": { - "en": 0.8857938718662952, + "en": 0.8607242339832869, "de": 1.0, "fr": 1.0, "es": 0.6666666666666666 }, "by_difficulty": { - "easy": 0.9186046511627907, + "easy": 0.8837209302325582, "medium": 0.8701298701298701, "hard": 0.65625 }, "by_entity_type": { - "admin1": 0.7818181818181819, - "admin3": 0.8181818181818182, + "admin1": 0.7090909090909091, + "admin3": 0.9090909090909091, "country": 0.963855421686747, - "admin5": 0.8857142857142857, - "admin2": 0.96875, + "admin5": 0.7428571428571429, + "admin2": 0.9375, "continental_union": 1.0, - "city": 0.8888888888888888, - "admin4": 0.875, + "city": 0.875, + "admin4": 0.84375, "continent": 1.0, "world_region": 0.5555555555555556 }, - "wrong_match_rate": 0.051771117166212535, - "abstention_precision": 0.5384615384615384, + "wrong_match_rate": 0.05994550408719346, + "abstention_precision": 0.42424242424242425, "abstention_recall": 0.8235294117647058, "error_rate": 0.0, "row_count": 367, - "accuracy_ci_low": 0.8489180096995185, - "accuracy_ci_high": 0.9142110459404097, + "accuracy_ci_low": 0.8218906848279234, + "accuracy_ci_high": 0.8927001269903839, "by_entity_type_n": { "admin1": 55, "admin3": 33, @@ -1249,38 +1249,38 @@ "world_region": 9 }, "by_entity_type_wrong_match": { - "admin1": 0.01818181818181818, - "admin3": 0.09090909090909091, + "admin1": 0.09090909090909091, + "admin3": 0.0, "country": 0.0, - "admin5": 0.08571428571428572, - "admin2": 0.0, + "admin5": 0.05714285714285714, + "admin2": 0.03125, "continental_union": 0.0, - "city": 0.08333333333333333, - "admin4": 0.0625, + "city": 0.09722222222222222, + "admin4": 0.09375, "continent": 0.0, "world_region": 0.4444444444444444 } }, - "ambiguity_recall": 0.8941176470588236, + "ambiguity_recall": 0.8823529411764706, "latency_ms": { - "p50": 0.4820830072276294, - "p95": 2.454450010554865, - "p99": 3.2821616320870763, - "mean": 0.8395072174250917, - "min": 0.004832982085645199, - "max": 5.1496250089257956, + "p50": 1.271958986762911, + "p95": 9.987666900269684, + "p99": 14.436404776060947, + "mean": 2.8435676790043023, + "min": 0.006167043466120958, + "max": 34.70008395379409, "sample_count": 367 }, - "throughput_qps": 1190.3762781393418, + "throughput_qps": 351.51884636226765, "effective_warmup": 100, - "cold_start_ms": 5808.8847500039265, - "peak_rss_mb": 1139.515625, - "wheel_size_mb": 9.61152458190918, - "data_size_mb": 807.2899999999998, + "cold_start_ms": 33800.74145796243, + "peak_rss_mb": 1633.515625, + "wheel_size_mb": 9.546000480651855, + "data_size_mb": 806.6399999999999, "calibration": { - "n_with_confidence": 247, - "ece": 0.02998936843963204, - "brier": 0.07066714542601314, + "n_with_confidence": 275, + "ece": 0.037325150612446095, + "brier": 0.07532996240930376, "reliability_bins": [ { "lower": 0.0, @@ -1334,23 +1334,23 @@ { "lower": 0.7, "upper": 0.8, - "count": 3, - "mean_confidence": 0.7800108870404773, - "observed_accuracy": 0.6666666666666666 + "count": 1, + "mean_confidence": 0.7906888665288548, + "observed_accuracy": 0.0 }, { "lower": 0.8, "upper": 0.9, - "count": 118, - "mean_confidence": 0.873116884264454, - "observed_accuracy": 0.9152542372881356 + "count": 148, + "mean_confidence": 0.8621713112965406, + "observed_accuracy": 0.9256756756756757 }, { "lower": 0.9, "upper": 1.0, "count": 126, - "mean_confidence": 0.9198798913756091, - "observed_accuracy": 0.9365079365079365 + "mean_confidence": 0.921230806537951, + "observed_accuracy": 0.9206349206349206 } ] } @@ -1360,48 +1360,48 @@ }, { "name": "resolvekit_typed", - "version": "0.1.2", + "version": "0.1.3", "offline": true, "dataset": "eval_geo", "metrics": { "accuracy": { - "overall": 0.9128065395095368, + "overall": 0.888283378746594, "by_capability": { "multilingual": 0.875, "transliteration": 0.8333333333333334, - "informal_alias": 0.96875, - "iso_code": 1.0 + "informal_alias": 0.9375, + "iso_code": 0.9411764705882353 }, "by_language": { - "en": 0.9136490250696379, + "en": 0.8885793871866295, "de": 1.0, "fr": 1.0, "es": 0.6666666666666666 }, "by_difficulty": { - "easy": 0.9573643410852714, - "medium": 0.8441558441558441, + "easy": 0.9263565891472868, + "medium": 0.8311688311688312, "hard": 0.71875 }, "by_entity_type": { "admin1": 0.8181818181818182, "admin3": 0.8484848484848485, "country": 0.9518072289156626, - "admin5": 0.9714285714285714, + "admin5": 0.8, "admin2": 0.9375, "continental_union": 1.0, - "city": 0.8888888888888888, - "admin4": 0.9375, + "city": 0.875, + "admin4": 0.875, "continent": 1.0, "world_region": 1.0 }, - "wrong_match_rate": 0.02452316076294278, - "abstention_precision": 0.4230769230769231, + "wrong_match_rate": 0.035422343324250684, + "abstention_precision": 0.3548387096774194, "abstention_recall": 0.6470588235294118, "error_rate": 0.0, "row_count": 367, - "accuracy_ci_low": 0.8794995670550372, - "accuracy_ci_high": 0.9375608706089142, + "accuracy_ci_low": 0.8519473453836499, + "accuracy_ci_high": 0.9165748484586806, "by_entity_type_n": { "admin1": 55, "admin3": 33, @@ -1415,38 +1415,38 @@ "world_region": 9 }, "by_entity_type_wrong_match": { - "admin1": 0.01818181818181818, + "admin1": 0.03636363636363636, "admin3": 0.0, "country": 0.03614457831325301, "admin5": 0.0, "admin2": 0.0, "continental_union": 0.0, - "city": 0.06944444444444445, - "admin4": 0.0, + "city": 0.09722222222222222, + "admin4": 0.03125, "continent": 0.0, "world_region": 0.0 } }, "ambiguity_recall": 0.9294117647058824, "latency_ms": { - "p50": 0.29087503207847476, - "p95": 2.4170999706257135, - "p99": 3.586995628429573, - "mean": 0.7024642266586869, - "min": 0.0070829992182552814, - "max": 8.74579098308459, + "p50": 0.3674579784274101, + "p95": 2.7362918248400088, + "p99": 4.256144511746242, + "mean": 0.8093237956828496, + "min": 0.0077500008046627045, + "max": 5.012542009353638, "sample_count": 367 }, - "throughput_qps": 1422.437665791873, + "throughput_qps": 1234.4876119711125, "effective_warmup": 100, - "cold_start_ms": 176.1454590014182, - "peak_rss_mb": 173.984375, - "wheel_size_mb": 9.61152458190918, - "data_size_mb": 807.2899999999998, + "cold_start_ms": 278.9324159966782, + "peak_rss_mb": 239.71875, + "wheel_size_mb": 9.546000480651855, + "data_size_mb": 806.6399999999999, "calibration": { - "n_with_confidence": 277, - "ece": 0.07022480631081299, - "brier": 0.03764011854201866, + "n_with_confidence": 295, + "ece": 0.06877744772805863, + "brier": 0.04909516668820359, "reliability_bins": [ { "lower": 0.0, @@ -1500,23 +1500,23 @@ { "lower": 0.7, "upper": 0.8, - "count": 5, - "mean_confidence": 0.781223196075759, - "observed_accuracy": 0.8 + "count": 4, + "mean_confidence": 0.7626155148303156, + "observed_accuracy": 1.0 }, { "lower": 0.8, "upper": 0.9, - "count": 125, - "mean_confidence": 0.8736450914409711, - "observed_accuracy": 0.976 + "count": 156, + "mean_confidence": 0.8600238033141157, + "observed_accuracy": 0.967948717948718 }, { "lower": 0.9, "upper": 1.0, - "count": 147, - "mean_confidence": 0.9213331717102354, - "observed_accuracy": 0.9659863945578231 + "count": 135, + "mean_confidence": 0.9221961299548104, + "observed_accuracy": 0.9407407407407408 } ] } @@ -1580,7 +1580,7 @@ }, { "name": "resolvekit", - "version": "0.1.2", + "version": "0.1.3", "offline": true, "dataset": "eval_org", "metrics": { @@ -1600,7 +1600,7 @@ "by_entity_type": { "org": 0.75 }, - "wrong_match_rate": 0.1, + "wrong_match_rate": 0.15, "abstention_precision": 1.0, "abstention_recall": 1.0, "error_rate": 0.0, @@ -1611,27 +1611,27 @@ "org": 20 }, "by_entity_type_wrong_match": { - "org": 0.1 + "org": 0.15 } }, "ambiguity_recall": 1.0, "latency_ms": { - "p50": 0.8636039856355637, - "p95": 3.4459110291209103, - "p99": 6.526982187060634, - "mean": 1.4385436050361022, - "min": 0.006415997631847858, - "max": 7.297249976545572, + "p50": 1.6519170021638274, + "p95": 10.822186048608286, + "p99": 25.014837232884, + "mean": 3.540093800984323, + "min": 0.00762502895668149, + "max": 28.563000028952956, "sample_count": 20 }, - "throughput_qps": 694.774124987808, + "throughput_qps": 282.39314071371894, "effective_warmup": 5, - "cold_start_ms": 5808.8847500039265, - "peak_rss_mb": 1139.515625, - "wheel_size_mb": 9.61152458190918, - "data_size_mb": 807.2899999999998, + "cold_start_ms": 33800.74145796243, + "peak_rss_mb": 1633.515625, + "wheel_size_mb": 9.546000480651855, + "data_size_mb": 806.6399999999999, "calibration": { - "n_with_confidence": 4, + "n_with_confidence": 5, "ece": null, "brier": null, "reliability_bins": [ @@ -1694,9 +1694,9 @@ { "lower": 0.8, "upper": 0.9, - "count": 4, - "mean_confidence": 0.8627862031382588, - "observed_accuracy": 0.5 + "count": 5, + "mean_confidence": 0.8520686907994488, + "observed_accuracy": 0.4 }, { "lower": 0.9, @@ -1713,7 +1713,7 @@ }, { "name": "resolvekit_typed", - "version": "0.1.2", + "version": "0.1.3", "offline": true, "dataset": "eval_org", "metrics": { @@ -1733,7 +1733,7 @@ "by_entity_type": { "org": 0.75 }, - "wrong_match_rate": 0.1, + "wrong_match_rate": 0.15, "abstention_precision": 1.0, "abstention_recall": 1.0, "error_rate": 0.0, @@ -1744,27 +1744,27 @@ "org": 20 }, "by_entity_type_wrong_match": { - "org": 0.1 + "org": 0.15 } }, "ambiguity_recall": 1.0, "latency_ms": { - "p50": 0.870500021846965, - "p95": 3.1969169998774327, - "p99": 5.596617002156559, - "mean": 1.3592020521173254, - "min": 0.0076249707490205765, - "max": 6.196542002726346, + "p50": 0.9256459889002144, + "p95": 3.5210104164434615, + "p99": 5.47626850137021, + "mean": 1.3822625071043149, + "min": 0.007792026735842228, + "max": 5.9650830226019025, "sample_count": 20 }, - "throughput_qps": 735.3729544354547, + "throughput_qps": 723.0865021584927, "effective_warmup": 5, - "cold_start_ms": 176.1454590014182, - "peak_rss_mb": 173.984375, - "wheel_size_mb": 9.61152458190918, - "data_size_mb": 807.2899999999998, + "cold_start_ms": 278.9324159966782, + "peak_rss_mb": 239.71875, + "wheel_size_mb": 9.546000480651855, + "data_size_mb": 806.6399999999999, "calibration": { - "n_with_confidence": 4, + "n_with_confidence": 5, "ece": null, "brier": null, "reliability_bins": [ @@ -1827,9 +1827,9 @@ { "lower": 0.8, "upper": 0.9, - "count": 4, - "mean_confidence": 0.8627862031382588, - "observed_accuracy": 0.5 + "count": 5, + "mean_confidence": 0.8520686907994488, + "observed_accuracy": 0.4 }, { "lower": 0.9, @@ -1903,18 +1903,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.10295800166204572, - "p95": 0.18740842351689935, - "p99": 0.24237801553681487, - "mean": 0.11103353033315909, - "min": 0.017332960851490498, - "max": 0.48274995060637593, + "p50": 0.1437499886378646, + "p95": 0.2854077727533877, + "p99": 0.3301985817961394, + "mean": 0.15822433852898024, + "min": 0.02508395118638873, + "max": 0.5878749652765691, "sample_count": 2045 }, - "throughput_qps": 8970.775316352365, + "throughput_qps": 6295.990033660281, "effective_warmup": 100, - "cold_start_ms": 547.5679590017535, - "peak_rss_mb": 125.9375, + "cold_start_ms": 635.2173330378719, + "peak_rss_mb": 126.25, "wheel_size_mb": 0.28513336181640625, "data_size_mb": null, "calibration": null @@ -1960,66 +1960,66 @@ }, { "name": "resolvekit", - "version": "0.1.2", + "version": "0.1.3", "offline": true, "dataset": "geo_admin", "metrics": { "accuracy": { - "overall": 0.9339069221744232, + "overall": 0.8631208447399296, "by_capability": { - "typo": 0.9257714762301918, - "alias": 0.9344262295081968 + "typo": 0.8632193494578816, + "alias": 0.4918032786885246 }, "by_language": { - "en": 0.9339069221744232 + "en": 0.8631208447399296 }, "by_difficulty": { - "easy": 0.941747572815534, - "medium": 0.9265707797123391 + "easy": 0.8996763754045307, + "medium": 0.8289174867524602 }, "by_entity_type": { - "admin1": 0.9570164348925411, - "admin2": 0.9209702660406885, - "admin3": 0.930327868852459 + "admin1": 0.8862199747155499, + "admin2": 0.8348982785602503, + "admin3": 0.8995901639344263 }, - "wrong_match_rate": 0.04262807978099335, + "wrong_match_rate": 0.0778255768478686, "abstention_precision": 0.0, "abstention_recall": 0.0, "error_rate": 0.0, "row_count": 2557, - "accuracy_ci_low": 0.9236113684980024, - "accuracy_ci_high": 0.9429006406789807, + "accuracy_ci_low": 0.8492521828857204, + "accuracy_ci_high": 0.8759000483182714, "by_entity_type_n": { "admin1": 791, "admin2": 1278, "admin3": 488 }, "by_entity_type_wrong_match": { - "admin1": 0.03034134007585335, - "admin2": 0.048513302034428794, - "admin3": 0.0471311475409836 + "admin1": 0.06826801517067003, + "admin2": 0.09154929577464789, + "admin3": 0.05737704918032787 } }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.4985000123269856, - "p95": 2.694774803239852, - "p99": 10.30254853190855, - "mean": 1.0659289564066856, - "min": 0.012542004697024822, - "max": 62.83512501977384, + "p50": 0.7465419475920498, + "p95": 4.280749417375771, + "p99": 12.485782955773177, + "mean": 1.4572726272332117, + "min": 0.015790981706231833, + "max": 61.29895802587271, "sample_count": 2557 }, - "throughput_qps": 937.6241586539993, + "throughput_qps": 685.8353750532901, "effective_warmup": 100, - "cold_start_ms": 5808.8847500039265, - "peak_rss_mb": 1139.515625, - "wheel_size_mb": 9.61152458190918, - "data_size_mb": 807.2899999999998, + "cold_start_ms": 33800.74145796243, + "peak_rss_mb": 1633.515625, + "wheel_size_mb": 9.546000480651855, + "data_size_mb": 806.6399999999999, "calibration": { - "n_with_confidence": 2076, - "ece": 0.09787790969721948, - "brier": 0.05618076623232687, + "n_with_confidence": 2035, + "ece": 0.09234771472461292, + "brier": 0.09694682527757838, "reliability_bins": [ { "lower": 0.0, @@ -2073,23 +2073,23 @@ { "lower": 0.7, "upper": 0.8, - "count": 0, - "mean_confidence": 0.0, - "observed_accuracy": 0.0 + "count": 7, + "mean_confidence": 0.7552001140886796, + "observed_accuracy": 0.8571428571428571 }, { "lower": 0.8, "upper": 0.9, - "count": 1981, - "mean_confidence": 0.8766260183439197, - "observed_accuracy": 0.9641595153962645 + "count": 1912, + "mean_confidence": 0.8539692065389376, + "observed_accuracy": 0.9267782426778243 }, { "lower": 0.9, "upper": 1.0, - "count": 95, - "mean_confidence": 0.9135861354813954, - "observed_accuracy": 0.6 + "count": 116, + "mean_confidence": 0.9138200272901443, + "observed_accuracy": 0.5 } ] } @@ -2099,66 +2099,66 @@ }, { "name": "resolvekit_typed", - "version": "0.1.2", + "version": "0.1.3", "offline": true, "dataset": "geo_admin", "metrics": { "accuracy": { - "overall": 0.9773171685569026, + "overall": 0.9296050058662495, "by_capability": { - "typo": 0.9724770642201835, - "alias": 0.9344262295081968 + "typo": 0.9399499582985822, + "alias": 0.5163934426229508 }, "by_language": { - "en": 0.9773171685569026 + "en": 0.9296050058662495 }, "by_difficulty": { - "easy": 0.9862459546925566, - "medium": 0.9689629068887207 + "easy": 0.9603559870550162, + "medium": 0.9008327024981075 }, "by_entity_type": { - "admin1": 0.9797724399494311, - "admin2": 0.9726134585289515, - "admin3": 0.985655737704918 + "admin1": 0.943109987357775, + "admin2": 0.9233176838810642, + "admin3": 0.9241803278688525 }, - "wrong_match_rate": 0.0011732499022291747, + "wrong_match_rate": 0.016816581931951506, "abstention_precision": 0.0, "abstention_recall": 0.0, "error_rate": 0.0, "row_count": 2557, - "accuracy_ci_low": 0.9707900959966484, - "accuracy_ci_high": 0.9824121637604566, + "accuracy_ci_low": 0.9190316309337913, + "accuracy_ci_high": 0.9388894525100268, "by_entity_type_n": { "admin1": 791, "admin2": 1278, "admin3": 488 }, "by_entity_type_wrong_match": { - "admin1": 0.0, - "admin2": 0.001564945226917058, - "admin3": 0.0020491803278688526 + "admin1": 0.0037926675094816687, + "admin2": 0.01643192488262911, + "admin3": 0.0389344262295082 } }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.24925003526732326, - "p95": 1.4904499636031665, - "p99": 2.485555082093926, - "mean": 0.4664215534562329, - "min": 0.05520798731595278, - "max": 24.331375025212765, + "p50": 0.3785419976338744, + "p95": 2.7445495827123496, + "p99": 5.928013462107641, + "mean": 0.8001667311942109, + "min": 0.01895899185910821, + "max": 39.02670799288899, "sample_count": 2557 }, - "throughput_qps": 2141.5126392437533, + "throughput_qps": 1248.7136271705726, "effective_warmup": 100, - "cold_start_ms": 176.1454590014182, - "peak_rss_mb": 173.984375, - "wheel_size_mb": 9.61152458190918, - "data_size_mb": 807.2899999999998, + "cold_start_ms": 278.9324159966782, + "peak_rss_mb": 239.71875, + "wheel_size_mb": 9.546000480651855, + "data_size_mb": 806.6399999999999, "calibration": { - "n_with_confidence": 2168, - "ece": 0.12063326428791973, - "brier": 0.015958564088723183, + "n_with_confidence": 2112, + "ece": 0.13348579166063027, + "brier": 0.0372349082708186, "reliability_bins": [ { "lower": 0.0, @@ -2212,23 +2212,23 @@ { "lower": 0.7, "upper": 0.8, - "count": 0, - "mean_confidence": 0.0, - "observed_accuracy": 0.0 + "count": 7, + "mean_confidence": 0.75956599109231, + "observed_accuracy": 0.8571428571428571 }, { "lower": 0.8, "upper": 0.9, - "count": 2086, - "mean_confidence": 0.8766557976287161, - "observed_accuracy": 0.99856184084372 + "count": 2022, + "mean_confidence": 0.8523495487064912, + "observed_accuracy": 0.9871414441147379 }, { "lower": 0.9, "upper": 1.0, - "count": 82, - "mean_confidence": 0.9117449898815622, - "observed_accuracy": 1.0 + "count": 83, + "mean_confidence": 0.9119245952942451, + "observed_accuracy": 0.8072289156626506 } ] } @@ -2292,18 +2292,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.0982089841272682, - "p95": 0.18124792259186506, - "p99": 0.2374545292695984, - "mean": 0.10387829144065108, - "min": 0.01850002445280552, - "max": 0.45650004176422954, + "p50": 0.13766702613793314, + "p95": 0.291479067527689, + "p99": 0.39694052073173214, + "mean": 0.1595576027284551, + "min": 0.023250002413988113, + "max": 1.305249985307455, "sample_count": 2048 }, - "throughput_qps": 9588.703293343735, + "throughput_qps": 6246.661539692942, "effective_warmup": 100, - "cold_start_ms": 547.5679590017535, - "peak_rss_mb": 125.9375, + "cold_start_ms": 635.2173330378719, + "peak_rss_mb": 126.25, "wheel_size_mb": 0.28513336181640625, "data_size_mb": null, "calibration": null @@ -2349,18 +2349,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 68.81568749668077, - "p95": 73.22504200856201, - "p99": 79.61777351738417, - "mean": 69.7751113987124, - "min": 66.20041595306247, - "max": 991.1713750334457, + "p50": 72.73075002012774, + "p95": 86.22871656261847, + "p99": 104.09777402004691, + "mean": 74.73349756017456, + "min": 66.22112501645461, + "max": 711.5353330154903, "sample_count": 2048 }, - "throughput_qps": 14.331355816134646, + "throughput_qps": 13.380287707957145, "effective_warmup": 100, - "cold_start_ms": 51.88416701275855, - "peak_rss_mb": 190.78125, + "cold_start_ms": 62.39195802481845, + "peak_rss_mb": 191.171875, "wheel_size_mb": 164.73299312591553, "data_size_mb": null, "calibration": null @@ -2397,60 +2397,60 @@ }, { "name": "resolvekit", - "version": "0.1.2", + "version": "0.1.3", "offline": true, "dataset": "geo_cities", "metrics": { "accuracy": { - "overall": 0.85791015625, + "overall": 0.73974609375, "by_capability": { - "typo": 0.8333333333333334, - "alias": 0.9852941176470589 + "typo": 0.7258064516129032, + "alias": 0.6176470588235294 }, "by_language": { - "en": 0.85791015625 + "en": 0.73974609375 }, "by_difficulty": { - "medium": 0.8527204502814258, - "easy": 0.8635437881873728 + "medium": 0.7120075046904315, + "easy": 0.769857433808554 }, "by_entity_type": { - "city": 0.85791015625 + "city": 0.73974609375 }, - "wrong_match_rate": 0.0107421875, + "wrong_match_rate": 0.11767578125, "abstention_precision": 0.0, "abstention_recall": 0.0, "error_rate": 0.0, "row_count": 2048, - "accuracy_ci_low": 0.8421179072247582, - "accuracy_ci_high": 0.8723621968924407, + "accuracy_ci_low": 0.7203063278510635, + "accuracy_ci_high": 0.7582881211551364, "by_entity_type_n": { "city": 2048 }, "by_entity_type_wrong_match": { - "city": 0.0107421875 + "city": 0.11767578125 } }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.6114165007602423, - "p95": 2.4082206044113255, - "p99": 3.966243496397509, - "mean": 0.8845630542850813, - "min": 0.01270900247618556, - "max": 9.39195801038295, + "p50": 0.8882709953468293, + "p95": 3.9102267561247555, + "p99": 6.920042731799181, + "mean": 1.3174343440596203, + "min": 0.014999997802078724, + "max": 12.844790995586663, "sample_count": 2048 }, - "throughput_qps": 1129.7571036259465, + "throughput_qps": 758.5426679955389, "effective_warmup": 100, - "cold_start_ms": 5808.8847500039265, - "peak_rss_mb": 1139.515625, - "wheel_size_mb": 9.61152458190918, - "data_size_mb": 807.2899999999998, + "cold_start_ms": 33800.74145796243, + "peak_rss_mb": 1633.515625, + "wheel_size_mb": 9.546000480651855, + "data_size_mb": 806.6399999999999, "calibration": { - "n_with_confidence": 1043, - "ece": 0.10049286992190337, - "brier": 0.030797223277077325, + "n_with_confidence": 1256, + "ece": 0.0553159417774422, + "brier": 0.16964541656249008, "reliability_bins": [ { "lower": 0.0, @@ -2504,23 +2504,23 @@ { "lower": 0.7, "upper": 0.8, - "count": 1, - "mean_confidence": 0.7, + "count": 5, + "mean_confidence": 0.7483103026207971, "observed_accuracy": 0.0 }, { "lower": 0.8, "upper": 0.9, - "count": 944, - "mean_confidence": 0.877175441473085, - "observed_accuracy": 0.9872881355932204 + "count": 1158, + "mean_confidence": 0.8564271889115633, + "observed_accuracy": 0.8583765112262521 }, { "lower": 0.9, "upper": 1.0, - "count": 98, - "mean_confidence": 0.9064522440904342, - "observed_accuracy": 0.9081632653061225 + "count": 93, + "mean_confidence": 0.9083651195586429, + "observed_accuracy": 0.22580645161290322 } ] } @@ -2530,60 +2530,60 @@ }, { "name": "resolvekit_typed", - "version": "0.1.2", + "version": "0.1.3", "offline": true, "dataset": "geo_cities", "metrics": { "accuracy": { - "overall": 0.8623046875, + "overall": 0.73876953125, "by_capability": { - "typo": 0.8365591397849462, - "alias": 0.9852941176470589 + "typo": 0.7204301075268817, + "alias": 0.6176470588235294 }, "by_language": { - "en": 0.8623046875 + "en": 0.73876953125 }, "by_difficulty": { - "medium": 0.8555347091932458, - "easy": 0.869653767820774 + "medium": 0.7073170731707317, + "easy": 0.7729124236252546 }, "by_entity_type": { - "city": 0.8623046875 + "city": 0.73876953125 }, - "wrong_match_rate": 0.00537109375, + "wrong_match_rate": 0.1162109375, "abstention_precision": 0.0, "abstention_recall": 0.0, "error_rate": 0.0, "row_count": 2048, - "accuracy_ci_low": 0.8467010544180495, - "accuracy_ci_high": 0.8765516567074096, + "accuracy_ci_low": 0.719308615725717, + "accuracy_ci_high": 0.7573363650564252, "by_entity_type_n": { "city": 2048 }, "by_entity_type_wrong_match": { - "city": 0.00537109375 + "city": 0.1162109375 } }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.5121459835208952, - "p95": 2.55935403110925, - "p99": 3.9847877464489994, - "mean": 0.8234369047102064, - "min": 0.11687498772516847, - "max": 8.570917008910328, + "p50": 0.6537910085171461, + "p95": 3.737535228719933, + "p99": 6.373665994033216, + "mean": 1.1076737646078527, + "min": 0.017208978533744812, + "max": 12.668582960031927, "sample_count": 2048 }, - "throughput_qps": 1213.5641870611796, + "throughput_qps": 902.2530886638563, "effective_warmup": 100, - "cold_start_ms": 176.1454590014182, - "peak_rss_mb": 173.984375, - "wheel_size_mb": 9.61152458190918, - "data_size_mb": 807.2899999999998, + "cold_start_ms": 278.9324159966782, + "peak_rss_mb": 239.71875, + "wheel_size_mb": 9.546000480651855, + "data_size_mb": 806.6399999999999, "calibration": { - "n_with_confidence": 1033, - "ece": 0.10976086044731097, - "brier": 0.022541843898969082, + "n_with_confidence": 1261, + "ece": 0.04953286736184118, + "brier": 0.16737999004166876, "reliability_bins": [ { "lower": 0.0, @@ -2637,23 +2637,23 @@ { "lower": 0.7, "upper": 0.8, - "count": 0, - "mean_confidence": 0.0, + "count": 4, + "mean_confidence": 0.7603878782759963, "observed_accuracy": 0.0 }, { "lower": 0.8, "upper": 0.9, - "count": 944, - "mean_confidence": 0.8771394837919659, - "observed_accuracy": 0.9883474576271186 + "count": 1169, + "mean_confidence": 0.856696397989509, + "observed_accuracy": 0.8571428571428571 }, { "lower": 0.9, "upper": 1.0, - "count": 89, - "mean_confidence": 0.9055882972844036, - "observed_accuracy": 1.0 + "count": 88, + "mean_confidence": 0.9079259486353848, + "observed_accuracy": 0.23863636363636365 } ] } @@ -2703,18 +2703,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.13283296721056104, - "p95": 0.24343319237232197, - "p99": 0.3957998135592792, - "mean": 0.13239579499181264, - "min": 0.027292000595480204, - "max": 1.1515420046634972, + "p50": 0.13404100900515914, + "p95": 0.26006249245256136, + "p99": 0.5298268049955368, + "mean": 0.14038577718308837, + "min": 0.027165981009602547, + "max": 4.561915993690491, "sample_count": 4055 }, - "throughput_qps": 7522.001962117365, + "throughput_qps": 7095.886113849257, "effective_warmup": 100, - "cold_start_ms": 24.00350000243634, - "peak_rss_mb": 115.625, + "cold_start_ms": 28.529625036753714, + "peak_rss_mb": 117.71875, "wheel_size_mb": 0.14886188507080078, "data_size_mb": null, "calibration": null @@ -2764,18 +2764,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.05887501174584031, - "p95": 0.3259212069679049, - "p99": 0.36991912056691945, - "mean": 0.10900656351332728, - "min": 0.0010830117389559746, - "max": 0.461542047560215, + "p50": 0.059416983276605606, + "p95": 0.33912512008100737, + "p99": 0.38980497280135756, + "mean": 0.11293375336377894, + "min": 0.0010420335456728935, + "max": 0.7732079830020666, "sample_count": 4055 }, - "throughput_qps": 9143.127716036095, + "throughput_qps": 8820.447340571847, "effective_warmup": 100, - "cold_start_ms": 5.1318330224603415, - "peak_rss_mb": 113.34375, + "cold_start_ms": 7.409334008116275, + "peak_rss_mb": 113.8125, "wheel_size_mb": 0.33511829376220703, "data_size_mb": null, "calibration": null @@ -2825,18 +2825,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.10950001887977123, - "p95": 0.1995963102672249, - "p99": 0.2585153747349978, - "mean": 0.12268732091166198, - "min": 0.020666979253292084, - "max": 0.8494590292684734, + "p50": 0.10787503561004996, + "p95": 0.20042959949932992, + "p99": 0.23064700653776526, + "mean": 0.12031795533658746, + "min": 0.021541956812143326, + "max": 1.3670000480487943, "sample_count": 4055 }, - "throughput_qps": 8117.957627601737, + "throughput_qps": 8275.103491139442, "effective_warmup": 100, - "cold_start_ms": 547.5679590017535, - "peak_rss_mb": 125.9375, + "cold_start_ms": 635.2173330378719, + "peak_rss_mb": 126.25, "wheel_size_mb": 0.28513336181640625, "data_size_mb": null, "calibration": null @@ -2886,18 +2886,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.0004169996827840805, - "p95": 0.000625033862888813, - "p99": 0.0013108854182064546, - "mean": 0.0005082388060528018, - "min": 0.0002919696271419525, - "max": 0.11350004933774471, + "p50": 0.00045797787606716156, + "p95": 0.0007079797796905041, + "p99": 0.0016249832697212696, + "mean": 0.0005514345030758517, + "min": 0.000292027834802866, + "max": 0.15479198191314936, "sample_count": 4055 }, - "throughput_qps": 1290117.3274060548, + "throughput_qps": 1202847.5611027572, "effective_warmup": 100, - "cold_start_ms": 51.88416701275855, - "peak_rss_mb": 190.78125, + "cold_start_ms": 62.39195802481845, + "peak_rss_mb": 191.171875, "wheel_size_mb": 164.73299312591553, "data_size_mb": null, "calibration": null @@ -2947,18 +2947,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.09058299474418163, - "p95": 5.780353693990037, - "p99": 6.234529975336045, - "mean": 2.026445543595605, - "min": 0.0007500057108700275, - "max": 20.27991699287668, + "p50": 0.10008300887420774, + "p95": 6.092178710969164, + "p99": 6.557791538070887, + "mean": 2.123919008118841, + "min": 0.0008330098353326321, + "max": 21.383750019595027, "sample_count": 4055 }, - "throughput_qps": 493.34352747645767, + "throughput_qps": 470.1906609234513, "effective_warmup": 100, - "cold_start_ms": 150.46075003920123, - "peak_rss_mb": 177.265625, + "cold_start_ms": 181.13599997013807, + "peak_rss_mb": 177.8125, "wheel_size_mb": 0.19714641571044922, "data_size_mb": null, "calibration": null @@ -3008,18 +3008,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.002416956704109907, - "p95": 0.004332978278398514, - "p99": 0.008240002207458028, - "mean": 0.0029135139001035, - "min": 0.0012919772416353226, - "max": 0.4570410237647593, + "p50": 0.0022080494090914726, + "p95": 0.002792046871036291, + "p99": 0.005477321101352574, + "mean": 0.002444780913426204, + "min": 0.0008330098353326321, + "max": 0.33429096220061183, "sample_count": 4055 }, - "throughput_qps": 296705.50810861774, + "throughput_qps": 362236.8559471939, "effective_warmup": 100, - "cold_start_ms": 1.6964579699561, - "peak_rss_mb": 112.609375, + "cold_start_ms": 1.7942499835044146, + "peak_rss_mb": 112.9375, "wheel_size_mb": 20.141146659851074, "data_size_mb": null, "calibration": null @@ -3069,18 +3069,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.32758305314928293, - "p95": 0.5704411829356104, - "p99": 0.6911318155471237, - "mean": 0.3295122584501458, - "min": 0.001125037670135498, - "max": 0.7675830274820328, + "p50": 0.34958403557538986, + "p95": 0.6289293756708502, + "p99": 0.7618610339704901, + "mean": 0.35592015919076914, + "min": 0.0015409896150231361, + "max": 1.221165992319584, "sample_count": 4055 }, - "throughput_qps": 2989.586947160201, + "throughput_qps": 2804.6915347111735, "effective_warmup": 100, - "cold_start_ms": 2.521958958823234, - "peak_rss_mb": 112.890625, + "cold_start_ms": 3.0825839494355023, + "peak_rss_mb": 113.3125, "wheel_size_mb": 4.124938011169434, "data_size_mb": null, "calibration": null @@ -3090,64 +3090,64 @@ }, { "name": "resolvekit", - "version": "0.1.2", + "version": "0.1.3", "offline": true, "dataset": "geo_countries_en", "metrics": { "accuracy": { - "overall": 0.8034525277435265, + "overall": 0.8305795314426634, "by_capability": { - "typo": 0.7406869859700048, - "case_noise": 0.6595469255663431, - "alias": 0.9179954441913439, - "unicode_normalization": 0.9962406015037594, - "prefix_truncation": 0.42592592592592593 + "typo": 0.7648766328011611, + "case_noise": 0.6919093851132686, + "alias": 0.9658314350797267, + "unicode_normalization": 0.9906015037593985, + "prefix_truncation": 0.49074074074074076 }, "by_language": { - "en": 0.8034525277435265 + "en": 0.8305795314426634 }, "by_difficulty": { "easy": 0.9959349593495935, - "hard": 0.6586632057105776, - "medium": 0.8809523809523809 + "hard": 0.691109669046074, + "medium": 0.9074074074074074 }, "by_entity_type": { - "country": 0.8034525277435265 + "country": 0.8305795314426634 }, - "wrong_match_rate": 0.037731196054254006, + "wrong_match_rate": 0.03822441430332922, "abstention_precision": 0.0, "abstention_recall": 0.0, "error_rate": 0.0, "row_count": 4055, - "accuracy_ci_low": 0.7909363808832427, - "accuracy_ci_high": 0.81539425296066, + "accuracy_ci_low": 0.8187218000189874, + "accuracy_ci_high": 0.8418114910559834, "by_entity_type_n": { "country": 4055 }, "by_entity_type_wrong_match": { - "country": 0.037731196054254006 + "country": 0.03822441430332922 } }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.7899579941295087, - "p95": 2.9350000666454434, - "p99": 5.799745023250582, - "mean": 1.0607762251746338, - "min": 0.012625008821487427, - "max": 15.430832980200648, + "p50": 0.9531250107102096, + "p95": 5.8449126197956485, + "p99": 14.64508714503609, + "mean": 1.7063695238041892, + "min": 0.016790989320725203, + "max": 241.87245802022517, "sample_count": 4055 }, - "throughput_qps": 942.1837354912639, + "throughput_qps": 585.7515854643683, "effective_warmup": 100, - "cold_start_ms": 5808.8847500039265, - "peak_rss_mb": 1139.515625, - "wheel_size_mb": 9.61152458190918, - "data_size_mb": 807.2899999999998, + "cold_start_ms": 33800.74145796243, + "peak_rss_mb": 1633.515625, + "wheel_size_mb": 9.546000480651855, + "data_size_mb": 806.6399999999999, "calibration": { - "n_with_confidence": 3283, - "ece": 0.0629066693962569, - "brier": 0.047136470231138046, + "n_with_confidence": 3383, + "ece": 0.059399730113697806, + "brier": 0.044939948484853574, "reliability_bins": [ { "lower": 0.0, @@ -3201,23 +3201,23 @@ { "lower": 0.7, "upper": 0.8, - "count": 245, - "mean_confidence": 0.7620265727768, - "observed_accuracy": 0.9346938775510204 + "count": 110, + "mean_confidence": 0.7710159675114389, + "observed_accuracy": 0.8363636363636363 }, { "lower": 0.8, "upper": 0.9, - "count": 803, - "mean_confidence": 0.8629243241884428, - "observed_accuracy": 0.8567870485678705 + "count": 1157, + "mean_confidence": 0.8658078268145427, + "observed_accuracy": 0.898876404494382 }, { "lower": 0.9, "upper": 1.0, - "count": 2235, - "mean_confidence": 0.9188855151521665, - "observed_accuracy": 0.9901565995525727 + "count": 2116, + "mean_confidence": 0.9170601611411513, + "observed_accuracy": 0.9905482041587902 } ] } @@ -3227,64 +3227,64 @@ }, { "name": "resolvekit_typed", - "version": "0.1.2", + "version": "0.1.3", "offline": true, "dataset": "geo_countries_en", "metrics": { "accuracy": { - "overall": 0.8014796547472256, + "overall": 0.8246609124537608, "by_capability": { - "typo": 0.7464925012094823, - "case_noise": 0.6711974110032363, - "alias": 0.9009111617312073, + "typo": 0.7716497339138848, + "case_noise": 0.7048543689320388, + "alias": 0.908883826879271, "unicode_normalization": 0.9849624060150376, - "prefix_truncation": 0.4660493827160494 + "prefix_truncation": 0.5740740740740741 }, "by_language": { - "en": 0.8014796547472256 + "en": 0.8246609124537608 }, "by_difficulty": { "easy": 0.9634146341463414, - "hard": 0.6703439325113563, - "medium": 0.873015873015873 + "hard": 0.7040882543802726, + "medium": 0.8915343915343915 }, "by_entity_type": { - "country": 0.8014796547472256 + "country": 0.8246609124537608 }, - "wrong_match_rate": 0.01183723797780518, + "wrong_match_rate": 0.013070283600493218, "abstention_precision": 0.0, "abstention_recall": 0.0, "error_rate": 0.0, "row_count": 4055, - "accuracy_ci_low": 0.7889193209981676, - "accuracy_ci_high": 0.8134693014107482, + "accuracy_ci_low": 0.8126510417589937, + "accuracy_ci_high": 0.8360562150110168, "by_entity_type_n": { "country": 4055 }, "by_entity_type_wrong_match": { - "country": 0.01183723797780518 + "country": 0.013070283600493218 } }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.38587499875575304, - "p95": 1.3847580179572057, - "p99": 2.6231593138072644, - "mean": 0.5274225702324398, - "min": 0.08654198609292507, - "max": 9.922042023390532, + "p50": 0.4182500415481627, + "p95": 2.191520488122473, + "p99": 6.724513869266958, + "mean": 0.7403214185776559, + "min": 0.017667014617472887, + "max": 26.887457992415875, "sample_count": 4055 }, - "throughput_qps": 1893.8850295894076, + "throughput_qps": 1349.6150453077182, "effective_warmup": 100, - "cold_start_ms": 176.1454590014182, - "peak_rss_mb": 173.984375, - "wheel_size_mb": 9.61152458190918, - "data_size_mb": 807.2899999999998, + "cold_start_ms": 278.9324159966782, + "peak_rss_mb": 239.71875, + "wheel_size_mb": 9.546000480651855, + "data_size_mb": 806.6399999999999, "calibration": { - "n_with_confidence": 3243, - "ece": 0.09204341557699694, - "brier": 0.02457832691784888, + "n_with_confidence": 3317, + "ece": 0.08933887591335946, + "brier": 0.024268154109410935, "reliability_bins": [ { "lower": 0.0, @@ -3338,23 +3338,23 @@ { "lower": 0.7, "upper": 0.8, - "count": 276, - "mean_confidence": 0.7592597237930101, - "observed_accuracy": 0.9528985507246377 + "count": 129, + "mean_confidence": 0.7687975635601574, + "observed_accuracy": 0.937984496124031 }, { "lower": 0.8, "upper": 0.9, - "count": 722, - "mean_confidence": 0.8619029486283539, - "observed_accuracy": 0.9847645429362881 + "count": 1079, + "mean_confidence": 0.8645900176512238, + "observed_accuracy": 0.9796107506950881 }, { "lower": 0.9, "upper": 1.0, - "count": 2245, - "mean_confidence": 0.9196675236557936, - "observed_accuracy": 0.9893095768374165 + "count": 2109, + "mean_confidence": 0.9177787737555505, + "observed_accuracy": 0.9890943575154102 } ] } @@ -3403,18 +3403,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.10364549234509468, - "p95": 0.18883508746512234, - "p99": 0.29498325951863164, - "mean": 0.10699268743813595, - "min": 0.028749986086040735, - "max": 1.039499999023974, + "p50": 0.10529151768423617, + "p95": 0.20609760249499232, + "p99": 0.40940348815638616, + "mean": 0.1110740698477967, + "min": 0.026083027478307486, + "max": 1.211875001899898, "sample_count": 2140 }, - "throughput_qps": 9307.53634539201, + "throughput_qps": 8962.061975811886, "effective_warmup": 100, - "cold_start_ms": 24.00350000243634, - "peak_rss_mb": 115.625, + "cold_start_ms": 28.529625036753714, + "peak_rss_mb": 117.71875, "wheel_size_mb": 0.14886188507080078, "data_size_mb": null, "calibration": null @@ -3463,18 +3463,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.13270799536257982, - "p95": 0.34771635255310684, - "p99": 0.3696342371404171, - "mean": 0.1464578160411182, - "min": 0.001374981366097927, - "max": 0.44645898742601275, + "p50": 0.13699999544769526, + "p95": 0.37022769101895386, + "p99": 0.4062031174544245, + "mean": 0.1548401706484277, + "min": 0.0015409896150231361, + "max": 0.5102090071886778, "sample_count": 2140 }, - "throughput_qps": 6813.435886933442, + "throughput_qps": 6439.711144067767, "effective_warmup": 100, - "cold_start_ms": 5.1318330224603415, - "peak_rss_mb": 113.34375, + "cold_start_ms": 7.409334008116275, + "peak_rss_mb": 113.8125, "wheel_size_mb": 0.33511829376220703, "data_size_mb": null, "calibration": null @@ -3523,18 +3523,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.1048955018632114, - "p95": 0.18615481094457204, - "p99": 0.2429281454533339, - "mean": 0.11474187417874095, - "min": 0.020042003598064184, - "max": 0.3750419709831476, + "p50": 0.11114549124613404, + "p95": 0.19650623435154557, + "p99": 0.25107714522164337, + "mean": 0.12238991466376965, + "min": 0.025291985366493464, + "max": 1.238624972756952, "sample_count": 2140 }, - "throughput_qps": 8684.083993546279, + "throughput_qps": 8135.397342133112, "effective_warmup": 100, - "cold_start_ms": 547.5679590017535, - "peak_rss_mb": 125.9375, + "cold_start_ms": 635.2173330378719, + "peak_rss_mb": 126.25, "wheel_size_mb": 0.28513336181640625, "data_size_mb": null, "calibration": null @@ -3583,18 +3583,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.0004169996827840805, - "p95": 0.0006660120561718941, - "p99": 0.001175611978396784, - "mean": 0.0005136362590313515, - "min": 0.00029098009690642357, - "max": 0.06804201984778047, + "p50": 0.00045797787606716156, + "p95": 0.0007079797796905041, + "p99": 0.0015420146519318223, + "mean": 0.0005724878489936345, + "min": 0.0002919696271419525, + "max": 0.11424999684095383, "sample_count": 2140 }, - "throughput_qps": 1296806.2466994685, + "throughput_qps": 1179767.9939802662, "effective_warmup": 100, - "cold_start_ms": 51.88416701275855, - "peak_rss_mb": 190.78125, + "cold_start_ms": 62.39195802481845, + "peak_rss_mb": 191.171875, "wheel_size_mb": 164.73299312591553, "data_size_mb": null, "calibration": null @@ -3643,18 +3643,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.09764547576196492, - "p95": 5.931359724490903, - "p99": 6.313897648942659, - "mean": 2.4748171647851764, - "min": 0.0007080379873514175, - "max": 19.047666050028056, + "p50": 0.09772900375537574, + "p95": 5.977754146442749, + "p99": 6.358159633236937, + "mean": 2.5037667911258206, + "min": 0.0007919734343886375, + "max": 19.095207971986383, "sample_count": 2140 }, - "throughput_qps": 403.98682767673745, + "throughput_qps": 399.30544795287864, "effective_warmup": 100, - "cold_start_ms": 150.46075003920123, - "peak_rss_mb": 177.265625, + "cold_start_ms": 181.13599997013807, + "peak_rss_mb": 177.8125, "wheel_size_mb": 0.19714641571044922, "data_size_mb": null, "calibration": null @@ -3703,18 +3703,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.002374988980591297, - "p95": 0.0028339592972770333, - "p99": 0.005483593558892613, - "mean": 0.002518326324785006, - "min": 0.0013750395737588406, - "max": 0.062167004216462374, + "p50": 0.002207991201430559, + "p95": 0.0026659719878807664, + "p99": 0.0045945279998705155, + "mean": 0.002348657901982002, + "min": 0.0009160139597952366, + "max": 0.09899999713525176, "sample_count": 2140 }, - "throughput_qps": 353970.1207915434, + "throughput_qps": 382566.9591579617, "effective_warmup": 100, - "cold_start_ms": 1.6964579699561, - "peak_rss_mb": 112.609375, + "cold_start_ms": 1.7942499835044146, + "peak_rss_mb": 112.9375, "wheel_size_mb": 20.141146659851074, "data_size_mb": null, "calibration": null @@ -3763,18 +3763,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.3407704643905163, - "p95": 0.6525680975755677, - "p99": 0.7081916125025604, - "mean": 0.35840572487937167, - "min": 0.0010840012691915035, - "max": 0.7811670075170696, + "p50": 0.3597080067265779, + "p95": 0.6886416289489716, + "p99": 0.7502075080992654, + "mean": 0.37823551395368354, + "min": 0.001374981366097927, + "max": 0.8523750002495944, "sample_count": 2140 }, - "throughput_qps": 2786.2958284365604, + "throughput_qps": 2639.2292362700823, "effective_warmup": 100, - "cold_start_ms": 2.521958958823234, - "peak_rss_mb": 112.890625, + "cold_start_ms": 3.0825839494355023, + "peak_rss_mb": 113.3125, "wheel_size_mb": 4.124938011169434, "data_size_mb": null, "calibration": null @@ -3784,63 +3784,63 @@ }, { "name": "resolvekit", - "version": "0.1.2", + "version": "0.1.3", "offline": true, "dataset": "geo_countries_multilingual", "metrics": { "accuracy": { - "overall": 0.6345794392523364, + "overall": 0.6481308411214953, "by_capability": { - "multilingual": 0.6341920374707259, - "alias": 0.45397048489107517, + "multilingual": 0.6477751756440281, + "alias": 0.47575544624033733, "case_noise": 0.8 }, "by_language": { - "fr": 0.5591517857142857, - "de": 0.7210084033613445, - "es": 0.6594761171032357 + "fr": 0.5714285714285714, + "de": 0.7394957983193278, + "es": 0.6702619414483821 }, "by_difficulty": { - "medium": 0.4642365887207703, - "easy": 0.9956268221574344 + "medium": 0.4855570839064649, + "easy": 0.9927113702623906 }, "by_entity_type": { - "country": 0.6345794392523364 + "country": 0.6481308411214953 }, - "wrong_match_rate": 0.024766355140186914, + "wrong_match_rate": 0.028037383177570093, "abstention_precision": 0.0, "abstention_recall": 0.0, "error_rate": 0.0, "row_count": 2140, - "accuracy_ci_low": 0.6139523967003898, - "accuracy_ci_high": 0.6547241697026503, + "accuracy_ci_low": 0.6276482905479454, + "accuracy_ci_high": 0.6680825134442899, "by_entity_type_n": { "country": 2140 }, "by_entity_type_wrong_match": { - "country": 0.024766355140186914 + "country": 0.028037383177570093 } }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.4538334906101227, - "p95": 2.2137868887512013, - "p99": 3.789976217085496, - "mean": 0.7471719615283274, - "min": 0.007916998583823442, - "max": 7.112917024642229, + "p50": 0.55018748389557, + "p95": 2.5697589240735392, + "p99": 5.957911145524168, + "mean": 0.9377399732567648, + "min": 0.009125040378421545, + "max": 17.083415994420648, "sample_count": 2140 }, - "throughput_qps": 1337.3787649425624, + "throughput_qps": 1065.5457741195087, "effective_warmup": 100, - "cold_start_ms": 5808.8847500039265, - "peak_rss_mb": 1139.515625, - "wheel_size_mb": 9.61152458190918, - "data_size_mb": 807.2899999999998, + "cold_start_ms": 33800.74145796243, + "peak_rss_mb": 1633.515625, + "wheel_size_mb": 9.546000480651855, + "data_size_mb": 806.6399999999999, "calibration": { - "n_with_confidence": 1382, - "ece": 0.07181478782911134, - "brier": 0.03581593998020828, + "n_with_confidence": 1409, + "ece": 0.06661447656178998, + "brier": 0.039720045155079764, "reliability_bins": [ { "lower": 0.0, @@ -3894,23 +3894,23 @@ { "lower": 0.7, "upper": 0.8, - "count": 32, - "mean_confidence": 0.7519314584852467, - "observed_accuracy": 0.71875 + "count": 19, + "mean_confidence": 0.7640987984708342, + "observed_accuracy": 0.47368421052631576 }, { "lower": 0.8, "upper": 0.9, - "count": 168, - "mean_confidence": 0.8774388382157395, - "observed_accuracy": 0.7976190476190477 + "count": 301, + "mean_confidence": 0.8763230262190078, + "observed_accuracy": 0.8571428571428571 }, { "lower": 0.9, "upper": 1.0, - "count": 1182, - "mean_confidence": 0.9198168313975806, - "observed_accuracy": 0.9915397631133672 + "count": 1089, + "mean_confidence": 0.9177514330461939, + "observed_accuracy": 0.9935720844811754 } ] } @@ -3920,63 +3920,63 @@ }, { "name": "resolvekit_typed", - "version": "0.1.2", + "version": "0.1.3", "offline": true, "dataset": "geo_countries_multilingual", "metrics": { "accuracy": { - "overall": 0.6135514018691589, + "overall": 0.6271028037383177, "by_capability": { - "multilingual": 0.6131147540983607, - "alias": 0.44553759662684467, + "multilingual": 0.6266978922716627, + "alias": 0.46591707659873505, "case_noise": 0.8 }, "by_language": { - "fr": 0.5457589285714286, - "de": 0.6907563025210084, - "es": 0.6363636363636364 + "fr": 0.5691964285714286, + "de": 0.6991596638655462, + "es": 0.6409861325115562 }, "by_difficulty": { - "medium": 0.4497936726272352, + "medium": 0.46973865199449794, "easy": 0.9606413994169096 }, "by_entity_type": { - "country": 0.6135514018691589 + "country": 0.6271028037383177 }, - "wrong_match_rate": 0.0065420560747663555, + "wrong_match_rate": 0.008878504672897197, "abstention_precision": 0.0, "abstention_recall": 0.0, "error_rate": 0.0, "row_count": 2140, - "accuracy_ci_low": 0.5927344031816617, - "accuracy_ci_high": 0.6339614497209036, + "accuracy_ci_low": 0.6064035042864376, + "accuracy_ci_high": 0.6473465862053226, "by_entity_type_n": { "country": 2140 }, "by_entity_type_wrong_match": { - "country": 0.0065420560747663555 + "country": 0.008878504672897197 } }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.2998755080625415, - "p95": 1.3359601172851399, - "p99": 2.5492549955379227, - "mean": 0.4804065982238868, - "min": 0.013499986380338669, - "max": 9.24637500429526, + "p50": 0.354750023689121, + "p95": 1.6970353695796796, + "p99": 4.7677736362675445, + "mean": 0.6060234842186567, + "min": 0.016167003195732832, + "max": 8.21304100099951, "sample_count": 2140 }, - "throughput_qps": 2079.269393903852, + "throughput_qps": 1648.3361481805055, "effective_warmup": 100, - "cold_start_ms": 176.1454590014182, - "peak_rss_mb": 173.984375, - "wheel_size_mb": 9.61152458190918, - "data_size_mb": 807.2899999999998, + "cold_start_ms": 278.9324159966782, + "peak_rss_mb": 239.71875, + "wheel_size_mb": 9.546000480651855, + "data_size_mb": 806.6399999999999, "calibration": { - "n_with_confidence": 1313, - "ece": 0.07655015989216392, - "brier": 0.01593430963161928, + "n_with_confidence": 1340, + "ece": 0.07890589123263772, + "brier": 0.019131723752646688, "reliability_bins": [ { "lower": 0.0, @@ -4030,23 +4030,23 @@ { "lower": 0.7, "upper": 0.8, - "count": 26, - "mean_confidence": 0.7522973197795066, - "observed_accuracy": 0.8846153846153846 + "count": 25, + "mean_confidence": 0.7550216299706639, + "observed_accuracy": 0.76 }, { "lower": 0.8, "upper": 0.9, - "count": 136, - "mean_confidence": 0.8806256865219483, - "observed_accuracy": 0.9485294117647058 + "count": 252, + "mean_confidence": 0.8745994050640742, + "observed_accuracy": 0.9642857142857143 }, { "lower": 0.9, "upper": 1.0, - "count": 1151, - "mean_confidence": 0.9202126988534636, - "observed_accuracy": 0.996524761077324 + "count": 1063, + "mean_confidence": 0.9181481796075749, + "observed_accuracy": 0.9962370649106302 } ] } @@ -4090,18 +4090,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.05220901221036911, - "p95": 0.3039795032236725, - "p99": 0.35245678853243567, - "mean": 0.10576314359371151, - "min": 0.03037502756342292, - "max": 0.3744999994523823, + "p50": 0.057833967730402946, + "p95": 0.38212937070056785, + "p99": 0.5862020782660684, + "mean": 0.13101664704403707, + "min": 0.036582991015166044, + "max": 0.6707910215482116, "sample_count": 35 }, - "throughput_qps": 9409.973155159385, + "throughput_qps": 7606.56089835876, "effective_warmup": 8, - "cold_start_ms": 24.00350000243634, - "peak_rss_mb": 115.625, + "cold_start_ms": 28.529625036753714, + "peak_rss_mb": 117.71875, "wheel_size_mb": 0.14886188507080078, "data_size_mb": null, "calibration": null @@ -4145,18 +4145,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.17691700486466289, - "p95": 0.24743398535065353, - "p99": 0.2725512860342859, - "mean": 0.16152154255126203, - "min": 0.06837502587586641, - "max": 0.283417000900954, + "p50": 0.18287496641278267, + "p95": 0.29049181030131876, + "p99": 0.29794342815876007, + "mean": 0.1676083226422114, + "min": 0.07050001295283437, + "max": 0.30154199339449406, "sample_count": 35 }, - "throughput_qps": 6178.10560352383, + "throughput_qps": 5954.110514222308, "effective_warmup": 8, - "cold_start_ms": 5.1318330224603415, - "peak_rss_mb": 113.34375, + "cold_start_ms": 7.409334008116275, + "peak_rss_mb": 113.8125, "wheel_size_mb": 0.33511829376220703, "data_size_mb": null, "calibration": null @@ -4200,18 +4200,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.21245802054181695, - "p95": 0.3166296111885458, - "p99": 0.35292625892907364, - "mean": 0.22464776910575374, - "min": 0.15999999595806003, - "max": 0.3701669629663229, + "p50": 0.18387497402727604, + "p95": 0.4374203912448137, + "p99": 0.6571289349813005, + "mean": 0.2311809387590204, + "min": 0.14108396135270596, + "max": 0.7511249859817326, "sample_count": 35 }, - "throughput_qps": 4424.429158680332, + "throughput_qps": 4303.697438476037, "effective_warmup": 8, - "cold_start_ms": 547.5679590017535, - "peak_rss_mb": 125.9375, + "cold_start_ms": 635.2173330378719, + "peak_rss_mb": 126.25, "wheel_size_mb": 0.28513336181640625, "data_size_mb": null, "calibration": null @@ -4255,18 +4255,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.0004169996827840805, + "p50": 0.00045797787606716156, "p95": 0.0005833047907799482, - "p99": 0.0006387801840901372, - "mean": 0.00044054052393351285, - "min": 0.00033294782042503357, - "max": 0.000667001586407423, + "p99": 0.0007483456283807746, + "mean": 0.0004512241243251732, + "min": 0.000333995558321476, + "max": 0.0008330098353326321, "sample_count": 35 }, - "throughput_qps": 1383891.7307409043, + "throughput_qps": 1310468.619649551, "effective_warmup": 8, - "cold_start_ms": 51.88416701275855, - "peak_rss_mb": 190.78125, + "cold_start_ms": 62.39195802481845, + "peak_rss_mb": 191.171875, "wheel_size_mb": 164.73299312591553, "data_size_mb": null, "calibration": null @@ -4310,18 +4310,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 5.239624995738268, - "p95": 5.4286044265609235, - "p99": 5.520252728601918, - "mean": 3.6546916867207204, - "min": 0.0013329554349184036, - "max": 5.538541998248547, + "p50": 5.417666980065405, + "p95": 5.800766078755259, + "p99": 6.017605942906812, + "mean": 3.8308417158467427, + "min": 0.0014170072972774506, + "max": 6.100042024627328, "sample_count": 35 }, - "throughput_qps": 273.5887218672053, + "throughput_qps": 260.9631728162371, "effective_warmup": 8, - "cold_start_ms": 150.46075003920123, - "peak_rss_mb": 177.265625, + "cold_start_ms": 181.13599997013807, + "peak_rss_mb": 177.8125, "wheel_size_mb": 0.19714641571044922, "data_size_mb": null, "calibration": null @@ -4365,18 +4365,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.002208980731666088, - "p95": 0.0027330941520631287, - "p99": 0.004639570834115141, - "mean": 0.0023416842200926374, - "min": 0.0019999570213258266, - "max": 0.005291018169373274, + "p50": 0.0021249870769679546, + "p95": 0.0024873123038560126, + "p99": 0.003178956685587763, + "mean": 0.0021582885113145623, + "min": 0.00200001522898674, + "max": 0.0032500247471034527, "sample_count": 35 }, - "throughput_qps": 381820.9316933471, + "throughput_qps": 412575.25040139427, "effective_warmup": 8, - "cold_start_ms": 1.6964579699561, - "peak_rss_mb": 112.609375, + "cold_start_ms": 1.7942499835044146, + "peak_rss_mb": 112.9375, "wheel_size_mb": 20.141146659851074, "data_size_mb": null, "calibration": null @@ -4420,18 +4420,18 @@ }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.3429169883020222, - "p95": 0.6236205867026001, - "p99": 0.7088498212397094, - "mean": 0.3881417148347412, - "min": 0.23087498266249895, - "max": 0.7512080483138561, + "p50": 0.3743750276044011, + "p95": 0.6503331824205816, + "p99": 0.7536917331162836, + "mean": 0.4095904545725456, + "min": 0.23608299670740962, + "max": 0.799874949734658, "sample_count": 35 }, - "throughput_qps": 2574.4048988065783, + "throughput_qps": 2439.335980185155, "effective_warmup": 8, - "cold_start_ms": 2.521958958823234, - "peak_rss_mb": 112.890625, + "cold_start_ms": 3.0825839494355023, + "peak_rss_mb": 113.3125, "wheel_size_mb": 4.124938011169434, "data_size_mb": null, "calibration": null @@ -4441,7 +4441,7 @@ }, { "name": "resolvekit", - "version": "0.1.2", + "version": "0.1.3", "offline": true, "dataset": "no_match", "metrics": { @@ -4459,7 +4459,7 @@ "by_entity_type": { "country": 0.7714285714285715 }, - "wrong_match_rate": 0.08571428571428572, + "wrong_match_rate": 0.11428571428571428, "abstention_precision": 1.0, "abstention_recall": 0.7714285714285715, "error_rate": 0.0, @@ -4470,27 +4470,27 @@ "country": 35 }, "by_entity_type_wrong_match": { - "country": 0.08571428571428572 + "country": 0.11428571428571428 } }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.004958012141287327, - "p95": 2.3519043636042625, - "p99": 3.331632032059128, - "mean": 0.5229428793037576, - "min": 0.0022500171326100826, - "max": 3.8277909625321627, + "p50": 0.008832954335957766, + "p95": 12.29023311170749, + "p99": 18.81680479971689, + "mean": 2.376851228265358, + "min": 0.0026249908842146397, + "max": 21.84283302631229, "sample_count": 35 }, - "throughput_qps": 1910.6324024700366, + "throughput_qps": 420.57345357763313, "effective_warmup": 8, - "cold_start_ms": 5808.8847500039265, - "peak_rss_mb": 1139.515625, - "wheel_size_mb": 9.61152458190918, - "data_size_mb": 807.2899999999998, + "cold_start_ms": 33800.74145796243, + "peak_rss_mb": 1633.515625, + "wheel_size_mb": 9.546000480651855, + "data_size_mb": 806.6399999999999, "calibration": { - "n_with_confidence": 3, + "n_with_confidence": 4, "ece": null, "brier": null, "reliability_bins": [ @@ -4546,22 +4546,22 @@ { "lower": 0.7, "upper": 0.8, - "count": 1, - "mean_confidence": 0.7492740791589273, + "count": 0, + "mean_confidence": 0.0, "observed_accuracy": 0.0 }, { "lower": 0.8, "upper": 0.9, - "count": 2, - "mean_confidence": 0.8795809489770892, + "count": 3, + "mean_confidence": 0.8527122492343491, "observed_accuracy": 0.0 }, { "lower": 0.9, "upper": 1.0, - "count": 0, - "mean_confidence": 0.0, + "count": 1, + "mean_confidence": 0.9183267893336846, "observed_accuracy": 0.0 } ] @@ -4572,56 +4572,56 @@ }, { "name": "resolvekit_typed", - "version": "0.1.2", + "version": "0.1.3", "offline": true, "dataset": "no_match", "metrics": { "accuracy": { - "overall": 0.9142857142857143, + "overall": 0.8857142857142857, "by_capability": { - "abstention": 0.9142857142857143 + "abstention": 0.8857142857142857 }, "by_language": { - "en": 0.9142857142857143 + "en": 0.8857142857142857 }, "by_difficulty": { - "easy": 0.9142857142857143 + "easy": 0.8857142857142857 }, "by_entity_type": { - "country": 0.9142857142857143 + "country": 0.8857142857142857 }, - "wrong_match_rate": 0.08571428571428572, + "wrong_match_rate": 0.11428571428571428, "abstention_precision": 1.0, - "abstention_recall": 0.9142857142857143, + "abstention_recall": 0.8857142857142857, "error_rate": 0.0, "row_count": 35, - "accuracy_ci_low": 0.7762040092883747, - "accuracy_ci_high": 0.970418168994703, + "accuracy_ci_low": 0.7404820521187976, + "accuracy_ci_high": 0.9546489414551025, "by_entity_type_n": { "country": 35 }, "by_entity_type_wrong_match": { - "country": 0.08571428571428572 + "country": 0.11428571428571428 } }, "ambiguity_recall": 0.0, "latency_ms": { - "p50": 0.007125025149434805, - "p95": 1.7998413881286979, - "p99": 3.0636733619030503, - "mean": 0.3237953076937369, - "min": 0.003708992153406143, - "max": 3.688083030283451, + "p50": 0.007791037205606699, + "p95": 2.104633196722716, + "p99": 676.6520442394504, + "mean": 29.517883316813304, + "min": 0.0037499703466892242, + "max": 1024.1134999669157, "sample_count": 35 }, - "throughput_qps": 3084.3686741684132, + "throughput_qps": 33.87715851977475, "effective_warmup": 8, - "cold_start_ms": 176.1454590014182, - "peak_rss_mb": 173.984375, - "wheel_size_mb": 9.61152458190918, - "data_size_mb": 807.2899999999998, + "cold_start_ms": 278.9324159966782, + "peak_rss_mb": 239.71875, + "wheel_size_mb": 9.546000480651855, + "data_size_mb": 806.6399999999999, "calibration": { - "n_with_confidence": 3, + "n_with_confidence": 4, "ece": null, "brier": null, "reliability_bins": [ @@ -4677,15 +4677,15 @@ { "lower": 0.7, "upper": 0.8, - "count": 3, - "mean_confidence": 0.7630579243858749, + "count": 1, + "mean_confidence": 0.7902588519152615, "observed_accuracy": 0.0 }, { "lower": 0.8, "upper": 0.9, - "count": 0, - "mean_confidence": 0.0, + "count": 3, + "mean_confidence": 0.8242706463215316, "observed_accuracy": 0.0 }, { diff --git a/benchmarks/results/latest.md b/benchmarks/results/latest.md index 05d0980..e357098 100644 --- a/benchmarks/results/latest.md +++ b/benchmarks/results/latest.md @@ -1,4 +1,4 @@ -# resolvekit benchmark — 2026-06-11 +# resolvekit benchmark — 2026-06-12 Hardware: arm, 18 cores, 49,152 MB RAM, Python 3.12.13. Warmup: 100 queries discarded. Seed: 42. @@ -24,15 +24,15 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | version | accuracy | acc CI | coverage | wrong-match | abst P | p50 ms | p95 ms | qps | mem MB | wheel MB | data MB | |---|---|---|---|---|---|---|---|---|---|---|---|---| -| country_converter | 1.3.2 | 0.913 | [0.73, 0.98] | 48.3% | 0.000 | 0.000 | 0.1 | 0.1 | 9342.8 | 115.6 | 0.1 | — | -| countryguess | 0.4.9 | 0.957 | [0.79, 0.99] | 48.3% | 0.000 | 0.000 | 0.0 | 0.1 | 32641.5 | 113.3 | 0.3 | — | -| data_commons_resolve | 2.1.6 | 0.909 | [0.79, 0.96] | 93.1% | 0.023 | 0.000 | 0.0 | 0.2 | 12578.3 | 125.9 | 0.3 | — | -| geonamescache | 3.0.1 | 0.536 | [0.36, 0.70] | 60.3% | 0.214 | 0.000 | 0.0 | 68.4 | 68.3 | 190.8 | 164.7 | — | -| hdx_python_country | 4.1.1 | 1.000 | [0.86, 1.00] | 48.3% | 0.000 | 0.000 | 0.0 | 4.9 | 2057.5 | 177.3 | 0.2 | — | -| pycountry | 26.2.16 | 0.652 | [0.45, 0.81] | 48.3% | 0.000 | 0.000 | 0.0 | 0.0 | 342216.0 | 112.6 | 20.1 | — | -| rapidfuzz_dict | 3.14.5 | 0.783 | [0.58, 0.90] | 48.3% | 0.217 | 0.000 | 0.2 | 0.4 | 4747.6 | 112.9 | 4.1 | — | -| resolvekit | 0.1.2 | 0.872 | [0.75, 0.94] | 100.0% | 0.000 | 0.000 | 2.3 | 3.3 | 463.4 | 1139.5 | 9.6 | 807.3 | -| resolvekit_typed | 0.1.2 | 0.915 | [0.80, 0.97] | 100.0% | 0.000 | 0.000 | 2.2 | 3.6 | 477.7 | 174.0 | 9.6 | 807.3 | +| country_converter | 1.3.2 | 0.913 | [0.73, 0.98] | 48.3% | 0.000 | 0.000 | 0.1 | 0.1 | 8776.8 | 117.7 | 0.1 | — | +| countryguess | 0.4.9 | 0.957 | [0.79, 0.99] | 48.3% | 0.000 | 0.000 | 0.0 | 0.1 | 31285.5 | 113.8 | 0.3 | — | +| data_commons_resolve | 2.1.6 | 0.909 | [0.79, 0.96] | 93.1% | 0.023 | 0.000 | 0.1 | 0.3 | 6844.3 | 126.2 | 0.3 | — | +| geonamescache | 3.0.1 | 0.536 | [0.36, 0.70] | 60.3% | 0.214 | 0.000 | 0.0 | 73.8 | 63.8 | 191.2 | 164.7 | — | +| hdx_python_country | 4.1.1 | 1.000 | [0.86, 1.00] | 48.3% | 0.000 | 0.000 | 0.0 | 5.1 | 1970.4 | 177.8 | 0.2 | — | +| pycountry | 26.2.16 | 0.652 | [0.45, 0.81] | 48.3% | 0.000 | 0.000 | 0.0 | 0.0 | 406784.5 | 112.9 | 20.1 | — | +| rapidfuzz_dict | 3.14.5 | 0.783 | [0.58, 0.90] | 48.3% | 0.217 | 0.000 | 0.2 | 0.4 | 4723.0 | 113.3 | 4.1 | — | +| resolvekit | 0.1.3 | 0.851 | [0.72, 0.93] | 100.0% | 0.021 | 0.000 | 2.9 | 5.0 | 348.4 | 1633.5 | 9.5 | 806.6 | +| resolvekit_typed | 0.1.3 | 0.915 | [0.80, 0.97] | 100.0% | 0.000 | 0.000 | 2.1 | 4.1 | 127.9 | 239.7 | 9.5 | 806.6 | #### recall metrics @@ -45,7 +45,7 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | hdx_python_country | 0.000 | 1.000 | | pycountry | 0.000 | 0.652 | | rapidfuzz_dict | 0.000 | 0.783 | -| resolvekit | 0.000 | 0.872 | +| resolvekit | 0.000 | 0.851 | | resolvekit_typed | 0.000 | 0.915 | #### per-capability accuracy @@ -59,7 +59,7 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | hdx_python_country | 1.000 | 1.000 | | pycountry | 0.875 | 0.652 | | rapidfuzz_dict | 0.875 | 0.783 | -| resolvekit | 0.821 | 0.872 | +| resolvekit | 0.786 | 0.851 | | resolvekit_typed | 0.893 | 0.915 | #### per-entity-type accuracy @@ -73,7 +73,7 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | hdx_python_country | — | — | — | — | — | 1.000 (n=23) | | pycountry | — | — | — | — | — | 0.652 (n=23) | | rapidfuzz_dict | — | — | — | — | — | 0.783 (n=23) | -| resolvekit | 0.667 (n=6) | 0.700 (n=10) | 1.000 (n=2) | 1.000 (n=1) | 0.833 (n=6) | 1.000 (n=22) | +| resolvekit | 0.667 (n=6) | 0.700 (n=10) | 0.500 (n=2) | 1.000 (n=1) | 0.833 (n=6) | 1.000 (n=22) | | resolvekit_typed | 1.000 (n=6) | 0.700 (n=10) | 1.000 (n=2) | 1.000 (n=1) | 0.833 (n=6) | 1.000 (n=22) | ### eval_geo @@ -82,15 +82,15 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | version | accuracy | acc CI | coverage | wrong-match | abst P | p50 ms | p95 ms | qps | mem MB | wheel MB | data MB | |---|---|---|---|---|---|---|---|---|---|---|---|---| -| country_converter | 1.3.2 | 0.700 | [0.40, 0.89] | 23.6% | 0.000 | 0.250 | 0.1 | n/a | 11116.8 | 115.6 | 0.1 | — | -| countryguess | 0.4.9 | 0.800 | [0.49, 0.94] | 23.6% | 0.100 | 0.500 | 0.0 | n/a | 11155.0 | 113.3 | 0.3 | — | -| data_commons_resolve | 2.1.6 | 0.763 | [0.70, 0.82] | 65.7% | 0.092 | 0.281 | 0.1 | 0.2 | 8552.3 | 125.9 | 0.3 | — | -| geonamescache | 3.0.1 | 0.280 | [0.20, 0.37] | 42.8% | 0.330 | 0.188 | 0.0 | 70.0 | 33.2 | 190.8 | 164.7 | — | -| hdx_python_country | 4.1.1 | 0.800 | [0.49, 0.94] | 23.6% | 0.000 | 0.333 | 0.1 | n/a | 451.6 | 177.3 | 0.2 | — | -| pycountry | 26.2.16 | 0.500 | [0.24, 0.76] | 23.6% | 0.000 | 0.167 | 0.0 | n/a | 305343.4 | 112.6 | 20.1 | — | -| rapidfuzz_dict | 3.14.5 | 0.600 | [0.31, 0.83] | 23.6% | 0.400 | 0.000 | 0.2 | n/a | 4457.3 | 112.9 | 4.1 | — | -| resolvekit | 0.1.2 | 0.886 | [0.85, 0.91] | 100.0% | 0.052 | 0.538 | 0.5 | 2.5 | 1190.4 | 1139.5 | 9.6 | 807.3 | -| resolvekit_typed | 0.1.2 | 0.913 | [0.88, 0.94] | 100.0% | 0.025 | 0.423 | 0.3 | 2.4 | 1422.4 | 174.0 | 9.6 | 807.3 | +| country_converter | 1.3.2 | 0.700 | [0.40, 0.89] | 23.6% | 0.000 | 0.250 | 0.1 | n/a | 9989.2 | 117.7 | 0.1 | — | +| countryguess | 0.4.9 | 0.800 | [0.49, 0.94] | 23.6% | 0.100 | 0.500 | 0.0 | n/a | 10736.8 | 113.8 | 0.3 | — | +| data_commons_resolve | 2.1.6 | 0.763 | [0.70, 0.82] | 65.7% | 0.092 | 0.281 | 0.1 | 0.2 | 8804.7 | 126.2 | 0.3 | — | +| geonamescache | 3.0.1 | 0.280 | [0.20, 0.37] | 42.8% | 0.330 | 0.188 | 0.0 | 77.9 | 31.0 | 191.2 | 164.7 | — | +| hdx_python_country | 4.1.1 | 0.800 | [0.49, 0.94] | 23.6% | 0.000 | 0.333 | 0.1 | n/a | 438.6 | 177.8 | 0.2 | — | +| pycountry | 26.2.16 | 0.500 | [0.24, 0.76] | 23.6% | 0.000 | 0.167 | 0.0 | n/a | 427040.2 | 112.9 | 20.1 | — | +| rapidfuzz_dict | 3.14.5 | 0.600 | [0.31, 0.83] | 23.6% | 0.400 | 0.000 | 0.2 | n/a | 4301.7 | 113.3 | 4.1 | — | +| resolvekit | 0.1.3 | 0.861 | [0.82, 0.89] | 100.0% | 0.060 | 0.424 | 1.3 | 10.0 | 351.5 | 1633.5 | 9.5 | 806.6 | +| resolvekit_typed | 0.1.3 | 0.888 | [0.85, 0.92] | 100.0% | 0.035 | 0.355 | 0.4 | 2.7 | 1234.5 | 239.7 | 9.5 | 806.6 | #### recall metrics @@ -103,7 +103,7 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | hdx_python_country | 1.000 | 1.000 | | pycountry | 1.000 | 0.750 | | rapidfuzz_dict | 0.000 | 0.750 | -| resolvekit | 0.824 | 0.894 | +| resolvekit | 0.824 | 0.882 | | resolvekit_typed | 0.647 | 0.929 | #### per-capability accuracy @@ -117,8 +117,8 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | hdx_python_country | 1.000 | — | 1.000 | 1.000 | | pycountry | 0.000 | — | 0.000 | 0.000 | | rapidfuzz_dict | 0.000 | — | 0.500 | 1.000 | -| resolvekit | 0.938 | 1.000 | 0.875 | 0.833 | -| resolvekit_typed | 0.969 | 1.000 | 0.875 | 0.833 | +| resolvekit | 0.906 | 0.941 | 0.875 | 0.833 | +| resolvekit_typed | 0.938 | 0.941 | 0.875 | 0.833 | #### per-entity-type accuracy @@ -131,8 +131,8 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | hdx_python_country | — | — | — | — | — | — | — | — | 0.800 (n=10) | — | | pycountry | — | — | — | — | — | — | — | — | 0.500 (n=10) | — | | rapidfuzz_dict | — | — | — | — | — | — | — | — | 0.600 (n=10) | — | -| resolvekit | 0.782 (n=55) | 0.969 (n=32) | 0.818 (n=33) | 0.875 (n=32) | 0.886 (n=35) | 0.889 (n=72) | 1.000 (n=7) | 1.000 (n=9) | 0.964 (n=83) | 0.556 (n=9) | -| resolvekit_typed | 0.818 (n=55) | 0.938 (n=32) | 0.848 (n=33) | 0.938 (n=32) | 0.971 (n=35) | 0.889 (n=72) | 1.000 (n=7) | 1.000 (n=9) | 0.952 (n=83) | 1.000 (n=9) | +| resolvekit | 0.709 (n=55) | 0.938 (n=32) | 0.909 (n=33) | 0.844 (n=32) | 0.743 (n=35) | 0.875 (n=72) | 1.000 (n=7) | 1.000 (n=9) | 0.964 (n=83) | 0.556 (n=9) | +| resolvekit_typed | 0.818 (n=55) | 0.938 (n=32) | 0.848 (n=33) | 0.875 (n=32) | 0.800 (n=35) | 0.875 (n=72) | 1.000 (n=7) | 1.000 (n=9) | 0.952 (n=83) | 1.000 (n=9) | ### eval_org @@ -140,8 +140,8 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | version | accuracy | acc CI | coverage | wrong-match | abst P | p50 ms | p95 ms | qps | mem MB | wheel MB | data MB | |---|---|---|---|---|---|---|---|---|---|---|---|---| -| resolvekit | 0.1.2 | 0.750 | [0.53, 0.89] | 100.0% | 0.100 | 1.000 | 0.9 | 3.4 | 694.8 | 1139.5 | 9.6 | 807.3 | -| resolvekit_typed | 0.1.2 | 0.750 | [0.53, 0.89] | 100.0% | 0.100 | 1.000 | 0.9 | 3.2 | 735.4 | 174.0 | 9.6 | 807.3 | +| resolvekit | 0.1.3 | 0.750 | [0.53, 0.89] | 100.0% | 0.150 | 1.000 | 1.7 | 10.8 | 282.4 | 1633.5 | 9.5 | 806.6 | +| resolvekit_typed | 0.1.3 | 0.750 | [0.53, 0.89] | 100.0% | 0.150 | 1.000 | 0.9 | 3.5 | 723.1 | 239.7 | 9.5 | 806.6 | | country_converter | *skipped (scope: supports ['country'], dataset has ['org'])* | — | — | — | — | — | — | — | — | — | — | — | | countryguess | *skipped (scope: supports ['country'], dataset has ['org'])* | — | — | — | — | — | — | — | — | — | — | — | | geonamescache | *skipped (scope: supports ['city', 'country'], dataset has ['org'])* | — | — | — | — | — | — | — | — | — | — | — | @@ -176,9 +176,9 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | version | accuracy | acc CI | coverage | wrong-match | abst P | p50 ms | p95 ms | qps | mem MB | wheel MB | data MB | |---|---|---|---|---|---|---|---|---|---|---|---|---| -| data_commons_resolve | 2.1.6 | 0.598 | [0.58, 0.62] | 80.7% | 0.146 | 0.000 | 0.1 | 0.2 | 8970.8 | 125.9 | 0.3 | — | -| resolvekit | 0.1.2 | 0.934 | [0.92, 0.94] | 100.0% | 0.043 | 0.000 | 0.5 | 2.7 | 937.6 | 1139.5 | 9.6 | 807.3 | -| resolvekit_typed | 0.1.2 | 0.977 | [0.97, 0.98] | 100.0% | 0.001 | 0.000 | 0.2 | 1.5 | 2141.5 | 174.0 | 9.6 | 807.3 | +| data_commons_resolve | 2.1.6 | 0.598 | [0.58, 0.62] | 80.7% | 0.146 | 0.000 | 0.1 | 0.3 | 6296.0 | 126.2 | 0.3 | — | +| resolvekit | 0.1.3 | 0.863 | [0.85, 0.88] | 100.0% | 0.078 | 0.000 | 0.7 | 4.3 | 685.8 | 1633.5 | 9.5 | 806.6 | +| resolvekit_typed | 0.1.3 | 0.930 | [0.92, 0.94] | 100.0% | 0.017 | 0.000 | 0.4 | 2.7 | 1248.7 | 239.7 | 9.5 | 806.6 | | country_converter | *skipped (scope: supports ['country'], dataset has ['admin1', 'admin2', 'admin3'])* | — | — | — | — | — | — | — | — | — | — | — | | countryguess | *skipped (scope: supports ['country'], dataset has ['admin1', 'admin2', 'admin3'])* | — | — | — | — | — | — | — | — | — | — | — | | geonamescache | *skipped (scope: supports ['city', 'country'], dataset has ['admin1', 'admin2', 'admin3'])* | — | — | — | — | — | — | — | — | — | — | — | @@ -199,16 +199,16 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | alias | typo | |---|---|---| | data_commons_resolve | 0.725 | 0.419 | -| resolvekit | 0.934 | 0.926 | -| resolvekit_typed | 0.934 | 0.972 | +| resolvekit | 0.492 | 0.863 | +| resolvekit_typed | 0.516 | 0.940 | #### per-entity-type accuracy | tool | admin1 | admin2 | admin3 | |---|---|---|---| | data_commons_resolve | 0.652 (n=781) | 0.564 (n=1,264) | — | -| resolvekit | 0.957 (n=791) | 0.921 (n=1,278) | 0.930 (n=488) | -| resolvekit_typed | 0.980 (n=791) | 0.973 (n=1,278) | 0.986 (n=488) | +| resolvekit | 0.886 (n=791) | 0.835 (n=1,278) | 0.900 (n=488) | +| resolvekit_typed | 0.943 (n=791) | 0.923 (n=1,278) | 0.924 (n=488) | ### geo_cities @@ -216,10 +216,10 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | version | accuracy | acc CI | coverage | wrong-match | abst P | p50 ms | p95 ms | qps | mem MB | wheel MB | data MB | |---|---|---|---|---|---|---|---|---|---|---|---|---| -| data_commons_resolve | 2.1.6 | 0.502 | [0.48, 0.52] | 100.0% | 0.198 | 0.000 | 0.1 | 0.2 | 9588.7 | 125.9 | 0.3 | — | -| geonamescache | 3.0.1 | 0.000 | [0.00, 0.00] | 100.0% | 0.154 | 0.000 | 68.8 | 73.2 | 14.3 | 190.8 | 164.7 | — | -| resolvekit | 0.1.2 | 0.858 | [0.84, 0.87] | 100.0% | 0.011 | 0.000 | 0.6 | 2.4 | 1129.8 | 1139.5 | 9.6 | 807.3 | -| resolvekit_typed | 0.1.2 | 0.862 | [0.85, 0.88] | 100.0% | 0.005 | 0.000 | 0.5 | 2.6 | 1213.6 | 174.0 | 9.6 | 807.3 | +| data_commons_resolve | 2.1.6 | 0.502 | [0.48, 0.52] | 100.0% | 0.198 | 0.000 | 0.1 | 0.3 | 6246.7 | 126.2 | 0.3 | — | +| geonamescache | 3.0.1 | 0.000 | [0.00, 0.00] | 100.0% | 0.154 | 0.000 | 72.7 | 86.2 | 13.4 | 191.2 | 164.7 | — | +| resolvekit | 0.1.3 | 0.740 | [0.72, 0.76] | 100.0% | 0.118 | 0.000 | 0.9 | 3.9 | 758.5 | 1633.5 | 9.5 | 806.6 | +| resolvekit_typed | 0.1.3 | 0.739 | [0.72, 0.76] | 100.0% | 0.116 | 0.000 | 0.7 | 3.7 | 902.3 | 239.7 | 9.5 | 806.6 | | country_converter | *skipped (scope: supports ['country'], dataset has ['city'])* | — | — | — | — | — | — | — | — | — | — | — | | countryguess | *skipped (scope: supports ['country'], dataset has ['city'])* | — | — | — | — | — | — | — | — | — | — | — | | hdx_python_country | *skipped (scope: supports ['country'], dataset has ['city'])* | — | — | — | — | — | — | — | — | — | — | — | @@ -241,8 +241,8 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r |---|---|---| | data_commons_resolve | 0.787 | 0.201 | | geonamescache | 0.000 | 0.000 | -| resolvekit | 0.985 | 0.833 | -| resolvekit_typed | 0.985 | 0.837 | +| resolvekit | 0.618 | 0.726 | +| resolvekit_typed | 0.618 | 0.720 | #### per-entity-type accuracy @@ -250,8 +250,8 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r |---|---| | data_commons_resolve | 0.502 (n=2,048) | | geonamescache | 0.000 (n=2,048) | -| resolvekit | 0.858 (n=2,048) | -| resolvekit_typed | 0.862 (n=2,048) | +| resolvekit | 0.740 (n=2,048) | +| resolvekit_typed | 0.739 (n=2,048) | ### geo_countries_en @@ -259,15 +259,15 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | version | accuracy | acc CI | coverage | wrong-match | abst P | p50 ms | p95 ms | qps | mem MB | wheel MB | data MB | |---|---|---|---|---|---|---|---|---|---|---|---|---| -| country_converter | 1.3.2 | 0.566 | [0.55, 0.58] | 100.0% | 0.025 | 0.000 | 0.1 | 0.2 | 7522.0 | 115.6 | 0.1 | — | -| countryguess | 0.4.9 | 0.675 | [0.66, 0.69] | 100.0% | 0.038 | 0.000 | 0.1 | 0.3 | 9143.1 | 113.3 | 0.3 | — | -| data_commons_resolve | 2.1.6 | 0.625 | [0.61, 0.64] | 100.0% | 0.047 | 0.000 | 0.1 | 0.2 | 8118.0 | 125.9 | 0.3 | — | -| geonamescache | 3.0.1 | 0.057 | [0.05, 0.07] | 100.0% | 0.000 | 0.000 | 0.0 | 0.0 | 1290117.3 | 190.8 | 164.7 | — | -| hdx_python_country | 4.1.1 | 0.642 | [0.63, 0.66] | 100.0% | 0.042 | 0.000 | 0.1 | 5.8 | 493.3 | 177.3 | 0.2 | — | -| pycountry | 26.2.16 | 0.099 | [0.09, 0.11] | 100.0% | 0.001 | 0.000 | 0.0 | 0.0 | 296705.5 | 112.6 | 20.1 | — | -| rapidfuzz_dict | 3.14.5 | 0.469 | [0.45, 0.48] | 100.0% | 0.507 | 0.000 | 0.3 | 0.6 | 2989.6 | 112.9 | 4.1 | — | -| resolvekit | 0.1.2 | 0.803 | [0.79, 0.82] | 100.0% | 0.038 | 0.000 | 0.8 | 2.9 | 942.2 | 1139.5 | 9.6 | 807.3 | -| resolvekit_typed | 0.1.2 | 0.801 | [0.79, 0.81] | 100.0% | 0.012 | 0.000 | 0.4 | 1.4 | 1893.9 | 174.0 | 9.6 | 807.3 | +| country_converter | 1.3.2 | 0.566 | [0.55, 0.58] | 100.0% | 0.025 | 0.000 | 0.1 | 0.3 | 7095.9 | 117.7 | 0.1 | — | +| countryguess | 0.4.9 | 0.675 | [0.66, 0.69] | 100.0% | 0.038 | 0.000 | 0.1 | 0.3 | 8820.4 | 113.8 | 0.3 | — | +| data_commons_resolve | 2.1.6 | 0.625 | [0.61, 0.64] | 100.0% | 0.047 | 0.000 | 0.1 | 0.2 | 8275.1 | 126.2 | 0.3 | — | +| geonamescache | 3.0.1 | 0.057 | [0.05, 0.07] | 100.0% | 0.000 | 0.000 | 0.0 | 0.0 | 1202847.6 | 191.2 | 164.7 | — | +| hdx_python_country | 4.1.1 | 0.642 | [0.63, 0.66] | 100.0% | 0.042 | 0.000 | 0.1 | 6.1 | 470.2 | 177.8 | 0.2 | — | +| pycountry | 26.2.16 | 0.099 | [0.09, 0.11] | 100.0% | 0.001 | 0.000 | 0.0 | 0.0 | 362236.9 | 112.9 | 20.1 | — | +| rapidfuzz_dict | 3.14.5 | 0.469 | [0.45, 0.48] | 100.0% | 0.507 | 0.000 | 0.3 | 0.6 | 2804.7 | 113.3 | 4.1 | — | +| resolvekit | 0.1.3 | 0.831 | [0.82, 0.84] | 100.0% | 0.038 | 0.000 | 1.0 | 5.8 | 585.8 | 1633.5 | 9.5 | 806.6 | +| resolvekit_typed | 0.1.3 | 0.825 | [0.81, 0.84] | 100.0% | 0.013 | 0.000 | 0.4 | 2.2 | 1349.6 | 239.7 | 9.5 | 806.6 | #### recall metrics @@ -294,8 +294,8 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | hdx_python_country | 0.644 | 0.665 | 0.389 | 0.599 | 0.791 | | pycountry | 0.195 | 0.012 | 0.000 | 0.009 | 0.000 | | rapidfuzz_dict | 0.442 | 0.449 | 0.188 | 0.443 | 0.573 | -| resolvekit | 0.918 | 0.660 | 0.426 | 0.741 | 0.996 | -| resolvekit_typed | 0.901 | 0.671 | 0.466 | 0.746 | 0.985 | +| resolvekit | 0.966 | 0.692 | 0.491 | 0.765 | 0.991 | +| resolvekit_typed | 0.909 | 0.705 | 0.574 | 0.772 | 0.985 | #### per-entity-type accuracy @@ -308,8 +308,8 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | hdx_python_country | 0.642 (n=4,055) | | pycountry | 0.099 (n=4,055) | | rapidfuzz_dict | 0.469 (n=4,055) | -| resolvekit | 0.803 (n=4,055) | -| resolvekit_typed | 0.801 (n=4,055) | +| resolvekit | 0.831 (n=4,055) | +| resolvekit_typed | 0.825 (n=4,055) | ### geo_countries_multilingual @@ -317,15 +317,15 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | version | accuracy | acc CI | coverage | wrong-match | abst P | p50 ms | p95 ms | qps | mem MB | wheel MB | data MB | |---|---|---|---|---|---|---|---|---|---|---|---|---| -| country_converter | 1.3.2 | 0.419 | [0.40, 0.44] | 100.0% | 0.025 | 0.000 | 0.1 | 0.2 | 9307.5 | 115.6 | 0.1 | — | -| countryguess | 0.4.9 | 0.512 | [0.49, 0.53] | 100.0% | 0.029 | 0.000 | 0.1 | 0.3 | 6813.4 | 113.3 | 0.3 | — | -| data_commons_resolve | 2.1.6 | 0.827 | [0.81, 0.84] | 100.0% | 0.029 | 0.000 | 0.1 | 0.2 | 8684.1 | 125.9 | 0.3 | — | -| geonamescache | 3.0.1 | 0.148 | [0.13, 0.16] | 100.0% | 0.002 | 0.000 | 0.0 | 0.0 | 1296806.2 | 190.8 | 164.7 | — | -| hdx_python_country | 4.1.1 | 0.565 | [0.54, 0.59] | 100.0% | 0.084 | 0.000 | 0.1 | 5.9 | 404.0 | 177.3 | 0.2 | — | -| pycountry | 26.2.16 | 0.143 | [0.13, 0.16] | 100.0% | 0.002 | 0.000 | 0.0 | 0.0 | 353970.1 | 112.6 | 20.1 | — | -| rapidfuzz_dict | 3.14.5 | 0.370 | [0.35, 0.39] | 100.0% | 0.495 | 0.000 | 0.3 | 0.7 | 2786.3 | 112.9 | 4.1 | — | -| resolvekit | 0.1.2 | 0.635 | [0.61, 0.65] | 100.0% | 0.025 | 0.000 | 0.5 | 2.2 | 1337.4 | 1139.5 | 9.6 | 807.3 | -| resolvekit_typed | 0.1.2 | 0.614 | [0.59, 0.63] | 100.0% | 0.007 | 0.000 | 0.3 | 1.3 | 2079.3 | 174.0 | 9.6 | 807.3 | +| country_converter | 1.3.2 | 0.419 | [0.40, 0.44] | 100.0% | 0.025 | 0.000 | 0.1 | 0.2 | 8962.1 | 117.7 | 0.1 | — | +| countryguess | 0.4.9 | 0.512 | [0.49, 0.53] | 100.0% | 0.029 | 0.000 | 0.1 | 0.4 | 6439.7 | 113.8 | 0.3 | — | +| data_commons_resolve | 2.1.6 | 0.827 | [0.81, 0.84] | 100.0% | 0.029 | 0.000 | 0.1 | 0.2 | 8135.4 | 126.2 | 0.3 | — | +| geonamescache | 3.0.1 | 0.148 | [0.13, 0.16] | 100.0% | 0.002 | 0.000 | 0.0 | 0.0 | 1179768.0 | 191.2 | 164.7 | — | +| hdx_python_country | 4.1.1 | 0.565 | [0.54, 0.59] | 100.0% | 0.084 | 0.000 | 0.1 | 6.0 | 399.3 | 177.8 | 0.2 | — | +| pycountry | 26.2.16 | 0.143 | [0.13, 0.16] | 100.0% | 0.002 | 0.000 | 0.0 | 0.0 | 382567.0 | 112.9 | 20.1 | — | +| rapidfuzz_dict | 3.14.5 | 0.370 | [0.35, 0.39] | 100.0% | 0.495 | 0.000 | 0.4 | 0.7 | 2639.2 | 113.3 | 4.1 | — | +| resolvekit | 0.1.3 | 0.648 | [0.63, 0.67] | 100.0% | 0.028 | 0.000 | 0.6 | 2.6 | 1065.5 | 1633.5 | 9.5 | 806.6 | +| resolvekit_typed | 0.1.3 | 0.627 | [0.61, 0.65] | 100.0% | 0.009 | 0.000 | 0.4 | 1.7 | 1648.3 | 239.7 | 9.5 | 806.6 | #### recall metrics @@ -352,8 +352,8 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | hdx_python_country | 0.451 | 0.800 | 0.565 | | pycountry | 0.036 | 0.400 | 0.142 | | rapidfuzz_dict | 0.311 | 0.600 | 0.370 | -| resolvekit | 0.454 | 0.800 | 0.634 | -| resolvekit_typed | 0.446 | 0.800 | 0.613 | +| resolvekit | 0.476 | 0.800 | 0.648 | +| resolvekit_typed | 0.466 | 0.800 | 0.627 | #### per-entity-type accuracy @@ -366,8 +366,8 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | hdx_python_country | 0.565 (n=2,140) | | pycountry | 0.143 (n=2,140) | | rapidfuzz_dict | 0.370 (n=2,140) | -| resolvekit | 0.635 (n=2,140) | -| resolvekit_typed | 0.614 (n=2,140) | +| resolvekit | 0.648 (n=2,140) | +| resolvekit_typed | 0.627 (n=2,140) | ### no_match @@ -375,15 +375,15 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | tool | version | accuracy | acc CI | coverage | wrong-match | abst P | p50 ms | p95 ms | qps | mem MB | wheel MB | data MB | |---|---|---|---|---|---|---|---|---|---|---|---|---| -| country_converter | 1.3.2 | 1.000 | [0.90, 1.00] | 100.0% | 0.000 | 1.000 | 0.1 | 0.3 | 9410.0 | 115.6 | 0.1 | — | -| countryguess | 0.4.9 | 0.943 | [0.81, 0.98] | 100.0% | 0.057 | 1.000 | 0.2 | 0.2 | 6178.1 | 113.3 | 0.3 | — | -| data_commons_resolve | 2.1.6 | 0.971 | [0.85, 0.99] | 100.0% | 0.029 | 1.000 | 0.2 | 0.3 | 4424.4 | 125.9 | 0.3 | — | -| geonamescache | 3.0.1 | 1.000 | [0.90, 1.00] | 100.0% | 0.000 | 1.000 | 0.0 | 0.0 | 1383891.7 | 190.8 | 164.7 | — | -| hdx_python_country | 4.1.1 | 1.000 | [0.90, 1.00] | 100.0% | 0.000 | 1.000 | 5.2 | 5.4 | 273.6 | 177.3 | 0.2 | — | -| pycountry | 26.2.16 | 1.000 | [0.90, 1.00] | 100.0% | 0.000 | 1.000 | 0.0 | 0.0 | 381820.9 | 112.6 | 20.1 | — | -| rapidfuzz_dict | 3.14.5 | 0.200 | [0.10, 0.36] | 100.0% | 0.800 | 1.000 | 0.3 | 0.6 | 2574.4 | 112.9 | 4.1 | — | -| resolvekit | 0.1.2 | 0.771 | [0.61, 0.88] | 100.0% | 0.086 | 1.000 | 0.0 | 2.4 | 1910.6 | 1139.5 | 9.6 | 807.3 | -| resolvekit_typed | 0.1.2 | 0.914 | [0.78, 0.97] | 100.0% | 0.086 | 1.000 | 0.0 | 1.8 | 3084.4 | 174.0 | 9.6 | 807.3 | +| country_converter | 1.3.2 | 1.000 | [0.90, 1.00] | 100.0% | 0.000 | 1.000 | 0.1 | 0.4 | 7606.6 | 117.7 | 0.1 | — | +| countryguess | 0.4.9 | 0.943 | [0.81, 0.98] | 100.0% | 0.057 | 1.000 | 0.2 | 0.3 | 5954.1 | 113.8 | 0.3 | — | +| data_commons_resolve | 2.1.6 | 0.971 | [0.85, 0.99] | 100.0% | 0.029 | 1.000 | 0.2 | 0.4 | 4303.7 | 126.2 | 0.3 | — | +| geonamescache | 3.0.1 | 1.000 | [0.90, 1.00] | 100.0% | 0.000 | 1.000 | 0.0 | 0.0 | 1310468.6 | 191.2 | 164.7 | — | +| hdx_python_country | 4.1.1 | 1.000 | [0.90, 1.00] | 100.0% | 0.000 | 1.000 | 5.4 | 5.8 | 261.0 | 177.8 | 0.2 | — | +| pycountry | 26.2.16 | 1.000 | [0.90, 1.00] | 100.0% | 0.000 | 1.000 | 0.0 | 0.0 | 412575.3 | 112.9 | 20.1 | — | +| rapidfuzz_dict | 3.14.5 | 0.200 | [0.10, 0.36] | 100.0% | 0.800 | 1.000 | 0.4 | 0.7 | 2439.3 | 113.3 | 4.1 | — | +| resolvekit | 0.1.3 | 0.771 | [0.61, 0.88] | 100.0% | 0.114 | 1.000 | 0.0 | 12.3 | 420.6 | 1633.5 | 9.5 | 806.6 | +| resolvekit_typed | 0.1.3 | 0.886 | [0.74, 0.95] | 100.0% | 0.114 | 1.000 | 0.0 | 2.1 | 33.9 | 239.7 | 9.5 | 806.6 | #### recall metrics @@ -397,7 +397,7 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | pycountry | 1.000 | 0.000 | | rapidfuzz_dict | 0.200 | 0.000 | | resolvekit | 0.771 | 0.000 | -| resolvekit_typed | 0.914 | 0.000 | +| resolvekit_typed | 0.886 | 0.000 | #### per-capability accuracy @@ -411,7 +411,7 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | pycountry | 1.000 | | rapidfuzz_dict | 0.200 | | resolvekit | 0.771 | -| resolvekit_typed | 0.914 | +| resolvekit_typed | 0.886 | #### per-entity-type accuracy @@ -425,7 +425,7 @@ _resolvekit_typed passes entity_type + language hints from the dataset; scores r | pycountry | 1.000 (n=35) | | rapidfuzz_dict | 0.200 (n=35) | | resolvekit | 0.771 (n=35) | -| resolvekit_typed | 0.914 (n=35) | +| resolvekit_typed | 0.886 (n=35) | ## Comparison by entity type @@ -446,16 +446,16 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C | tool | accuracy | n | wrong-match | |---|---|---|---| | data_commons_resolve | 0.700 | 40 | 0.100 | -| resolvekit | 0.782 | 55 | 0.018 | -| resolvekit_typed | 0.818 | 55 | 0.018 | +| resolvekit | 0.709 | 55 | 0.091 | +| resolvekit_typed | 0.818 | 55 | 0.036 | **geo_admin** | tool | accuracy | n | wrong-match | |---|---|---|---| | data_commons_resolve | 0.652 | 781 | 0.119 | -| resolvekit | 0.957 | 791 | 0.030 | -| resolvekit_typed | 0.980 | 791 | 0.000 | +| resolvekit | 0.886 | 791 | 0.068 | +| resolvekit_typed | 0.943 | 791 | 0.004 | ### admin2 @@ -472,7 +472,7 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C | tool | accuracy | n | wrong-match | |---|---|---|---| | data_commons_resolve | 0.615 | 26 | 0.231 | -| resolvekit | 0.969 | 32 | 0.000 | +| resolvekit | 0.938 | 32 | 0.031 | | resolvekit_typed | 0.938 | 32 | 0.000 | **geo_admin** @@ -480,8 +480,8 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C | tool | accuracy | n | wrong-match | |---|---|---|---| | data_commons_resolve | 0.564 | 1,264 | 0.163 | -| resolvekit | 0.921 | 1,278 | 0.049 | -| resolvekit_typed | 0.973 | 1,278 | 0.002 | +| resolvekit | 0.835 | 1,278 | 0.092 | +| resolvekit_typed | 0.923 | 1,278 | 0.016 | ### admin3 @@ -489,22 +489,22 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C | tool | accuracy | n | wrong-match | |---|---|---|---| -| resolvekit | 1.000 | 2 | 0.000 | +| resolvekit | 0.500 | 2 | 0.500 | | resolvekit_typed | 1.000 | 2 | 0.000 | **eval_geo** | tool | accuracy | n | wrong-match | |---|---|---|---| -| resolvekit | 0.818 | 33 | 0.091 | +| resolvekit | 0.909 | 33 | 0.000 | | resolvekit_typed | 0.848 | 33 | 0.000 | **geo_admin** | tool | accuracy | n | wrong-match | |---|---|---|---| -| resolvekit | 0.930 | 488 | 0.047 | -| resolvekit_typed | 0.986 | 488 | 0.002 | +| resolvekit | 0.900 | 488 | 0.057 | +| resolvekit_typed | 0.924 | 488 | 0.039 | ### admin4 @@ -519,8 +519,8 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C | tool | accuracy | n | wrong-match | |---|---|---|---| -| resolvekit | 0.875 | 32 | 0.062 | -| resolvekit_typed | 0.938 | 32 | 0.000 | +| resolvekit | 0.844 | 32 | 0.094 | +| resolvekit_typed | 0.875 | 32 | 0.031 | ### admin5 @@ -528,8 +528,8 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C | tool | accuracy | n | wrong-match | |---|---|---|---| -| resolvekit | 0.886 | 35 | 0.086 | -| resolvekit_typed | 0.971 | 35 | 0.000 | +| resolvekit | 0.743 | 35 | 0.057 | +| resolvekit_typed | 0.800 | 35 | 0.000 | ### city @@ -548,8 +548,8 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C |---|---|---|---| | data_commons_resolve | 0.794 | 68 | 0.059 | | geonamescache | 0.000 | 44 | 0.727 | -| resolvekit | 0.889 | 72 | 0.083 | -| resolvekit_typed | 0.889 | 72 | 0.069 | +| resolvekit | 0.875 | 72 | 0.097 | +| resolvekit_typed | 0.875 | 72 | 0.097 | **geo_cities** @@ -557,8 +557,8 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C |---|---|---|---| | data_commons_resolve | 0.502 | 2,048 | 0.198 | | geonamescache | 0.000 | 2,048 | 0.154 | -| resolvekit | 0.858 | 2,048 | 0.011 | -| resolvekit_typed | 0.862 | 2,048 | 0.005 | +| resolvekit | 0.740 | 2,048 | 0.118 | +| resolvekit_typed | 0.739 | 2,048 | 0.116 | ### continent @@ -619,8 +619,8 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C | hdx_python_country | 0.642 | 4,055 | 0.042 | | pycountry | 0.099 | 4,055 | 0.001 | | rapidfuzz_dict | 0.469 | 4,055 | 0.507 | -| resolvekit | 0.803 | 4,055 | 0.038 | -| resolvekit_typed | 0.801 | 4,055 | 0.012 | +| resolvekit | 0.831 | 4,055 | 0.038 | +| resolvekit_typed | 0.825 | 4,055 | 0.013 | **geo_countries_multilingual** @@ -633,8 +633,8 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C | hdx_python_country | 0.565 | 2,140 | 0.084 | | pycountry | 0.143 | 2,140 | 0.002 | | rapidfuzz_dict | 0.370 | 2,140 | 0.495 | -| resolvekit | 0.635 | 2,140 | 0.025 | -| resolvekit_typed | 0.614 | 2,140 | 0.007 | +| resolvekit | 0.648 | 2,140 | 0.028 | +| resolvekit_typed | 0.627 | 2,140 | 0.009 | **no_match** @@ -647,8 +647,8 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C | hdx_python_country | 1.000 | 35 | 0.000 | | pycountry | 1.000 | 35 | 0.000 | | rapidfuzz_dict | 0.200 | 35 | 0.800 | -| resolvekit | 0.771 | 35 | 0.086 | -| resolvekit_typed | 0.914 | 35 | 0.086 | +| resolvekit | 0.771 | 35 | 0.114 | +| resolvekit_typed | 0.886 | 35 | 0.114 | ### org @@ -656,8 +656,8 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C | tool | accuracy | n | wrong-match | |---|---|---|---| -| resolvekit | 0.750 | 20 | 0.100 | -| resolvekit_typed | 0.750 | 20 | 0.100 | +| resolvekit | 0.750 | 20 | 0.150 | +| resolvekit_typed | 0.750 | 20 | 0.150 | ### world_region @@ -670,9 +670,26 @@ Each sub-table is scoped to a single dataset so comparisons are like-for-like. C ## Calibration +### resolvekit on ambiguous + +ECE: 0.031. Brier: 0.048. Reliability diagram data: + +| bin | count | mean conf | observed acc | +|---|---|---|---| +| [0.0, 0.1) | 0 | 0.000 | 0.000 | +| [0.1, 0.2) | 0 | 0.000 | 0.000 | +| [0.2, 0.3) | 0 | 0.000 | 0.000 | +| [0.3, 0.4) | 0 | 0.000 | 0.000 | +| [0.4, 0.5) | 0 | 0.000 | 0.000 | +| [0.5, 0.6) | 0 | 0.000 | 0.000 | +| [0.6, 0.7) | 0 | 0.000 | 0.000 | +| [0.7, 0.8) | 0 | 0.000 | 0.000 | +| [0.8, 0.9) | 0 | 0.000 | 0.000 | +| [0.9, 1.0) | 21 | 0.922 | 0.952 | + ### resolvekit on eval_geo -ECE: 0.030. Brier: 0.071. Reliability diagram data: +ECE: 0.037. Brier: 0.075. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -683,13 +700,13 @@ ECE: 0.030. Brier: 0.071. Reliability diagram data: | [0.4, 0.5) | 0 | 0.000 | 0.000 | | [0.5, 0.6) | 0 | 0.000 | 0.000 | | [0.6, 0.7) | 0 | 0.000 | 0.000 | -| [0.7, 0.8) | 3 | 0.780 | 0.667 | -| [0.8, 0.9) | 118 | 0.873 | 0.915 | -| [0.9, 1.0) | 126 | 0.920 | 0.937 | +| [0.7, 0.8) | 1 | 0.791 | 0.000 | +| [0.8, 0.9) | 148 | 0.862 | 0.926 | +| [0.9, 1.0) | 126 | 0.921 | 0.921 | ### resolvekit on geo_admin -ECE: 0.098. Brier: 0.056. Reliability diagram data: +ECE: 0.092. Brier: 0.097. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -700,13 +717,13 @@ ECE: 0.098. Brier: 0.056. Reliability diagram data: | [0.4, 0.5) | 0 | 0.000 | 0.000 | | [0.5, 0.6) | 0 | 0.000 | 0.000 | | [0.6, 0.7) | 0 | 0.000 | 0.000 | -| [0.7, 0.8) | 0 | 0.000 | 0.000 | -| [0.8, 0.9) | 1,981 | 0.877 | 0.964 | -| [0.9, 1.0) | 95 | 0.914 | 0.600 | +| [0.7, 0.8) | 7 | 0.755 | 0.857 | +| [0.8, 0.9) | 1,912 | 0.854 | 0.927 | +| [0.9, 1.0) | 116 | 0.914 | 0.500 | ### resolvekit on geo_cities -ECE: 0.100. Brier: 0.031. Reliability diagram data: +ECE: 0.055. Brier: 0.170. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -717,13 +734,13 @@ ECE: 0.100. Brier: 0.031. Reliability diagram data: | [0.4, 0.5) | 0 | 0.000 | 0.000 | | [0.5, 0.6) | 0 | 0.000 | 0.000 | | [0.6, 0.7) | 0 | 0.000 | 0.000 | -| [0.7, 0.8) | 1 | 0.700 | 0.000 | -| [0.8, 0.9) | 944 | 0.877 | 0.987 | -| [0.9, 1.0) | 98 | 0.906 | 0.908 | +| [0.7, 0.8) | 5 | 0.748 | 0.000 | +| [0.8, 0.9) | 1,158 | 0.856 | 0.858 | +| [0.9, 1.0) | 93 | 0.908 | 0.226 | ### resolvekit on geo_countries_en -ECE: 0.063. Brier: 0.047. Reliability diagram data: +ECE: 0.059. Brier: 0.045. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -734,13 +751,13 @@ ECE: 0.063. Brier: 0.047. Reliability diagram data: | [0.4, 0.5) | 0 | 0.000 | 0.000 | | [0.5, 0.6) | 0 | 0.000 | 0.000 | | [0.6, 0.7) | 0 | 0.000 | 0.000 | -| [0.7, 0.8) | 245 | 0.762 | 0.935 | -| [0.8, 0.9) | 803 | 0.863 | 0.857 | -| [0.9, 1.0) | 2,235 | 0.919 | 0.990 | +| [0.7, 0.8) | 110 | 0.771 | 0.836 | +| [0.8, 0.9) | 1,157 | 0.866 | 0.899 | +| [0.9, 1.0) | 2,116 | 0.917 | 0.991 | ### resolvekit on geo_countries_multilingual -ECE: 0.072. Brier: 0.036. Reliability diagram data: +ECE: 0.067. Brier: 0.040. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -751,13 +768,13 @@ ECE: 0.072. Brier: 0.036. Reliability diagram data: | [0.4, 0.5) | 0 | 0.000 | 0.000 | | [0.5, 0.6) | 0 | 0.000 | 0.000 | | [0.6, 0.7) | 0 | 0.000 | 0.000 | -| [0.7, 0.8) | 32 | 0.752 | 0.719 | -| [0.8, 0.9) | 168 | 0.877 | 0.798 | -| [0.9, 1.0) | 1,182 | 0.920 | 0.992 | +| [0.7, 0.8) | 19 | 0.764 | 0.474 | +| [0.8, 0.9) | 301 | 0.876 | 0.857 | +| [0.9, 1.0) | 1,089 | 0.918 | 0.994 | ### resolvekit_typed on ambiguous -ECE: 0.088. Brier: 0.008. Reliability diagram data: +ECE: 0.092. Brier: 0.009. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -769,12 +786,12 @@ ECE: 0.088. Brier: 0.008. Reliability diagram data: | [0.5, 0.6) | 0 | 0.000 | 0.000 | | [0.6, 0.7) | 0 | 0.000 | 0.000 | | [0.7, 0.8) | 0 | 0.000 | 0.000 | -| [0.8, 0.9) | 8 | 0.883 | 1.000 | -| [0.9, 1.0) | 23 | 0.922 | 1.000 | +| [0.8, 0.9) | 5 | 0.855 | 1.000 | +| [0.9, 1.0) | 24 | 0.919 | 1.000 | ### resolvekit_typed on eval_geo -ECE: 0.070. Brier: 0.038. Reliability diagram data: +ECE: 0.069. Brier: 0.049. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -785,13 +802,13 @@ ECE: 0.070. Brier: 0.038. Reliability diagram data: | [0.4, 0.5) | 0 | 0.000 | 0.000 | | [0.5, 0.6) | 0 | 0.000 | 0.000 | | [0.6, 0.7) | 0 | 0.000 | 0.000 | -| [0.7, 0.8) | 5 | 0.781 | 0.800 | -| [0.8, 0.9) | 125 | 0.874 | 0.976 | -| [0.9, 1.0) | 147 | 0.921 | 0.966 | +| [0.7, 0.8) | 4 | 0.763 | 1.000 | +| [0.8, 0.9) | 156 | 0.860 | 0.968 | +| [0.9, 1.0) | 135 | 0.922 | 0.941 | ### resolvekit_typed on geo_admin -ECE: 0.121. Brier: 0.016. Reliability diagram data: +ECE: 0.133. Brier: 0.037. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -802,13 +819,13 @@ ECE: 0.121. Brier: 0.016. Reliability diagram data: | [0.4, 0.5) | 0 | 0.000 | 0.000 | | [0.5, 0.6) | 0 | 0.000 | 0.000 | | [0.6, 0.7) | 0 | 0.000 | 0.000 | -| [0.7, 0.8) | 0 | 0.000 | 0.000 | -| [0.8, 0.9) | 2,086 | 0.877 | 0.999 | -| [0.9, 1.0) | 82 | 0.912 | 1.000 | +| [0.7, 0.8) | 7 | 0.760 | 0.857 | +| [0.8, 0.9) | 2,022 | 0.852 | 0.987 | +| [0.9, 1.0) | 83 | 0.912 | 0.807 | ### resolvekit_typed on geo_cities -ECE: 0.110. Brier: 0.023. Reliability diagram data: +ECE: 0.050. Brier: 0.167. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -819,13 +836,13 @@ ECE: 0.110. Brier: 0.023. Reliability diagram data: | [0.4, 0.5) | 0 | 0.000 | 0.000 | | [0.5, 0.6) | 0 | 0.000 | 0.000 | | [0.6, 0.7) | 0 | 0.000 | 0.000 | -| [0.7, 0.8) | 0 | 0.000 | 0.000 | -| [0.8, 0.9) | 944 | 0.877 | 0.988 | -| [0.9, 1.0) | 89 | 0.906 | 1.000 | +| [0.7, 0.8) | 4 | 0.760 | 0.000 | +| [0.8, 0.9) | 1,169 | 0.857 | 0.857 | +| [0.9, 1.0) | 88 | 0.908 | 0.239 | ### resolvekit_typed on geo_countries_en -ECE: 0.092. Brier: 0.025. Reliability diagram data: +ECE: 0.089. Brier: 0.024. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -836,13 +853,13 @@ ECE: 0.092. Brier: 0.025. Reliability diagram data: | [0.4, 0.5) | 0 | 0.000 | 0.000 | | [0.5, 0.6) | 0 | 0.000 | 0.000 | | [0.6, 0.7) | 0 | 0.000 | 0.000 | -| [0.7, 0.8) | 276 | 0.759 | 0.953 | -| [0.8, 0.9) | 722 | 0.862 | 0.985 | -| [0.9, 1.0) | 2,245 | 0.920 | 0.989 | +| [0.7, 0.8) | 129 | 0.769 | 0.938 | +| [0.8, 0.9) | 1,079 | 0.865 | 0.980 | +| [0.9, 1.0) | 2,109 | 0.918 | 0.989 | ### resolvekit_typed on geo_countries_multilingual -ECE: 0.077. Brier: 0.016. Reliability diagram data: +ECE: 0.079. Brier: 0.019. Reliability diagram data: | bin | count | mean conf | observed acc | |---|---|---|---| @@ -853,9 +870,9 @@ ECE: 0.077. Brier: 0.016. Reliability diagram data: | [0.4, 0.5) | 0 | 0.000 | 0.000 | | [0.5, 0.6) | 0 | 0.000 | 0.000 | | [0.6, 0.7) | 0 | 0.000 | 0.000 | -| [0.7, 0.8) | 26 | 0.752 | 0.885 | -| [0.8, 0.9) | 136 | 0.881 | 0.949 | -| [0.9, 1.0) | 1,151 | 0.920 | 0.997 | +| [0.7, 0.8) | 25 | 0.755 | 0.760 | +| [0.8, 0.9) | 252 | 0.875 | 0.964 | +| [0.9, 1.0) | 1,063 | 0.918 | 0.996 | ## Caveats diff --git a/docs/explanation/how-resolution-works.md b/docs/explanation/how-resolution-works.md index fca608d..064e1a8 100644 --- a/docs/explanation/how-resolution-works.md +++ b/docs/explanation/how-resolution-works.md @@ -142,6 +142,20 @@ rk.resolve("Congo").is_ambiguous # True rk.resolve("zzznotacountry").status # no_match ``` +## Prominence-based tiebreaking + +*Added in v0.1.3.* + +When two candidates have similar feature scores, a prominence difference in the data can tip the decision toward resolved. "Paris" without context would otherwise tie or produce ambiguity between Paris, France and Paris, Texas; with the remote geo data loaded (admin1 through cities), the city-level prominence score — derived from Data Commons population counts — gives Paris, France a gap wide enough to resolve cleanly. + +This only affects cases where one candidate is substantially more prominent than the rest. Genuinely evenly-matched names — "Congo" (two countries of similar size), "Springfield" (many US cities with no dominant one) — stay `AMBIGUOUS` regardless. The behavior is: + +- **Dominant entity beats obscure peers** — bare "Paris" resolves to Paris, France with remote geo data; bare "Sudan" resolves to the country, not Sudan, Texas (even with bundled data). +- **Genuinely ambiguous names stay `AMBIGUOUS`** — prominence doesn't force a wrong answer; it only resolves when the gap is real. + +!!! note + Prominence-based resolution of city names like "Paris" requires the remote geo data packs (`rk.download("geo.cities")`). Country-level prominence is part of the bundled data. + ## The explain() scorecard `result.explain(verbosity="full")` returns a `Scorecard` you can render as text. It shows the normalized query, the match tier, confidence, reason codes, and which sources contributed: diff --git a/docs/how-to/build-typeahead-autocomplete.md b/docs/how-to/build-typeahead-autocomplete.md index e5dd1ee..c60330a 100644 --- a/docs/how-to/build-typeahead-autocomplete.md +++ b/docs/how-to/build-typeahead-autocomplete.md @@ -1,11 +1,25 @@ # How to build typeahead autocomplete Turn a partial query into a ranked list of entity suggestions with -`Resolver.suggest()` — exact, prefix, infix, and typo-tolerant matches, ready to +`suggest()` — exact, prefix, infix, and typo-tolerant matches, ready to wire into a search box. -`suggest()` lives on the `Resolver` class only; there is no `rk.suggest()`. -Construct a resolver once and reuse it across keystrokes: +`suggest()` is available as `rk.suggest()` at module level (added in v0.1.3) +and as `Resolver.suggest()` on any resolver instance. For scripts and notebooks, +the module-level form is the shortest path: + +```python +import resolvekit as rk + +for s in rk.suggest("germ", top_k=3): + print(s.canonical_name, s.entity_id) +# Germany country/DEU +# German Dem Rep German_Dem_Rep +``` + +For production services that need fine-grained control (a specific module set, +`warm=False` for lazy startup, custom `Resolver.lite()` footprint), build a +resolver and call suggest on it directly: ```python from resolvekit import Resolver @@ -20,15 +34,17 @@ caches per-prefix state. Empty, whitespace-only, or below-floor prefixes return ## Quick reference table +`rk.suggest()` and `r.suggest()` accept the same parameters: + | You pass | You get back | |---|---| -| `r.suggest("unit")` | up to 10 `SuggestionResult`, ranked best-first | -| `r.suggest("germny")` | typo-tolerant fuzzy matches (Germany, …) | -| `r.suggest("united", entity_type="geo.country")` | countries only | -| `r.suggest("united", domain="geo")` | geo packs only (simple domain name) | -| `r.suggest("germ", to="iso3")` | each suggestion's `display` rendered as ISO-3 | -| `r.suggest("germny", fuzzy="never")` | prefix/infix only, no fuzzy | -| `r.suggest("")` | `[]` | +| `rk.suggest("unit")` | up to 10 `SuggestionResult`, ranked best-first | +| `rk.suggest("germny")` | typo-tolerant fuzzy matches (Germany, …) | +| `rk.suggest("united", entity_type="geo.country")` | countries only | +| `rk.suggest("united", domain="geo")` | geo packs only (simple domain name) | +| `rk.suggest("germ", to="iso3")` | each suggestion's `display` rendered as ISO-3 | +| `rk.suggest("germny", fuzzy="never")` | prefix/infix only, no fuzzy | +| `rk.suggest("")` | `[]` | ## A basic call diff --git a/docs/how-to/clean-a-dataframe-column.md b/docs/how-to/clean-a-dataframe-column.md index 82deeab..921286c 100644 --- a/docs/how-to/clean-a-dataframe-column.md +++ b/docs/how-to/clean-a-dataframe-column.md @@ -140,6 +140,59 @@ df["iso3"] = df["country"].resolvekit.resolve(to="iso3", on_error="null") Note: `not_found` (for rows that simply don't match any entity) is independent of `on_error` (for rows that hit an unexpected runtime error). The defaults — `not_found="null"`, `on_error="raise"` — match `rk.bulk()`. +## Per-row context + +*Added in v0.1.3.* + +Pass a column as a context value to resolve each row under its own constraint. A common use: you have a city column and an ISO country column, and you want each city resolved against its own country rather than globally. + +```python +import pandas as pd +import resolvekit as rk + +df = pd.DataFrame({ + "city": ["Lyon", "Marseille", "Munich", "Hamburg"], + "country": ["FR", "FR", "DE", "DE"], +}) + +df["city_id"] = rk.bulk( + values=df["city"], + context={"country": df["country"]}, # per-row country constraint + to=None, +).values +``` + +!!! warning "Heads up" + City-level resolution requires the remote geo data packs. On a fresh install (bundled packs only), city names return `None`. Download the packs first: `rk.download("geo.cities")`. The API form above works regardless — the output is `None` until the data is available. + +The same form works with the pandas accessor and with polars: + +```python +import resolvekit.pandas # registers accessor + +df["city_id"] = df["city"].resolvekit.bulk( + context={"country": df["country"]}, + to=None, +).values +``` + +For polars, pass the Series directly to `rk.bulk()` — the Expr namespace exposes `resolve` but not `bulk`: + +```python +import polars as pl +import resolvekit as rk + +city_ids = rk.bulk( + values=df["city"], # polars Series + context={"country": df["country"]}, # per-row country constraint + to=None, +).values +``` + +Any column-like value works for a context value: a pandas `Series`, a polars `Series` or `Expr`, or a plain Python `list`. The column must be the same length as the input values. + +**Deduplication still applies.** The work is deduplicated on unique `(text, context)` pairs, so a 50k-row frame with 4 distinct country codes and 200 city names costs roughly 200 unique lookups, not 50 000. + ## Input code systems If your column already contains ISO 2-letter codes, skip fuzzy matching entirely: @@ -154,6 +207,7 @@ Common `from_system` values: `"iso2"`, `"iso3"`, `"iso_numeric"`, `"dcid"`, `"wi ## Next - [**`bulk` reference**](../reference/api.md) — full parameter list, output shapes, and `BulkResult` API. +- [**Refine resolution with context**](refine-resolution-with-context.md) — the full context key vocabulary, country name resolution, and point-in-time filtering. - [**Handle ambiguous matches**](handle-ambiguous-matches.md) — inspect candidates, override with context, and decide a tiebreak policy. - [**Convert between code systems**](convert-between-code-systems.md) — pivot between ISO 2, ISO 3, numeric, DCID, Wikidata, and other code systems. - [**Extract entities from free text**](extract-entities-from-text.md) — when your column contains running text rather than clean country names, use `parse()`/`parse_bulk()` to detect and link every entity mention with character offsets. diff --git a/docs/how-to/handle-ambiguous-matches.md b/docs/how-to/handle-ambiguous-matches.md index 429e1e4..e0c069c 100644 --- a/docs/how-to/handle-ambiguous-matches.md +++ b/docs/how-to/handle-ambiguous-matches.md @@ -48,6 +48,32 @@ entity_id = rk.resolve_id("Congo", on_ambiguous="best") `"best"` returns whichever candidate has the highest confidence score. When two candidates are tied — as `COD` and `COG` are — the tie is broken by internal ranking heuristics, not a guarantee. Use `"best"` only when a wrong answer is better than no answer (reporting, fuzzy deduplication), not when accuracy matters. +## Reading the AMBIGUOUS repr + +*Added in v0.1.3.* + +When a result is `AMBIGUOUS`, its repr lists the top candidates with their containing region and ends with copy-pasteable resolution hints: + +```python +r = rk.resolve("Congo") +print(repr(r)) +# AMBIGUOUS — candidates: +# Congo [DRC], CD (conf=0.92) +# Congo [Republic], CG (conf=0.91) +# try: +# resolvekit.resolve(text='Congo [DRC]') +# resolvekit.resolve(text='Congo [Republic]') +``` + +For city-level ambiguity where candidates span multiple countries (requires remote geo data), the hint ends with a `context={'country': ...}` line you can paste directly into the next call. + +The `refinement_hints` tuple lists the context keys that could break the tie: + +```python +r.refinement_hints +# (RefinementHint.PARENT_IDS, RefinementHint.COUNTRY, RefinementHint.LANGUAGES) +``` + ## Inspecting ambiguity without resolving `rk.resolve()` returns a `ResolutionResult` whether or not the input is ambiguous. Check `.is_ambiguous` before acting on the result: @@ -101,14 +127,19 @@ This is useful interactively when building a correction map — you can see all ## Narrowing with context -`ResolutionContext` lets you constrain the candidate set before resolution. `entity_types` filters to a specific entity type, which prunes candidates that don't match — useful when a query like "Congo" could match both countries and sub-national regions depending on loaded packs: +*Added in v0.1.3: `context=` now accepts a plain `dict` on every surface — no import needed.* + +Context lets you constrain the candidate set before resolution. `entity_types` filters to a specific entity type, which prunes candidates that don't match — useful when a query like "Congo" could match both countries and sub-national regions depending on loaded packs: ```python import resolvekit as rk -from resolvekit import ResolutionContext -ctx = ResolutionContext(entity_types=["geo.country"]) -r = rk.resolve("Congo", context=ctx) +# Dict form — no import needed +r = rk.resolve("Congo", context={"entity_types": {"geo.country"}}) + +# ResolutionContext form — same result +from resolvekit import ResolutionContext +r = rk.resolve("Congo", context=ResolutionContext(entity_types=frozenset({"geo.country"}))) r.status # 'ambiguous' for c in r.candidates: @@ -175,5 +206,6 @@ Switch to `on_ambiguous="raise"` if you want the job to fail fast on any ambiguo ## Next +- [Refine resolution with context](refine-resolution-with-context.md) — the full context key vocabulary, country names in context, and per-row context for bulk operations. - [Resolver reference](../reference/resolver.md) — `ResolutionContext` fields and `on_ambiguous` across all resolver methods. - [How resolution works](../explanation/how-resolution-works.md) — why two candidates can score identically and how the pipeline decides what to surface as ambiguous versus resolved. diff --git a/docs/how-to/refine-resolution-with-context.md b/docs/how-to/refine-resolution-with-context.md new file mode 100644 index 0000000..4d9c9ef --- /dev/null +++ b/docs/how-to/refine-resolution-with-context.md @@ -0,0 +1,164 @@ +# How to refine resolution with context hints + +*Added in v0.1.3.* + +Context hints narrow the candidate set before the resolution pipeline scores anything. They work by filtering or prioritizing candidates that match the constraint — entity type, country, parent ID, or other attributes — which resolves many inputs that would otherwise come back `AMBIGUOUS` or as the wrong entity. + +## Pass context as a plain dict + +Every resolution surface accepts `context=` as a plain `dict`. No import needed. + +```python +import resolvekit as rk + +# With bundled data, France resolves unambiguously: +rk.resolve("France") +# ResolutionResult(status='resolved', entity_id='country/FRA', ...) + +# After downloading more data packs, entity_types pins the domain explicitly: +rk.resolve("France", context={"entity_types": {"geo.country"}}) +# ResolutionResult(status='resolved', entity_id='country/FRA', ...) +``` + +The valid context keys are: + +| Key | Type | What it does | +|-----|------|-------------| +| `country` | `str` | ISO alpha-2 (`"FR"`) or alpha-3 (`"FRA"`) or country name (`"France"`) — restricts to entities in that country | +| `entity_types` | `set[str]` or `list[str]` | Restrict to these entity types, e.g. `{"geo.country"}`, `["org.lender"]` | +| `parent_ids` | `list[str]` | Restrict to entities whose parent is one of these entity IDs | +| `languages` | `list[str]` | Preferred BCP 47 language codes for name matching | +| `attributes` | `dict` | Pack-specific escape hatch for domain attributes | +| `as_of` | `date` or ISO date `str` | Resolve against entities valid at this date | + +An unknown key raises `UnknownContextKeyError` immediately and lists the valid ones: + +```python +try: + rk.resolve("Germany", context={"region": "Europe"}) +except rk.UnknownContextKeyError as e: + print(e.valid) + # ['as_of', 'attributes', 'country', 'entity_types', 'languages', 'parent_ids'] +``` + +## Context works on all resolution surfaces + +`context=` is accepted on every call: + +```python +rk.resolve("...", context={...}) +rk.resolve_id("...", context={...}) +rk.bulk(values=..., context={...}) +rk.snap(query="...", context={...}) +rk.suggest("...", context={...}) +rk.parse("...", context={...}) +rk.parse_bulk(values=..., context={...}) +``` + +The pandas and polars accessors also accept it: + +```python +import resolvekit.pandas # or resolvekit.polars + +df["country"].resolvekit.bulk(context={"entity_types": {"geo.country"}}) +df["country"].resolvekit.resolve(context={"entity_types": {"geo.country"}}) +``` + +`ResolutionContext` objects continue to work everywhere — the dict form is a shortcut for the common case: + +```python +from resolvekit import ResolutionContext +# these are equivalent: +rk.resolve("France", context={"entity_types": {"geo.country"}}) +rk.resolve("France", context=ResolutionContext(entity_types=frozenset({"geo.country"}))) +``` + +## Restrict by entity type + +Use `entity_types` when the same name exists in multiple domains or as multiple entity types, and you know which you want: + +```python +# Works on any name that could match both geo and org entities +rk.resolve("Sudan", context={"entity_types": {"geo.country"}}) +# ResolutionResult(status='resolved', entity_id='country/SDN', ...) + +# Bulk with entity type filter +rk.bulk( + values=["Germany", "France", "Japan"], + context={"entity_types": {"geo.country"}}, + to="iso3", +) +# ['DEU', 'FRA', 'JPN'] +``` + +!!! info "Why" + Context doesn't lower confidence thresholds or force a match. It prunes candidates that fail a hard constraint before scoring. If two candidates both pass, resolution stays `AMBIGUOUS`. + +## Use country names in context + +The `country` key accepts ISO alpha-2 (`"FR"`), ISO alpha-3 (`"FRA"`), and plain country names (`"France"`). Names are resolved to ISO codes automatically: + +```python +# All three of these set the same constraint: +rk.resolve("Paris", context={"country": "FR"}) +rk.resolve("Paris", context={"country": "FRA"}) +rk.resolve("Paris", context={"country": "France"}) +``` + +An ambiguous or unrecognized country name raises `ValueError` with a did-you-mean suggestion: + +```python +try: + rk.resolve("Paris", context={"country": "Congo"}) +except ValueError as e: + print(e) + # cannot resolve country name 'Congo' — ambiguous (did you mean 'CD' or 'CG'?); + # pass an ISO code +``` + +!!! note + City-level disambiguation with `country=` requires the remote geo data packs (`rk.download("geo.cities")`). Country-level resolution and org resolution work with the bundled packs. + +## Point-in-time filtering with `as_of` + +Pass a date to restrict candidates to entities valid on that date. This is a hard filter — an entity outside its `[valid_from, valid_until)` window is dropped before scoring, regardless of confidence: + +```python +from datetime import date + +rk.resolve("South Sudan", context={"as_of": date(2000, 1, 1)}).status +# 'no_match' — South Sudan didn't exist until 2011 + +rk.resolve("South Sudan", context={"as_of": date(2020, 1, 1)}).entity_id +# 'country/SSD' +``` + +## Handle AMBIGUOUS results: reading the hint + +When resolution stays `AMBIGUOUS`, the repr tells you which inputs to try next and, when candidates span different countries, ends with a copy-pasteable `context=` hint: + +```python +r = rk.resolve("Congo") +print(repr(r)) +# AMBIGUOUS — candidates: +# Congo [DRC], CD (conf=0.92) +# Congo [Republic], CG (conf=0.91) +# try: +# resolvekit.resolve(text='Congo [DRC]') +# resolvekit.resolve(text='Congo [Republic]') +``` + +The `refinement_hints` tuple on the result lists the constraints that could break the tie — `country`, `parent_ids`, `entity_types`, etc.: + +```python +r.refinement_hints +# (RefinementHint.PARENT_IDS, RefinementHint.COUNTRY, RefinementHint.LANGUAGES) +``` + +For country-level ambiguity, the most direct fix is to use the more specific canonical name shown in the hint, or to add a `country=` context when the right country is known. + +## Next + +- [Handle ambiguous matches](handle-ambiguous-matches.md) — `on_ambiguous` options, inspecting candidates, and when to use `"best"` versus a correction map. +- [Clean a DataFrame column](clean-a-dataframe-column.md) — per-row context for bulk resolution, so each row resolves under its own country or filter. +- [API reference — `ResolutionContext`](../reference/api.md#resolutioncontext) — full field list and the `.replace()` method for building context incrementally. diff --git a/docs/reference/api.md b/docs/reference/api.md index 1848701..67b3404 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -33,7 +33,7 @@ rk.resolve( to: str | None = UNSET, as_result: bool = False, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict | None = None, from_system: str | None = None, include_entity: bool = True, timeout: float | None = None, @@ -50,7 +50,7 @@ Resolve a text string or code against all loaded modules. | `to` | Pivot the resolved entity to a specific representation. Omit (default) to use the `default_to` configured via [`configure()`](#configure), or return a raw `ResolutionResult` when no default is set. Pass `None` to always return a `ResolutionResult`. When set to a code system name (`"iso3"`, `"iso2"`, `"name"`, `"flag"`, `"aliases"`, `"dcid"`, etc.), returns the pivot value directly. A per-call `to=` overrides the configured default. | | `as_result` | Return the full `ResolutionResult` even when a `default_to` is configured — equivalent to passing `to=None`. Cannot be combined with an explicit `to=`. | | `domain` | Restrict resolution to one or more domains (`"geo"`, `"org"`, or a list). | -| `context` | A [`ResolutionContext`](#resolutioncontext) with hints (entity type, parent, country, language). | +| `context` | Resolution hints as a [`ResolutionContext`](#resolutioncontext) or a plain `dict`. Dict shorthand keys: `country` (ISO alpha-2/alpha-3 or a country name like `"France"`), `entity_types`, `parent_ids`, `languages`, `attributes`, `as_of`. Unknown keys raise [`UnknownContextKeyError`](#unknowncontextkeyerror). | | `from_system` | Treat `text` as a code in this system (e.g. `"iso2"`, `"iso3"`, `"dcid"`, `"wikidata"`). Skips name-matching. | | `include_entity` | Populate `result.entity`. Defaults to `True` at the module level for notebook ergonomics. Set to `False` in pipelines where you don't need the full entity. | | `timeout` | Maximum seconds before the pipeline is cut short. `None` = no limit. | @@ -85,7 +85,7 @@ rk.resolve_id( on_ambiguous: Literal["raise", "null", "best"] = "raise", from_system: str | None = None, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict | None = None, timeout: float | None = None, ) -> str | None ``` @@ -131,7 +131,7 @@ rk.bulk( to: str | None = UNSET, on_missing: Literal["raise", "null", "auto"] = UNSET, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict | None = None, output: Literal["series", "record", "frame"] = "series", from_system: str | None = None, not_found: str = "null", @@ -153,7 +153,7 @@ See [how to clean a DataFrame column](../how-to/clean-a-dataframe-column.md) for | `to` | Pivot each resolved entity. Omit to use the `default_to` configured via [`configure()`](#configure). Pass `None` to always return a [`BulkResult`](#bulkresult). When set to a code system name, returns the native input shape (e.g. `pd.Series`) of pivot values; unresolved rows become `None`. | | `on_missing` | Miss policy override for the configured output chain. Omit to inherit the resolver's `on_missing` policy. `"auto"` = null per row with `UserWarning` for bulk; `"raise"` = raises [`OutputMissingError`](#outputmissingerror) on the first resolved-but-missing entity; `"null"` = returns `None` per row silently. Only relevant when `to` is omitted and a `default_to` is configured. | | `domain` | Domain filter, broadcast to every row. | -| `context` | Context hints, broadcast to every row. | +| `context` | Resolution hints as a [`ResolutionContext`](#resolutioncontext) or a plain `dict`, broadcast to every row. Dict values may be a `pd.Series` or `pl.Series` for per-row context. Dict shorthand keys: `country`, `entity_types`, `parent_ids`, `languages`, `attributes`, `as_of`. Unknown keys raise [`UnknownContextKeyError`](#unknowncontextkeyerror). | | `output` | Shape of the returned object when `to=None`: `"series"` (default) — series of values; `"record"` — series of structs; `"frame"` — DataFrame. Ignored when `to` is set. | | `from_system` | Treat every value as a code in this system. | | `not_found` | What fills unresolved rows in the output. `"null"` (default) → `None`; `"raise"` → raises; any other string → used as a literal sentinel value. | @@ -220,7 +220,7 @@ rk.snap( max_distance: float = 0.5, to: Any = None, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict | None = None, ) -> Any ``` @@ -547,6 +547,50 @@ Return the singleton [`Resolver`](resolver.md) instance, creating it on first ca --- +### `suggest` { #suggest } + +```python +rk.suggest( + prefix: str, + *, + top_k: int = 10, + domain: str | list[str] | None = None, + entity_type: str | list[str] | None = None, + context: ResolutionContext | dict | None = None, + to: str | list[str] | None = None, + fuzzy: Literal["auto", "always", "never"] = "auto", + timeout: float | None = None, +) -> list[SuggestionResult] +``` + +Return a ranked typeahead suggestion list for `prefix`. Delegates to [`Resolver.suggest`](resolver.md#resolversuggest) on the singleton resolver. + +**Parameters** + +| Name | Meaning | +|---|---| +| `prefix` | Partial query string. Required. | +| `top_k` | Maximum suggestions to return. Clamped to `[1, 100]`. Default `10`. | +| `domain` | Pack filter by simple domain name (`"geo"`, `"org"`). A dotted name raises `ValueError` — pass it via `entity_type=` instead. | +| `entity_type` | Entity-type prefix filter (`"geo.country"`, `["geo.country", "geo.region"]`). | +| `context` | Accepted and key-validated, but does not yet affect ranking. Dict shorthand keys accepted (same as `resolve`). Unknown keys raise [`UnknownContextKeyError`](#unknowncontextkeyerror). | +| `to` | Output code system or name variant for the `display` field (`"iso3"`, `"name:fr"`). `None` renders `display` from `canonical_name`. | +| `fuzzy` | `"auto"` (default), `"always"`, or `"never"`. | +| `timeout` | Per-call time budget in seconds. `None` = no limit. | + +**Returns** — `list[SuggestionResult]`, sorted best-first, length at most `top_k`. Never raises a verdict; an empty list means no suggestions. + +**Example** + +```python +import resolvekit as rk + +for s in rk.suggest("unit", top_k=3): + print(s.canonical_name, s.entity_id) +``` + +--- + ### `parse` { #parse } ```python @@ -556,7 +600,7 @@ rk.parse( to: str | list[str] | None = None, include_nil: bool = False, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict | None = None, confidence_threshold: float | None = None, timeout: float | None = None, ) -> ParseResult @@ -615,7 +659,7 @@ rk.parse_bulk( to: str | list[str] | None = None, include_nil: bool = False, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict | None = None, confidence_threshold: float | None = None, timeout: float | None = None, ) -> ParseResult @@ -1239,6 +1283,7 @@ from resolvekit import ( NoModulesInstalledError, OutputMissingError, UnknownCodeSystemError, + UnknownContextKeyError, UnknownDomainError, UnknownOutputError, ) @@ -1343,6 +1388,35 @@ from resolvekit import OutputMissingError # or: from resolvekit.errors import O Carries `.hint` listing the available codes. +### `UnknownContextKeyError(ValueError, ResolverError)` { #unknowncontextkeyerror } + +*Added in v0.1.3.* + +Raised when a `context=` dict contains a key that is not a valid `ResolutionContext` field. + +```python +from resolvekit.errors import UnknownContextKeyError +``` + +| Attribute | Type | Meaning | +|---|---|---| +| `.unknown` | `list[str]` | The unrecognised key(s). | +| `.valid` | `list[str]` | Valid context keys: `['as_of', 'attributes', 'country', 'entity_types', 'languages', 'parent_ids']`. | + +Carries `.hint` listing the valid keys. + +**Example** + +```python +try: + rk.resolve("Germany", context={"region": "Europe"}) +except UnknownContextKeyError as e: + print(e.unknown) # ['region'] + print(e.valid) # ['as_of', 'attributes', 'country', 'entity_types', 'languages', 'parent_ids'] +``` + +--- + ### `CrosswalkError(ResolverError)` { #crosswalkerror } *Added in v0.1.* diff --git a/docs/reference/resolver.md b/docs/reference/resolver.md index be7fea5..d19e94c 100644 --- a/docs/reference/resolver.md +++ b/docs/reference/resolver.md @@ -154,7 +154,7 @@ Resolves a single string. - **`to`** (`str | type[EntityRecord] | None`, default: `UNSET`) — Controls the return type. `UNSET` (omitted) activates the resolver's configured `default_to` spec when set, or returns a raw `ResolutionResult` when no default is configured. `None` (explicit) always returns a `ResolutionResult`. A code system name (`"iso3"`, `"dcid"`) returns that code string. An attribute name (`"flag"`, `"name"`) returns that attribute. `EntityRecord` returns the full entity object. A per-call `to=` overrides any configured `default_to`. - **`as_result`** (`bool`, default: `False`) — Return the full `ResolutionResult` even when a `default_to` is configured — equivalent to `to=None`. Raises `ValueError` when combined with an explicit non-`None` `to=`. - **`domain`** (`str | list[str] | None`, default: `None`) — Route to a specific domain (`"geo"`) or list of domains. Requires `routing_mode=RoutingMode.EXPLICIT`; with the default `AUTO` mode, passing `domain=` raises `ValueError`. To scope resolution without EXPLICIT routing, load fewer modules instead (`from_modules` / `auto(domains=...)`). -- **`context`** (`ResolutionContext | None`, default: `None`) — Resolution context. When `as_of` is set inside it, acts as a hard point-in-time filter: entities outside their `[valid_from, valid_until)` window are dropped. With no `as_of` set, context is a no-op on temporal filtering. +- **`context`** (`ResolutionContext | dict | None`, default: `None`) — Resolution hints. Accepts a [`ResolutionContext`](api.md#resolutioncontext) or a plain `dict` with shorthand keys (`country`, `entity_types`, `parent_ids`, `languages`, `attributes`, `as_of`). Unknown dict keys raise `UnknownContextKeyError`. When `as_of` is set, acts as a hard point-in-time filter: entities outside their `[valid_from, valid_until)` window are dropped. - **`from_system`** (`str | None`, default: `None`) — Treat the input as a code in this system. Accepts any system known to the loaded packs (`code_systems()` lists them); common ones are `"iso2"`, `"iso3"`, `"iso_numeric"`, `"dcid"`, `"wikidata"`. An unknown system raises `UnknownCodeSystemError`. Skips name resolution. - **`include_entity`** (`bool`, default: `False`) — Populate `result.entity` with the full `EntityRecord`. When `to=` is set or a default spec is active, this is forced `True` internally. Note: the module-level `rk.resolve()` defaults this to `True` for notebook ergonomics; `Resolver.resolve()` defaults to `False`. - **`timeout`** (`float | None`, default: `None`) — Per-call limit in seconds. Overrides `default_timeout`. Must be positive if set. @@ -277,7 +277,7 @@ Resolves a collection of values. - **`on_missing`** (`"raise" | "null" | "auto" | UNSET`, default: `UNSET`) — Miss policy override for the output spec. `UNSET` inherits the spec's configured `on_missing` policy. `"raise"` aborts the batch on the first resolved-but-missing entity; `"null"` returns `None` per row silently; `"auto"` returns `None` with a `UserWarning`. Only relevant on the spec path. - **`output`** (`"series" | "record" | "frame"`, default: `"series"`) — Output shape when `to=None`. Ignored when `to=` is a scalar. - **`domain`** — Optional domain filter. -- **`context`** (`ResolutionContext | None`) — Broadcast to every row. +- **`context`** (`ResolutionContext | dict | None`) — Resolution hints, broadcast to every row. Dict shorthand keys: `country` (ISO alpha-2/alpha-3 or a country name), `entity_types`, `parent_ids`, `languages`, `attributes`, `as_of`. Dict values may be a `pd.Series` or `pl.Series` for per-row context. Unknown keys raise `UnknownContextKeyError`. - **`from_system`** — Force code-system interpretation for all inputs. - **`not_found`** (`"null" | "raise" | str`, default: `"null"`) — What to do when a value has no match. `"null"` → `None`. `"raise"` → `ValueError`. Any other string is used as a literal sentinel in the output. - **`on_error`** (`"raise" | "null" | "keep"`, default: `"raise"`) — What to do on pipeline errors. @@ -354,7 +354,7 @@ To warm the module-level singleton, use [`rk.warm()`](api.md#warm). ### `suggest(prefix, *, top_k=10, domain=None, entity_type=None, context=None, to=None, fuzzy="auto", timeout=None)` { #resolversuggest } -Returns a ranked typeahead suggestion list for `prefix`. Built for per-keystroke autocomplete: it bypasses the resolve pipeline and the query cache, never raises a thresholded verdict, and returns `[]` for empty, whitespace-only, or below-floor prefixes. This method exists only on `Resolver` — there is no module-level `rk.suggest()`. +Returns a ranked typeahead suggestion list for `prefix`. Built for per-keystroke autocomplete: it bypasses the resolve pipeline and the query cache, never raises a thresholded verdict, and returns `[]` for empty, whitespace-only, or below-floor prefixes. A module-level `rk.suggest()` wrapper is also available — see [`rk.suggest`](api.md#suggest). **Parameters** diff --git a/scripts/build/enrich_prominence.py b/scripts/build/enrich_prominence.py index f8837b2..d488948 100644 --- a/scripts/build/enrich_prominence.py +++ b/scripts/build/enrich_prominence.py @@ -1,10 +1,20 @@ """Populate per-entity prominence scores into the geo-shared staging store. -Fetches Wikidata sitelink counts (primary signal, queried straight from WDQS -via :mod:`resolvekit.builder.sources.wikidata.sitelinks`) and DC population -observations (fallback) for every entity in the shared geo store, then -normalizes per entity-type bucket to [0, 1] and writes -``attrs_json["prominence"]`` via the ``apply_contribution`` path. +Signal strategy is per entity-type bucket (see ``EnrichProminenceSettings``): + +- **Country/region tiers** (geo.country, geo.continent, geo.region, …): Wikidata + sitelink counts are the primary signal (WDQS via + :mod:`resolvekit.builder.sources.wikidata.sitelinks`); DC population is the + fallback for entities with no sitelinks. + +- **City/admin tiers** (geo.city, geo.admin1-5): DC population (``Count_Person``) + is the primary signal; Wikidata sitelinks are used only as a fallback for + population-less entities. This avoids a full WDQS sweep over 100k+ city QIDs, + which is the main build failure path (transient WDQS rate-limiting). + +Each bucket is normalized to [0, 1] independently, so mixed signals across +tiers do not interfere. Results are written to ``attrs_json["prominence"]`` +via the ``apply_contribution`` path. Sitelinks comes from Wikidata directly because the ONE-hosted DC instance does not import ``wikidataSitelinkCount``; the local ``codes`` table already stores @@ -76,6 +86,15 @@ class EnrichProminenceSettings: # bucket finishes so reruns after a WDQS hiccup don't re-fetch what # already succeeded. Delete this file to force a full refetch. prominence_cache_path: Path = _SHARED_GEO_ROOT / "prominence_cache.json" + # Entity-type buckets for which DC population is the PRIMARY signal and + # Wikidata sitelinks are used only as a fallback for population-less entities. + # City and sub-country admin tiers use population-primary because: (a) DC has + # near-complete Count_Person coverage for these tiers and (b) avoiding a full + # WDQS sitelink sweep over 100k+ cities eliminates the main build failure + # path (transient WDQS rate-limiting on large batches). + population_primary_buckets: frozenset[str] = frozenset( + {"geo.city", "geo.admin1", "geo.admin2", "geo.admin3", "geo.admin4", "geo.admin5"} + ) def _fetch_bucket( @@ -88,9 +107,18 @@ def _fetch_bucket( wikidata_user_agent: str, wikidata_batch_size: int, wikidata_request_delay: float, + population_primary: bool = False, ) -> dict[str, float]: """Fetch sitelinks + population for one entity-type bucket and compute prominences. + When ``population_primary=False`` (default): sitelinks are fetched first; + population is the fallback for entities with no sitelinks. + + When ``population_primary=True``: DC population is fetched first; Wikidata + sitelinks are only fetched for entities that have no population data. This + avoids a full WDQS sweep for large city/admin2 buckets (100k+ QIDs) where + WDQS rate-limiting is the primary build failure path. + Raises ``BuildExecutionError`` if >``failure_threshold`` of entities produce neither signal (network error / malformed payload on both fetches). Entities that legitimately have no sitelinks and no population data are missing, not @@ -98,41 +126,78 @@ def _fetch_bucket( """ total = len(entity_ids) - bucket_qids = {entity_to_qid[eid] for eid in entity_ids if eid in entity_to_qid} sitelinks_by_entity: dict[str, int] = {} - if bucket_qids: - try: - sitelinks_by_qid = wd_sitelinks.fetch_sitelinks_by_qid( - qids=bucket_qids, - user_agent=wikidata_user_agent, - batch_size=wikidata_batch_size, - request_delay=wikidata_request_delay, - ) - except Exception as exc: - logger.warning( - "bucket %s: sitelinks fetch failed (%s); falling back to population for all entities", - entity_type, - exc, - ) - else: - for eid in entity_ids: - qid = entity_to_qid.get(eid) - if qid is not None and qid in sitelinks_by_qid: - sitelinks_by_entity[eid] = sitelinks_by_qid[qid] - - missing_sitelinks = [eid for eid in entity_ids if eid not in sitelinks_by_entity] - populations: dict[str, float] = {} - if missing_sitelinks: + # Used for the qid_rate metric regardless of which branch runs. + bucket_qids: set[str] = {entity_to_qid[eid] for eid in entity_ids if eid in entity_to_qid} + + if population_primary: + # Population-primary: fetch DC population for all entities first. try: - populations = fetch_population(dc=dc, entity_ids=missing_sitelinks) + populations = fetch_population(dc=dc, entity_ids=entity_ids) except Exception as exc: logger.warning( - "bucket %s: population fetch failed (%s); proceeding with sitelinks only", + "bucket %s: population fetch failed (%s); falling back to sitelinks for all entities", entity_type, exc, ) + # Sitelinks only for entities that produced no population. + missing_population = [eid for eid in entity_ids if eid not in populations] + if missing_population: + missing_qids = {entity_to_qid[eid] for eid in missing_population if eid in entity_to_qid} + if missing_qids: + try: + sitelinks_by_qid = wd_sitelinks.fetch_sitelinks_by_qid( + qids=missing_qids, + user_agent=wikidata_user_agent, + batch_size=wikidata_batch_size, + request_delay=wikidata_request_delay, + ) + except Exception as exc: + logger.warning( + "bucket %s: sitelinks fallback fetch failed (%s); proceeding with population only", + entity_type, + exc, + ) + else: + for eid in missing_population: + qid = entity_to_qid.get(eid) + if qid is not None and qid in sitelinks_by_qid: + sitelinks_by_entity[eid] = sitelinks_by_qid[qid] + else: + # Sitelinks-primary: fetch WDQS sitelinks first. + if bucket_qids: + try: + sitelinks_by_qid = wd_sitelinks.fetch_sitelinks_by_qid( + qids=bucket_qids, + user_agent=wikidata_user_agent, + batch_size=wikidata_batch_size, + request_delay=wikidata_request_delay, + ) + except Exception as exc: + logger.warning( + "bucket %s: sitelinks fetch failed (%s); falling back to population for all entities", + entity_type, + exc, + ) + else: + for eid in entity_ids: + qid = entity_to_qid.get(eid) + if qid is not None and qid in sitelinks_by_qid: + sitelinks_by_entity[eid] = sitelinks_by_qid[qid] + + missing_sitelinks = [eid for eid in entity_ids if eid not in sitelinks_by_entity] + if missing_sitelinks: + try: + populations = fetch_population(dc=dc, entity_ids=missing_sitelinks) + except Exception as exc: + logger.warning( + "bucket %s: population fetch failed (%s); proceeding with sitelinks only", + entity_type, + exc, + ) + missing_any = [ eid for eid in entity_ids @@ -346,6 +411,7 @@ def _checkpoint(entity_type: str, result: dict[str, float]) -> None: wikidata_user_agent=settings.wikidata_user_agent, wikidata_batch_size=settings.wikidata_batch_size, wikidata_request_delay=settings.wikidata_request_delay, + population_primary=entity_type in settings.population_primary_buckets, ) _checkpoint(entity_type, result) elif pending: @@ -361,6 +427,7 @@ def _checkpoint(entity_type: str, result: dict[str, float]) -> None: wikidata_user_agent=settings.wikidata_user_agent, wikidata_batch_size=settings.wikidata_batch_size, wikidata_request_delay=settings.wikidata_request_delay, + population_primary=entity_type in settings.population_primary_buckets, ): entity_type for entity_type, entity_ids in pending } diff --git a/src/resolvekit/__init__.py b/src/resolvekit/__init__.py index 5fa8e1c..f5a43fe 100644 --- a/src/resolvekit/__init__.py +++ b/src/resolvekit/__init__.py @@ -18,13 +18,9 @@ With context hints:: - from resolvekit import Resolver, ResolutionContext - resolver = Resolver.from_modules(module_ids=["geo.countries"]) - result = resolver.resolve( - "Paris", - context=ResolutionContext(country="FR"), - ) + result = resolver.resolve("Paris", context={"country": "FR"}) + # Typed alternative: context=ResolutionContext(country="FR") With explanation:: @@ -55,6 +51,7 @@ resolve, resolve_id, snap, + suggest, to, warm, ) @@ -77,6 +74,7 @@ OutputMissingError, ResolutionError, UnknownCodeSystemError, + UnknownContextKeyError, UnknownDomainError, UnknownOutputError, ) @@ -120,6 +118,7 @@ "Resolver", "ResolverError", "UnknownCodeSystemError", + "UnknownContextKeyError", "UnknownDomainError", "UnknownOutputError", "bulk", @@ -132,6 +131,7 @@ "resolve", "resolve_id", "snap", + "suggest", "to", "warm", ] diff --git a/src/resolvekit/_convenience.py b/src/resolvekit/_convenience.py index da60867..72fe798 100644 --- a/src/resolvekit/_convenience.py +++ b/src/resolvekit/_convenience.py @@ -226,7 +226,7 @@ def resolve( to: Any = ..., # UNSET sentinel — actual type is str|None|_Unset; deferred import as_result: bool = False, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict | None = None, from_system: str | None = None, include_entity: bool = True, timeout: float | None = None, @@ -282,7 +282,7 @@ def resolve_id( on_ambiguous: Literal["raise", "null", "best"] = "raise", from_system: str | None = None, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict | None = None, timeout: float | None = None, ) -> str | None: """Resolve text and return entity_id or None. @@ -328,7 +328,7 @@ def bulk( to: Any = ..., # UNSET sentinel — actual type is str|None|_Unset; deferred import on_missing: Any = ..., # UNSET sentinel — actual type is Literal[...] | _Unset domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict | None = None, output: Literal["series", "record", "frame"] = "series", from_system: str | None = None, not_found: str = "null", @@ -397,7 +397,7 @@ def snap( max_distance: float = 0.5, to: Any = ..., # UNSET sentinel — actual type is str|None|_Unset; deferred import domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict | None = None, ) -> Any: """Return the closest match from an explicit candidate list. @@ -521,7 +521,7 @@ def parse( text: str, *, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict | None = None, to: str | list[str] | None = None, confidence_threshold: float | None = None, include_nil: bool = False, @@ -581,6 +581,57 @@ def parse( ) +def suggest( + prefix: str, + *, + top_k: int = 10, + domain: str | list[str] | None = None, + entity_type: str | list[str] | None = None, + context: ResolutionContext | dict | None = None, + to: str | list[str] | None = None, + fuzzy: Literal["auto", "always", "never"] = "auto", + timeout: float | None = None, +) -> list: + """Return a ranked typeahead suggestion list for *prefix*. + + Module-level wrapper; delegates to the singleton default resolver's + :meth:`~resolvekit.core.api.resolver.Resolver.suggest`. + + Args: + prefix: Partial query string (e.g. ``"unit"`` → United States, …). + top_k: Maximum suggestions to return; clamped to [1, 100]. Default 10. + domain: Domain pack filter (e.g. ``"geo"``). + entity_type: Sub-type filter within a domain (e.g. ``"geo.country"``). + Accepts a single string or list. + context: Resolution hints, as a ``ResolutionContext`` or a plain ``dict``. + Dict shorthand keys: ``country`` (ISO alpha-2/alpha-3 or a country + name like ``"France"``), ``entity_types``, ``parent_ids``, + ``languages``, ``attributes`` (pack-specific escape hatch), and + ``as_of``. An empty dict is treated as no context. Unknown keys raise + ``UnknownContextKeyError`` listing the valid keys. + context is validated for shape but does not yet affect suggest ranking. + to: Output code system or name variant for ``display`` (e.g. ``"iso3"``). + ``None`` (default) uses ``canonical_name`` as the display value. + fuzzy: Fuzzy-matching policy: ``"auto"`` (default), ``"always"``, + or ``"never"``. + timeout: Per-call time budget in seconds. + + Returns: + ``list[SuggestionResult]``, sorted by match quality (best first), + length at most ``top_k``. + """ + return _get_default().suggest( + prefix, + top_k=top_k, + domain=domain, + entity_type=entity_type, + context=context, + to=to, + fuzzy=fuzzy, + timeout=timeout, + ) + + def warm() -> None: """Pre-build all lazily-constructed indexes, blocking until complete. @@ -598,7 +649,7 @@ def parse_bulk( *, values: Any, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict | None = None, to: str | list[str] | None = None, confidence_threshold: float | None = None, include_nil: bool = False, diff --git a/src/resolvekit/_data/geo/admin1/metadata.json b/src/resolvekit/_data/geo/admin1/metadata.json index 65e0f0e..d37c7e2 100644 --- a/src/resolvekit/_data/geo/admin1/metadata.json +++ b/src/resolvekit/_data/geo/admin1/metadata.json @@ -7,7 +7,8 @@ "geo.admin3", "geo.cities", "geo.countries", - "geo.continental_unions" + "geo.continental_unions", + "geo.regions" ], "entity_schema_version": "1.0", "feature_schema_version": "geo.features.v1", @@ -16,7 +17,7 @@ "fts": "fts5", "symspell": "symspell.dict" }, - "build_timestamp": "2026-05-31T15:26:15.400997+00:00", + "build_timestamp": "2026-06-12T05:45:26.185584+00:00", "source_datasets": [ "datacommons" ], @@ -29,15 +30,15 @@ "remote_artifacts": { "sqlite": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin1-entities.sqlite.gz", - "sha256": "8174d2fe365c4f5cd4971b2bb5495c31b4f9f5fbe2c1308a681c3638df9c17aa", - "gz_sha256": "956b54a116ae8655241f242b06c339fdc57246e182ef9db900cecaeecda7ae7d", - "size_mb": 11.45 + "sha256": "74be1278d44329532c920a1e9e16d913a0b5390cb9c6a713322d7a34b5e3dd24", + "gz_sha256": "46d39fb8cb3f2d35c147f78e90c49fb6d14520f8454930db191ab8ec376efd41", + "size_mb": 11.55 }, "symspell": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin1-symspell.dict.gz", - "sha256": "b7967ea6e710877e2ca06634e363405156071f2b6c18f179c0e3a16f68ff629b", - "gz_sha256": "f5829bca78f84a5a0e1a7b734eb87d4a8b403b1fe5156952da7f6eea73bd69c6", - "size_mb": 0.26 + "sha256": "eb40c9f872c158e257c9f8acc4b4a1447f7af88790efd9f7600ee0494c2f7f80", + "gz_sha256": "91f3bacaa5d33ba8582e1edcb23e30d9e21cac0dbd6f7d89224d1a2923a266ad", + "size_mb": 0.27 } }, "data_version": "2026.06", @@ -50,11 +51,11 @@ "allow_new_entities": false, "quality_metrics": { "entity_count": 4795, - "names_count": 15109, - "codes_count": 36741, - "relations_count": 5002, + "names_count": 15472, + "codes_count": 37805, + "relations_count": 5013, "names_coverage": 1.0, "codes_coverage": 1.0, - "relations_density": 1.043169968717414 + "relations_density": 1.0454640250260687 } } diff --git a/src/resolvekit/_data/geo/admin2/metadata.json b/src/resolvekit/_data/geo/admin2/metadata.json index 097a143..6e5e233 100644 --- a/src/resolvekit/_data/geo/admin2/metadata.json +++ b/src/resolvekit/_data/geo/admin2/metadata.json @@ -16,7 +16,7 @@ "fts": "fts5", "symspell": "symspell.dict" }, - "build_timestamp": "2026-05-31T15:26:18.391311+00:00", + "build_timestamp": "2026-06-12T05:45:29.183373+00:00", "source_datasets": [ "datacommons" ], @@ -29,14 +29,14 @@ "remote_artifacts": { "sqlite": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin2-entities.sqlite.gz", - "sha256": "07a593107e9bd4697c7d834a9c5168fe382814f7623764ee875ff392700da349", - "gz_sha256": "9f8deb3852fd62ec7b7b38ba233180b8b8d0b1b2c5ca969766e3fde8edd717ed", - "size_mb": 97.77 + "sha256": "d59a081d1d8b40bec03701bc3d26f6d4098e955e5684bfad1e3f500ca48410a1", + "gz_sha256": "03201a429a725e1eabc488971a13038fea063a9d885be1f583fdc8b270a1f7ac", + "size_mb": 97.53 }, "symspell": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin2-symspell.dict.gz", - "sha256": "ff62f9d223f06ee4fbf6454e561a44baea1ea5e89a195c889054404cb8eae613", - "gz_sha256": "14d8de7f696453f17bf5a7e48c9ed20467c47e4652efe4ca526f050a41f4fa6f", + "sha256": "efa0292ca63d41a57604757102490c7efa23c0f1fa8f2e7d44b0eb6b61f41253", + "gz_sha256": "f9c8fe69e06d6eaecc2024668f43616ec9381dd469f87b2375208dd2728a4486", "size_mb": 2.01 } }, @@ -50,11 +50,11 @@ "allow_new_entities": false, "quality_metrics": { "entity_count": 58817, - "names_count": 119022, - "codes_count": 236568, - "relations_count": 64877, + "names_count": 118933, + "codes_count": 236574, + "relations_count": 64976, "names_coverage": 1.0, "codes_coverage": 1.0, - "relations_density": 1.1030314364894502 + "relations_density": 1.1047146233231888 } } diff --git a/src/resolvekit/_data/geo/admin3/metadata.json b/src/resolvekit/_data/geo/admin3/metadata.json index 4428f41..9be0a87 100644 --- a/src/resolvekit/_data/geo/admin3/metadata.json +++ b/src/resolvekit/_data/geo/admin3/metadata.json @@ -17,7 +17,7 @@ "fts": "fts5", "symspell": "symspell.dict" }, - "build_timestamp": "2026-05-31T15:26:24.446467+00:00", + "build_timestamp": "2026-06-12T05:45:35.307273+00:00", "source_datasets": [ "datacommons" ], @@ -30,15 +30,15 @@ "remote_artifacts": { "sqlite": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin3-entities.sqlite.gz", - "sha256": "b1d6ea6e1a0e1e63aafe19ce8af2d917232e80762f24c299ce85ea57e456b6d2", - "gz_sha256": "93e3916af670f7cd210e1e9c2e8580d80702ff537f4e8fad2640237e4c457cd5", - "size_mb": 157.63 + "sha256": "534689b2be34ea71bf716ee05a2450e57e615703fb47bf3847b37717989bfe27", + "gz_sha256": "25a904f7d22986f329386c5ab6172850925fc7e35d8575f8f0e2f9cdd97dee0d", + "size_mb": 156.94 }, "symspell": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin3-symspell.dict.gz", - "sha256": "a6f57760fca120beb49aef70360ba694914167c8f59594e7f5054747d72064f1", - "gz_sha256": "d3b31a8ba6fa8eb2ea5db2283479f9c56a85d26f4a9d26e345d2a531d715cd45", - "size_mb": 2.54 + "sha256": "de37ec12016f845f0e7619ca1ae3add2fdb265a8bd604a1c58533b70c1ace000", + "gz_sha256": "81b8d0f01d06718faad18d3fcc89c2242e61a6c38ca16dc064220d31391abecb", + "size_mb": 2.55 } }, "data_version": "2026.06", @@ -50,12 +50,12 @@ "base_module_ids": null, "allow_new_entities": false, "quality_metrics": { - "entity_count": 116912, - "names_count": 168417, - "codes_count": 337851, - "relations_count": 125190, + "entity_count": 116687, + "names_count": 169645, + "codes_count": 335609, + "relations_count": 125261, "names_coverage": 1.0, "codes_coverage": 1.0, - "relations_density": 1.0708053920897769 + "relations_density": 1.0734786222972568 } } diff --git a/src/resolvekit/_data/geo/admin4/metadata.json b/src/resolvekit/_data/geo/admin4/metadata.json index e8b6331..ad64394 100644 --- a/src/resolvekit/_data/geo/admin4/metadata.json +++ b/src/resolvekit/_data/geo/admin4/metadata.json @@ -16,7 +16,7 @@ "fts": "fts5", "symspell": "symspell.dict" }, - "build_timestamp": "2026-05-31T15:26:36.251620+00:00", + "build_timestamp": "2026-06-12T05:45:47.295017+00:00", "source_datasets": [ "datacommons" ], @@ -29,14 +29,14 @@ "remote_artifacts": { "sqlite": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin4-entities.sqlite.gz", - "sha256": "0ebb77d0859d3b334c499fb5cd4e3e73d581a76c41dc9df9b2c3ab468fc12eca", - "gz_sha256": "b7a07b82691d1ef3432e2020457202737c2701361fd9b8b0c3ef8d745c4d0551", - "size_mb": 374.84 + "sha256": "2c4f59eee7e7ba5c411bc929e134529170378be07dee2f0ea8a5a36ced802bfb", + "gz_sha256": "a8e61c14881f4e346eaf084b05c9af7684c86acde7fa6e31f911fdcbc8931e40", + "size_mb": 374.34 }, "symspell": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin4-symspell.dict.gz", - "sha256": "196e17aa697340a47648ce52eab5edd335e87baefe7f5a95e9e7794da1e0702f", - "gz_sha256": "55156293cf68ad14884242d561699abe4933734a976949367e8e5231d26a6aff", + "sha256": "ed6ea61439dddf62dcbfb00745c2ac5bb55cf897f1f801dc079b767bd36b3e5f", + "gz_sha256": "d97fa637bcf123960c4daeb15b739ec8907256bf1b44d204057036884503b285", "size_mb": 4.65 } }, @@ -50,11 +50,11 @@ "allow_new_entities": false, "quality_metrics": { "entity_count": 292451, - "names_count": 353990, + "names_count": 352684, "codes_count": 739522, - "relations_count": 342326, + "relations_count": 340404, "names_coverage": 1.0, "codes_coverage": 1.0, - "relations_density": 1.1705413898396655 + "relations_density": 1.163969348711408 } } diff --git a/src/resolvekit/_data/geo/admin5/metadata.json b/src/resolvekit/_data/geo/admin5/metadata.json index 49dbe66..63faf08 100644 --- a/src/resolvekit/_data/geo/admin5/metadata.json +++ b/src/resolvekit/_data/geo/admin5/metadata.json @@ -16,7 +16,7 @@ "fts": "fts5", "symspell": "symspell.dict" }, - "build_timestamp": "2026-05-31T15:26:39.748789+00:00", + "build_timestamp": "2026-06-12T05:45:51.625980+00:00", "source_datasets": [ "datacommons" ], @@ -29,14 +29,14 @@ "remote_artifacts": { "sqlite": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin5-entities.sqlite.gz", - "sha256": "7d769ee1ddff7c8f30becec71e492ee52052f18815b03ac57c5798d3978d4d1f", - "gz_sha256": "32a1123918e1351793d66979ca1342d56c29ce391240f4e4b76e3369e52615c0", + "sha256": "75b1448c6ed841095b2d86321a0b88afbf43e9787421816d3fc65dc149079b52", + "gz_sha256": "19f30e5f24d16bfda76b44be8393e9cbb2af31713c65fb82ea0d90ea53f59427", "size_mb": 1.88 }, "symspell": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin5-symspell.dict.gz", - "sha256": "d27bac63c7ccefbb2bd3d26e24c04ee7def450d190662d6b81b130f833662104", - "gz_sha256": "19d9d7f3f3274d510fd880658d0665f9e2d63d044a818ea32d97848b9950e057", + "sha256": "d0f6974c45a47132d72db73deb110eb9383f0a87a1c456fec206ba797817638e", + "gz_sha256": "a8a9342187300ecd825b73c522a01b172f9243e7205aa583a7b732244746b9f8", "size_mb": 0.04 } }, @@ -50,11 +50,11 @@ "allow_new_entities": false, "quality_metrics": { "entity_count": 1458, - "names_count": 1590, + "names_count": 1605, "codes_count": 3185, - "relations_count": 1776, + "relations_count": 1777, "names_coverage": 1.0, "codes_coverage": 1.0, - "relations_density": 1.2181069958847737 + "relations_density": 1.218792866941015 } } diff --git a/src/resolvekit/_data/geo/cities/metadata.json b/src/resolvekit/_data/geo/cities/metadata.json index cbe7666..01f6486 100644 --- a/src/resolvekit/_data/geo/cities/metadata.json +++ b/src/resolvekit/_data/geo/cities/metadata.json @@ -17,7 +17,7 @@ "fts": "fts5", "symspell": "symspell.dict" }, - "build_timestamp": "2026-05-31T15:26:43.708515+00:00", + "build_timestamp": "2026-06-12T05:45:54.981831+00:00", "source_datasets": [ "datacommons" ], @@ -30,15 +30,15 @@ "remote_artifacts": { "sqlite": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-cities-entities.sqlite.gz", - "sha256": "f99b2a2fdcc0f4bc44fa024e9a0852a3c013a61cb438825c7e0ce1476586e918", - "gz_sha256": "8819f578d017d5b90437e2e5c093090aef9f3c11572fcb6a7d8a42139b9c9caf", - "size_mb": 151.61 + "sha256": "06e3a01b92d5f7a90f3e926a0779f77ec4bca1517eb39a0f3353840ed466767a", + "gz_sha256": "c7aa08a48bf46b8b603e04a8f96f9afa7578dc6f7f149ff1171d3976c7f7790e", + "size_mb": 152.21 }, "symspell": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-cities-symspell.dict.gz", - "sha256": "ff96a9a5d3a7bced178ca74d416e7de85aca931b4337d83f279fe4bb28a4ff5e", - "gz_sha256": "f6fb9fb323bd781d100d67527ab270ecc8d5bd35ea14a45d438d845c023fa759", - "size_mb": 2.61 + "sha256": "bdfbc85e011729e35268d5c799bbc7a5da5fca0b5fb591b069af6ace1975ca32", + "gz_sha256": "a5caf0b166d6b4f294608791ed81d16dbd38234b825034da695d77ba169b39a6", + "size_mb": 2.67 } }, "data_version": "2026.06", @@ -51,11 +51,11 @@ "allow_new_entities": false, "quality_metrics": { "entity_count": 105509, - "names_count": 159153, - "codes_count": 350828, - "relations_count": 146300, + "names_count": 161423, + "codes_count": 351511, + "relations_count": 146295, "names_coverage": 1.0, "codes_coverage": 1.0, - "relations_density": 1.3866115686813447 + "relations_density": 1.3865641793591068 } } diff --git a/src/resolvekit/_data/geo/continents/metadata.json b/src/resolvekit/_data/geo/continents/metadata.json index 142ba0d..1e8e05f 100644 --- a/src/resolvekit/_data/geo/continents/metadata.json +++ b/src/resolvekit/_data/geo/continents/metadata.json @@ -1,19 +1,8 @@ { - "allow_new_entities": false, - "artifacts": { - "symspell": "symspell.dict" - }, - "base_module_ids": null, - "build_timestamp": "2026-06-07T17:57:36.514718+00:00", - "checksums": { - "sqlite": "3e5efd12f749319d61cdd7554733bee76117176d46fd88ac2523fad938c5c3ec", - "symspell": "d61c6ea1750ca00fb06efeb2a40b9b0030562ef6c02f1f23d9277c44e9f569db" - }, - "data_version": "2026.06", "datapack_id": "geo.continents-v2026.06", - "description": null, - "distribution": "bundled", + "module_id": "geo.continents", "domain_pack_id": "geo", + "module_dependencies": [], "entity_schema_version": "1.0", "feature_schema_version": "geo.features.v1", "normalizer_version": "casefold.1", @@ -21,11 +10,28 @@ "fts": "fts5", "symspell": "symspell.dict" }, - "link_keys": null, + "build_timestamp": "2026-06-07T17:57:36.514718+00:00", + "source_datasets": [ + "seed" + ], + "artifacts": { + "symspell": "symspell.dict" + }, + "description": null, + "checksums": { + "sqlite": "3e5efd12f749319d61cdd7554733bee76117176d46fd88ac2523fad938c5c3ec", + "symspell": "d61c6ea1750ca00fb06efeb2a40b9b0030562ef6c02f1f23d9277c44e9f569db" + }, + "distribution": "bundled", + "remote_artifacts": null, + "data_version": "2026.06", "min_resolvekit_version": null, - "module_dependencies": [], - "module_id": "geo.continents", "pack_type": "base", + "store_type": "sqlite", + "store_file": "entities.sqlite", + "link_keys": null, + "base_module_ids": null, + "allow_new_entities": false, "quality_metrics": { "codes_count": 16, "codes_coverage": 1.0, @@ -34,11 +40,5 @@ "names_coverage": 1.0, "relations_count": 2, "relations_density": 0.25 - }, - "remote_artifacts": null, - "source_datasets": [ - "seed" - ], - "store_file": "entities.sqlite", - "store_type": "sqlite" + } } diff --git a/src/resolvekit/_data/geo/countries/entities.sqlite b/src/resolvekit/_data/geo/countries/entities.sqlite index 137991313cb72cd227714c778796554d5fd09b24..30d4494bdfb66f3fd7034573c2078b9799ea6396 100644 GIT binary patch delta 51277 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

E`~JKnM35xsBc2Ndplcg#a#Pja1UjoipTl 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 delta 46261 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�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 NzX;QI{p(}e{{)^41B?Iw diff --git a/src/resolvekit/_data/geo/countries/geo_calibrator.json b/src/resolvekit/_data/geo/countries/geo_calibrator.json index 02f5f37..f989da8 100644 --- a/src/resolvekit/_data/geo/countries/geo_calibrator.json +++ b/src/resolvekit/_data/geo/countries/geo_calibrator.json @@ -1,7 +1,7 @@ { "method": "platt", - "a": -13.736865875514662, - "b": 10.181431464564893, + "a": -8.499867056667657, + "b": 5.556690191789257, "domain": "geo", - "fit_n_samples": 11331 + "fit_n_samples": 12673 } diff --git a/src/resolvekit/_data/geo/countries/metadata.json b/src/resolvekit/_data/geo/countries/metadata.json index 06cba24..31dde5a 100644 --- a/src/resolvekit/_data/geo/countries/metadata.json +++ b/src/resolvekit/_data/geo/countries/metadata.json @@ -13,7 +13,7 @@ "fts": "fts5", "symspell": "symspell.dict" }, - "build_timestamp": "2026-06-11T07:13:20.206900+00:00", + "build_timestamp": "2026-06-11T21:35:44.579025+00:00", "source_datasets": [ "datacommons" ], @@ -23,9 +23,9 @@ }, "description": null, "checksums": { - "sqlite": "1c92afe09b85d3e4736fb2857f2673874cb78fc6b0d05b7891371883a4f8668c", - "symspell": "e3cfcc2ccd077c9b646df6bd11ae73f7742cce095a27206e48b656b8739ae647", - "calibrator": "47b7059db5a0ccfa07b3a1dd3877a630a255450e907e46551555f0d00d32b846" + "sqlite": "b37f34de8e4f70ff907281424466a759a13505170fef910e25996b2eece479e8", + "symspell": "84a1c9985f13522d95729044cd927b87ac5ac9234514e7654cbe6eaae9195d09", + "calibrator": "81f42e1038c555e4ad6036a842fd4e6ddae1fa94f02fc0cb6dd404cca175e995" }, "distribution": "bundled", "remote_artifacts": null, @@ -39,7 +39,7 @@ "allow_new_entities": false, "quality_metrics": { "entity_count": 239, - "names_count": 4305, + "names_count": 4327, "codes_count": 9125, "relations_count": 1889, "names_coverage": 1.0, diff --git a/src/resolvekit/_data/geo/countries/symspell.dict b/src/resolvekit/_data/geo/countries/symspell.dict index b539ce5..7c062d2 100644 --- a/src/resolvekit/_data/geo/countries/symspell.dict +++ b/src/resolvekit/_data/geo/countries/symspell.dict @@ -152,7 +152,7 @@ aomen 1 aotearoa 1 aotearoa new zealand 1 ar 1 -arab 10 +arab 12 arab republic of egypt 1 arabe 2 arabes 2 @@ -325,7 +325,7 @@ bolivarienne 1 bolivia 8 bolivia (estado plurinacional de) 1 bolivia (plurinational state of) 1 -bolivia, 1 +bolivia, 2 bolivia, plurinational state of 1 bolivie 1 bolivie (état plurinational de) 1 @@ -481,12 +481,13 @@ chili 1 china 34 china (people's republic of) 1 china pr 1 -china, 5 +china, 7 china, hong kong special administrative region 1 china, macao special administrative region 1 china, región administrativa especial de hong kong 1 china, región administrativa especial de macao 1 china, republic of 1 +china, taiwan province of 1 chine 3 chine (république populaire de) 1 chine, 2 @@ -576,7 +577,9 @@ congo [republic] 1 congo belge 1 congo democrático 1 congo republic 1 -congo, 1 +congo, 5 +congo, dem. rep. 1 +congo, democratic republic of the 1 congo, the democratic republic of 1 congo-brazzaville 3 congo-kinshasa 2 @@ -662,7 +665,8 @@ de) 9 del 28 del) 1 della 1 -democratic 32 +dem. 4 +democratic 34 democratic cambodia 1 democratic federal macedonia 1 democratic kampuchea 1 @@ -765,6 +769,8 @@ egipto 1 egito 1 egitto 1 egypt 3 +egypt, 2 +egypt, arab rep. 1 egypte 1 ehemalige 1 ehemalige jugoslawische republik mazedonien 1 @@ -1168,7 +1174,7 @@ iran (islamic republic of) 1 iran (islamische republik) 1 iran (république islamique d') 1 iran (république islamique de) 1 -iran, 1 +iran, 2 iran, islamic republic of 1 iraq 5 iraque 1 @@ -1192,7 +1198,7 @@ isla de san martín 1 isla juana 1 isla niue 1 isla trapobana 1 -islamic 12 +islamic 13 islamic republic of afghanistan 1 islamic republic of iran 1 islamic republic of mauritania 1 @@ -1372,8 +1378,11 @@ korea (demokratische volksrepublik, nordkorea) 1 korea (republik korea, südkorea) 1 korea (south) 1 korea republic 1 -korea, 3 +korea, 9 +korea, dem. people's rep. 1 korea, democratic people’s republic of 1 +korea, north 1 +korea, rep. 1 korea, republic of 1 kosova 1 kosovo 7 @@ -1678,6 +1687,8 @@ myanmar (birmania) 2 myanmar (birmanie) 1 myanmar (burma) 1 myanmar (union de) 1 +myanmar, 2 +myanmar, republic of the union of 1 mys 1 mzanzi 1 méjico 1 @@ -1771,7 +1782,7 @@ nordkorea) 1 nordmazedonien 1 nordrhodesien 1 norte 8 -north 6 +north 8 north korea 2 north macedonia 1 north sudan 1 @@ -1815,7 +1826,7 @@ occupied 1 occupied palestinian territory 1 oceania 3 ocidental 1 -of 515 +of 527 of) 6 og 1 olandesi 1 @@ -1900,7 +1911,7 @@ peeloo 1 pelew 1 pelew islands 1 pellew 1 -people's 17 +people's 19 people's democratic republic of algeria 1 people's republic of albania 1 people's republic of bangladesh 1 @@ -1931,7 +1942,7 @@ pl 1 pleasant 1 pleasant island 1 plurinacional 2 -plurinational 4 +plurinational 5 plurinational state of bolivia 1 pm 1 png 1 @@ -1985,7 +1996,7 @@ prinçipatu 1 prinçipatu de mùnegu 1 prk 1 proper 1 -province 3 +province 5 province of sahara 1 provinces 1 provincia 3 @@ -2017,6 +2028,7 @@ ras 1 ras di macao 1 ratcha-anachak 1 ratcha-anachak thai 1 +rb 2 rdc 2 red 1 red china 1 @@ -2031,10 +2043,11 @@ reino de españa 1 reino de marruecos 1 reino unido 2 reino unido de gran bretaña e irlanda del norte 1 +rep. 8 repubblica 2 repubblica centrafricana 1 repubblica dominicana 1 -republic 398 +republic 405 republic niger 1 republic of albania 1 republic of angola 1 @@ -2607,7 +2620,7 @@ staat der vatikanstadt 1 staat libyen 1 staat palästina 1 staaten 3 -state 38 +state 39 state of eritrea 1 state of israel 1 state of japan 1 @@ -2780,16 +2793,18 @@ thai 2 thailand 3 thailandia 1 thaïlande 1 -the 352 +the 368 the arab republic of egypt 1 the argentine republic 1 the bahamas 1 the bolivarian republic of venezuela 1 +the bolivia, plurinational state of 1 the bonaire, sint eustatius and saba 1 the bosnia and herzegovina 1 the british virgin islands 1 the brunei darussalam 1 the burma 1 +the china, taiwan province of 1 the co-operative republic of guyana 1 the commonwealth of australia 1 the commonwealth of dominica 1 @@ -2801,6 +2816,8 @@ the comoros 1 the congo 2 the congo (brazzaville) 1 the congo (kinshasa) 1 +the congo, dem. rep. 1 +the congo, democratic republic of the 1 the congo-brazzaville 1 the congo-kinshasa 1 the curaçao 1 @@ -2821,6 +2838,7 @@ the drc 1 the dutch antilles 1 the eastern republic of uruguay 1 the ecuadorian state 1 +the egypt, arab rep. 1 the emirates 1 the federal democratic republic of ethiopia 1 the federal democratic republic of nepal 1 @@ -2848,6 +2866,7 @@ the hong kong special administrative region of china 1 the hungary 1 the independent state of papua new guinea 1 the independent state of samoa 1 +the iran, islamic republic of 1 the islamic republic of afghanistan 1 the islamic republic of iran 1 the islamic republic of mauritania 1 @@ -2873,6 +2892,9 @@ the kingdom of sweden 1 the kingdom of thailand 1 the kingdom of the netherlands 1 the kingdom of tonga 1 +the korea, dem. people's rep. 1 +the korea, north 1 +the korea, rep. 1 the kyrgyz republic 1 the land of a thousand hills 1 the lao pdr 1 @@ -2885,6 +2907,7 @@ the mexican republic 1 the montenegro 1 the most serene republic of san marino 1 the mountain kingdom 1 +the myanmar, republic of the union of 1 the nation of brunei, the abode of peace 1 the netherland antilles 1 the netherlands 1 @@ -3078,7 +3101,9 @@ the us of america 1 the usa 1 the vatican 1 the vatican city state 1 +the venezuela, rb 1 the virgin islands of the united states 1 +the yemen, republic of 1 the zaire 1 the) 1 thousand 2 @@ -3197,7 +3222,7 @@ ungheria 1 unida 1 unido 3 unidos 7 -union 9 +union 11 union des comores 2 union islands 1 union of burma 1 @@ -3258,8 +3283,9 @@ venezuela 11 venezuela (bolivarian republic of) 1 venezuela (república bolivariana de) 1 venezuela (république bolivarienne du) 1 -venezuela, 1 +venezuela, 3 venezuela, bolivarian republic of 1 +venezuela, rb 1 verde 14 vereinigte 3 vereinigte arabische emirate 1 @@ -3336,6 +3362,8 @@ ya 1 yarden 1 ye 1 yemen 4 +yemen, 2 +yemen, republic of 1 yibuti 1 yisrael 1 yougoslave 1 diff --git a/src/resolvekit/_data/manifest.json b/src/resolvekit/_data/manifest.json index c82288e..079176c 100644 --- a/src/resolvekit/_data/manifest.json +++ b/src/resolvekit/_data/manifest.json @@ -10,7 +10,7 @@ ], "size_mb": 2.61, "checksums": { - "sqlite": "1c92afe09b85d3e4736fb2857f2673874cb78fc6b0d05b7891371883a4f8668c" + "sqlite": "b37f34de8e4f70ff907281424466a759a13505170fef910e25996b2eece479e8" } }, { @@ -20,19 +20,19 @@ "entity_types": [ "geo.admin1" ], - "size_mb": 11.709999999999999, + "size_mb": 11.82, "remote_artifacts": { "sqlite": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin1-entities.sqlite.gz", - "sha256": "8174d2fe365c4f5cd4971b2bb5495c31b4f9f5fbe2c1308a681c3638df9c17aa", - "gz_sha256": "956b54a116ae8655241f242b06c339fdc57246e182ef9db900cecaeecda7ae7d", - "size_mb": 11.45 + "sha256": "74be1278d44329532c920a1e9e16d913a0b5390cb9c6a713322d7a34b5e3dd24", + "gz_sha256": "46d39fb8cb3f2d35c147f78e90c49fb6d14520f8454930db191ab8ec376efd41", + "size_mb": 11.55 }, "symspell": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin1-symspell.dict.gz", - "sha256": "b7967ea6e710877e2ca06634e363405156071f2b6c18f179c0e3a16f68ff629b", - "gz_sha256": "f5829bca78f84a5a0e1a7b734eb87d4a8b403b1fe5156952da7f6eea73bd69c6", - "size_mb": 0.26 + "sha256": "eb40c9f872c158e257c9f8acc4b4a1447f7af88790efd9f7600ee0494c2f7f80", + "gz_sha256": "91f3bacaa5d33ba8582e1edcb23e30d9e21cac0dbd6f7d89224d1a2923a266ad", + "size_mb": 0.27 } } }, @@ -43,18 +43,18 @@ "entity_types": [ "geo.admin2" ], - "size_mb": 99.78, + "size_mb": 99.54, "remote_artifacts": { "sqlite": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin2-entities.sqlite.gz", - "sha256": "07a593107e9bd4697c7d834a9c5168fe382814f7623764ee875ff392700da349", - "gz_sha256": "9f8deb3852fd62ec7b7b38ba233180b8b8d0b1b2c5ca969766e3fde8edd717ed", - "size_mb": 97.77 + "sha256": "d59a081d1d8b40bec03701bc3d26f6d4098e955e5684bfad1e3f500ca48410a1", + "gz_sha256": "03201a429a725e1eabc488971a13038fea063a9d885be1f583fdc8b270a1f7ac", + "size_mb": 97.53 }, "symspell": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin2-symspell.dict.gz", - "sha256": "ff62f9d223f06ee4fbf6454e561a44baea1ea5e89a195c889054404cb8eae613", - "gz_sha256": "14d8de7f696453f17bf5a7e48c9ed20467c47e4652efe4ca526f050a41f4fa6f", + "sha256": "efa0292ca63d41a57604757102490c7efa23c0f1fa8f2e7d44b0eb6b61f41253", + "gz_sha256": "f9c8fe69e06d6eaecc2024668f43616ec9381dd469f87b2375208dd2728a4486", "size_mb": 2.01 } } @@ -66,19 +66,19 @@ "entity_types": [ "geo.admin3" ], - "size_mb": 160.17, + "size_mb": 159.49, "remote_artifacts": { "sqlite": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin3-entities.sqlite.gz", - "sha256": "b1d6ea6e1a0e1e63aafe19ce8af2d917232e80762f24c299ce85ea57e456b6d2", - "gz_sha256": "93e3916af670f7cd210e1e9c2e8580d80702ff537f4e8fad2640237e4c457cd5", - "size_mb": 157.63 + "sha256": "534689b2be34ea71bf716ee05a2450e57e615703fb47bf3847b37717989bfe27", + "gz_sha256": "25a904f7d22986f329386c5ab6172850925fc7e35d8575f8f0e2f9cdd97dee0d", + "size_mb": 156.94 }, "symspell": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin3-symspell.dict.gz", - "sha256": "a6f57760fca120beb49aef70360ba694914167c8f59594e7f5054747d72064f1", - "gz_sha256": "d3b31a8ba6fa8eb2ea5db2283479f9c56a85d26f4a9d26e345d2a531d715cd45", - "size_mb": 2.54 + "sha256": "de37ec12016f845f0e7619ca1ae3add2fdb265a8bd604a1c58533b70c1ace000", + "gz_sha256": "81b8d0f01d06718faad18d3fcc89c2242e61a6c38ca16dc064220d31391abecb", + "size_mb": 2.55 } } }, @@ -89,18 +89,18 @@ "entity_types": [ "geo.admin4" ], - "size_mb": 379.48999999999995, + "size_mb": 378.98999999999995, "remote_artifacts": { "sqlite": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin4-entities.sqlite.gz", - "sha256": "0ebb77d0859d3b334c499fb5cd4e3e73d581a76c41dc9df9b2c3ab468fc12eca", - "gz_sha256": "b7a07b82691d1ef3432e2020457202737c2701361fd9b8b0c3ef8d745c4d0551", - "size_mb": 374.84 + "sha256": "2c4f59eee7e7ba5c411bc929e134529170378be07dee2f0ea8a5a36ced802bfb", + "gz_sha256": "a8e61c14881f4e346eaf084b05c9af7684c86acde7fa6e31f911fdcbc8931e40", + "size_mb": 374.34 }, "symspell": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin4-symspell.dict.gz", - "sha256": "196e17aa697340a47648ce52eab5edd335e87baefe7f5a95e9e7794da1e0702f", - "gz_sha256": "55156293cf68ad14884242d561699abe4933734a976949367e8e5231d26a6aff", + "sha256": "ed6ea61439dddf62dcbfb00745c2ac5bb55cf897f1f801dc079b767bd36b3e5f", + "gz_sha256": "d97fa637bcf123960c4daeb15b739ec8907256bf1b44d204057036884503b285", "size_mb": 4.65 } } @@ -116,14 +116,14 @@ "remote_artifacts": { "sqlite": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin5-entities.sqlite.gz", - "sha256": "7d769ee1ddff7c8f30becec71e492ee52052f18815b03ac57c5798d3978d4d1f", - "gz_sha256": "32a1123918e1351793d66979ca1342d56c29ce391240f4e4b76e3369e52615c0", + "sha256": "75b1448c6ed841095b2d86321a0b88afbf43e9787421816d3fc65dc149079b52", + "gz_sha256": "19f30e5f24d16bfda76b44be8393e9cbb2af31713c65fb82ea0d90ea53f59427", "size_mb": 1.88 }, "symspell": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-admin5-symspell.dict.gz", - "sha256": "d27bac63c7ccefbb2bd3d26e24c04ee7def450d190662d6b81b130f833662104", - "gz_sha256": "19d9d7f3f3274d510fd880658d0665f9e2d63d044a818ea32d97848b9950e057", + "sha256": "d0f6974c45a47132d72db73deb110eb9383f0a87a1c456fec206ba797817638e", + "gz_sha256": "a8a9342187300ecd825b73c522a01b172f9243e7205aa583a7b732244746b9f8", "size_mb": 0.04 } } @@ -135,19 +135,19 @@ "entity_types": [ "geo.city" ], - "size_mb": 154.22000000000003, + "size_mb": 154.88, "remote_artifacts": { "sqlite": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-cities-entities.sqlite.gz", - "sha256": "f99b2a2fdcc0f4bc44fa024e9a0852a3c013a61cb438825c7e0ce1476586e918", - "gz_sha256": "8819f578d017d5b90437e2e5c093090aef9f3c11572fcb6a7d8a42139b9c9caf", - "size_mb": 151.61 + "sha256": "06e3a01b92d5f7a90f3e926a0779f77ec4bca1517eb39a0f3353840ed466767a", + "gz_sha256": "c7aa08a48bf46b8b603e04a8f96f9afa7578dc6f7f149ff1171d3976c7f7790e", + "size_mb": 152.21 }, "symspell": { "url": "https://github.com/jm-rivera/resolvekit/releases/download/data-v2026.06/geo-cities-symspell.dict.gz", - "sha256": "ff96a9a5d3a7bced178ca74d416e7de85aca931b4337d83f279fe4bb28a4ff5e", - "gz_sha256": "f6fb9fb323bd781d100d67527ab270ecc8d5bd35ea14a45d438d845c023fa759", - "size_mb": 2.61 + "sha256": "bdfbc85e011729e35268d5c799bbc7a5da5fca0b5fb591b069af6ace1975ca32", + "gz_sha256": "a5caf0b166d6b4f294608791ed81d16dbd38234b825034da695d77ba169b39a6", + "size_mb": 2.67 } } }, diff --git a/src/resolvekit/_pandas_integration.py b/src/resolvekit/_pandas_integration.py index 8e55337..161072e 100644 --- a/src/resolvekit/_pandas_integration.py +++ b/src/resolvekit/_pandas_integration.py @@ -11,6 +11,11 @@ from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from resolvekit.core.model import ResolutionContext + _REGISTERED: bool = False @@ -50,6 +55,7 @@ def resolve( *, to: str, domain: str | list[str] | None = None, + context: ResolutionContext | dict | None = None, from_system: str | None = None, not_found: str = "null", on_error: str = "raise", @@ -63,6 +69,13 @@ def resolve( Args: to: Target pivot (e.g. ``"iso3"``, ``"flag"``, ``"name"``). domain: Optional domain filter. + context: Resolution hints, as a ``ResolutionContext`` or a plain ``dict``. + Dict shorthand keys: ``country`` (ISO alpha-2/alpha-3 or a country + name like ``"France"``), ``entity_types``, ``parent_ids``, + ``languages``, ``attributes`` (pack-specific escape hatch), and + ``as_of``. An empty dict is treated as no context. Unknown keys raise + ``UnknownContextKeyError`` listing the valid keys. + Dict values may be a ``pd.Series`` for per-row context. from_system: Force code-system for lookup. not_found: ``"null"`` (default), ``"raise"``, or sentinel string. on_error: ``"raise"`` (default), ``"null"``, or ``"keep"``. @@ -75,9 +88,10 @@ def resolve( Returns: ``pd.Series`` of pivot values, aligned to the input Series. """ - return self.bulk( # type: ignore[return-value] + return self.bulk( # type: ignore[return-value] # ty: ignore[invalid-return-type] to=to, domain=domain, + context=context, from_system=from_system, not_found=not_found, on_error=on_error, @@ -90,6 +104,7 @@ def bulk( to: str | None = None, output: str = "series", domain: str | list[str] | None = None, + context: ResolutionContext | dict | None = None, from_system: str | None = None, not_found: str = "null", on_error: str = "raise", @@ -105,6 +120,13 @@ def bulk( or ``"frame"`` (DataFrame). Forwarded straight through to the underlying dispatch. domain: Optional domain filter. + context: Resolution hints, as a ``ResolutionContext`` or a plain ``dict``. + Dict shorthand keys: ``country`` (ISO alpha-2/alpha-3 or a country + name like ``"France"``), ``entity_types``, ``parent_ids``, + ``languages``, ``attributes`` (pack-specific escape hatch), and + ``as_of``. An empty dict is treated as no context. Unknown keys raise + ``UnknownContextKeyError`` listing the valid keys. + Dict values may be a ``pd.Series`` for per-row context. from_system: Force code-system for lookup. not_found: ``"null"`` (default), ``"raise"``, or sentinel. on_error: ``"raise"`` (default), ``"null"``, or ``"keep"``. @@ -128,7 +150,7 @@ def bulk( to=to, output=output, domain=domain, - context=None, + context=context, from_system=from_system, not_found=not_found, on_error=on_error, diff --git a/src/resolvekit/_polars_integration.py b/src/resolvekit/_polars_integration.py index 07287ae..422e6df 100644 --- a/src/resolvekit/_polars_integration.py +++ b/src/resolvekit/_polars_integration.py @@ -13,6 +13,11 @@ from __future__ import annotations +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from resolvekit.core.model import ResolutionContext + _REGISTERED: bool = False @@ -53,6 +58,7 @@ def resolve( *, to: str, domain: str | list[str] | None = None, + context: ResolutionContext | dict | None = None, from_system: str | None = None, not_found: str = "null", on_error: str = "raise", @@ -67,6 +73,13 @@ def resolve( Args: to: Target pivot (e.g. ``"iso3"``, ``"flag"``, ``"name"``). domain: Optional domain filter. + context: Resolution hints, as a ``ResolutionContext`` or a plain ``dict``. + Dict shorthand keys: ``country`` (ISO alpha-2/alpha-3 or a country + name like ``"France"``), ``entity_types``, ``parent_ids``, + ``languages``, ``attributes`` (pack-specific escape hatch), and + ``as_of``. An empty dict is treated as no context. Unknown keys raise + ``UnknownContextKeyError`` listing the valid keys. + Dict values may be a ``pl.Expr`` or ``pl.Series`` for per-row context. from_system: Force code-system for lookup. not_found: ``"null"`` (default), ``"raise"``, or sentinel. on_error: ``"raise"`` (default), ``"null"``, or ``"keep"``. @@ -81,9 +94,17 @@ def resolve( """ from resolvekit._convenience import _get_default from resolvekit.core.api._pivot import validate_scalar_pivot - from resolvekit.core.api.bulk import _bulk_dispatch - from resolvekit.core.api.loading.paths import _normalize_domain - from resolvekit.core.errors import UnknownDomainError + from resolvekit.core.api.bulk import ( + _bulk_dispatch, + _is_per_row_value, + _validate_domain_available, + ) + from resolvekit.core.api.context_input import ( + _VALID_CONTEXT_KEYS, + ) + from resolvekit.core.errors import ( + UnknownContextKeyError, + ) # Validate on_error / on_ambiguous eagerly — these are caller mistakes # that must raise directly, not be swallowed inside map_batches. @@ -106,23 +127,98 @@ def resolve( # Validate domain eagerly so UnknownDomainError propagates directly # rather than being mangled by polars's map_batches exception reconstruction. - if domain is not None: - norm_domain = _normalize_domain(domain) - if norm_domain is not None: - available = resolver._runner.available_packs - if available: - unknown = sorted(norm_domain - available) - if unknown: - raise UnknownDomainError(unknown, sorted(available)) - - def _apply(series: pl.Series) -> pl.Series: + _validate_domain_available(domain, resolver) + + # Eager context validation: unknown-key check and scalar country-name + # coercion run before map_batches so errors surface with a clean traceback. + # Per-row (Expr/Series) values are deferred — only their keys are checked here. + _has_per_row_ctx = False + _per_row_ctx_keys: list[str] = [] # keys with Expr/Series values + _scalar_ctx: dict[str, object] = {} # scalar context entries + + if isinstance(context, dict) and context: + from typing import cast + + ctx_as_dict: dict[str, Any] = cast("dict[str, Any]", context) + unknown = sorted(set(ctx_as_dict) - _VALID_CONTEXT_KEYS) + if unknown: + from resolvekit.core.api.context_input import ( + _VALID_CONTEXT_KEYS_SORTED, + ) + + raise UnknownContextKeyError(unknown, _VALID_CONTEXT_KEYS_SORTED) + for k, v in ctx_as_dict.items(): + if isinstance(v, (pl.Expr, pl.Series)) or _is_per_row_value(v): + _has_per_row_ctx = True + _per_row_ctx_keys.append(k) + else: + _scalar_ctx[k] = v + + if not _has_per_row_ctx: + # Uniform context — simple closure, single map_batches. + def _apply(series: pl.Series) -> pl.Series: + result = _bulk_dispatch( + resolver=resolver, + values=series, + to=to, + output="series", + domain=domain, + context=context, + from_system=from_system, + not_found=not_found, + on_error=on_error, + on_ambiguous=on_ambiguous, + ) + vals = ( + result.to_list() + if isinstance(result, pl.Series) + else list(result) + ) + return pl.Series(values=vals, dtype=pl.String) + + return self._expr.map_batches(_apply, return_dtype=pl.String) + + # Per-row context path: pack value + per-row context columns into a + # single struct so map_batches receives them aligned in one Series. + # ctx_as_dict is always set here because _has_per_row_ctx is True. + ctx_dict: dict[str, Any] = ctx_as_dict # type: ignore[possibly-undefined] + + # Build a list of (field_name, Expr) for each per-row context key. + ctx_exprs: list[pl.Expr] = [] + ctx_field_names: list[str] = [] + for k in _per_row_ctx_keys: + v = ctx_dict[k] + if isinstance(v, pl.Expr): + ctx_exprs.append(v.alias(f"__ctx_{k}")) + elif isinstance(v, pl.Series): + ctx_exprs.append(pl.lit(v).alias(f"__ctx_{k}")) + else: + # list / np.ndarray — convert to Series first + ctx_exprs.append( + pl.lit(pl.Series(values=list(v))).alias(f"__ctx_{k}") + ) + ctx_field_names.append(k) + + # Pack [value_col, ctx_col_0, ..., ctx_col_N] into a struct. + struct_expr = pl.struct([self._expr.alias("__value__"), *ctx_exprs]) + + def _apply_struct(struct_series: pl.Series) -> pl.Series: + # Unpack struct fields back into individual Series. + rows = struct_series.to_list() # list[dict] + value_list = [r["__value__"] for r in rows] + + # Build a per-row context dict where per-row keys map to lists. + per_row_ctx: dict[str, object] = dict(_scalar_ctx) + for k in ctx_field_names: + per_row_ctx[k] = [r[f"__ctx_{k}"] for r in rows] + result = _bulk_dispatch( resolver=resolver, - values=series, + values=value_list, # pass list directly; avoids a pl.Series → .to_list() round-trip in _flatten_input to=to, output="series", domain=domain, - context=None, + context=per_row_ctx, from_system=from_system, not_found=not_found, on_error=on_error, @@ -133,6 +229,6 @@ def _apply(series: pl.Series) -> pl.Series: ) return pl.Series(values=vals, dtype=pl.String) - return self._expr.map_batches(_apply, return_dtype=pl.String) + return struct_expr.map_batches(_apply_struct, return_dtype=pl.String) _REGISTERED = True diff --git a/src/resolvekit/builder/data/formal_names.yaml b/src/resolvekit/builder/data/formal_names.yaml index 03b5796..1b02722 100644 --- a/src/resolvekit/builder/data/formal_names.yaml +++ b/src/resolvekit/builder/data/formal_names.yaml @@ -27,6 +27,8 @@ overrides: - Swiss Confederation VEN: - Bolivarian Republic of Venezuela + # World Bank / IMF-style comma alias + - Venezuela, RB ETH: - Federal Democratic Republic of Ethiopia NPL: @@ -42,6 +44,8 @@ overrides: MMR: - Republic of the Union of Myanmar - Burma + # World Bank / IMF-style comma alias (WB uses "Myanmar" with no suffix, but some datasets use this form) + - Myanmar, Republic of the Union of COD: - Democratic Republic of the Congo - Democratic Republic of Congo @@ -52,6 +56,9 @@ overrides: - Congo # Historical name for DRC (source: Wikidata CC0 skos:altLabel) - Zaire + # World Bank / IMF-style comma aliases + - Congo, Dem. Rep. + - Congo, Democratic Republic of the COG: - Republic of the Congo - Republic of Congo @@ -71,8 +78,13 @@ overrides: PRK: - Democratic People's Republic of Korea - DPRK + # World Bank / IMF-style comma alias + - Korea, Dem. People's Rep. + - Korea, North KOR: - Republic of Korea + # World Bank / IMF-style comma alias + - Korea, Rep. TZA: - United Republic of Tanzania MKD: @@ -130,6 +142,8 @@ overrides: - Commonwealth of Dominica IRN: - Islamic Republic of Iran + # World Bank / IMF-style comma alias + - Iran, Islamic Republic of PAK: - Islamic Republic of Pakistan MRT: @@ -156,6 +170,8 @@ overrides: BOL: - Republic of Bolivia - Plurinational State of Bolivia + # World Bank / IMF-style comma alias + - Bolivia, Plurinational State of LBN: - Republic of Lebanon - Lebanese Republic @@ -168,6 +184,8 @@ overrides: - Islamic Republic of The Gambia EGY: - Arab Republic of Egypt + # World Bank / IMF-style comma alias + - Egypt, Arab Rep. JAM: - Commonwealth of Jamaica URY: @@ -349,6 +367,8 @@ overrides: - Republic of Vanuatu YEM: - Republic of Yemen + # World Bank / IMF-style comma alias + - Yemen, Republic of ZMB: - Republic of Zambia TON: @@ -419,6 +439,8 @@ overrides: # Historical names (source: Wikidata CC0 skos:altLabel) - Formosa - Republic of China + # World Bank / IMF-style comma alias + - China, Taiwan Province of BFA: - Republic of Burkina Faso # Historical name (source: Wikidata CC0 skos:altLabel) diff --git a/src/resolvekit/builder/pipeline/stages.py b/src/resolvekit/builder/pipeline/stages.py index 2d0bebf..a09c891 100644 --- a/src/resolvekit/builder/pipeline/stages.py +++ b/src/resolvekit/builder/pipeline/stages.py @@ -538,6 +538,12 @@ def _external_shipped_ids( db_path = pack_dir / "entities.sqlite" if not db_path.exists(): continue - with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) as conn: - ids.update(row[0] for row in conn.execute("SELECT entity_id FROM entities")) + try: + with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) as conn: + ids.update(row[0] for row in conn.execute("SELECT entity_id FROM entities")) + except sqlite3.OperationalError: + # File exists but has no entities table (e.g. 0-byte placeholder + # written by a prior interrupted build). Skip it — this pack has + # no shipped ids to preserve. + pass return ids diff --git a/src/resolvekit/builder/sources/datacommons/geo/fetch.py b/src/resolvekit/builder/sources/datacommons/geo/fetch.py index 94f2977..96026ab 100644 --- a/src/resolvekit/builder/sources/datacommons/geo/fetch.py +++ b/src/resolvekit/builder/sources/datacommons/geo/fetch.py @@ -65,6 +65,7 @@ def fetch_raw_chunk( codes_by_entity=codes, foreign_names_by_entity=_foreign_names_from_aliases(aliases), cache_dir=wikidata_cache_dir, + batch_size=200, ) aliases = merge_alias_rows(aliases, en_aliases) diff --git a/src/resolvekit/builder/sources/wikidata/aliases.py b/src/resolvekit/builder/sources/wikidata/aliases.py index 2215087..5b7b403 100644 --- a/src/resolvekit/builder/sources/wikidata/aliases.py +++ b/src/resolvekit/builder/sources/wikidata/aliases.py @@ -147,23 +147,39 @@ def _fetch_batched( canonical = [q.upper() for q in qids] out: list[dict[str, Any]] = [] + n_batches = 0 + n_empty_batches = 0 for i in range(0, len(canonical), batch_size): batch = canonical[i : i + batch_size] values = " ".join(f"wd:{q}" for q in batch) query = _ALIASES_TEMPLATE.format(values=values) bindings = sparql_request(query=query, user_agent=user_agent, timeout=60) + n_batches += 1 if not bindings: - raise RuntimeError( - f"Wikidata alias fetch: batch {i // batch_size + 1} " - f"({len(batch)} QIDs) returned no bindings — a partial failure " - "would silently drop aliases, so fail loud instead." + # Empty response after internal retries: either no English altLabels exist + # or WDQS returned empty. Treat as "no aliases" for this batch. + logger.warning( + "Wikidata alias fetch: batch %d (%d QIDs) returned no bindings", + i // batch_size + 1, + len(batch), ) + n_empty_batches += 1 + continue out.extend(bindings) if request_delay > 0 and i + batch_size < len(canonical): time.sleep(request_delay) + # Multi-batch fetch with all-empty response: raise loud so the chunk is retried. + # A single empty batch plausibly means "no aliases"; every batch returning empty + # is more consistent with a WDQS outage than with every entity being alias-less. + if n_empty_batches == n_batches and n_batches > 1: + raise RuntimeError( + f"Wikidata alias fetch: all {n_batches} batches returned no bindings " + "— likely a WDQS outage rather than genuinely alias-less entities." + ) + logger.info("Wikidata alias fetch: %d bindings for %d QIDs", len(out), len(qids)) return out diff --git a/src/resolvekit/core/api/batch.py b/src/resolvekit/core/api/batch.py index 9ae247c..a2c06c0 100644 --- a/src/resolvekit/core/api/batch.py +++ b/src/resolvekit/core/api/batch.py @@ -103,10 +103,13 @@ def resolve_many_internal( # One weakref reused across all results in the batch — avoids per-result allocation. _self_ref: weakref.ref[Explainer] = explainer_ref_factory() - dedup_cache: dict[tuple[str, int], ResolutionResult] = {} + dedup_cache: dict[tuple[str, tuple], ResolutionResult] = {} # type: ignore[type-arg] results: list[ResolutionResult] = [] for text, ctx in zip(texts, contexts, strict=True): - cache_key = (text, id(ctx)) if isinstance(text, str) else None + ctx_part: tuple = () if ctx is None else ctx._cache_key() # type: ignore[union-attr] + cache_key: tuple[str, tuple] | None = ( + (text, ctx_part) if isinstance(text, str) else None + ) # type: ignore[type-arg] if cache_key is not None and cache_key in dedup_cache: results.append(dedup_cache[cache_key]) continue diff --git a/src/resolvekit/core/api/bulk.py b/src/resolvekit/core/api/bulk.py index 8a56054..990c7bb 100644 --- a/src/resolvekit/core/api/bulk.py +++ b/src/resolvekit/core/api/bulk.py @@ -39,17 +39,30 @@ from resolvekit.core.model.entity_attributes import dispatch_pivot from resolvekit.core.model.result import ReasonCode -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - def _closest_match(value: str, choices: tuple[str, ...]) -> str | None: """Return the closest match to *value* from *choices*, or None.""" matches = difflib.get_close_matches(value, choices, n=1, cutoff=0.6) return matches[0] if matches else None +def _validate_domain_available(domain: str | list[str] | None, resolver: Any) -> None: + """Raise ``UnknownDomainError`` when *domain* names a pack the resolver lacks. + + No-op when *domain* is None or the resolver reports no available packs. + """ + from resolvekit.core.api.loading import _normalize_domain + + norm_domain = _normalize_domain(domain) + if norm_domain is None: + return + available = resolver._runner.available_packs + if not available: + return + unknown = sorted(norm_domain - available) + if unknown: + raise UnknownDomainError(unknown, sorted(available)) + + def _numeric_to_str(v: int | float) -> str: """Coerce an ``int`` or ``float`` to its canonical string form. @@ -79,21 +92,12 @@ def _coerce_item_to_str(v: object) -> str: return str(v) -# --------------------------------------------------------------------------- -# Module-level sentinels for the crosswalk short-circuit -# --------------------------------------------------------------------------- - -# _IGNORE_RESULT: placed in unique_results[i] for IGNORE entries so that -# _assemble_output can bypass _apply_not_found unconditionally. +# Sentinel for crosswalk IGNORE entries — bypasses _apply_not_found. _IGNORE_RESULT: ResolutionResult = ResolutionResult( status=ResolutionStatus.NO_MATCH, reasons=(ReasonCode.SENTINEL_BLOCKED,), ) -# --------------------------------------------------------------------------- -# Input-kind detection -# --------------------------------------------------------------------------- - _InputKind = Literal["pandas", "polars", "numpy", "list", "tuple", "dict"] @@ -173,11 +177,6 @@ def _detect_input_kind(values: Any) -> tuple[_InputKind, Any]: raise TypeError(_base_msg + (f"; {_df_hint}" if _df_hint else "")) -# --------------------------------------------------------------------------- -# Dedup helpers -# --------------------------------------------------------------------------- - - def _dedup_list( items: list[str | None], ) -> tuple[list[str], list[int | None]]: @@ -197,9 +196,156 @@ def _dedup_list( return uniques, indexer -# --------------------------------------------------------------------------- -# Null handling helpers -# --------------------------------------------------------------------------- +def _dedup_pairs( + items: list[str | None], + contexts: list[Any], +) -> tuple[list[tuple[str, Any]], list[int | None]]: + """Return (unique_pairs, indexer) for per-row context resolution. + + ``unique_pairs`` is a list of ``(text, context)`` tuples for distinct + non-null ``(text, context._cache_key())`` pairs. ``indexer[i]`` is the + position of row ``i`` in ``unique_pairs``, or ``None`` when ``items[i]`` + is null. + + Uses ``context._cache_key()`` for identity so that structurally-equal + ``ResolutionContext`` objects deduplicate — the same guarantee provided by + ``_QueryCache`` and ``BatchResolver``. + """ + # Compute (value, ctx_key, ctx) once per row — avoids calling ctx._cache_key() twice. + row_keys: list[tuple[str | None, tuple, Any]] = [ + (v, (() if ctx is None else ctx._cache_key()), ctx) + for v, ctx in zip(items, contexts, strict=True) + ] + seen: dict[tuple, int] = {} + unique_pairs: list[tuple[str, Any]] = [] + for v, ctx_key, ctx in row_keys: + if v is None: + continue + pair_key = (v, ctx_key) + if pair_key not in seen: + seen[pair_key] = len(unique_pairs) + unique_pairs.append((v, ctx)) + indexer: list[int | None] = [ + None if v is None else seen[(v, ctx_key)] + for v, ctx_key, _ in row_keys + ] + return unique_pairs, indexer + + +def _is_per_row_value(v: Any) -> bool: + """Return True when *v* is a Series, list, or numpy array (per-row context value).""" + if isinstance(v, list): + return True + try: + import pandas as pd + + if isinstance(v, pd.Series): + return True + except ImportError: + pass + try: + import polars as pl + + if isinstance(v, (pl.Series, pl.Expr)): + return True + except ImportError: + pass + try: + import numpy as np + + if isinstance(v, np.ndarray): + return True + except ImportError: + pass + return False + + +def _context_has_per_row_value(context: Any) -> bool: + """Return True when *context* is a dict carrying any per-row (Series/list/array) value. + + Such a context bypasses eager coercion and the uniform-context dedup path, + routing through the per-row ``_dedup_pairs`` machinery instead. + """ + return isinstance(context, dict) and any( + _is_per_row_value(v) for v in context.values() + ) + + +def _extract_scalar_list(v: Any, n: int, key: str) -> list[Any]: + """Extract a Python list of length *n* from a per-row context value *v*. + + Raises ``ValueError`` when the length differs from *n*. + """ + values = _to_plain_list(v) + if len(values) != n: + raise ValueError(f"context[{key!r}] length {len(values)} != {n}") + return values + + +def _to_plain_list(v: Any) -> list[Any]: + """Materialize a per-row context value (list, Series, or ndarray) to a list.""" + if isinstance(v, list): + return v + try: + import pandas as pd + + if isinstance(v, pd.Series): + return v.tolist() + except ImportError: + pass + try: + import polars as pl + + if isinstance(v, pl.Series): + return v.to_list() + except ImportError: + pass + try: + import numpy as np + + if isinstance(v, np.ndarray): + return v.tolist() + except ImportError: + pass + return list(v) + + +def _expand_per_row_contexts( + context_dict: dict[str, Any], + n: int, + *, + resolver: Any, +) -> list[Any]: + """Expand a per-row context dict into a list of *n* ResolutionContext objects. + + Validates lengths, broadcasts scalars to length-*n*, then deduplicates by + frozenset signature before construction — coercing each unique signature + once rather than once per row. + + Country-name coercion happens inside ``coerce_context`` — once per unique + value, not per row. Returns a ``list[ResolutionContext | None]`` of length *n*. + """ + from resolvekit.core.api.context_input import coerce_context + + # Validate lengths and convert per-row values to column lists. + columns: dict[str, list[Any]] = {} + for key, val in context_dict.items(): + if _is_per_row_value(val): + columns[key] = _extract_scalar_list(val, n, key) + else: + columns[key] = [val] * n + + # Dedup-before-construct — group rows by frozenset signature. + # Signature uses scalar values only; per-row country coercion handled in coerce_context. + sig_to_ctx: dict[frozenset, Any] = {} + result: list[Any] = [] + for i in range(n): + row = {key: col[i] for key, col in columns.items()} + sig: frozenset = frozenset((k, str(v)) for k, v in row.items()) + if sig not in sig_to_ctx: + sig_to_ctx[sig] = coerce_context(row, resolver=resolver) + result.append(sig_to_ctx[sig]) + return result def _apply_not_found( @@ -244,11 +390,6 @@ def _apply_not_found( return not_found # literal sentinel string -# --------------------------------------------------------------------------- -# Pivot helper -# --------------------------------------------------------------------------- - - def _pivot_result( result: ResolutionResult, to: Any, @@ -284,11 +425,6 @@ def _pivot_result( return None -# --------------------------------------------------------------------------- -# Flatten helper -# --------------------------------------------------------------------------- - - def _flatten_input( kind: _InputKind, raw: Any, @@ -338,11 +474,6 @@ def _flatten_input( return items, orig_index, orig_name, orig_polars_name, orig_keys -# --------------------------------------------------------------------------- -# Resolve-uniques helper -# --------------------------------------------------------------------------- - - def _resolve_uniques( *, resolver: Any, @@ -357,17 +488,14 @@ def _resolve_uniques( """Resolve each unique value and return one ``ResolutionResult`` per unique. When *crosswalk* is provided, values present in it skip name resolution - entirely. For a mapped value the synthetic RESOLVED result (or the - ``_IGNORE_RESULT`` sentinel for IGNORE entries) is placed directly in - ``unique_results`` so the broadcast in ``_assemble_output`` carries it - correctly without a second write. Unknown entity-ids under ``strict=True`` - raise ``CrosswalkError`` before the remainder is resolved. - - Uses the code-input short-circuit when all *to-resolve* uniques look like - codes or ``from_system`` is set; otherwise dispatches to - ``_resolve_many_internal``. + entirely. Synthetic RESOLVED results (or ``_IGNORE_RESULT`` for IGNORE entries) + are placed directly in ``unique_results`` for broadcast by ``_assemble_output``. + Unknown entity-ids under ``strict=True`` raise ``CrosswalkError`` before + the remainder is resolved. + + Uses the code-input short-circuit when all uniques look like codes or + ``from_system`` is set; otherwise dispatches to ``_resolve_many_internal``. """ - # --- crosswalk pre-filter ------------------------------------------------- unique_results: list[ResolutionResult | None] = [None] * len(uniques) to_resolve_idx: list[int] = [] offenders: list[str] = [] @@ -375,15 +503,12 @@ def _resolve_uniques( for i, u in enumerate(uniques): hit = crosswalk._get(u) if crosswalk is not None else _MISSING if hit is _MISSING: - # Not in the crosswalk — resolve normally. to_resolve_idx.append(i) continue if hit is None: - # IGNORE entry — place the sentinel; never reaches _apply_not_found. unique_results[i] = _IGNORE_RESULT continue - # Crosswalk hit: apply-time existence check via the runner (one read per unique). - # hit is str here (not _MISSING, not None) — ty doesn't narrow through `is`. + # Crosswalk hit: verify entity exists via the runner. eid: str = hit # ty: ignore[invalid-assignment] # type: ignore[assignment] entity = resolver._runner.get_entity(eid) # type: ignore[union-attr] if entity is None: @@ -407,12 +532,10 @@ def _resolve_uniques( if crosswalk is not None and crosswalk.strict and offenders: raise CrosswalkError(offenders) - # --- resolve the non-crosswalked subset ----------------------------------- to_resolve = [uniques[i] for i in to_resolve_idx] - # use_code_path detection runs over the to-resolve subset only — never - # the full uniques list, so a fully-crosswalked batch doesn't mis-route the - # empty remainder through the code path. + # Code-path detection runs over the to-resolve subset only (never the full + # uniques list) so a fully-crosswalked batch routes the empty remainder correctly. use_code_path = from_system is not None or ( bool(to_resolve) and all(_looks_like_code(u) for u in to_resolve) ) @@ -449,16 +572,7 @@ def _resolve_uniques( elif to_resolve: # Batch resolve via resolve_many (with include_entity when pivoting). try: - from resolvekit.core.api.loading import _normalize_domain - - norm_domain = _normalize_domain(domain) - if norm_domain is not None: - available = resolver._runner.available_packs - if available: - unknown = sorted(norm_domain - available) - if unknown: - raise UnknownDomainError(unknown, sorted(available)) - + _validate_domain_available(domain, resolver) raw_results = resolver._resolve_many_internal( to_resolve, domain=domain, @@ -477,19 +591,13 @@ def _resolve_uniques( else: resolved_subset = [] - # Scatter resolved results back at their original unique indices. + # Scatter resolved results back to their original unique indices. for list_pos, orig_idx in enumerate(to_resolve_idx): unique_results[orig_idx] = resolved_subset[list_pos] - # All slots must be filled now (None would only remain if there's a logic bug). return unique_results # ty: ignore[invalid-return-type] # type: ignore[return-value] -# --------------------------------------------------------------------------- -# Assemble-output helper -# --------------------------------------------------------------------------- - - def _assemble_output( *, items: list[str | None], @@ -504,37 +612,30 @@ def _assemble_output( ) -> tuple[list[Any], list[ResolutionResult]]: """Apply not_found / on_ambiguous contracts, pivot, and broadcast to original order. - Returns ``(out_values, out_source)``. The lazy entity fetch for the - ``on_ambiguous="best"`` promoted path runs inside the per-unique loop — - not the broadcast loop — so each entity is fetched at most once. + Returns ``(out_values, out_source)``. Entity fetches for ``on_ambiguous="best"`` + happen per-unique (not per-broadcast), so each entity is fetched at most once. When ``spec`` is set, pivoting goes through ``apply_output`` (batch-safe: - per-entity misses return None, never raise — unless ``on_missing="raise"`` - in the spec, which propagates on the first miss and aborts the batch). - When ``spec`` is None and ``to`` is not None, the explicit-``to`` path - uses ``dispatch_pivot`` with ``UnknownCodeSystemError`` re-raise. + per-entity misses return None unless ``on_missing="raise"`` in the spec). + Otherwise uses ``dispatch_pivot`` with ``UnknownCodeSystemError`` re-raise. """ - # Shared sentinel for null-input rows — allocated once, reused for all nulls. _null_sentinel = ResolutionResult( status=ResolutionStatus.NO_MATCH, reasons=(ReasonCode.INVALID_QUERY,), ) - # Whether pivoting is active on this call (either explicit to= or spec path). pivoting = spec is not None or to is not None unique_out: list[Any] = [] # pivot result per unique (len == len(uniques)) for i, r in enumerate(unique_results): if r is _IGNORE_RESULT: - # IGNORE entry: unconditional None, bypassing _apply_not_found - # (so not_found="raise" never fires on a crosswalk IGNORE). + # IGNORE entry bypasses _apply_not_found, so not_found="raise" never fires. unique_out.append(None) continue coerced = _apply_not_found(r, uniques[i], not_found, on_ambiguous) if coerced is None or isinstance(coerced, str): unique_out.append(coerced) elif hasattr(coerced, "status"): - # Still a ResolutionResult (RESOLVED or "best" path). # When on_ambiguous="best" promotes an AMBIGUOUS result, the synthetic # RESOLVED result has entity_id set but entity=None. Fetch the entity # lazily so that pivot dispatch works correctly. @@ -549,7 +650,6 @@ def _assemble_output( unique_out.append(coerced) # Broadcast back to original order. - # indexer[i] is None iff items[i] is None, so the null check is sufficient. out_values: list[Any] = [] out_source: list[ResolutionResult] = [] for idx, item in enumerate(items): @@ -557,7 +657,7 @@ def _assemble_output( out_values.append(None) out_source.append(_null_sentinel) else: - ui = indexer[idx] # invariant: item is not None → ui is not None + ui = indexer[idx] if ui is not None: # mypy narrowing out_values.append(unique_out[ui]) out_source.append(unique_results[ui]) @@ -565,11 +665,6 @@ def _assemble_output( return out_values, out_source -# --------------------------------------------------------------------------- -# Core dispatch — invoked by Resolver.bulk() and the convenience layer. -# --------------------------------------------------------------------------- - - def _bulk_dispatch( *, resolver: Any, @@ -641,11 +736,8 @@ def _bulk_dispatch( items, orig_index, orig_name, orig_polars_name, orig_keys = _flatten_input( kind, raw ) - uniques, indexer = _dedup_list(items) - # Effective-pivot detection: - # has_pivot — caller passed an explicit to= (not UNSET, not None) - # spec_active — to= was omitted (UNSET) and an output_spec is provided + # Pivot detection: explicit to= (not UNSET/None) vs. spec path (UNSET + output_spec). has_pivot = to is not _UNSET and to is not None spec_active = to is _UNSET and output_spec is not None @@ -653,9 +745,8 @@ def _bulk_dispatch( include_entity_for_call = has_pivot or spec_active # Build the effective spec for this call. - # On the spec path, honour the per-call on_missing override. The chain is - # already validated; construct a new frozen OutputSpec directly rather than - # re-running compile_output_spec (which would re-validate code systems). + # On the spec path, honour the per-call on_missing override. The chain is + # already validated; construct a new frozen OutputSpec directly. effective_spec: OutputSpec | None = None if spec_active and output_spec is not None: if on_missing != output_spec.on_missing: @@ -666,31 +757,84 @@ def _bulk_dispatch( else: effective_spec = output_spec - unique_results = _resolve_uniques( - resolver=resolver, - uniques=uniques, - from_system=from_system, - domain=domain, - context=context, - include_entity=include_entity_for_call, - on_error=on_error, - crosswalk=crosswalk, - ) - out_values, out_source = _assemble_output( - items=items, - indexer=indexer, - unique_results=unique_results, - uniques=uniques, - to=to if has_pivot else None, - spec=effective_spec, - not_found=not_found, - on_ambiguous=on_ambiguous, - resolver=resolver, - ) + # Per-row context (Series/list values) uses _dedup_pairs instead of _dedup_list. + # Crosswalk is incompatible with per-row context. + _context_is_per_row = _context_has_per_row_value(context) + + if _context_is_per_row: + if crosswalk is not None: + raise ValueError( + "crosswalk= is not supported together with per-row context; " + "remove either crosswalk= or the per-row context column(s)" + ) + # Expand per-row context into a list of ResolutionContext | None per row. + n = len(items) + row_contexts = _expand_per_row_contexts(context, n, resolver=resolver) + + # Deduplicate by (text, ctx._cache_key()) to avoid redundant resolutions. + unique_pairs, pair_indexer = _dedup_pairs(items, row_contexts) + + unique_texts = [p[0] for p in unique_pairs] + unique_ctxs = [p[1] for p in unique_pairs] + + # Resolve the unique (text, context) pairs. + try: + _validate_domain_available(domain, resolver) + raw_results = resolver._resolve_many_internal( + unique_texts, + domain=domain, + context=unique_ctxs, + include_entity=include_entity_for_call, + ) + unique_results: list[ResolutionResult] = list(raw_results) + except Exception: + if on_error == "raise": + raise + _batch_sentinel = ResolutionResult( + status=ResolutionStatus.ERROR, + reasons=(ReasonCode.INTERNAL_ERROR,), + ) + unique_results = [_batch_sentinel] * len(unique_pairs) + + # Re-use _assemble_output with the per-row indexer. + # ``uniques`` here are the unique texts (for not_found messages). + out_values, out_source = _assemble_output( + items=items, + indexer=pair_indexer, + unique_results=unique_results, + uniques=unique_texts, + to=to if has_pivot else None, + spec=effective_spec, + not_found=not_found, + on_ambiguous=on_ambiguous, + resolver=resolver, + ) + else: + # Uniform (scalar or None) context. + uniques, indexer = _dedup_list(items) + + unique_results = _resolve_uniques( + resolver=resolver, + uniques=uniques, + from_system=from_system, + domain=domain, + context=context, + include_entity=include_entity_for_call, + on_error=on_error, + crosswalk=crosswalk, + ) + out_values, out_source = _assemble_output( + items=items, + indexer=indexer, + unique_results=unique_results, + uniques=uniques, + to=to if has_pivot else None, + spec=effective_spec, + not_found=not_found, + on_ambiguous=on_ambiguous, + resolver=resolver, + ) - # --------------------------------------------------------------------------- - # Empty-column warning (spec path only) - # --------------------------------------------------------------------------- # After assembling values, check whether every RESOLVED row came back None. # Warn only when the column is wholly empty among resolved rows — a partial # miss (some resolved rows have a value) is not warned. @@ -711,9 +855,6 @@ def _bulk_dispatch( stacklevel=4, ) - # --------------------------------------------------------------------------- - # Decide return shape - # --------------------------------------------------------------------------- # Return a native series (not BulkResult) when pivoting and output="series". scalar_to = (has_pivot or spec_active) and output == "series" @@ -808,9 +949,7 @@ def _build_records_native( if kind == "polars": import polars as pl - # Let polars infer the struct dtype from the records — passing - # bare `pl.Struct` (the dtype class, not a fully-specified - # `pl.Struct({...})` schema) raises in polars 0.20+. + # Let polars infer the struct dtype from records; bare pl.Struct raises. return pl.Series(name=orig_polars_name or "", values=records) if kind == "numpy": import numpy as np @@ -848,11 +987,6 @@ def _build_frame_native( return records -# --------------------------------------------------------------------------- -# Native shape assembly -# --------------------------------------------------------------------------- - - def _build_native( values: list[Any], kind: _InputKind, diff --git a/src/resolvekit/core/api/cache.py b/src/resolvekit/core/api/cache.py index d3a2a68..f548db1 100644 --- a/src/resolvekit/core/api/cache.py +++ b/src/resolvekit/core/api/cache.py @@ -1,11 +1,11 @@ """Query-level LRU cache for ``Resolver.resolve()``. Thin wrapper over :func:`functools.lru_cache`. Each ``_QueryCache`` instance -owns a per-instance cached lookup keyed by ``(raw_text, id(context), domains)``. -Two structurally-equal but distinct ``ResolutionContext`` objects produce -distinct cache entries — this is intentional. The ``auto()`` use-case (one -long-lived resolver, a small number of reused context objects) benefits from -O(1) key construction without paying for structural comparison. +owns a per-instance cached lookup keyed by ``(raw_text, context_key, domains)`` +where ``context_key`` is ``context._cache_key()`` (a stable content-based +tuple) rather than ``id(context)``. Two structurally-equal ``ResolutionContext`` +objects therefore share the same cache entry — this is required for per-row +bulk context where a fresh instance is constructed per unique row signature. The key uses the *case-preserving* raw text (whitespace-trimmed) rather than the casefolded normalized form because some pack sources (e.g. the geo @@ -75,7 +75,7 @@ def __init__(self, *, maxsize: int) -> None: self._pending: Callable[[], ResolutionResult] | None = None @functools.lru_cache(maxsize=maxsize) - def lookup(_key: tuple[str, int, frozenset[str]]) -> ResolutionResult: + def lookup(_key: tuple) -> ResolutionResult: # type: ignore[type-arg] assert self._pending is not None # set by get_or_call return self._pending() @@ -101,7 +101,8 @@ def get_or_call( """ self._pending = inner domain_key = frozenset(domains) if domains else frozenset() - key = (raw_text, 0 if context is None else id(context), domain_key) + ctx_key: tuple = () if context is None else context._cache_key() # type: ignore[union-attr] # ty: ignore[unresolved-attribute] + key = (raw_text, ctx_key, domain_key) try: return _detach_mutables(self._lookup(key)) finally: diff --git a/src/resolvekit/core/api/context_input.py b/src/resolvekit/core/api/context_input.py new file mode 100644 index 0000000..a0c4985 --- /dev/null +++ b/src/resolvekit/core/api/context_input.py @@ -0,0 +1,159 @@ +"""Context coercion helper for the public resolution API. + +Accepts ``dict | ResolutionContext | None`` at every public surface and +normalises to a validated ``ResolutionContext | None``. Country *names* are +resolved to ISO alpha-2 codes via the caller's resolver so that ``ResolutionContext`` +itself stays a strict ISO-typed value object. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from resolvekit.core.model.query import ResolutionContext + +if TYPE_CHECKING: + from resolvekit.core.api.resolver import Resolver + +# The set of valid dict keys, derived from ResolutionContext field names so it +# stays in sync if the model ever gains or loses a field. +_VALID_CONTEXT_KEYS: frozenset[str] = frozenset(ResolutionContext.model_fields) + +# Sorted list kept for deterministic error messages. +_VALID_CONTEXT_KEYS_SORTED: list[str] = sorted(_VALID_CONTEXT_KEYS) + +# A 2- or 3-letter uppercase alphabetic string is treated as an ISO code and +# passed directly through the ResolutionContext validator without name lookup. +_ISO_CODE_LENGTHS = frozenset({2, 3}) + + +def _looks_like_iso_code(value: str) -> bool: + return ( + isinstance(value, str) and len(value) in _ISO_CODE_LENGTHS and value.isalpha() + ) + + +def _iso2_or_id_tail(entity_id: str, resolver: Resolver) -> str | None: + """Return *entity_id*'s ISO alpha-2 code, falling back to its id tail. + + Looks the entity up to read its ``iso2``; if that's missing, strips the + prefix (``"country/GEO"`` → ``"GEO"``). Returns ``None`` when neither + yields a usable code. + """ + entity = resolver._runner.get_entity(entity_id) + if entity is not None: + iso2 = getattr(entity, "iso2", None) + if iso2: + return iso2 + code = entity_id.split("/", 1)[-1] + return code if _looks_like_iso_code(code) else None + + +def _resolve_country_name(name: str, resolver: Resolver) -> str: + """Resolve a country name to an ISO alpha-2 code via *resolver*'s country tier. + + Uses ``entity_types={'geo.country'}`` to restrict lookup to the country + tier, so "Georgia" resolves to the country GEO, not the US state. + + Raises: + ValueError: When the name is unresolvable or ambiguous (with verbatim + messages from the Canonical specs). + """ + result = resolver.resolve( + name, + to=None, + domain=None, + context=ResolutionContext(entity_types=frozenset({"geo.country"})), + ) + + unresolvable = ValueError( + f"cannot resolve country name {name!r} to an ISO code; " + "pass an ISO alpha-2/alpha-3 code" + ) + + if result.is_resolved: + if result.entity_id is not None: + # Prefer the already-hydrated entity's iso2 to skip a store read. + iso2 = getattr(result.entity, "iso2", None) if result.entity else None + code = iso2 or _iso2_or_id_tail(result.entity_id, resolver) + if code: + return code + raise unresolvable + + if result.is_ambiguous and result.candidates: + codes = [ + _iso2_or_id_tail(c.entity_id, resolver) or c.entity_id.split("/", 1)[-1] + for c in result.candidates[:2] + ] + if len(codes) >= 2: + raise ValueError( + f"cannot resolve country name {name!r} — ambiguous " + f"(did you mean {codes[0]!r} or {codes[1]!r}?); pass an ISO code" + ) + + raise unresolvable + + +def coerce_context( + value: ResolutionContext | dict[str, Any] | None, + *, + resolver: Resolver, +) -> ResolutionContext | None: + """Coerce a dict | ResolutionContext into a validated ResolutionContext. + + Empty dict ≡ None. Unknown keys raise UnknownContextKeyError listing + valid keys. A dict-form ``country`` name (e.g. "France") is resolved to + its ISO alpha-2 via *resolver*'s country tier; an unresolvable or + ambiguous name raises ValueError naming the input. + + Args: + value: The context to coerce. Accepts a ``ResolutionContext``, a + plain ``dict``, or ``None``. + + Dict shorthand keys: ``country`` (ISO alpha-2/alpha-3 or a country + name like ``"France"``), ``entity_types``, ``parent_ids``, + ``languages``, ``attributes`` (pack-specific escape hatch), and + ``as_of``. An empty dict is treated as no context. Unknown keys raise + ``UnknownContextKeyError`` listing the valid keys. + resolver: A live :class:`Resolver` instance used to resolve country + names to ISO codes. Not consulted for pure-key-validation. + + Returns: + A validated :class:`ResolutionContext`, or ``None`` when *value* is + ``None`` or an empty ``dict``. + + Raises: + UnknownContextKeyError: When *value* contains keys not in + ``ResolutionContext.model_fields``. + ValueError: When a dict-form ``country`` value cannot be resolved to + an ISO code (unresolvable or ambiguous name). + """ + from resolvekit.core.errors import UnknownContextKeyError + + if value is None: + return None + if isinstance(value, ResolutionContext): + return value + + # Dict path. + if not isinstance(value, dict): + raise TypeError( + f"context must be a ResolutionContext, dict, or None; " + f"got {type(value).__name__!r}" + ) + + if not value: + return None + + # Key validation — pure dict-key check, no store access needed. + unknown = sorted(set(value) - _VALID_CONTEXT_KEYS) + if unknown: + raise UnknownContextKeyError(unknown, _VALID_CONTEXT_KEYS_SORTED) + + # Country-name coercion — only when the value looks like a name, not a code. + coerced = dict(value) + raw_country = coerced.get("country") + if isinstance(raw_country, str) and not _looks_like_iso_code(raw_country): + coerced["country"] = _resolve_country_name(raw_country, resolver) + + return ResolutionContext.model_validate(coerced) diff --git a/src/resolvekit/core/api/resolver.py b/src/resolvekit/core/api/resolver.py index 469ad60..694659a 100644 --- a/src/resolvekit/core/api/resolver.py +++ b/src/resolvekit/core/api/resolver.py @@ -26,6 +26,7 @@ from resolvekit.core.api.cache import _QueryCache from resolvekit.core.api.code_lookup import CodeLookup from resolvekit.core.api.containment_api import ContainmentAPI +from resolvekit.core.api.context_input import coerce_context from resolvekit.core.api.group_api import GroupAPI from resolvekit.core.api.loading import ( _build_resolver_from_paths, @@ -1636,7 +1637,7 @@ def resolve( to: _Unset = ..., as_result: Literal[True], domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict[str, Any] | None = None, from_system: str | None = None, include_entity: bool = False, timeout: float | None = None, @@ -1649,7 +1650,7 @@ def resolve( to: _Unset = ..., as_result: bool = False, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict[str, Any] | None = None, from_system: str | None = None, include_entity: bool = False, timeout: float | None = None, @@ -1662,7 +1663,7 @@ def resolve( to: None, as_result: bool = False, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict[str, Any] | None = None, from_system: str | None = None, include_entity: bool = False, timeout: float | None = None, @@ -1675,7 +1676,7 @@ def resolve( to: type[EntityRecord], as_result: bool = False, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict[str, Any] | None = None, from_system: str | None = None, include_entity: bool = False, timeout: float | None = None, @@ -1689,7 +1690,7 @@ def resolve( | str, as_result: bool = False, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict[str, Any] | None = None, from_system: str | None = None, include_entity: bool = False, timeout: float | None = None, @@ -1701,7 +1702,7 @@ def resolve( to: "Literal['iso3','iso2','numeric','name','flag','continent','aliases'] | str | type[EntityRecord] | None | _Unset" = UNSET, as_result: bool = False, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict[str, Any] | None = None, from_system: str | None = None, include_entity: bool = False, timeout: float | None = None, @@ -1763,6 +1764,8 @@ def resolve( if self._closed: raise RuntimeError("Resolver has been closed") + context = coerce_context(context, resolver=self) + # as_result + explicit non-None to= is contradictory. if as_result and to is not UNSET and to is not None: raise ValueError("pass either to= or as_result=True, not both") @@ -1875,7 +1878,7 @@ def resolve_id( on_ambiguous: "Literal['raise', 'null', 'best']" = "raise", from_system: str | None = None, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict[str, Any] | None = None, timeout: float | None = None, ) -> str | None: """Resolve text and return entity_id or None. @@ -1944,7 +1947,7 @@ def require_id( text: str, *, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict[str, Any] | None = None, ) -> str: """Resolve text and return entity_id, or raise on failure. @@ -1975,7 +1978,7 @@ def suggest( top_k: int = 10, domain: str | list[str] | None = None, entity_type: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict[str, Any] | None = None, to: str | list[str] | None = None, fuzzy: "Literal['auto', 'always', 'never']" = "auto", timeout: float | None = None, @@ -2010,7 +2013,13 @@ def suggest( :meth:`resolve`. entity_type: Sub-type filter within a domain (e.g. ``"geo.country"``). Accepts a single string or list. - context: Reserved for future caller hints; currently ignored. + context: Resolution hints, as a ``ResolutionContext`` or a plain ``dict``. + Dict shorthand keys: ``country`` (ISO alpha-2/alpha-3 or a country + name like ``"France"``), ``entity_types``, ``parent_ids``, + ``languages``, ``attributes`` (pack-specific escape hatch), and + ``as_of``. An empty dict is treated as no context. Unknown keys raise + ``UnknownContextKeyError`` listing the valid keys. + context is validated for shape but does not yet affect suggest ranking. to: Output code system or name variant for ``display`` (e.g. ``"iso3"``, ``"name"``). Overrides ``default_to`` for this call. ``None`` (default) uses ``canonical_name`` as the @@ -2029,17 +2038,21 @@ def suggest( Raises: RuntimeError: If the resolver has been closed. + UnknownContextKeyError: If *context* is a dict with unrecognised keys. ValueError: If *domain* contains dotted names (use *entity_type* instead) or *to* references an unknown code system. """ if self._closed: raise RuntimeError("Resolver has been closed") + # Key validation only — context is inert for suggest ranking but a + # typo'd key should raise consistently across all surfaces. + coerced_context = coerce_context(context, resolver=self) return self._suggest_flow.suggest( prefix, top_k=top_k, domain=domain, entity_type=entity_type, - context=context, + context=coerced_context, to=to, fuzzy=fuzzy, timeout=timeout, @@ -2079,7 +2092,7 @@ def bulk( on_missing: Any = UNSET, output: "Literal['series', 'record', 'frame']" = "series", domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict[str, Any] | None = None, from_system: str | None = None, not_found: str = "null", on_error: "Literal['raise', 'null', 'keep']" = "raise", @@ -2128,6 +2141,13 @@ def bulk( """ if self._closed: raise RuntimeError("Resolver has been closed") + # Skip eager coercion when the dict contains per-row (Series/list/array) + # values — pydantic would raise on those. _bulk_dispatch → _expand_per_row_contexts + # handles coercion row-by-row via coerce_context internally. + from resolvekit.core.api.bulk import _context_has_per_row_value + + if not _context_has_per_row_value(context): + context = coerce_context(context, resolver=self) return self._bulk_with_spec( values=values, spec=self._output_spec if to is UNSET else None, @@ -2148,7 +2168,7 @@ def parse( text: str, *, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict[str, Any] | None = None, to: str | list[str] | None = None, confidence_threshold: float | None = None, include_nil: bool = False, @@ -2207,6 +2227,7 @@ def parse( """ if self._closed: raise RuntimeError("Resolver has been closed") + context = coerce_context(context, resolver=self) _validate_confidence_threshold(confidence_threshold) self._validate_parse_domain(domain) @@ -2232,7 +2253,7 @@ def parse_bulk( *, values: Any, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict[str, Any] | None = None, to: str | list[str] | None = None, confidence_threshold: float | None = None, include_nil: bool = False, @@ -2286,6 +2307,7 @@ def parse_bulk( """ if self._closed: raise RuntimeError("Resolver has been closed") + context = coerce_context(context, resolver=self) _validate_confidence_threshold(confidence_threshold) self._validate_parse_domain(domain) @@ -2318,7 +2340,7 @@ def snap( max_distance: float = 0.5, to: Any = UNSET, domain: str | list[str] | None = None, - context: ResolutionContext | None = None, + context: ResolutionContext | dict[str, Any] | None = None, ) -> Any: """Return the closest match among *candidates*. @@ -2335,7 +2357,12 @@ def snap( resolver's configured ``default_to`` spec when set. ``None`` (explicit) forces entity_id (pre-spec behavior). domain: Optional domain filter. - context: Optional resolution context. + context: Resolution hints, as a ``ResolutionContext`` or a plain ``dict``. + Dict shorthand keys: ``country`` (ISO alpha-2/alpha-3 or a country + name like ``"France"``), ``entity_types``, ``parent_ids``, + ``languages``, ``attributes`` (pack-specific escape hatch), and + ``as_of``. An empty dict is treated as no context. Unknown keys raise + ``UnknownContextKeyError`` listing the valid keys. Returns: The closest matching candidate, pivoted per the active output path, @@ -2343,6 +2370,7 @@ def snap( """ if self._closed: raise RuntimeError("Resolver has been closed") + context = coerce_context(context, resolver=self) return self._snap_with_spec( query=query, candidates=candidates, @@ -2438,7 +2466,7 @@ def _bulk_with_spec( to: Any = UNSET, output: str = "series", domain: str | list[str] | None = None, - context: "ResolutionContext | None" = None, + context: "ResolutionContext | dict | None" = None, from_system: str | None = None, not_found: str = "null", on_error: str = "raise", diff --git a/src/resolvekit/core/engine/enrichment.py b/src/resolvekit/core/engine/enrichment.py index fb18d4d..a4d00cf 100644 --- a/src/resolvekit/core/engine/enrichment.py +++ b/src/resolvekit/core/engine/enrichment.py @@ -219,6 +219,14 @@ def _enrich_result_candidates( if not result.candidates: return result + # For AMBIGUOUS results, resolve parent names for distinguishable display. + # Guard here so the hot RESOLVED path pays zero extra lookups. + parent_context: dict[str, tuple[str | None, str | None]] = {} + if result.status == ResolutionStatus.AMBIGUOUS: + parent_context = self._resolve_parent_context( + result.candidates[:3], entities + ) + enriched = [] for summary in result.candidates: entity = entities.get(summary.entity_id) @@ -226,6 +234,9 @@ def _enrich_result_candidates( match_tier = summary.match_tier if match_tier is None and candidate is not None: match_tier = derive_candidate_match_tier(candidate) + parent_name, parent_country = parent_context.get( + summary.entity_id, (None, None) + ) enriched.append( summary.model_copy( update={ @@ -237,6 +248,8 @@ def _enrich_result_candidates( else None, "pack_id": summary.pack_id or self._pack_id, "match_tier": match_tier, + "parent_name": parent_name, + "parent_country": parent_country, } ) ) @@ -261,6 +274,76 @@ def rank_of(c: CandidateSummary) -> int: return result.model_copy(update={"candidates": tuple(enriched)}) + def _resolve_parent_context( + self, + summaries: tuple[CandidateSummary, ...], + entities: dict[str, EntityRecord], + ) -> dict[str, tuple[str | None, str | None]]: + """Derive (parent_name, parent_country) for each candidate in *summaries*. + + Only called for AMBIGUOUS results (top-3 candidates), so extra point + reads here are on the cold path. Returns a mapping from entity_id to a + ``(parent_name, parent_country)`` pair; absent entities produce + ``(None, None)``. + """ + result: dict[str, tuple[str | None, str | None]] = {} + store = self._store + for summary in summaries: + entity = entities.get(summary.entity_id) + if entity is None: + result[summary.entity_id] = (None, None) + continue + + # Country: if the entity itself carries an ISO alpha-2 code it IS the + # country-level entity (country records have iso2; cities/regions do not — + # their country affiliation lives in a 'country_code' attribute instead). + # Fall back to the attribute, then to relation traversal below. + # + # Normalize subdivision codes (e.g. "US-VT") to their country prefix + # ("US") — data-derived check only (string shape), no domain strings. + if entity.iso2 is not None: + raw = entity.iso2 + parent_country: str | None = raw.split("-", 1)[0] if "-" in raw else raw + else: + attr_val = entity.attributes.get("country_code") + if isinstance(attr_val, str): + parent_country = ( + attr_val.split("-", 1)[0] if "-" in attr_val else attr_val + ) + else: + parent_country = None + parent_name: str | None = None + + if parent_country is None and store is not None: + # Follow the first contained_in / part_of relation to a country parent. + for relation in entity.relations: + if relation.relation_type not in PARENT_RELATION_TYPES: + continue + parent_entity = store.get_entity(relation.target_id) + if parent_entity is None: + continue + iso2 = parent_entity.iso2 + if iso2 is not None: + raw = iso2 + parent_country = raw.split("-", 1)[0] if "-" in raw else raw + parent_name = parent_entity.canonical_name + break + # Not a country itself; keep looking one more level (city → region → country) + for rel2 in parent_entity.relations: + if rel2.relation_type not in PARENT_RELATION_TYPES: + continue + grandparent = store.get_entity(rel2.target_id) + if grandparent is not None and grandparent.iso2 is not None: + raw = grandparent.iso2 + parent_country = raw.split("-", 1)[0] if "-" in raw else raw + parent_name = parent_entity.canonical_name + break + if parent_country is not None: + break + + result[summary.entity_id] = (parent_name, parent_country) + return result + def _derive_result_match_tier( self, result: ResolutionResult, diff --git a/src/resolvekit/core/engine/suggest_rank.py b/src/resolvekit/core/engine/suggest_rank.py index b5f4f29..6c783d0 100644 --- a/src/resolvekit/core/engine/suggest_rank.py +++ b/src/resolvekit/core/engine/suggest_rank.py @@ -49,6 +49,12 @@ "geo.subregion", "geo.region", "geo.continental_union", + # City and admin2 tiers gain live Wikidata sitelink / DC population + # prominence once the full geo build includes those tiers. The prefix + # is added here so ``ranking_quality`` reports ``"ranked"`` as soon as + # the data ships, without a follow-on code change. + "geo.city", + "geo.admin2", } ) diff --git a/src/resolvekit/core/errors.py b/src/resolvekit/core/errors.py index 92114ed..7c650fc 100644 --- a/src/resolvekit/core/errors.py +++ b/src/resolvekit/core/errors.py @@ -12,6 +12,7 @@ __all__ = [ "ExplainNotAvailableError", "ResolverError", + "UnknownContextKeyError", ] @@ -427,7 +428,7 @@ def _ambiguous_resolution_hint( """ if entity_types_would_disambiguate(candidates): return ( - "use ResolutionContext(entity_types=...) to keep the matching type, " + "use context={'entity_types': {...}} to keep the matching type, " "or inspect .candidates and pass on_ambiguous='best' to take the top match" ) return ( @@ -531,6 +532,38 @@ def __init__(self, unknown: list[str], available: list[str]) -> None: ) +class UnknownContextKeyError(ValueError, ResolverError): + """A context dict contains one or more unrecognised keys. + + Raised when a ``dict`` passed as ``context=`` carries keys that are not + fields of :class:`~resolvekit.core.model.ResolutionContext`. + + Attributes: + unknown: The unrecognised key names. + valid: The full sorted list of valid keys. + """ + + def __init__(self, unknown: list[str], valid: list[str]) -> None: + self.unknown = unknown + self.valid = valid + close_matches = [] + for name in unknown: + close = difflib.get_close_matches(name, valid, n=1, cutoff=0.5) + if close: + close_matches.append(f"'{name}' → did you mean '{close[0]}'?") + unknown_str = ", ".join(f"'{u}'" for u in unknown) + hint = ( + "; ".join(close_matches) + f"; valid keys: {valid}" + if close_matches + else f"valid keys: {valid}" + ) + ResolverError.__init__( + self, + f"unknown context key(s): {unknown_str}", + hint=hint, + ) + + class UnknownCodeSystemError(ValueError, ResolverError): """The requested code system is not available. diff --git a/src/resolvekit/core/explain/result_html.py b/src/resolvekit/core/explain/result_html.py index 8ad132d..5b84927 100644 --- a/src/resolvekit/core/explain/result_html.py +++ b/src/resolvekit/core/explain/result_html.py @@ -75,30 +75,35 @@ def render_refinement_hint( # noqa: PLR0911 ) if entity_type is None: return None - return f"{prefix}context=ResolutionContext(entity_types={{{entity_type!r}}}))" + return f"{prefix}context={{'entity_types': {{{entity_type!r}}}}})" if hint == RefinementHint.COUNTRY: - # Infer ISO-2 from candidate entity_id / pack_id; placeholder when absent. + # Prefer parent_country from candidate summaries (populated for AMBIGUOUS); + # fall back to inferring ISO-2 from candidate entity_id / pack_id. country = next( - ( - m.group(1) - for c in result.candidates - for field in (c.pack_id, c.entity_id) - if field - if (m := _COUNTRY_CODE_RE.search(field.upper())) - ), - None, + (c.parent_country for c in result.candidates if c.parent_country), None ) + if country is None: + country = next( + ( + m.group(1) + for c in result.candidates + for field in (c.pack_id, c.entity_id) + if field + if (m := _COUNTRY_CODE_RE.search(field.upper())) + ), + None, + ) placeholder = country or "" - return f'{prefix}context=ResolutionContext(country="{placeholder}"))' + return f"{prefix}context={{'country': {placeholder!r}}})" if hint == RefinementHint.PARENT_IDS: entity_id = next((c.entity_id for c in result.candidates), None) placeholder = entity_id if entity_id else "" - return f'{prefix}context=ResolutionContext(parent_ids=["{placeholder}"]))' + return f"{prefix}context={{'parent_ids': [{placeholder!r}]}})" if hint == RefinementHint.LANGUAGES: - return f'{prefix}context=ResolutionContext(languages=[""]))' + return f"{prefix}context={{'languages': ['']}})" if hint == RefinementHint.DID_YOU_MEAN: return did_you_mean_lines(result) @@ -152,7 +157,17 @@ def disambiguate_hint(result: ResolutionResult) -> str | None: if disambiguating_type is not None: return ( f"resolvekit.resolve(text={result.query_text!r}, " - f"context=ResolutionContext(entity_types={{{disambiguating_type!r}}}))" + f"context={{'entity_types': {{{disambiguating_type!r}}}}})" + ) + # Same-name / same-type candidates: emit a country hint only when candidates + # span ≥2 distinct countries — if they all share the same country, the hint + # cannot disambiguate and would be useless/misleading. + countries = [c.parent_country for c in result.candidates if c.parent_country is not None] + if len(set(countries)) >= 2: + qt = result.query_text + return ( + f"resolvekit.resolve(text={qt!r}, " + f"context={{'country': {countries[0]!r}}})" ) return None diff --git a/src/resolvekit/core/model/query.py b/src/resolvekit/core/model/query.py index 4f5bcfe..6935041 100644 --- a/src/resolvekit/core/model/query.py +++ b/src/resolvekit/core/model/query.py @@ -111,6 +111,26 @@ def _validate_country(cls, value: Any) -> Any: f" (two or three letters), got {value!r}" ) + def _cache_key(self) -> tuple: # type: ignore[type-arg] + """Return a stable, content-based hashable key for dedup and cache keying. + + Used by ``_QueryCache.get_or_call`` and ``BatchResolver.resolve_many_internal`` + instead of ``id(self)`` so that two structurally-equal ``ResolutionContext`` + objects share the same cache entry — which is required for per-row bulk + context where a fresh instance is constructed for each unique row signature. + + Cost: O(|attributes|) — one ``sorted()`` on the small ``attributes`` dict. + All other fields are scalars or short frozensets already on the model. + """ + return ( + self.as_of, + self.entity_types, + tuple(self.parent_ids) if self.parent_ids is not None else (), + self.country, + tuple(self.languages) if self.languages is not None else (), + tuple(sorted(self.attributes.items())), + ) + def replace(self, **updates: Any) -> "ResolutionContext": """Return a new ResolutionContext with the specified fields replaced. diff --git a/src/resolvekit/core/model/result.py b/src/resolvekit/core/model/result.py index 9e60771..5da009b 100644 --- a/src/resolvekit/core/model/result.py +++ b/src/resolvekit/core/model/result.py @@ -182,6 +182,12 @@ class CandidateSummary(BaseModel): confidence: Calibrated confidence score top_evidence: Key evidence (limited to top 3) key_features: Selected features for transparency (limited set) + parent_name: Human-readable name of the parent entity (e.g. country or + administrative container). Only populated for AMBIGUOUS results where + candidates share the same canonical name. + parent_country: ISO alpha-2 code of the country the entity belongs to. + Only populated for AMBIGUOUS results where candidates share the same + canonical name. """ model_config = ConfigDict(frozen=True) @@ -194,6 +200,8 @@ class CandidateSummary(BaseModel): match_tier: MatchTier | None = Field(default=None) top_evidence: tuple[CandidateEvidenceSummary, ...] = Field(default=(), max_length=3) key_features: dict[str, float | bool | None] = Field(default_factory=dict) + parent_name: str | None = Field(default=None) + parent_country: str | None = Field(default=None) def __repr__(self) -> str: # explicit by design conf = f"{self.confidence:.2f}" if self.confidence is not None else "?" @@ -451,9 +459,23 @@ def __repr__(self) -> str: # explicit by design f"confidence={self.confidence}, pack_id='{self.pack_id}')" ) if self.status == ResolutionStatus.AMBIGUOUS: + lines: list[str] = [] + for c in self.candidates[:3]: + label = c.canonical_name or c.entity_id + parts: list[str] = [label] + if c.parent_name: + parts.append(c.parent_name) + elif c.parent_country: + parts.append(c.parent_country) + description = ", ".join(parts) + conf = f" (conf={c.confidence:.2f})" if c.confidence is not None else "" + lines.append(f" {description}{conf}") hint = disambiguate_hint(self) - if hint is not None: - return f"AMBIGUOUS — try:\n {hint}" + if lines or hint: + header = "AMBIGUOUS — candidates:" + body = "\n".join(lines) if lines else " (no candidate names available)" + tail = f"\n try:\n {hint}" if hint is not None else "" + return f"{header}\n{body}{tail}" n = len(self.candidates) return ( f"ResolutionResult(status='{s}', candidates={n}, " diff --git a/src/resolvekit/errors/__init__.py b/src/resolvekit/errors/__init__.py index 4abd4e8..8911377 100644 --- a/src/resolvekit/errors/__init__.py +++ b/src/resolvekit/errors/__init__.py @@ -32,6 +32,7 @@ RegistryError, ResolutionError, UnknownCodeSystemError, + UnknownContextKeyError, UnknownDomainError, UnknownOutputError, UnsupportedStoreError, @@ -64,6 +65,7 @@ "ResolutionError", "ResolverError", "UnknownCodeSystemError", + "UnknownContextKeyError", "UnknownDomainError", "UnknownOutputError", "UnsupportedStoreError", diff --git a/src/resolvekit/packs/geo/decision.py b/src/resolvekit/packs/geo/decision.py index 42a7e4d..8445665 100644 --- a/src/resolvekit/packs/geo/decision.py +++ b/src/resolvekit/packs/geo/decision.py @@ -3,11 +3,14 @@ from typing import override from resolvekit.core.engine.decision import ThresholdDecisionPolicy +from resolvekit.core.explain import TraceSink from resolvekit.core.model import ( Candidate, MatchTier, + Query, ReasonCode, ResolutionContext, + ResolutionResult, ) @@ -19,12 +22,27 @@ def _hierarchy_rank(candidate: Candidate) -> float | None: # Decision policy defaults DEFAULT_CONFIDENCE_THRESHOLD = 0.7 -DEFAULT_MIN_GAP = 0.1 +# min_gap of 0.07 from Platt calibration fit on full geo-tier mix. +# Calibrated-probability gaps scale with slope ratio ≈ 0.62 from the fit (a=-8.50). +# Empirical validation: South Sudan (country/SSD, fts-match rival of Sudan/SDN) sits +# at calibrated gap ≈0.074 — within this threshold — which produces the expected +# Sudan→country/SDN resolution. +DEFAULT_MIN_GAP = 0.07 DEFAULT_MAX_CANDIDATES = 5 # Strong early accept threshold for exact code matches EXACT_CODE_MIN_SCORE = 0.9 +# Hierarchy rank thresholds (mirrors scoring.py constants) +_CITY_RANK = 0.70 +_ADMIN2_RANK = 0.60 + +# Lower min_gap for city/admin2 ties: with live prominence data, a dominant city +# (prom=1.0) produces a calibrated gap of ≈0.034 vs an obscure same-named peer (prom=0.0). +# Setting this to 0.03 allows such dominant cities to resolve while equal-prominence +# pairs (gap≈0) and near-equal cities (gap≈0.004) stay AMBIGUOUS. +CITY_ADMIN_MIN_GAP = 0.03 + class GeoDecisionPolicy(ThresholdDecisionPolicy): """Decision policy for geographic entity resolution. @@ -37,6 +55,10 @@ class GeoDecisionPolicy(ThresholdDecisionPolicy): - Accept if confidence >= threshold and gap to runner-up >= min_gap - AMBIGUOUS if gap to runner-up < min_gap (e.g., "Springfield") - NO_MATCH if confidence < threshold + + City/admin2 ties use ``city_admin_min_gap`` (lower than ``min_gap``) so that + a genuinely dominant city (high prominence) can resolve over an obscure + same-named peer while equal-prominence pairs still stay AMBIGUOUS. """ def __init__( @@ -45,6 +67,7 @@ def __init__( min_gap: float = DEFAULT_MIN_GAP, max_candidates: int = DEFAULT_MAX_CANDIDATES, exact_code_min_score: float = EXACT_CODE_MIN_SCORE, + city_admin_min_gap: float = CITY_ADMIN_MIN_GAP, ) -> None: super().__init__( confidence_threshold=confidence_threshold, @@ -53,8 +76,41 @@ def __init__( max_candidates=max_candidates, ) self._exact_code_min_score = exact_code_min_score + self._city_admin_min_gap = city_admin_min_gap self._tiebreak_winner_id: str | None = None + @override + def decide( + self, + query: Query, + context: ResolutionContext, + candidates: list[Candidate], + trace: TraceSink, + ) -> ResolutionResult: + """Resolve with a per-tier gap: city/admin2 ties use a lower min_gap. + + When the top candidates are all city or admin2 entities, substitutes + ``city_admin_min_gap`` for the default ``min_gap`` so that a genuinely + dominant city (high prominence) can clear the gap while equal-prominence + pairs still stay AMBIGUOUS. All other cases use the parent logic unchanged. + """ + if candidates and self._all_city_admin(candidates): + original_min_gap = self._min_gap + self._min_gap = self._city_admin_min_gap + try: + return super().decide(query, context, candidates, trace) + finally: + self._min_gap = original_min_gap + return super().decide(query, context, candidates, trace) + + def _all_city_admin(self, candidates: list[Candidate]) -> bool: + """Return True when every candidate is a city or admin2-and-below entity.""" + for c in candidates: + rank = _hierarchy_rank(c) + if rank is None or rank > _CITY_RANK: + return False + return True + @override def _early_accept( self, top: Candidate, all_candidates: list[Candidate] diff --git a/src/resolvekit/packs/geo/scoring.py b/src/resolvekit/packs/geo/scoring.py index 31c3f80..2345ceb 100644 --- a/src/resolvekit/packs/geo/scoring.py +++ b/src/resolvekit/packs/geo/scoring.py @@ -35,7 +35,12 @@ FALLBACK_SCORE = 0.3 CONSTRAINT_FAIL_PENALTY = 0.05 HIERARCHY_TIEBREAK_FACTOR = 0.009 # must be < min inter-tier gap (0.01) -PROMINENCE_TIEBREAK_FACTOR = 0.05 # bounded by inter-tier gap +# Raised from 0.05 to let a dominant city/admin2 (prominence≈1.0) separate from +# an obscure same-named peer when city_min_gap is applied. Max score adjustment +# is ±(PROMINENCE_TIEBREAK_FACTOR / 2) = ±0.06, which clears CITY_ADMIN_MIN_GAP +# (0.06) only when the prominence delta is large (≈1.0). Equal-prominence pairs +# still produce zero net gap and stay AMBIGUOUS. +PROMINENCE_TIEBREAK_FACTOR = 0.12 # Acronym inputs (4-10 all-uppercase chars) route to geo so that group entities # like NATO/ASEAN/OPEC can resolve via the geo pack. The routing boost is diff --git a/src/resolvekit/shared/scoring_base.py b/src/resolvekit/shared/scoring_base.py index bf2a33d..2a40439 100644 --- a/src/resolvekit/shared/scoring_base.py +++ b/src/resolvekit/shared/scoring_base.py @@ -22,10 +22,12 @@ from resolvekit.calibration.models import Calibrator # Shared threshold constants — identical in both geo and org packs. +# min_gap of 0.07 from Platt calibration fit on full geo-tier mix. +# Calibrated-probability gaps scale with slope ratio ≈ 0.62 from the fit (a=-8.50). _HEURISTIC_THRESHOLDS = DecisionThresholds() _MODEL_THRESHOLDS = DecisionThresholds( confidence_threshold=0.70, - min_gap=0.08, + min_gap=0.07, exact_code_min_score=0.75, ) diff --git a/tests/benchmarks/bench_bulk_per_row_context.py b/tests/benchmarks/bench_bulk_per_row_context.py new file mode 100644 index 0000000..dfbaf8a --- /dev/null +++ b/tests/benchmarks/bench_bulk_per_row_context.py @@ -0,0 +1,164 @@ +"""Performance canary for per-row bulk context deduplication. + +Asserts that a frame with N rows but K << N unique (value, context) pairs +triggers <= K underlying _resolve_many_internal calls (i.e., O(unique pairs) +not O(N) rows). + +Run via: + + uv run pytest tests/benchmarks/bench_bulk_per_row_context.py +""" + +from __future__ import annotations + +from typing import Any + +from resolvekit.core.model import ResolutionContext, ResolutionResult, ResolutionStatus +from resolvekit.core.model.result import ReasonCode + +# --------------------------------------------------------------------------- +# Minimal mock resolver for counting resolve calls +# --------------------------------------------------------------------------- + + +def _make_counting_resolver() -> Any: + """Return a minimal resolver that counts calls to _resolve_many_internal.""" + from unittest.mock import MagicMock + + resolver = MagicMock() + resolver._runner.available_packs = frozenset() + + total_texts_resolved: list[int] = [0] + + def _resolve_many(texts, *, domain=None, context=None, include_entity=False): + total_texts_resolved[0] += len(texts) + return [ + ResolutionResult( + status=ResolutionStatus.RESOLVED, + entity_id="country/FRA", + confidence=0.95, + reasons=(ReasonCode.EXACT_NAME_MATCH,), + ) + for _ in texts + ] + + resolver._resolve_many_internal = _resolve_many + resolver._total_texts_resolved = total_texts_resolved + return resolver + + +# --------------------------------------------------------------------------- +# Canary: unique-pair count, not N rows +# --------------------------------------------------------------------------- + + +class TestPerRowContextDeduplicationCanary: + """Throughput canary: O(K unique pairs) resolutions, not O(N rows).""" + + def test_n_rows_k_unique_pairs_resolves_k_times(self) -> None: + """N=50, K=5 unique (value, country) pairs: only 5 texts resolved.""" + from resolvekit.core.api.bulk import _bulk_dispatch + + resolver = _make_counting_resolver() + + countries = ["FR", "DE", "GB", "US", "IT"] + N = 50 # noqa: N806 + K = 5 # noqa: N806 + + # 50 rows with 5 repeating countries — 5 unique (value="Paris", country=X) pairs. + values = ["Paris"] * N + ctx_column = [countries[i % K] for i in range(N)] + + _bulk_dispatch( + resolver=resolver, + values=values, + to=None, + output="series", + domain=None, + context={"country": ctx_column}, + from_system=None, + not_found="null", + on_error="null", + on_ambiguous="null", + ) + + actual = resolver._total_texts_resolved[0] + assert actual <= K, ( + f"Expected at most {K} underlying resolve calls (unique pairs), " + f"but got {actual}. Per-row context is not deduplicating correctly." + ) + + def test_fully_unique_pairs_resolves_all(self) -> None: + """When all (value, context) pairs are distinct, K == N.""" + from resolvekit.core.api.bulk import _bulk_dispatch + + resolver = _make_counting_resolver() + # Use 10 distinct valid 2-letter ISO-style country codes and 10 distinct city names. + N = 10 # noqa: N806 + # 26 letters = enough for 10 distinct 2-letter combos starting from "AA". + import string + + letters = string.ascii_uppercase + countries = [letters[i] + letters[i + 1] for i in range(N)] + cities = [f"City{i}" for i in range(N)] + + _bulk_dispatch( + resolver=resolver, + values=cities, + to=None, + output="series", + domain=None, + context={"country": countries}, + from_system=None, + not_found="null", + on_error="null", + on_ambiguous="null", + ) + + actual = resolver._total_texts_resolved[0] + assert actual == N, ( + f"With {N} unique pairs, expected {N} resolve calls, got {actual}." + ) + + def test_structurally_equal_context_objects_deduplicate(self) -> None: + """Two distinct ResolutionContext instances with equal content share one resolve call.""" + from resolvekit.core.api.bulk import _dedup_pairs + + ctx_a = ResolutionContext(country="FR") + ctx_b = ResolutionContext(country="FR") + + pairs, indexer = _dedup_pairs(["Paris", "Paris"], [ctx_a, ctx_b]) + assert len(pairs) == 1, ( + "Two equal-content ResolutionContext objects must deduplicate to one pair." + ) + assert indexer == [0, 0] + + def test_large_frame_dedup_ratio(self) -> None: + """1000-row frame with 4 unique pairs resolves exactly 4 times.""" + from resolvekit.core.api.bulk import _bulk_dispatch + + resolver = _make_counting_resolver() + N = 1000 # noqa: N806 + K = 4 # noqa: N806 + + cities = ["Paris", "Berlin", "Paris", "Berlin"] * (N // K) + countries = ["FR", "DE", "FR", "DE"] * (N // K) + + _bulk_dispatch( + resolver=resolver, + values=cities, + to=None, + output="series", + domain=None, + context={"country": countries}, + from_system=None, + not_found="null", + on_error="null", + on_ambiguous="null", + ) + + actual = resolver._total_texts_resolved[0] + assert actual <= K, ( + f"1000-row frame with {K} unique pairs: expected ≤{K} resolve calls, " + f"got {actual}." + ) diff --git a/tests/builder/test_reviewer_fixes.py b/tests/builder/test_reviewer_fixes.py index 1d4582e..4d00e6d 100644 --- a/tests/builder/test_reviewer_fixes.py +++ b/tests/builder/test_reviewer_fixes.py @@ -316,6 +316,33 @@ def test_load_release_candidates_prefers_in_memory(tmp_path: Path) -> None: # --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# _external_shipped_ids — 0-byte placeholder guard +# --------------------------------------------------------------------------- + + +def test_external_shipped_ids_tolerates_zero_byte_sqlite(tmp_path: Path) -> None: + """A 0-byte entities.sqlite must not raise; the pack is treated as not-shipped.""" + from resolvekit.builder.pipeline.stages import _external_shipped_ids + + pack_dir = tmp_path / "geo" / "countries" + pack_dir.mkdir(parents=True) + # metadata.json so iter_datapack_dirs picks this directory up as a pack. + (pack_dir / "metadata.json").write_text( + json.dumps({"datapack_id": "geo.countries-v0.0.0", "module_id": "geo.countries"}) + ) + # 0-byte placeholder — no SQLite header, no tables. + (pack_dir / "entities.sqlite").write_bytes(b"") + + result = _external_shipped_ids( + datapacks_root=tmp_path, exclude_module_ids=set() + ) + + assert result == set(), ( + "A 0-byte entities.sqlite should yield an empty id set, not raise" + ) + + def test_resolve_datapacks_resolves_from_disk_with_empty_ledger( tmp_path: Path, ) -> None: diff --git a/tests/building/test_wikidata_aliases.py b/tests/building/test_wikidata_aliases.py index ab2def9..2b628d9 100644 --- a/tests/building/test_wikidata_aliases.py +++ b/tests/building/test_wikidata_aliases.py @@ -283,12 +283,18 @@ def test_precision_filter_applied_per_entity() -> None: # --------------------------------------------------------------------------- -# Fail-loud: network failure (empty return) for non-empty QID list +# Warn-and-continue: empty batch → warning logged, no raise, entity gets no aliases # --------------------------------------------------------------------------- -def test_fail_loud_empty_return_for_nonempty_qid_list() -> None: - """sparql_request returns [] for a non-empty QID list → RuntimeError.""" +def test_empty_batch_logs_warning_and_continues(caplog: pytest.LogCaptureFixture) -> None: + """Empty single-batch response is logged as warning; treated as "no aliases". + + A single empty batch after internal retries is plausibly "no aliases"; multi-batch + all-empty responses trigger RuntimeError (likely WDQS outage). + """ + import logging + codes_by_entity = dict([_codes_entry("Q30", "country/USA")]) with ( @@ -296,16 +302,24 @@ def test_fail_loud_empty_return_for_nonempty_qid_list() -> None: "resolvekit.builder.sources.wikidata.aliases.sparql_request", return_value=[], ), - pytest.raises(RuntimeError, match="no bindings"), + caplog.at_level(logging.WARNING, logger="resolvekit.builder.sources.wikidata.aliases"), ): - fetch_wikidata_en_aliases( + result = fetch_wikidata_en_aliases( codes_by_entity=codes_by_entity, cache_dir=None, ) + # No aliases for the entity — not an error + assert result == {} + assert any("no bindings" in r.message for r in caplog.records) + + +def test_empty_batch_with_cache_dir_logs_warning( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """cache_dir given but no cache file + empty WDQS return → warning, no raise.""" + import logging -def test_fail_loud_with_cache_dir_but_no_cache_file(tmp_path: Path) -> None: - """cache_dir given but no cache file + empty return → RuntimeError.""" codes_by_entity = dict([_codes_entry("Q142", "country/FRA")]) with ( @@ -313,13 +327,46 @@ def test_fail_loud_with_cache_dir_but_no_cache_file(tmp_path: Path) -> None: "resolvekit.builder.sources.wikidata.aliases.sparql_request", return_value=[], ), - pytest.raises(RuntimeError), + caplog.at_level(logging.WARNING, logger="resolvekit.builder.sources.wikidata.aliases"), ): - fetch_wikidata_en_aliases( + result = fetch_wikidata_en_aliases( codes_by_entity=codes_by_entity, cache_dir=tmp_path, ) + assert result == {} + assert any("no bindings" in r.message for r in caplog.records) + + +def test_all_batches_empty_raises() -> None: + """All batches returning empty for a multi-batch fetch → RuntimeError. + + A single empty batch is plausibly "no aliases"; every batch in a multi-batch + chunk returning empty is more consistent with a WDQS outage than with every + entity being alias-less, so we fail loud. + """ + # 3 QIDs with batch_size=1 → 3 batches, all returning [] + codes_by_entity = dict( + [ + _codes_entry("Q1", "country/AA"), + _codes_entry("Q2", "country/BB"), + _codes_entry("Q3", "country/CC"), + ] + ) + + with ( + patch( + "resolvekit.builder.sources.wikidata.aliases.sparql_request", + return_value=[], + ), + pytest.raises(RuntimeError, match="all.*batches.*no bindings"), + ): + fetch_wikidata_en_aliases( + codes_by_entity=codes_by_entity, + cache_dir=None, + batch_size=1, + ) + # --------------------------------------------------------------------------- # Cache: write on first call, read on second without hitting network diff --git a/tests/core/test_context_input.py b/tests/core/test_context_input.py new file mode 100644 index 0000000..4df88c9 --- /dev/null +++ b/tests/core/test_context_input.py @@ -0,0 +1,424 @@ +"""Unit tests for coerce_context (Layer 1 — context channel + coercion). + +Covers: +- dict accepted on resolve/suggest (surface-level smoke) +- {} ≡ None (empty dict treated as no context) +- typo'd key raises UnknownContextKeyError with valid-keys list and close-match hint +- {"country": "France"} resolves to ISO code via the country tier +- {"country": "Georgia"} resolves to the country GEO (not the US state) +- ambiguous country name raises with verbatim message +- unresolvable country name raises with verbatim message +- rk.suggest importable and first positional is prefix +- AmbiguousResolutionError.hint uses dict form (no "ResolutionContext(" substring) +- key validation does not touch the store for a pure key-typo (no resolver needed + beyond passing one — coerce raises before any store read) +""" + +from __future__ import annotations + +import json +import sqlite3 + +import pytest + +from resolvekit.core.api.context_input import ( + _VALID_CONTEXT_KEYS, + _VALID_CONTEXT_KEYS_SORTED, + coerce_context, +) +from resolvekit.core.errors import UnknownContextKeyError +from resolvekit.core.model import CandidateSummary, ResolutionContext + +# --------------------------------------------------------------------------- +# Minimal stub resolver — raises if its store is actually read for key-typo tests +# --------------------------------------------------------------------------- + + +class _StoreReadError(RuntimeError): + """Raised if the stub resolver's store is accessed (should not happen for key-typo).""" + + +class _StubStore: + def get_entity(self, entity_id: str) -> None: + raise _StoreReadError( + "store accessed during key-typo coercion — should not happen" + ) + + +class _StubResolver: + """Minimal resolver stub. The ``_runner`` attribute mimics Resolver._runner.""" + + def __init__(self) -> None: + self._runner = _StubStore() + + def resolve(self, text: str, **kwargs: object) -> object: # type: ignore[override] + raise _StoreReadError( + f"resolver.resolve called during key-typo test with {text!r}" + ) + + +# --------------------------------------------------------------------------- +# _VALID_CONTEXT_KEYS metadata +# --------------------------------------------------------------------------- + + +class TestValidContextKeys: + def test_includes_expected_fields(self) -> None: + expected = { + "as_of", + "entity_types", + "parent_ids", + "country", + "languages", + "attributes", + } + assert expected <= _VALID_CONTEXT_KEYS + + def test_sorted_list_matches_frozenset(self) -> None: + assert sorted(_VALID_CONTEXT_KEYS) == _VALID_CONTEXT_KEYS_SORTED + + +# --------------------------------------------------------------------------- +# coerce_context: None and empty dict +# --------------------------------------------------------------------------- + + +class TestNoneAndEmptyDict: + def test_none_returns_none(self) -> None: + resolver = _StubResolver() # type: ignore[arg-type] + assert coerce_context(None, resolver=resolver) is None # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + + def test_empty_dict_returns_none(self) -> None: + resolver = _StubResolver() # type: ignore[arg-type] + assert coerce_context({}, resolver=resolver) is None # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + + def test_resolution_context_passthrough(self) -> None: + ctx = ResolutionContext(country="FR") + resolver = _StubResolver() # type: ignore[arg-type] + result = coerce_context(ctx, resolver=resolver) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + assert result is ctx + + +# --------------------------------------------------------------------------- +# coerce_context: unknown key raises UnknownContextKeyError without store access +# --------------------------------------------------------------------------- + + +class TestUnknownKeyError: + def test_single_typo_raises_before_store_access(self) -> None: + resolver = _StubResolver() # type: ignore[arg-type] + with pytest.raises(UnknownContextKeyError) as exc_info: + coerce_context({"countyr": "FR"}, resolver=resolver) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + err = exc_info.value + assert "countyr" in str(err) + assert "country" in (err.hint or "") + assert "valid keys" in (err.hint or "") + + def test_valid_keys_list_in_hint(self) -> None: + resolver = _StubResolver() # type: ignore[arg-type] + with pytest.raises(UnknownContextKeyError) as exc_info: + coerce_context({"zzz_unknown": "x"}, resolver=resolver) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + err = exc_info.value + # All valid keys must appear in the hint. + for key in _VALID_CONTEXT_KEYS_SORTED: + assert key in (err.hint or ""), f"{key!r} missing from hint: {err.hint!r}" + + def test_multiple_unknown_keys(self) -> None: + resolver = _StubResolver() # type: ignore[arg-type] + with pytest.raises(UnknownContextKeyError) as exc_info: + coerce_context({"countyr": "FR", "zzz_bad": "x"}, resolver=resolver) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + err = exc_info.value + assert "countyr" in str(err) + assert "zzz_bad" in str(err) + + def test_no_store_access_on_key_typo(self) -> None: + """Key validation must raise before any store/resolve call.""" + resolver = _StubResolver() # type: ignore[arg-type] + # _StoreReadError would propagate if coerce_context reached the store. + with pytest.raises(UnknownContextKeyError): + coerce_context({"countyr": "FR"}, resolver=resolver) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + + +# --------------------------------------------------------------------------- +# UnknownContextKeyError structure +# --------------------------------------------------------------------------- + + +class TestUnknownContextKeyErrorClass: + def test_inherits_from_value_error(self) -> None: + err = UnknownContextKeyError(["countyr"], _VALID_CONTEXT_KEYS_SORTED) + assert isinstance(err, ValueError) + + def test_hint_suggests_close_match(self) -> None: + err = UnknownContextKeyError(["countyr"], _VALID_CONTEXT_KEYS_SORTED) + assert err.hint is not None + assert "country" in err.hint + + def test_hint_without_close_match_lists_valid_keys(self) -> None: + err = UnknownContextKeyError( + ["completely_random_xyz"], _VALID_CONTEXT_KEYS_SORTED + ) + assert err.hint is not None + assert "valid keys" in err.hint + + def test_attributes_preserved(self) -> None: + err = UnknownContextKeyError(["a", "b"], _VALID_CONTEXT_KEYS_SORTED) + assert err.unknown == ["a", "b"] + assert err.valid == _VALID_CONTEXT_KEYS_SORTED + + +# --------------------------------------------------------------------------- +# coerce_context: valid dict keys produce a ResolutionContext +# --------------------------------------------------------------------------- + + +class TestValidDictCoercion: + def test_iso_code_country_passthrough(self) -> None: + resolver = _StubResolver() # type: ignore[arg-type] + result = coerce_context({"country": "FR"}, resolver=resolver) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + assert result is not None + assert result.country == "FR" + + def test_iso3_code_country_passthrough(self) -> None: + resolver = _StubResolver() # type: ignore[arg-type] + result = coerce_context({"country": "FRA"}, resolver=resolver) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + assert result is not None + assert result.country == "FRA" + + def test_entity_types_coerced(self) -> None: + resolver = _StubResolver() # type: ignore[arg-type] + result = coerce_context({"entity_types": {"geo.country"}}, resolver=resolver) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + assert result is not None + assert result.entity_types == frozenset({"geo.country"}) + + def test_mixed_valid_fields(self) -> None: + resolver = _StubResolver() # type: ignore[arg-type] + result = coerce_context( + {"country": "US", "languages": ["en"]}, + resolver=resolver, # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + ) + assert result is not None + assert result.country == "US" + assert result.languages == ["en"] + + +# --------------------------------------------------------------------------- +# AmbiguousResolutionError hint uses dict form +# --------------------------------------------------------------------------- + + +class TestAmbiguousHintDictForm: + def test_cross_type_hint_uses_dict_form(self) -> None: + from resolvekit.core.errors import AmbiguousResolutionError + + candidates = [ + CandidateSummary( + entity_id="city/X", entity_type="geo.city", confidence=0.90 + ), + CandidateSummary( + entity_id="admin1/Y", entity_type="geo.admin1", confidence=0.89 + ), + ] + err = AmbiguousResolutionError(candidates=candidates) + assert err.hint is not None + assert "ResolutionContext(" not in err.hint + assert "context=" in err.hint or "entity_types" in err.hint + + def test_same_type_hint_no_resolution_context(self) -> None: + from resolvekit.core.errors import AmbiguousResolutionError + + candidates = [ + CandidateSummary( + entity_id="country/COD", entity_type="geo.country", confidence=0.92 + ), + CandidateSummary( + entity_id="country/COG", entity_type="geo.country", confidence=0.91 + ), + ] + err = AmbiguousResolutionError(candidates=candidates) + assert err.hint is not None + assert "ResolutionContext(" not in err.hint + + +# --------------------------------------------------------------------------- +# suggest() importable from resolvekit and accepts prefix as first positional +# --------------------------------------------------------------------------- + + +class TestSuggestExport: + def test_suggest_importable_from_resolvekit(self) -> None: + import resolvekit as rk + + assert hasattr(rk, "suggest") + assert callable(rk.suggest) + + def test_suggest_first_param_is_prefix(self) -> None: + import inspect + + import resolvekit as rk + + sig = inspect.signature(rk.suggest) + params = list(sig.parameters) + assert params[0] == "prefix" + + def test_suggest_key_validation_before_store_access(self) -> None: + """suggest() with a bad key raises UnknownContextKeyError without resolving.""" + from resolvekit.core.api.context_input import coerce_context + from resolvekit.core.errors import UnknownContextKeyError + + resolver = _StubResolver() # type: ignore[arg-type] + with pytest.raises(UnknownContextKeyError): + coerce_context({"countyr": "FR"}, resolver=resolver) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + + +# --------------------------------------------------------------------------- +# _resolve_country_name: fixture + tests +# +# Uses a local resolver backed by an in-process SQLite datapack with France, +# Georgia (country GEO), North Korea (KP), and South Korea (KR). +# --------------------------------------------------------------------------- + + +def _build_country_datapack(tmp_path: Path) -> Path: # noqa: F821 + """Build a minimal geo datapack with France, Georgia, and both Koreas.""" + from pathlib import Path + + from resolvekit.core.datapack import NORMALIZER_VERSION + + pack_dir = Path(tmp_path) / "country_name_pack" + pack_dir.mkdir(parents=True, exist_ok=True) + db_path = pack_dir / "entities.sqlite" + conn = sqlite3.connect(str(db_path)) + conn.executescript( + """ + CREATE TABLE entities ( + entity_id TEXT PRIMARY KEY, + entity_type TEXT NOT NULL, + canonical_name TEXT NOT NULL, + canonical_name_norm TEXT NOT NULL, + valid_from TEXT, + valid_until TEXT + ); + CREATE TABLE names ( + entity_id TEXT NOT NULL, + name_kind TEXT NOT NULL, + value TEXT NOT NULL, + value_norm TEXT NOT NULL, + lang TEXT, + is_preferred INTEGER DEFAULT 0 + ); + CREATE TABLE codes ( + entity_id TEXT NOT NULL, + system TEXT NOT NULL, + value TEXT NOT NULL, + value_norm TEXT NOT NULL, + PRIMARY KEY (entity_id, system) + ); + CREATE TABLE relations ( + entity_id TEXT NOT NULL, + relation_type TEXT NOT NULL, + target_id TEXT NOT NULL + ); + CREATE VIRTUAL TABLE names_fts USING fts5(entity_id, value_norm); + + INSERT INTO entities VALUES + ('country/FRA', 'geo.country', 'France', 'france', NULL, NULL), + ('country/GEO', 'geo.country', 'Georgia', 'georgia', NULL, NULL), + ('country/PRK', 'geo.country', 'North Korea', 'north korea', NULL, NULL), + ('country/KOR', 'geo.country', 'South Korea', 'south korea', NULL, NULL); + + INSERT INTO codes VALUES + ('country/FRA', 'iso2', 'FR', 'fr'), + ('country/FRA', 'iso3', 'FRA', 'fra'), + ('country/GEO', 'iso2', 'GE', 'ge'), + ('country/GEO', 'iso3', 'GEO', 'geo'), + ('country/PRK', 'iso2', 'KP', 'kp'), + ('country/PRK', 'iso3', 'PRK', 'prk'), + ('country/KOR', 'iso2', 'KR', 'kr'), + ('country/KOR', 'iso3', 'KOR', 'kor'); + + INSERT INTO names VALUES + ('country/FRA', 'canonical', 'France', 'france', 'en', 1), + ('country/GEO', 'canonical', 'Georgia', 'georgia', 'en', 1), + ('country/PRK', 'canonical', 'North Korea', 'north korea', 'en', 1), + ('country/PRK', 'alias', 'Korea', 'korea', 'en', 0), + ('country/KOR', 'canonical', 'South Korea', 'south korea', 'en', 1), + ('country/KOR', 'alias', 'Korea', 'korea', 'en', 0); + + INSERT INTO names_fts(entity_id, value_norm) VALUES + ('country/FRA', 'france'), + ('country/GEO', 'georgia'), + ('country/PRK', 'north korea'), + ('country/PRK', 'korea'), + ('country/KOR', 'south korea'), + ('country/KOR', 'korea'); + """ + ) + conn.commit() + conn.close() + + (pack_dir / "metadata.json").write_text( + json.dumps( + { + "datapack_id": "country_name_test_v1", + "module_id": "geo.countries", + "domain_pack_id": "geo", + "entity_schema_version": "1.0", + "feature_schema_version": "geo.features.v1", + "normalizer_version": NORMALIZER_VERSION, + "index_versions": {"fts": "fts5", "symspell": None}, + "build_timestamp": "2024-01-15T10:00:00Z", + "source_datasets": ["test-fixture"], + } + ) + ) + return pack_dir + + +@pytest.fixture(scope="module") +def country_name_resolver(tmp_path_factory: pytest.TempPathFactory): + """Resolver backed by a fixture datapack with France, Georgia, and both Koreas.""" + from resolvekit.core.api.resolver import Resolver + + tmp = tmp_path_factory.mktemp("country_name_resolver") + pack_dir = _build_country_datapack(tmp) + return Resolver.from_datapacks(datapack_paths=[pack_dir]) + + +class TestResolveCountryName: + """Tests for _resolve_country_name called via coerce_context.""" + + def test_france_resolves_to_fr(self, country_name_resolver) -> None: + result = coerce_context({"country": "France"}, resolver=country_name_resolver) + assert result is not None + assert result.country == "FR" + + def test_georgia_resolves_to_country_not_us_state( + self, country_name_resolver + ) -> None: + """'Georgia' → country GEO (iso2 GE), not the US state.""" + result = coerce_context({"country": "Georgia"}, resolver=country_name_resolver) + assert result is not None + assert result.country == "GE" + + def test_ambiguous_korea_raises_with_canonical_message( + self, country_name_resolver + ) -> None: + """'Korea' matches both KP and KR → raises with ambiguous message.""" + with pytest.raises(ValueError) as exc_info: + coerce_context({"country": "Korea"}, resolver=country_name_resolver) + msg = str(exc_info.value) + assert "ambiguous" in msg + assert "did you mean" in msg + assert "pass an ISO code" in msg + + def test_unresolvable_name_raises_with_canonical_message( + self, country_name_resolver + ) -> None: + """'Atlantis' cannot be resolved → raises with unresolvable message.""" + with pytest.raises(ValueError) as exc_info: + coerce_context({"country": "Atlantis"}, resolver=country_name_resolver) + msg = str(exc_info.value) + assert "cannot resolve country name" in msg + assert "Atlantis" in msg + assert "ISO" in msg diff --git a/tests/core/test_per_row_context.py b/tests/core/test_per_row_context.py new file mode 100644 index 0000000..726d649 --- /dev/null +++ b/tests/core/test_per_row_context.py @@ -0,0 +1,549 @@ +"""Tests for per-row bulk context (Layer 2). + +Covers: +- Scalar context broadcasts correctly across all rows +- Per-row context dict (list values) disambiguates each row independently +- Mixed scalar + per-row dict works +- Per-row column length != values length raises ValueError +- crosswalk= + per-row context raises ValueError +- _cache_key equal for two structurally-equal ResolutionContext objects + (regression: old id() behavior intentionally changed — content-based keying) +- Per-row context does not thrash: two rows, two contexts, both correct +- _dedup_pairs deduplicates by (text, ctx._cache_key()), not by id() + +Pandas and polars paths are tested for basic per-row disambiguation parity. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from resolvekit.core.api.bulk import _dedup_pairs +from resolvekit.core.api.cache import _QueryCache +from resolvekit.core.model import ResolutionContext, ResolutionResult, ResolutionStatus +from resolvekit.core.model.result import ReasonCode + +# --------------------------------------------------------------------------- +# Helpers / stubs +# --------------------------------------------------------------------------- + + +def _resolved(entity_id: str = "country/FRA") -> ResolutionResult: + return ResolutionResult( + status=ResolutionStatus.RESOLVED, + entity_id=entity_id, + confidence=0.95, + reasons=(ReasonCode.EXACT_NAME_MATCH,), + ) + + +def _no_match() -> ResolutionResult: + return ResolutionResult( + status=ResolutionStatus.NO_MATCH, + reasons=(ReasonCode.NO_CANDIDATES,), + ) + + +# --------------------------------------------------------------------------- +# ResolutionContext._cache_key — content-based identity +# --------------------------------------------------------------------------- + + +class TestCacheKey: + def test_equal_contexts_have_equal_cache_key(self) -> None: + a = ResolutionContext(country="FR") + b = ResolutionContext(country="FR") + assert a._cache_key() == b._cache_key() + + def test_distinct_contexts_have_distinct_cache_key(self) -> None: + a = ResolutionContext(country="FR") + b = ResolutionContext(country="DE") + assert a._cache_key() != b._cache_key() + + def test_none_represented_consistently(self) -> None: + a = ResolutionContext() + b = ResolutionContext() + assert a._cache_key() == b._cache_key() + + def test_entity_types_in_key(self) -> None: + a = ResolutionContext(entity_types=frozenset({"geo.city"})) + b = ResolutionContext(entity_types=frozenset({"geo.city"})) + assert a._cache_key() == b._cache_key() + + def test_attributes_sorted_in_key(self) -> None: + a = ResolutionContext(attributes={"b": 2, "a": 1}) + b = ResolutionContext(attributes={"a": 1, "b": 2}) + assert a._cache_key() == b._cache_key() + + def test_cache_key_is_hashable(self) -> None: + ctx = ResolutionContext(country="FR", entity_types=frozenset({"geo.city"})) + key = ctx._cache_key() + assert hash(key) is not None + _ = {key: "value"} # usable as dict key + + +# --------------------------------------------------------------------------- +# _QueryCache — content-based key replaces id() +# --------------------------------------------------------------------------- + + +class TestQueryCacheContentKey: + def test_equal_contexts_share_cache_entry(self) -> None: + cache = _QueryCache(maxsize=8) + result = _resolved() + ctx_a = ResolutionContext(country="FR") + ctx_b = ResolutionContext(country="FR") + + call_count = 0 + + def _inner() -> ResolutionResult: + nonlocal call_count + call_count += 1 + return result + + cache.get_or_call(raw_text="Paris", context=ctx_a, domains=None, inner=_inner) + cache.get_or_call(raw_text="Paris", context=ctx_b, domains=None, inner=_inner) + # Two structurally-equal contexts must share the cache entry. + assert call_count == 1 + + def test_distinct_contexts_produce_distinct_entries(self) -> None: + cache = _QueryCache(maxsize=8) + ctx_fr = ResolutionContext(country="FR") + ctx_us = ResolutionContext(country="US") + call_count = 0 + + def _inner() -> ResolutionResult: + nonlocal call_count + call_count += 1 + return _resolved() + + cache.get_or_call(raw_text="Paris", context=ctx_fr, domains=None, inner=_inner) + cache.get_or_call(raw_text="Paris", context=ctx_us, domains=None, inner=_inner) + assert call_count == 2 + + def test_none_context_key_is_stable(self) -> None: + cache = _QueryCache(maxsize=8) + call_count = 0 + + def _inner() -> ResolutionResult: + nonlocal call_count + call_count += 1 + return _resolved() + + cache.get_or_call(raw_text="US", context=None, domains=None, inner=_inner) + cache.get_or_call(raw_text="US", context=None, domains=None, inner=_inner) + assert call_count == 1 + + +# --------------------------------------------------------------------------- +# _dedup_pairs +# --------------------------------------------------------------------------- + + +class TestDedupPairs: + def test_identical_pairs_deduplicated(self) -> None: + ctx_fr = ResolutionContext(country="FR") + items = ["Paris", "Paris", "Paris"] + contexts = [ctx_fr, ctx_fr, ctx_fr] + + pairs, indexer = _dedup_pairs(items, contexts) + assert len(pairs) == 1 + assert indexer == [0, 0, 0] + + def test_same_text_different_context_not_deduplicated(self) -> None: + ctx_fr = ResolutionContext(country="FR") + ctx_us = ResolutionContext(country="US") + items = ["Paris", "Paris"] + contexts = [ctx_fr, ctx_us] + + pairs, indexer = _dedup_pairs(items, contexts) + assert len(pairs) == 2 + assert indexer == [0, 1] + + def test_structurally_equal_contexts_deduplicate(self) -> None: + # Two distinct object instances, same content — must share the same pair slot. + ctx_a = ResolutionContext(country="FR") + ctx_b = ResolutionContext(country="FR") + items = ["Paris", "Paris"] + contexts = [ctx_a, ctx_b] + + pairs, indexer = _dedup_pairs(items, contexts) + assert len(pairs) == 1 + assert indexer == [0, 0] + + def test_null_items_produce_none_indexer(self) -> None: + items = [None, "Paris", None] + contexts = [None, ResolutionContext(country="FR"), None] + + pairs, indexer = _dedup_pairs(items, contexts) + assert len(pairs) == 1 + assert indexer[0] is None + assert indexer[1] == 0 + assert indexer[2] is None + + def test_none_context_deduplicates(self) -> None: + items = ["US", "US"] + contexts = [None, None] + + pairs, indexer = _dedup_pairs(items, contexts) + assert len(pairs) == 1 + assert indexer == [0, 0] + + def test_preserves_insertion_order(self) -> None: + ctx_fr = ResolutionContext(country="FR") + ctx_de = ResolutionContext(country="DE") + items = ["Paris", "Berlin", "Paris"] + contexts = [ctx_fr, ctx_de, ctx_fr] + + pairs, indexer = _dedup_pairs(items, contexts) + assert pairs[0] == ("Paris", ctx_fr) + assert pairs[1] == ("Berlin", ctx_de) + assert indexer == [0, 1, 0] + + +# --------------------------------------------------------------------------- +# _bulk_dispatch — per-row context with mocked resolver +# --------------------------------------------------------------------------- + + +def _make_mock_resolver( + results_by_pair: dict[tuple[str, str | None], ResolutionResult] | None = None, +) -> Any: + """Build a minimal mock resolver that records _resolve_many_internal calls.""" + resolver = MagicMock() + resolver._runner.available_packs = frozenset() + call_log: list[tuple[list[str], list[Any]]] = [] + + def _resolve_many(texts, *, domain=None, context=None, include_entity=False): + if context is None: + ctxs = [None] * len(texts) + elif isinstance(context, ResolutionContext): + ctxs = [context] * len(texts) + else: + ctxs = list(context) + + call_log.append((list(texts), list(ctxs))) + + if results_by_pair is None: + return [_resolved() for _ in texts] + + out = [] + for text, ctx in zip(texts, ctxs, strict=True): + country = ctx.country if ctx is not None else None + key = (text, country) + out.append(results_by_pair.get(key, _no_match())) + return out + + resolver._resolve_many_internal = _resolve_many + resolver._call_log = call_log + return resolver + + +class TestBulkDispatchPerRowContext: + """Tests for the per-row context path in _bulk_dispatch.""" + + def test_scalar_context_broadcasts(self) -> None: + from resolvekit.core.api.bulk import _bulk_dispatch + + resolver = _make_mock_resolver() + ctx_fr = ResolutionContext(country="FR") + + result = _bulk_dispatch( + resolver=resolver, + values=["Paris", "Lyon"], + to=None, + output="series", + domain=None, + context=ctx_fr, + from_system=None, + not_found="null", + on_error="null", + on_ambiguous="null", + ) + # Scalar context — existing uniform path, no per-row expansion. + assert len(result.values) == 2 + + def test_per_row_list_context_disambiguates(self) -> None: + from resolvekit.core.api.bulk import _bulk_dispatch + + results_by_pair = { + ("Paris", "FR"): _resolved("city/PAR_FR"), + ("Paris", "US"): _resolved("city/PAR_TX"), + } + resolver = _make_mock_resolver(results_by_pair) + + result = _bulk_dispatch( + resolver=resolver, + values=["Paris", "Paris"], + to=None, + output="series", + domain=None, + context={"country": ["FR", "US"]}, + from_system=None, + not_found="null", + on_error="null", + on_ambiguous="null", + ) + values = result.values + assert values[0].entity_id == "city/PAR_FR" + assert values[1].entity_id == "city/PAR_TX" + + def test_per_row_context_deduplicates_resolve_calls(self) -> None: + """A frame with N rows but K unique pairs triggers ≤ K underlying resolve calls.""" + from resolvekit.core.api.bulk import _bulk_dispatch + + resolver = _make_mock_resolver() + # 4 rows, only 2 unique (text, country) pairs. + _bulk_dispatch( + resolver=resolver, + values=["Paris", "Paris", "Paris", "Paris"], + to=None, + output="series", + domain=None, + context={"country": ["FR", "US", "FR", "US"]}, + from_system=None, + not_found="null", + on_error="null", + on_ambiguous="null", + ) + assert len(resolver._call_log) == 1 + call_texts, _call_ctxs = resolver._call_log[0] + assert len(call_texts) == 2 + + def test_per_row_context_length_mismatch_raises(self) -> None: + from resolvekit.core.api.bulk import _bulk_dispatch + + resolver = _make_mock_resolver() + with pytest.raises(ValueError, match="context\\['country'\\] length"): + _bulk_dispatch( + resolver=resolver, + values=["Paris", "Lyon", "Berlin"], + to=None, + output="series", + domain=None, + context={"country": ["FR", "DE"]}, # length 2 != 3 + from_system=None, + not_found="null", + on_error="null", + on_ambiguous="null", + ) + + def test_crosswalk_plus_per_row_context_raises(self) -> None: + from resolvekit.core.api.bulk import _bulk_dispatch + from resolvekit.core.model.crosswalk import Crosswalk + + resolver = _make_mock_resolver() + cw = Crosswalk({"Paris": "city/PAR_FR"}) + with pytest.raises(ValueError, match="crosswalk="): + _bulk_dispatch( + resolver=resolver, + values=["Paris"], + to=None, + output="series", + domain=None, + context={"country": ["FR"]}, + from_system=None, + not_found="null", + on_error="null", + on_ambiguous="null", + crosswalk=cw, + ) + + def test_mixed_scalar_and_per_row_context(self) -> None: + from resolvekit.core.api.bulk import _bulk_dispatch + + resolver = _make_mock_resolver() + result = _bulk_dispatch( + resolver=resolver, + values=["Paris", "Lyon"], + to=None, + output="series", + domain=None, + context={ + "country": ["FR", "FR"], + "entity_types": frozenset({"geo.city"}), # scalar broadcast + }, + from_system=None, + not_found="null", + on_error="null", + on_ambiguous="null", + ) + assert len(result.values) == 2 + # Each row context must carry both country and entity_types. + _, call_ctxs = resolver._call_log[0] + for ctx in call_ctxs: + assert ctx.country == "FR" + assert ctx.entity_types == frozenset({"geo.city"}) + + def test_scalar_set_value_does_not_crash_dedup_signature(self) -> None: + """Scalar set broadcast values (e.g. entity_types) can be dedup'd by string form.""" + from resolvekit.core.api.bulk import _bulk_dispatch + + resolver = _make_mock_resolver() + # entity_types is a set (scalar broadcast) alongside per-row country column. + result = _bulk_dispatch( + resolver=resolver, + values=["Paris", "Paris"], + to=None, + output="series", + domain=None, + context={ + "country": ["FR", "US"], + "entity_types": {"geo.city"}, + }, + from_system=None, + not_found="null", + on_error="null", + on_ambiguous="null", + ) + assert len(result.values) == 2 + + def test_scalar_dict_attributes_does_not_crash_dedup_signature(self) -> None: + """Scalar dict broadcast values (e.g. attributes) can be dedup'd by string form.""" + from resolvekit.core.api.bulk import _bulk_dispatch + + resolver = _make_mock_resolver() + result = _bulk_dispatch( + resolver=resolver, + values=["Paris", "Lyon"], + to=None, + output="series", + domain=None, + context={ + "country": ["FR", "FR"], + "attributes": {"geo_level": "city"}, + }, + from_system=None, + not_found="null", + on_error="null", + on_ambiguous="null", + ) + assert len(result.values) == 2 + + def test_empty_per_row_context_list_allowed(self) -> None: + """An empty list context value for an empty values list is valid.""" + from resolvekit.core.api.bulk import _bulk_dispatch + + resolver = _make_mock_resolver() + result = _bulk_dispatch( + resolver=resolver, + values=[], + to=None, + output="series", + domain=None, + context={"country": []}, + from_system=None, + not_found="null", + on_error="null", + on_ambiguous="null", + ) + assert list(result.values) == [] + + +# --------------------------------------------------------------------------- +# Pandas accessor — per-row context wiring +# --------------------------------------------------------------------------- + + +pd = pytest.importorskip("pandas") + + +class TestPandasAccessorPerRowContext: + def test_context_kwarg_forwarded_to_bulk_dispatch(self) -> None: + import resolvekit.pandas # noqa: F401 + + with ( + patch("resolvekit.core.api.bulk._bulk_dispatch") as mock_dispatch, + patch("resolvekit._convenience._get_default", return_value=MagicMock()), + ): + mock_dispatch.return_value = pd.Series(["city/PAR_FR", "city/PAR_TX"]) + s = pd.Series(["Paris", "Paris"]) + iso_series = pd.Series(["FR", "US"]) + s.resolvekit.bulk(context={"country": iso_series}) + + kwargs = mock_dispatch.call_args.kwargs + ctx = kwargs["context"] + assert isinstance(ctx, dict) + assert "country" in ctx + + def test_resolve_context_kwarg_forwarded(self) -> None: + import resolvekit.pandas # noqa: F401 + + with ( + patch("resolvekit.core.api.bulk._bulk_dispatch") as mock_dispatch, + patch("resolvekit._convenience._get_default", return_value=MagicMock()), + ): + mock_dispatch.return_value = pd.Series(["city/PAR_FR"]) + s = pd.Series(["Paris"]) + ctx = ResolutionContext(country="FR") + s.resolvekit.resolve(to="iso3", context=ctx) + + kwargs = mock_dispatch.call_args.kwargs + assert kwargs["context"] is ctx + + +# --------------------------------------------------------------------------- +# Polars accessor — per-row context wiring +# --------------------------------------------------------------------------- + + +pl = pytest.importorskip("polars") + + +class TestPolarsAccessorPerRowContext: + def test_resolve_context_kwarg_accepted(self) -> None: + import resolvekit.polars # noqa: F401 + + mock_resolver = MagicMock() + mock_resolver.code_systems.return_value = {"iso3"} + mock_resolver._runner.available_packs = frozenset() + + with ( + patch("resolvekit.core.api.bulk._bulk_dispatch") as mock_dispatch, + patch("resolvekit._convenience._get_default", return_value=mock_resolver), + ): + mock_dispatch.return_value = pl.Series(values=["city/PAR_FR"]) + + df = pl.DataFrame({"city": ["Paris"], "iso": ["FR"]}) + ctx_fr = ResolutionContext(country="FR") + # Scalar context on uniform path: map_batches dispatches once. + expr = pl.col("city").resolvekit.resolve(to="iso3", context=ctx_fr) + df.with_columns(expr) + + assert mock_dispatch.called + + def test_per_row_polars_series_context(self) -> None: + import resolvekit.polars # noqa: F401 + + mock_resolver = MagicMock() + mock_resolver.code_systems.return_value = {"iso3"} + mock_resolver._runner.available_packs = frozenset() + + calls: list[dict] = [] + + def _fake_dispatch(**kwargs: Any) -> pl.Series: + calls.append(kwargs) + return pl.Series(values=["city/PAR_FR", "city/PAR_TX"]) + + with ( + patch( + "resolvekit.core.api.bulk._bulk_dispatch", side_effect=_fake_dispatch + ), + patch("resolvekit._convenience._get_default", return_value=mock_resolver), + ): + df = pl.DataFrame({"city": ["Paris", "Paris"], "iso": ["FR", "US"]}) + expr = pl.col("city").resolvekit.resolve( + to="iso3", + context={"country": pl.col("iso")}, + ) + df.with_columns(expr) + + assert len(calls) == 1 + ctx_arg = calls[0]["context"] + # Per-row context must be a dict with "country" key expanded to a list. + assert isinstance(ctx_arg, dict) + assert "country" in ctx_arg + assert list(ctx_arg["country"]) == ["FR", "US"] diff --git a/tests/core/test_resolver_cache.py b/tests/core/test_resolver_cache.py index 0982330..c9d6f4e 100644 --- a/tests/core/test_resolver_cache.py +++ b/tests/core/test_resolver_cache.py @@ -111,13 +111,26 @@ def test_cache_hit_short_circuits_runner(self) -> None: assert info.hits == 1 assert info.misses == 1 - def test_cache_keyed_by_context(self) -> None: + def test_cache_equal_contexts_share_entry(self) -> None: + # Content-based keying: two structurally-equal contexts share one cache entry. resolver, backend = _make_resolver(cache_size=128) - ctx1 = ResolutionContext() - ctx2 = ResolutionContext() + ctx1 = ResolutionContext(country="FR") + ctx2 = ResolutionContext(country="FR") resolver.resolve("hello", context=ctx1) resolver.resolve("hello", context=ctx2) - # Two distinct context objects → two misses + assert backend.call_count == 1 + info = resolver.diagnostics.cache.info() + assert info is not None + assert info.misses == 1 + assert info.hits == 1 + + def test_cache_different_contexts_produce_distinct_entries(self) -> None: + # Content-based keying: structurally different contexts produce distinct entries. + resolver, backend = _make_resolver(cache_size=128) + ctx_fr = ResolutionContext(country="FR") + ctx_de = ResolutionContext(country="DE") + resolver.resolve("hello", context=ctx_fr) + resolver.resolve("hello", context=ctx_de) assert backend.call_count == 2 info = resolver.diagnostics.cache.info() assert info is not None @@ -320,14 +333,13 @@ def test_repeat_text_same_context_resolves_once(self) -> None: def test_repeat_text_distinct_context_resolves_twice(self) -> None: resolver, backend = _make_resolver(cache_size=0) - ctx_a = ResolutionContext() - ctx_b = ResolutionContext() + ctx_a = ResolutionContext(country="FR") + ctx_b = ResolutionContext(country="DE") resolver._resolve_many_internal(["Italy", "Italy"], context=[ctx_a, ctx_b]) assert backend.call_count == 2 def test_none_context_dedup_uses_shared_default(self) -> None: - """context=None expands to [None]*N — all entries share id(None), - so repeats dedup just like an explicit shared context.""" + """None context distributes to all rows and deduplicates as a shared default.""" resolver, backend = _make_resolver(cache_size=0) resolver._resolve_many_internal(["Italy", "Italy", "Italy"], context=None) assert backend.call_count == 1 diff --git a/tests/core/test_result.py b/tests/core/test_result.py index 8edd886..a63b7e1 100644 --- a/tests/core/test_result.py +++ b/tests/core/test_result.py @@ -232,8 +232,9 @@ def test_repr_ambiguous_without_query_text(self): ], ) r = repr(result) - assert "ResolutionResult(status='ambiguous'" in r - assert "candidates=1" in r + assert "AMBIGUOUS" in r + # Candidate entity_id or canonical_name is listed + assert "city/Paris_FR" in r or "Paris" in r class TestDisambiguateHint: @@ -329,12 +330,13 @@ def test_type_narrowing_only_when_no_canonical_names(self): ], ) r = repr(result) - assert "entity_types={'geo.region'}" in r + assert "entity_types" in r + assert "geo.region" in r def test_no_disambiguator_when_same_type_no_names(self): """If candidates share a single entity_type and have no canonical - names, no narrowing helps — fall back to the generic candidate-count - repr rather than printing a misleading suggestion.""" + names, no narrowing helps — the repr still lists candidates by + entity_id but omits the 'try:' hint block.""" result = ResolutionResult( status=ResolutionStatus.AMBIGUOUS, query_text="Foo", @@ -344,8 +346,11 @@ def test_no_disambiguator_when_same_type_no_names(self): ], ) r = repr(result) - assert "AMBIGUOUS — try" not in r - assert "candidates=2" in r + assert "AMBIGUOUS" in r + assert "try:" not in r + # Both entity_ids appear in the candidate listing + assert "a" in r + assert "b" in r class TestRefinementHintDIDYouMean: diff --git a/tests/packs/test_geo_scoring.py b/tests/packs/test_geo_scoring.py index adbfa59..42e8c3a 100644 --- a/tests/packs/test_geo_scoring.py +++ b/tests/packs/test_geo_scoring.py @@ -227,7 +227,7 @@ def model_version(self): scorer = GeoScorer(model=_MockModel()) t = scorer.decision_thresholds assert t.confidence_threshold == 0.70 - assert t.min_gap == 0.08 + assert t.min_gap == 0.07 assert t.exact_code_min_score == 0.75 def test_decision_thresholds_calibrator(self): @@ -240,7 +240,7 @@ def predict(self, raw_score, query_len=None): scorer = GeoScorer(calibrator=_MockCalibrator()) t = scorer.decision_thresholds assert t.confidence_threshold == 0.70 - assert t.min_gap == 0.08 + assert t.min_gap == 0.07 assert t.exact_code_min_score == 0.75 def test_prominence_none_is_no_op(self): diff --git a/tests/packs/test_prominence_scoring.py b/tests/packs/test_prominence_scoring.py new file mode 100644 index 0000000..dcfd1f1 --- /dev/null +++ b/tests/packs/test_prominence_scoring.py @@ -0,0 +1,289 @@ +"""Tests for geo prominence scoring and city/admin2 min_gap policy. + +Validates prominence-based scoring with PROMINENCE_TIEBREAK_FACTOR and a lower +min_gap for city/admin2 ties, allowing a dominant city to resolve while +equal-prominence pairs stay AMBIGUOUS. +""" + +from __future__ import annotations + +from resolvekit.core.model import RetrievalSummary + +_STUB_RETRIEVAL = RetrievalSummary(best_source="test") + + +def _city_candidate( + entity_id: str, + *, + calibrated_score: float, + prominence: float | None, + hierarchy_rank: float = 0.70, +): + """Build a synthetic city-tier candidate with the given calibrated score.""" + from resolvekit.core.model import ( + Candidate, + CandidateEvidence, + RetrievalSummary, + ScoreSummary, + ) + from resolvekit.packs.geo.features import GeoFeaturesV1 + + return Candidate( + entity_id=entity_id, + sources=[ + CandidateEvidence( + entity_id=entity_id, + source_name="geo_exact_name", + raw_score=calibrated_score, + ) + ], + retrieval=RetrievalSummary(best_source="geo_exact_name"), + features=GeoFeaturesV1( + exact_name_hit=True, + hierarchy_rank=hierarchy_rank, + candidate_prominence=prominence, + ), + scores=ScoreSummary( + raw_score=calibrated_score, calibrated_score=calibrated_score + ), + ) + + +class TestProminenceFactor: + """Scoring-level assertions for the new PROMINENCE_TIEBREAK_FACTOR=0.12.""" + + def test_factor_value(self): + from resolvekit.packs.geo.scoring import PROMINENCE_TIEBREAK_FACTOR + + assert PROMINENCE_TIEBREAK_FACTOR == 0.12 + + def test_dominant_city_outscores_obscure_peer_by_full_factor(self): + """Dominant city (prom=1.0) outscores obscure peer (prom=0.0) by exactly the factor.""" + from resolvekit.packs.geo.features import GeoFeaturesV1 + from resolvekit.packs.geo.scoring import PROMINENCE_TIEBREAK_FACTOR, GeoScorer + + scorer = GeoScorer() + dominant = GeoFeaturesV1(exact_name_hit=True, candidate_prominence=1.0) + obscure = GeoFeaturesV1(exact_name_hit=True, candidate_prominence=0.0) + + gap = scorer.score(dominant, _STUB_RETRIEVAL) - scorer.score( + obscure, _STUB_RETRIEVAL + ) + assert abs(gap - PROMINENCE_TIEBREAK_FACTOR) < 1e-9 + + def test_equal_prominence_produces_zero_gap(self): + """Two cities with equal prominence produce no score separation.""" + from resolvekit.packs.geo.features import GeoFeaturesV1 + from resolvekit.packs.geo.scoring import GeoScorer + + scorer = GeoScorer() + city_a = GeoFeaturesV1(exact_name_hit=True, candidate_prominence=0.5) + city_b = GeoFeaturesV1(exact_name_hit=True, candidate_prominence=0.5) + + assert scorer.score(city_a, _STUB_RETRIEVAL) == scorer.score( + city_b, _STUB_RETRIEVAL + ) + + def test_exact_code_with_max_prominence_beats_exact_name_with_max_prominence(self): + """Prominence cannot flip exact_code rank below exact_name.""" + from resolvekit.packs.geo.features import GeoFeaturesV1 + from resolvekit.packs.geo.scoring import GeoScorer + + scorer = GeoScorer() + exact_code = GeoFeaturesV1(exact_code_hit=True, candidate_prominence=1.0) + exact_name = GeoFeaturesV1(exact_name_hit=True, candidate_prominence=1.0) + + assert scorer.score(exact_code, _STUB_RETRIEVAL) > scorer.score( + exact_name, _STUB_RETRIEVAL + ) + + +class TestCityAdminDecision: + """Decision-policy assertions for city/admin2 per-tier min_gap.""" + + def test_city_admin_min_gap_is_below_default(self): + from resolvekit.packs.geo.decision import CITY_ADMIN_MIN_GAP, DEFAULT_MIN_GAP + + assert CITY_ADMIN_MIN_GAP < DEFAULT_MIN_GAP + assert CITY_ADMIN_MIN_GAP == 0.03 + + def test_dominant_city_resolves_over_obscure_peer(self): + """Dominant city (prom=1.0) resolves over obscure same-named peer (prom=0.0). + + Score gap ≈ PROMINENCE_TIEBREAK_FACTOR = 0.12, which clears CITY_ADMIN_MIN_GAP=0.03. + """ + from resolvekit.core.explain import NullTraceSink + from resolvekit.core.model import ( + NormalizedText, + Query, + ResolutionContext, + ResolutionStatus, + ) + from resolvekit.packs.geo.decision import GeoDecisionPolicy + from resolvekit.packs.geo.scoring import GeoScorer + + scorer = GeoScorer() + + from resolvekit.packs.geo.features import GeoFeaturesV1 + + feats_fr = GeoFeaturesV1( + exact_name_hit=True, hierarchy_rank=0.70, candidate_prominence=1.0 + ) + feats_tx = GeoFeaturesV1( + exact_name_hit=True, hierarchy_rank=0.70, candidate_prominence=0.0 + ) + score_fr = scorer.score(feats_fr, _STUB_RETRIEVAL) + score_tx = scorer.score(feats_tx, _STUB_RETRIEVAL) + + paris_fr = _city_candidate( + "city/Paris_FR", calibrated_score=score_fr, prominence=1.0 + ) + paris_tx = _city_candidate( + "city/Paris_TX", calibrated_score=score_tx, prominence=0.0 + ) + + policy = GeoDecisionPolicy() + query = Query( + raw_text="Paris", + normalized=NormalizedText(original="Paris", normalized="paris"), + ) + result = policy.decide( + query, ResolutionContext(), [paris_fr, paris_tx], NullTraceSink() + ) + + assert result.status == ResolutionStatus.RESOLVED + assert result.entity_id == "city/Paris_FR" + + def test_equal_prominence_cities_stay_ambiguous(self): + """Two same-named equal-prominence cities remain AMBIGUOUS (coin-flip preserved).""" + from resolvekit.core.explain import NullTraceSink + from resolvekit.core.model import ( + NormalizedText, + Query, + ResolutionContext, + ResolutionStatus, + ) + from resolvekit.packs.geo.decision import GeoDecisionPolicy + from resolvekit.packs.geo.scoring import GeoScorer + + scorer = GeoScorer() + + from resolvekit.packs.geo.features import GeoFeaturesV1 + + feats = GeoFeaturesV1( + exact_name_hit=True, hierarchy_rank=0.70, candidate_prominence=0.5 + ) + score = scorer.score(feats, _STUB_RETRIEVAL) + + candidates = [ + _city_candidate( + "city/Springfield_IL", calibrated_score=score, prominence=0.5 + ), + _city_candidate( + "city/Springfield_MO", calibrated_score=score, prominence=0.5 + ), + ] + + policy = GeoDecisionPolicy() + query = Query( + raw_text="Springfield", + normalized=NormalizedText(original="Springfield", normalized="springfield"), + ) + result = policy.decide(query, ResolutionContext(), candidates, NullTraceSink()) + + assert result.status == ResolutionStatus.AMBIGUOUS + + def test_country_tier_uses_default_min_gap(self): + """Country-tier candidates are not subject to city_admin_min_gap. + + A gap of 0.05 (between CITY_ADMIN_MIN_GAP=0.03 and DEFAULT_MIN_GAP=0.07) + stays AMBIGUOUS for country-tier entities — city-tier leniency does not apply. + """ + from resolvekit.core.explain import NullTraceSink + from resolvekit.core.model import ( + Candidate, + CandidateEvidence, + NormalizedText, + Query, + ResolutionContext, + ResolutionStatus, + RetrievalSummary, + ScoreSummary, + ) + from resolvekit.packs.geo.decision import GeoDecisionPolicy + from resolvekit.packs.geo.features import GeoFeaturesV1 + + policy = GeoDecisionPolicy() + query = Query( + raw_text="Georgia", + normalized=NormalizedText(original="Georgia", normalized="georgia"), + ) + + # hierarchy_rank=0.85 is country tier (> _CITY_RANK=0.70), so default min_gap applies + candidates = [ + Candidate( + entity_id="country/GEO", + sources=[ + CandidateEvidence( + entity_id="country/GEO", + source_name="geo_exact_name", + raw_score=0.85, + ) + ], + retrieval=RetrievalSummary(best_source="geo_exact_name"), + features=GeoFeaturesV1(exact_name_hit=True, hierarchy_rank=0.85), + scores=ScoreSummary(raw_score=0.85, calibrated_score=0.85), + ), + Candidate( + entity_id="admin1/USA_GA", + sources=[ + CandidateEvidence( + entity_id="admin1/USA_GA", + source_name="geo_exact_name", + raw_score=0.80, + ) + ], + retrieval=RetrievalSummary(best_source="geo_exact_name"), + features=GeoFeaturesV1(exact_name_hit=True, hierarchy_rank=0.85), + scores=ScoreSummary(raw_score=0.80, calibrated_score=0.80), + ), + ] + # Gap = 0.05, above CITY_ADMIN_MIN_GAP (0.03) but below DEFAULT_MIN_GAP (0.07) → AMBIGUOUS + result = policy.decide(query, ResolutionContext(), candidates, NullTraceSink()) + + assert result.status == ResolutionStatus.AMBIGUOUS + + def test_city_gap_above_city_threshold_resolves(self): + """A city-tier score gap > CITY_ADMIN_MIN_GAP resolves the top candidate.""" + from resolvekit.core.explain import NullTraceSink + from resolvekit.core.model import ( + NormalizedText, + Query, + ResolutionContext, + ResolutionStatus, + ) + from resolvekit.packs.geo.decision import CITY_ADMIN_MIN_GAP, GeoDecisionPolicy + + policy = GeoDecisionPolicy() + query = Query( + raw_text="Paris", + normalized=NormalizedText(original="Paris", normalized="paris"), + ) + + top_score = 0.96 + runner_up_score = top_score - ( + CITY_ADMIN_MIN_GAP + 0.02 + ) # gap=0.08 > threshold + + candidates = [ + _city_candidate( + "city/Paris_FR", calibrated_score=top_score, prominence=None + ), + _city_candidate( + "city/Paris_TX", calibrated_score=runner_up_score, prominence=None + ), + ] + result = policy.decide(query, ResolutionContext(), candidates, NullTraceSink()) + + assert result.status == ResolutionStatus.RESOLVED + assert result.entity_id == "city/Paris_FR" diff --git a/tests/test_ambiguous_error_hints.py b/tests/test_ambiguous_error_hints.py index d373823..f082e1e 100644 --- a/tests/test_ambiguous_error_hints.py +++ b/tests/test_ambiguous_error_hints.py @@ -72,7 +72,10 @@ def test_cross_type_hint_suggests_entity_types(self) -> None: ] err = AmbiguousResolutionError(candidates=candidates) assert err.hint is not None - assert "entity_types=" in err.hint + # Hint now uses dict form (context={'entity_types': ...}) rather than + # the old ResolutionContext(entity_types=...) constructor form. + assert "entity_types" in err.hint + assert "context=" in err.hint def test_no_candidates_hint_omits_entity_types(self) -> None: err = AmbiguousResolutionError(candidates=None) diff --git a/tests/test_disambiguation_repr.py b/tests/test_disambiguation_repr.py new file mode 100644 index 0000000..7f9d0b9 --- /dev/null +++ b/tests/test_disambiguation_repr.py @@ -0,0 +1,586 @@ +"""Tests for Layer 3: result teaches the fix. + +Covers: +- CandidateSummary.parent_name / parent_country fields exist and default to None. +- AMBIGUOUS __repr__ lists candidates with parent context, with a dict hint. +- NO_MATCH refinement hints use dict form (no ResolutionContext( substring). +- RESOLVED path leaves parent_name/parent_country as None (no extra lookups). +- _resolve_parent_context populates parent context for AMBIGUOUS candidates. +- dict-form hint is copy-pasteable (can be evaluated as a valid Python literal). +""" + +from __future__ import annotations + +import ast +from unittest.mock import MagicMock + +import pytest + +from resolvekit.core.explain import result_html as rh +from resolvekit.core.model.result import ( + CandidateSummary, + ReasonCode, + RefinementHint, + ResolutionResult, + ResolutionStatus, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _ambiguous( + candidates: list[CandidateSummary] | None = None, + *, + query_text: str | None = "Paris", +) -> ResolutionResult: + if candidates is None: + candidates = [ + CandidateSummary( + entity_id="city/paris-fr", + confidence=0.85, + canonical_name="Paris", + entity_type="geo.city", + pack_id="geo-FR", + parent_country="FR", + parent_name="France", + ), + CandidateSummary( + entity_id="city/paris-tx", + confidence=0.82, + canonical_name="Paris", + entity_type="geo.city", + pack_id="geo-US", + parent_country="US", + parent_name="Texas", + ), + ] + return ResolutionResult( + status=ResolutionStatus.AMBIGUOUS, + reasons=[ReasonCode.AMBIGUOUS_LOW_GAP], + candidates=candidates, + query_text=query_text, + ) + + +def _no_match( + *, + refinement_hints: list[RefinementHint] | None = None, + query_text: str | None = "Paris", + candidates: list[CandidateSummary] | None = None, +) -> ResolutionResult: + return ResolutionResult( + status=ResolutionStatus.NO_MATCH, + refinement_hints=refinement_hints or [], + query_text=query_text, + candidates=candidates or [], + reasons=[ReasonCode.NO_CANDIDATES], + ) + + +# --------------------------------------------------------------------------- +# CandidateSummary new fields +# --------------------------------------------------------------------------- + + +class TestCandidateSummaryParentFields: + def test_default_none(self) -> None: + cand = CandidateSummary(entity_id="city/paris-fr", confidence=0.9) + assert cand.parent_name is None + assert cand.parent_country is None + + def test_fields_roundtrip(self) -> None: + cand = CandidateSummary( + entity_id="city/paris-fr", + confidence=0.9, + parent_name="France", + parent_country="FR", + ) + assert cand.parent_name == "France" + assert cand.parent_country == "FR" + + def test_frozen_model(self) -> None: + cand = CandidateSummary(entity_id="city/X", confidence=0.9) + with pytest.raises(Exception): # pydantic frozen model raises on assignment + cand.parent_name = "foo" # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# AMBIGUOUS __repr__ with parent context +# --------------------------------------------------------------------------- + + +class TestAmbiguousReprWithParentContext: + def test_repr_starts_with_ambiguous_candidates(self) -> None: + r = repr(_ambiguous()) + assert r.startswith("AMBIGUOUS — candidates:") + + def test_repr_shows_parent_name_for_same_canonical_name(self) -> None: + r = repr(_ambiguous()) + assert "France" in r + assert "Texas" in r + + def test_repr_shows_parent_country_fallback_when_no_parent_name(self) -> None: + candidates = [ + CandidateSummary( + entity_id="city/paris-fr", + confidence=0.85, + canonical_name="Paris", + parent_country="FR", + ), + CandidateSummary( + entity_id="city/paris-tx", + confidence=0.82, + canonical_name="Paris", + parent_country="US", + ), + ] + r = repr(_ambiguous(candidates)) + assert "FR" in r + assert "US" in r + + def test_repr_shows_confidence(self) -> None: + r = repr(_ambiguous()) + assert "conf=0.85" in r + assert "conf=0.82" in r + + def test_repr_includes_try_hint_when_did_you_mean_available(self) -> None: + # Different canonical names (not equal to query_text) → did_you_mean fires + candidates = [ + CandidateSummary( + entity_id="city/paris-fr", + confidence=0.85, + canonical_name="Paris, France", + entity_type="geo.city", + parent_country="FR", + ), + CandidateSummary( + entity_id="city/paris-tx", + confidence=0.82, + canonical_name="Paris, TX", + entity_type="geo.city", + parent_country="US", + ), + ] + r = repr(_ambiguous(candidates)) + assert "try:" in r + assert "resolvekit.resolve(text='Paris, France')" in r + + def test_repr_try_hint_uses_parent_country_for_same_name_same_type(self) -> None: + # Same canonical name as query_text → did_you_mean returns None; + # same entity_type → type fallback also returns None; + # but top candidate has parent_country → country hint is emitted. + r = repr(_ambiguous()) + assert "AMBIGUOUS — candidates:" in r + assert "try:" in r + assert "context={'country':" in r + + def test_repr_does_not_include_try_hint_when_no_query_text(self) -> None: + r = repr(_ambiguous(query_text=None)) + # No query_text means disambiguate_hint returns None — "try:" block absent + assert "try:" not in r + + def test_repr_no_hint_when_no_parent_country_and_same_name_same_type(self) -> None: + # Same canonical name + same type + NO parent_country → still no hint + candidates = [ + CandidateSummary( + entity_id="city/paris-a", + confidence=0.85, + canonical_name="Paris", + entity_type="geo.city", + ), + CandidateSummary( + entity_id="city/paris-b", + confidence=0.82, + canonical_name="Paris", + entity_type="geo.city", + ), + ] + r = repr(_ambiguous(candidates)) + assert "try:" not in r + + def test_repr_no_hint_when_all_candidates_share_same_country(self) -> None: + # All candidates in the same country (e.g. Springfield, VT vs Springfield, NJ) + # → a country hint cannot disambiguate; must not emit one. + candidates = [ + CandidateSummary( + entity_id="city/springfield-vt", + confidence=0.90, + canonical_name="Springfield", + entity_type="geo.city", + parent_country="US", + parent_name="Vermont", + ), + CandidateSummary( + entity_id="city/springfield-nj", + confidence=0.90, + canonical_name="Springfield", + entity_type="geo.city", + parent_country="US", + parent_name="New Jersey", + ), + CandidateSummary( + entity_id="city/springfield-mo", + confidence=0.89, + canonical_name="Springfield", + entity_type="geo.city", + parent_country="US", + parent_name="Missouri", + ), + ] + result = ResolutionResult( + status=ResolutionStatus.AMBIGUOUS, + reasons=[ReasonCode.AMBIGUOUS_LOW_GAP], + candidates=candidates, + query_text="Springfield", + ) + r = repr(result) + assert "try:" not in r + + def test_repr_hint_fires_when_candidates_span_different_countries(self) -> None: + # Candidates in different countries → hint is useful, must be emitted. + r = repr(_ambiguous()) # fixture has FR + US + assert "try:" in r + assert "context={'country': 'FR'}" in r + + def test_parent_country_never_contains_hyphen(self) -> None: + # Subdivision codes (e.g. "US-VT") must be normalized to alpha-2 ("US") + # before being stored in parent_country. + candidates = [ + CandidateSummary( + entity_id="city/springfield-vt", + confidence=0.90, + canonical_name="Springfield", + entity_type="geo.city", + parent_country="US-VT", # raw subdivision code — should never appear + ), + ] + from resolvekit.core.explain.result_html import disambiguate_hint + + result = ResolutionResult( + status=ResolutionStatus.AMBIGUOUS, + reasons=[ReasonCode.AMBIGUOUS_LOW_GAP], + candidates=candidates, + query_text="Springfield", + ) + hint = disambiguate_hint(result) + # With only one candidate and one country, no hint should fire. + # The important thing is no "US-VT" leaks if a hint were produced. + if hint is not None: + assert "US-VT" not in hint, "Subdivision code leaked into hint" + + def test_repr_hint_uses_dict_form_not_resolution_context(self) -> None: + # Candidates with no canonical_name trigger the entity_types fallback hint + candidates = [ + CandidateSummary( + entity_id="city/X", + confidence=0.85, + entity_type="geo.city", + ), + ] + result = ResolutionResult( + status=ResolutionStatus.AMBIGUOUS, + reasons=[ReasonCode.AMBIGUOUS_LOW_GAP], + candidates=candidates, + query_text="Paris", + ) + r = repr(result) + assert "ResolutionContext(" not in r + + def test_repr_hint_country_dict_form(self) -> None: + # When disambiguate_hint generates a COUNTRY hint, it must be dict form + candidates = [ + CandidateSummary( + entity_id="city/paris-fr", + confidence=0.85, + canonical_name="Paris", + entity_type="geo.city", + parent_country="FR", + ), + ] + result = ResolutionResult( + status=ResolutionStatus.AMBIGUOUS, + reasons=[ReasonCode.AMBIGUOUS_LOW_GAP], + refinement_hints=[RefinementHint.COUNTRY], + candidates=candidates, + query_text="Paris", + ) + # Use render_refinement_hint directly to test the dict form + hint = rh.render_refinement_hint(result, RefinementHint.COUNTRY) + assert hint is not None + assert "ResolutionContext(" not in hint + assert "context={" in hint + assert "'country'" in hint or '"country"' in hint + + def test_repr_no_match_in_ambiguous(self) -> None: + r = repr(_ambiguous()) + assert "NO_MATCH" not in r + + +# --------------------------------------------------------------------------- +# NO_MATCH refinement hints use dict form +# --------------------------------------------------------------------------- + + +class TestNoMatchHintDictForm: + def test_country_hint_dict_form(self) -> None: + result = _no_match( + refinement_hints=[RefinementHint.COUNTRY], + candidates=[ + CandidateSummary( + entity_id="city/paris-fr", + pack_id="geo", + entity_type="geo.city", + canonical_name="Paris", + ) + ], + ) + hint = rh.refinement_hint(result) + assert hint is not None + assert "ResolutionContext(" not in hint + assert "context={" in hint + + def test_entity_types_hint_dict_form(self) -> None: + result = _no_match( + refinement_hints=[RefinementHint.ENTITY_TYPES], + candidates=[ + CandidateSummary( + entity_id="country/FRA", + entity_type="geo.country", + canonical_name="France", + ) + ], + ) + hint = rh.refinement_hint(result) + assert hint is not None + assert "ResolutionContext(" not in hint + assert "entity_types" in hint + + def test_parent_ids_hint_dict_form(self) -> None: + result = _no_match( + refinement_hints=[RefinementHint.PARENT_IDS], + candidates=[CandidateSummary(entity_id="city/paris-fr")], + ) + hint = rh.refinement_hint(result) + assert hint is not None + assert "ResolutionContext(" not in hint + assert "parent_ids" in hint + + def test_languages_hint_dict_form(self) -> None: + result = _no_match(refinement_hints=[RefinementHint.LANGUAGES]) + hint = rh.refinement_hint(result) + assert hint is not None + assert "ResolutionContext(" not in hint + assert "languages" in hint + + +# --------------------------------------------------------------------------- +# RESOLVED path leaves parent fields as None (no extra lookups) +# --------------------------------------------------------------------------- + + +class TestResolvedPathNoParentLookup: + def test_resolved_candidate_parent_fields_are_none(self) -> None: + cand = CandidateSummary( + entity_id="country/FRA", + confidence=0.99, + canonical_name="France", + entity_type="geo.country", + ) + result = ResolutionResult( + status=ResolutionStatus.RESOLVED, + entity_id="country/FRA", + confidence=0.99, + pack_id="geo", + candidates=[cand], + ) + for c in result.candidates: + assert c.parent_name is None + assert c.parent_country is None + + +# --------------------------------------------------------------------------- +# Dict-form hint is a valid Python literal (copy-pasteable) +# --------------------------------------------------------------------------- + + +class TestHintIsCopyPasteable: + def test_country_hint_evaluates_as_python_literal(self) -> None: + result = _no_match( + refinement_hints=[RefinementHint.COUNTRY], + candidates=[ + CandidateSummary( + entity_id="city/paris-fr", + parent_country="FR", + ) + ], + ) + hint = rh.render_refinement_hint(result, RefinementHint.COUNTRY) + assert hint is not None + # Extract the dict literal from the hint string: + # hint looks like: "resolvekit.resolve(text='Paris', context={'country': 'FR'})" + start = hint.index("context=") + len("context=") + # Find matching closing paren for the outer resolve() call + end = hint.rfind(")") + dict_str = hint[start:end] + # Should parse as a valid Python dict literal + parsed = ast.literal_eval(dict_str) + assert isinstance(parsed, dict) + assert "country" in parsed + + def test_entity_types_hint_evaluates_as_python_literal(self) -> None: + result = _no_match( + refinement_hints=[RefinementHint.ENTITY_TYPES], + candidates=[ + CandidateSummary( + entity_id="country/FRA", + entity_type="geo.country", + ) + ], + ) + hint = rh.render_refinement_hint(result, RefinementHint.ENTITY_TYPES) + assert hint is not None + start = hint.index("context=") + len("context=") + end = hint.rfind(")") + dict_str = hint[start:end] + parsed = ast.literal_eval(dict_str) + assert isinstance(parsed, dict) + assert "entity_types" in parsed + + +# --------------------------------------------------------------------------- +# parent_country is preferred over entity_id inference in COUNTRY hint +# --------------------------------------------------------------------------- + + +class TestCountryHintPrefersParentCountry: + def test_parent_country_takes_priority_over_entity_id_inference(self) -> None: + # parent_country="FR" is set directly — no pack_id / entity_id inference needed + result = _no_match( + refinement_hints=[RefinementHint.COUNTRY], + candidates=[ + CandidateSummary( + entity_id="city/paris-fr", + parent_country="FR", + ) + ], + ) + hint = rh.render_refinement_hint(result, RefinementHint.COUNTRY) + assert hint is not None + assert "'FR'" in hint + + +# --------------------------------------------------------------------------- +# Two-level enrichment: city → region → country (F2 regression guard) +# --------------------------------------------------------------------------- + + +class TestTwoLevelEnrichmentParentName: + """_resolve_parent_context uses the direct container (region) as parent_name. + + In the two-level path (city → region → country), parent_name is the direct + container and parent_country is the ISO-2 of the grandparent. + """ + + def _make_mock_entity( + self, + *, + iso2: str | None = None, + canonical_name: str = "", + relations: list | None = None, + attributes: dict | None = None, + ) -> MagicMock: + """Build a lightweight mock standing in for an EntityRecord.""" + entity = MagicMock() + entity.iso2 = iso2 + entity.canonical_name = canonical_name + entity.attributes = attributes or {} + entity.relations = relations or [] + return entity + + def _make_mock_relation(self, relation_type: str, target_id: str) -> MagicMock: + rel = MagicMock() + rel.relation_type = relation_type + rel.target_id = target_id + return rel + + def test_two_level_parent_name_is_region_not_country(self) -> None: + from resolvekit.core.engine.enrichment import ResultEnricher + from resolvekit.core.model.result import CandidateSummary + texas = self._make_mock_entity( + iso2=None, + canonical_name="Texas", + relations=[self._make_mock_relation("contained_in", "country/USA")], + ) + usa = self._make_mock_entity(iso2="US", canonical_name="United States") + + store = MagicMock() + store.get_entity.side_effect = { + "region/Texas": texas, + "country/USA": usa, + }.get + + paris_tx_entity = self._make_mock_entity( + iso2=None, + canonical_name="Paris", + relations=[self._make_mock_relation("contained_in", "region/Texas")], + ) + + enricher = ResultEnricher.__new__(ResultEnricher) + enricher._store = store # type: ignore[attr-defined] + + summary = CandidateSummary(entity_id="city/Paris_TX", confidence=0.85) + entities = {"city/Paris_TX": paris_tx_entity} + + result = enricher._resolve_parent_context((summary,), entities) + parent_name, parent_country = result["city/Paris_TX"] + + assert parent_country == "US", "parent_country must be the country ISO-2 code" + assert parent_name == "Texas", ( + "parent_name must be the direct container (region), not the country" + ) + + def test_subdivision_iso2_is_normalized_to_alpha2(self) -> None: + # When the grandparent entity carries a subdivision code (e.g. "US-VT"), + # _resolve_parent_context must strip the subdivision suffix so that + # parent_country is "US", never "US-VT". + from resolvekit.core.engine.enrichment import ResultEnricher + from resolvekit.core.model.result import CandidateSummary + + vermont = self._make_mock_entity( + iso2=None, + canonical_name="Vermont", + relations=[self._make_mock_relation("contained_in", "country/USA")], + ) + # Grandparent carries a subdivision-style iso2 code + usa = self._make_mock_entity(iso2="US-VT", canonical_name="United States") + + store = MagicMock() + store.get_entity.side_effect = { + "region/Vermont": vermont, + "country/USA": usa, + }.get + + springfield_vt = self._make_mock_entity( + iso2=None, + canonical_name="Springfield", + relations=[self._make_mock_relation("contained_in", "region/Vermont")], + ) + + enricher = ResultEnricher.__new__(ResultEnricher) + enricher._store = store # type: ignore[attr-defined] + + summary = CandidateSummary(entity_id="city/Springfield_VT", confidence=0.85) + entities = {"city/Springfield_VT": springfield_vt} + + result = enricher._resolve_parent_context((summary,), entities) + parent_name, parent_country = result["city/Springfield_VT"] + + assert parent_country == "US", ( + f"Expected 'US' after normalizing subdivision iso2; got {parent_country!r}" + ) + assert "-" not in (parent_country or ""), ( + "parent_country must never contain a hyphen (subdivision code leaked)" + ) + assert parent_name == "Vermont" diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 28848fa..66ac4d9 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -40,6 +40,7 @@ def _all(module_name: str) -> tuple[str, ...]: "Resolver", "ResolverError", "UnknownCodeSystemError", + "UnknownContextKeyError", "UnknownDomainError", "UnknownOutputError", "bulk", @@ -52,6 +53,7 @@ def _all(module_name: str) -> tuple[str, ...]: "resolve", "resolve_id", "snap", + "suggest", "to", "warm", ), @@ -102,6 +104,7 @@ def _all(module_name: str) -> tuple[str, ...]: "ResolutionError", "ResolverError", "UnknownCodeSystemError", + "UnknownContextKeyError", "UnknownDomainError", "UnknownOutputError", "UnsupportedStoreError", @@ -249,7 +252,7 @@ def test_resolvekit_all() -> None: def test_resolvekit_all_count() -> None: - assert len(resolvekit.__all__) == 37 + assert len(resolvekit.__all__) == 39 def test_resolvekit_all_excludes_removed_names() -> None: diff --git a/tests/test_result_repr_no_match.py b/tests/test_result_repr_no_match.py index e29acab..afb92f6 100644 --- a/tests/test_result_repr_no_match.py +++ b/tests/test_result_repr_no_match.py @@ -47,7 +47,7 @@ def test_no_match_with_country_hint_renders_runnable_line() -> None: r = repr(result) assert r.startswith("NO_MATCH — try:") assert "resolvekit.resolve(text='Paris'," in r - assert "context=ResolutionContext(country=" in r + assert "context={'country':" in r # --------------------------------------------------------------------------- @@ -68,7 +68,8 @@ def test_no_match_with_entity_types_hint_renders_runnable_line() -> None: ) r = repr(result) assert r.startswith("NO_MATCH — try:") - assert "entity_types={'geo.country'}" in r + assert "entity_types" in r + assert "geo.country" in r # --------------------------------------------------------------------------- @@ -154,7 +155,7 @@ def test_resolved_repr_unchanged() -> None: # --------------------------------------------------------------------------- -def test_ambiguous_repr_unchanged() -> None: +def test_ambiguous_repr_lists_candidates() -> None: result = ResolutionResult( status=ResolutionStatus.AMBIGUOUS, query_text="Paris", @@ -165,8 +166,8 @@ def test_ambiguous_repr_unchanged() -> None: ) r = repr(result) # Must use the AMBIGUOUS branch, not the NO_MATCH branch - assert r.startswith("AMBIGUOUS — try:") - assert "resolvekit.resolve(text='Paris, France')" in r - assert "resolvekit.resolve(text='Paris, TX')" in r + assert r.startswith("AMBIGUOUS — candidates:") + assert "Paris, France" in r + assert "Paris, TX" in r # Must NOT contain "NO_MATCH" assert "NO_MATCH" not in r diff --git a/zensical.toml b/zensical.toml index c71e795..e96ee6a 100644 --- a/zensical.toml +++ b/zensical.toml @@ -24,6 +24,7 @@ nav = [ { "Clean a DataFrame column" = "how-to/clean-a-dataframe-column.md" }, { "Reconcile a column with a review" = "how-to/reconcile-a-column-with-review.md" }, { "Handle ambiguous matches" = "how-to/handle-ambiguous-matches.md" }, + { "Refine resolution with context" = "how-to/refine-resolution-with-context.md" }, { "Convert between code systems" = "how-to/convert-between-code-systems.md" }, { "Resolve group membership" = "how-to/resolve-group-membership.md" }, { "List the entities in a region" = "how-to/list-entities-in-a-region.md" }, From 1c3140e0f6f71a61683f1fc701e36ccf15086d57 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Fri, 12 Jun 2026 08:13:55 +0200 Subject: [PATCH 8/9] style: fix import sort and regex string flagged by ruff --- src/resolvekit/core/api/bulk.py | 1 + tests/building/test_wikidata_aliases.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/resolvekit/core/api/bulk.py b/src/resolvekit/core/api/bulk.py index 990c7bb..43e464a 100644 --- a/src/resolvekit/core/api/bulk.py +++ b/src/resolvekit/core/api/bulk.py @@ -39,6 +39,7 @@ from resolvekit.core.model.entity_attributes import dispatch_pivot from resolvekit.core.model.result import ReasonCode + def _closest_match(value: str, choices: tuple[str, ...]) -> str | None: """Return the closest match to *value* from *choices*, or None.""" matches = difflib.get_close_matches(value, choices, n=1, cutoff=0.6) diff --git a/tests/building/test_wikidata_aliases.py b/tests/building/test_wikidata_aliases.py index 2b628d9..6abd594 100644 --- a/tests/building/test_wikidata_aliases.py +++ b/tests/building/test_wikidata_aliases.py @@ -359,7 +359,7 @@ def test_all_batches_empty_raises() -> None: "resolvekit.builder.sources.wikidata.aliases.sparql_request", return_value=[], ), - pytest.raises(RuntimeError, match="all.*batches.*no bindings"), + pytest.raises(RuntimeError, match=r"all .* batches returned no bindings"), ): fetch_wikidata_en_aliases( codes_by_entity=codes_by_entity, From 6a4dc6008ea71d3cbbe9e0884867902046e65488 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Fri, 12 Jun 2026 08:17:55 +0200 Subject: [PATCH 9/9] style: apply ruff format --- src/resolvekit/builder/pipeline/stages.py | 4 +++- src/resolvekit/core/api/bulk.py | 3 +-- src/resolvekit/core/explain/result_html.py | 7 ++++--- tests/builder/test_reviewer_fixes.py | 8 ++++---- tests/building/test_wikidata_aliases.py | 12 +++++++++--- tests/test_disambiguation_repr.py | 1 + 6 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/resolvekit/builder/pipeline/stages.py b/src/resolvekit/builder/pipeline/stages.py index a09c891..b8a3b08 100644 --- a/src/resolvekit/builder/pipeline/stages.py +++ b/src/resolvekit/builder/pipeline/stages.py @@ -540,7 +540,9 @@ def _external_shipped_ids( continue try: with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) as conn: - ids.update(row[0] for row in conn.execute("SELECT entity_id FROM entities")) + ids.update( + row[0] for row in conn.execute("SELECT entity_id FROM entities") + ) except sqlite3.OperationalError: # File exists but has no entities table (e.g. 0-byte placeholder # written by a prior interrupted build). Skip it — this pack has diff --git a/src/resolvekit/core/api/bulk.py b/src/resolvekit/core/api/bulk.py index 43e464a..9aef430 100644 --- a/src/resolvekit/core/api/bulk.py +++ b/src/resolvekit/core/api/bulk.py @@ -227,8 +227,7 @@ def _dedup_pairs( seen[pair_key] = len(unique_pairs) unique_pairs.append((v, ctx)) indexer: list[int | None] = [ - None if v is None else seen[(v, ctx_key)] - for v, ctx_key, _ in row_keys + None if v is None else seen[(v, ctx_key)] for v, ctx_key, _ in row_keys ] return unique_pairs, indexer diff --git a/src/resolvekit/core/explain/result_html.py b/src/resolvekit/core/explain/result_html.py index 5b84927..4c52aa2 100644 --- a/src/resolvekit/core/explain/result_html.py +++ b/src/resolvekit/core/explain/result_html.py @@ -162,12 +162,13 @@ def disambiguate_hint(result: ResolutionResult) -> str | None: # Same-name / same-type candidates: emit a country hint only when candidates # span ≥2 distinct countries — if they all share the same country, the hint # cannot disambiguate and would be useless/misleading. - countries = [c.parent_country for c in result.candidates if c.parent_country is not None] + countries = [ + c.parent_country for c in result.candidates if c.parent_country is not None + ] if len(set(countries)) >= 2: qt = result.query_text return ( - f"resolvekit.resolve(text={qt!r}, " - f"context={{'country': {countries[0]!r}}})" + f"resolvekit.resolve(text={qt!r}, context={{'country': {countries[0]!r}}})" ) return None diff --git a/tests/builder/test_reviewer_fixes.py b/tests/builder/test_reviewer_fixes.py index 4d00e6d..291a8b0 100644 --- a/tests/builder/test_reviewer_fixes.py +++ b/tests/builder/test_reviewer_fixes.py @@ -329,14 +329,14 @@ def test_external_shipped_ids_tolerates_zero_byte_sqlite(tmp_path: Path) -> None pack_dir.mkdir(parents=True) # metadata.json so iter_datapack_dirs picks this directory up as a pack. (pack_dir / "metadata.json").write_text( - json.dumps({"datapack_id": "geo.countries-v0.0.0", "module_id": "geo.countries"}) + json.dumps( + {"datapack_id": "geo.countries-v0.0.0", "module_id": "geo.countries"} + ) ) # 0-byte placeholder — no SQLite header, no tables. (pack_dir / "entities.sqlite").write_bytes(b"") - result = _external_shipped_ids( - datapacks_root=tmp_path, exclude_module_ids=set() - ) + result = _external_shipped_ids(datapacks_root=tmp_path, exclude_module_ids=set()) assert result == set(), ( "A 0-byte entities.sqlite should yield an empty id set, not raise" diff --git a/tests/building/test_wikidata_aliases.py b/tests/building/test_wikidata_aliases.py index 6abd594..55b8634 100644 --- a/tests/building/test_wikidata_aliases.py +++ b/tests/building/test_wikidata_aliases.py @@ -287,7 +287,9 @@ def test_precision_filter_applied_per_entity() -> None: # --------------------------------------------------------------------------- -def test_empty_batch_logs_warning_and_continues(caplog: pytest.LogCaptureFixture) -> None: +def test_empty_batch_logs_warning_and_continues( + caplog: pytest.LogCaptureFixture, +) -> None: """Empty single-batch response is logged as warning; treated as "no aliases". A single empty batch after internal retries is plausibly "no aliases"; multi-batch @@ -302,7 +304,9 @@ def test_empty_batch_logs_warning_and_continues(caplog: pytest.LogCaptureFixture "resolvekit.builder.sources.wikidata.aliases.sparql_request", return_value=[], ), - caplog.at_level(logging.WARNING, logger="resolvekit.builder.sources.wikidata.aliases"), + caplog.at_level( + logging.WARNING, logger="resolvekit.builder.sources.wikidata.aliases" + ), ): result = fetch_wikidata_en_aliases( codes_by_entity=codes_by_entity, @@ -327,7 +331,9 @@ def test_empty_batch_with_cache_dir_logs_warning( "resolvekit.builder.sources.wikidata.aliases.sparql_request", return_value=[], ), - caplog.at_level(logging.WARNING, logger="resolvekit.builder.sources.wikidata.aliases"), + caplog.at_level( + logging.WARNING, logger="resolvekit.builder.sources.wikidata.aliases" + ), ): result = fetch_wikidata_en_aliases( codes_by_entity=codes_by_entity, diff --git a/tests/test_disambiguation_repr.py b/tests/test_disambiguation_repr.py index 7f9d0b9..ae32cde 100644 --- a/tests/test_disambiguation_repr.py +++ b/tests/test_disambiguation_repr.py @@ -508,6 +508,7 @@ def _make_mock_relation(self, relation_type: str, target_id: str) -> MagicMock: def test_two_level_parent_name_is_region_not_country(self) -> None: from resolvekit.core.engine.enrichment import ResultEnricher from resolvekit.core.model.result import CandidateSummary + texas = self._make_mock_entity( iso2=None, canonical_name="Texas",