From 27b4df6e34dcb3dbb624f42e61755986bf9161b2 Mon Sep 17 00:00:00 2001 From: SecureCloudGroup Date: Tue, 13 Oct 2026 20:55:14 -0400 Subject: [PATCH] Locate round G1+G2: places from the right words, regions never a point, two-place asks stop honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sealed set G found four silent wrongs in place resolution ("how many quakes … hit Alaska" ran at Many, Louisiana; "hurricanes in the Atlantic" at Atlantic, Iowa; two-place compares sealed one city). G1b: a bare one-word place alias written lowercase names a place only with a cue (capitalised, its own state named, or a place-cue word before it); a prominent place (rank ≈ log10 population ≥ 5.0, 100k people) is its own cue unless the ask names another state (G1a); the intent's own place text never needs a cue. G1c: a closed REGION_WORDS table is vetoed before scoring. G2: two cued places joined by a comparison word give named_place the status "compound" and locate stops honestly with a closed reason; the intent validator accepts a short place list and routes to the same stop. Labeled locate set unchanged (103/108); 25 of 350 development asks change, every one a G1/G2 class; 61/61 probes; 14 lowercase big-city asks all resolve. --- CHANGELOG.md | 9 + app/smartbrain_3000/library_index.py | 73 ++++- app/smartbrain_3000/library_resolve.py | 45 ++- app/smartbrain_3000/ni_flow.py | 12 +- app/tests/test_library_resolve_g1g2.py | 427 +++++++++++++++++++++++++ docs/internal/ni-format.md | 32 +- 6 files changed, 590 insertions(+), 8 deletions(-) create mode 100644 app/tests/test_library_resolve_g1g2.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cc2bf782..6e84ae63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -122,6 +122,15 @@ to know when a release changes behavior. honest "no source reports a forecast discussion yet" when none of your sources carries one, instead of a stat built from the day's temperature. "Hourly rain forecast" now prefers the hour-by-hour list over a single hour's reading. +- **A card's place comes from the place you named, never from a stray word.** "How + many quakes hit Alaska" used to run at Many, Louisiana (the word "many" matched the + town) and "hurricanes in the Atlantic" at Atlantic, Iowa; a small town's one-word name + now needs a cue (a capital letter, its state, or "in / near / at" before it), a + region such as the Atlantic or the Gulf Coast is never a point on the map, and a + state you name wins over a same-named town elsewhere. Big cities keep working in + lowercase ("seattle weather tomorrow"). An ask that compares two places ("is it + warmer in Phoenix or Miami") no longer silently answers for one of them: it stops + with links and says that one source answers one place. - **Asks that say "advisories" or "watches" find the alerts sources, and a transit ask no longer lands on airport status.** "Heat advisories in Arizona" and "coastal flood advisories for Long Island" went to links because the plural never matched the diff --git a/app/smartbrain_3000/library_index.py b/app/smartbrain_3000/library_index.py index 4204b147..2b67927b 100644 --- a/app/smartbrain_3000/library_index.py +++ b/app/smartbrain_3000/library_index.py @@ -258,17 +258,75 @@ def folded(t: str) -> str: return bool({_fold(w) for w in need} & set((text + folded(name)).split())) +# G2 (blind50g review): a two-subject ask ("X or Y", "X vs Y", "X compared with Y") must not seal a +# one-subject card. A comparison word joining two independently-cued places (or a place and a state +# that doesn't contain it) makes the ask "compound": locate offers no candidate, closed. +_COMPARISON_PHRASES = ("compared with", "compared to", "versus", "against", "vs", "than", "or") +_COMPARISON_RE = re.compile( + r"\b(" + "|".join(p.replace(" ", r"\s+") for p in _COMPARISON_PHRASES) + r")\b", re.IGNORECASE) + + +def _comparison_split(ask: str) -> tuple[str, str] | None: + """``ask`` split at its leftmost comparison word/phrase, case-insensitive; None when it names + none. Splits the ORIGINAL ask (not a lowercased copy) so each half keeps its own capitalisation + — ``by_name``'s cue grammar (G1b) reads that back on each side independently.""" + assert isinstance(ask, str) and ask, "ask must be a non-empty string" + m = _COMPARISON_RE.search(ask) + if m is None: + return None + assert m.start() < m.end(), "a real match has a non-empty span" + return ask[:m.start()], ask[m.end():] + + +def _side_location(res, half: str) -> tuple[str, str, str]: + """(name, "place"|"state"|"", its state/own code) ``half`` names with its own cue, else ("", "", ""). + An "ambiguous" reading (no single dominant population, e.g. "Charleston") has no ``best``; its + likeliest candidate (``by_name`` ranks these by population, largest first) stands in for it.""" + assert isinstance(half, str), "half must be a string" + from .library_resolve import norm + low = f" {norm(half)} " + r = res.by_name("place", half) + words, _passed = _place_words_in(res, r, low, half, "") + top = r.get("best") or next(iter(r.get("candidates") or []), None) + if words and top: + assert isinstance(top, dict), "a resolved reading carries its entry dict" + return norm(top["name"]), "place", str(top.get("state") or "") + st = res.by_name("us_state", half) + if st["status"] == "resolved": + return norm(st["best"]["name"]), "state", str(st["best"].get("key") or "").upper() + return "", "", "" + + +def _is_compound(res, ask: str) -> bool: + """Two distinct, independently-cued places (or a place and a non-containing state) joined by a + comparison word: "is it warmer in Phoenix or Miami", never "Lake Michigan... in Chicago" (no + comparison word) or "Dodgers vs Giants" (neither side is a place).""" + assert isinstance(ask, str) and ask, "ask must be a non-empty string" + split = _comparison_split(ask) + if split is None: + return False + assert len(split) == 2, "a comparison split always returns two halves" + name_a, kind_a, code_a = _side_location(res, split[0]) + name_b, kind_b, code_b = _side_location(res, split[1]) + if not name_a or not name_b or name_a == name_b: + return False + return not (code_a and code_a == code_b and "state" in (kind_a, kind_b)) + + def named_place(res, ask: str, hint_place: str | None = None) -> tuple[set[str], bool, str]: """The place the ask names, decided as the fill decides it: (its words, named?, the resolver's status). Starts from the resolver's own reading (a "largest of its name" pick is a big place), then the other ties; a place is the ask's when it is big, follows in/at/near/for/around, is a reviewed area name, comes with a state, or is the intent's own place. A place the resolver can't settle is "ambiguous", never - dropped. A ZIP or a state name is a place too.""" + dropped. A ZIP or a state name is a place too. Two independently-cued places joined by a comparison + word are "compound" (G2): never one place sealed for an ask that names two.""" from .library_resolve import norm low = f" {norm(ask)} " if re.search(r"\b\d{5}\b", ask or ""): return set(), True, "resolved" + if _is_compound(res, ask): + return set(), False, "compound" st = res.by_name("us_state", ask) state_words = set(norm(st["best"]["name"]).split()) if st["status"] == "resolved" else set() hinted = f" {norm(hint_place or '')} " @@ -283,10 +341,11 @@ def named_place(res, ask: str, hint_place: str | None = None) -> tuple[set[str], status = r["status"] if not words and passed: # a longer name that isn't the place can hide the one that is ("Lake Erie Beach" in "Lake Erie water - # temp Cleveland"): the rest of the ask is read once more - rest = low + # temp Cleveland"): the rest of the ask is read once more, from the ORIGINAL ask (not the lowercased + # `low`) so a one-word alias elsewhere in it ("Cleveland") keeps its own capitalisation cue (G1b) + rest = ask for said in passed: - rest = rest.replace(f" {said} ", " ") + rest = re.sub(rf"\b{re.escape(said)}\b", " ", rest, flags=re.IGNORECASE) r = res.by_name("place", rest) words, _ = _place_words_in(res, r, low, ask, hinted) status = r["status"] if words else status @@ -305,7 +364,7 @@ def _said_place(res, hint_place: str, low: str) -> tuple[str, str]: """The words of the intent's place as the ask says them (its name or an alias), and the resolver's status for it; ('', 'none') when the ask doesn't say it.""" from .library_resolve import norm - h = res.by_name("place", hint_place) + h = res.by_name("place", hint_place, cued=True) # the intent named it: no G1b cue required of its text for c in ([h["best"]] if h["best"] else h["candidates"][:3]): # bounded: three readings nm = norm(c["name"]) said = nm if f" {nm} " in low else next((w for w in _aliases_in(res, c["id"], low)), "") @@ -1149,6 +1208,10 @@ def candidates(self, ask: str, limit: int = 3, hint: dict | None = None) -> tupl if route is None: # a pack without route asks: the keyword frame, as without vectors vectors = None ctx = self._context(con, ask, hint, route) + if ctx["place_status"] == "compound": + # G2: a two-subject ask ("Phoenix or Miami") gets no candidate — one source answers + # one place; the caller's existing "no row" path is the honest stop with links + return [], ["this compares two places; one source answers one place"] if vectors is not None: keyword = [sid for sid, _score in self._ranked(con, ctx, "s.tier <> 'harvested'", [])] ranked = vectors.fuse(keyword, vectors.dense(q), route) diff --git a/app/smartbrain_3000/library_resolve.py b/app/smartbrain_3000/library_resolve.py index 0f37dec3..18b2fe56 100644 --- a/app/smartbrain_3000/library_resolve.py +++ b/app/smartbrain_3000/library_resolve.py @@ -50,6 +50,22 @@ GEO_NEAR = ("tide_station", "buoy", "radar_site", "nwps_gauge") +# G1c (blind50g review, 2026-10-12): a basin/ocean/region word is never a point place — closed, from +# the ruling's own list. "Atlantic City" / "Pacific Grove" / "Midwest City" are different, longer +# aliases and are untouched (the veto matches only these exact words). +REGION_WORDS = frozenset([ + "atlantic", "pacific", "gulf of mexico", "caribbean", "arctic", "indian ocean", "great lakes", + "gulf coast", "east coast", "west coast", "midwest", "northeast", "southeast", "southwest", + "pacific northwest", "new england", +]) + +# G1b: a place cue that can admit a bare one-word place alias ("quakes near Many") +PLACE_CUES = frozenset(["in", "near", "at", "around", "for", "from", "to", "of"]) +# G1b: a prominent place needs no cue — its one-word name IS the cue ("seattle weather tomorrow"); the +# pack's place rank is ~log10(population), so 5.0 is a city of 100,000 people or more. Below it a bare +# lowercase one-word alias ("many", "normal", "hope", "winter", "gas") is an ordinary word first. +BARE_PLACE_RANK = 5.0 + def norm(text: str) -> str: return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]+", " ", (text or "").lower().replace("&", " and "))).strip() @@ -83,6 +99,16 @@ def _row(r: tuple) -> dict: _ENTRY_COLS = "e.id, e.resolver, e.kind, e.key, e.name, e.lat, e.lon, e.state, e.attrs, e.rank" +def _place_cued(gram: str, i: int, tokens: list[str], titled: set[str], state: str, named: set[str]) -> bool: + """A bare one-word place alias ("many", "normal", "hope") is the ask's place only with a cue + (G1b): the ask capitalises it, names its own state, or a place-cue word comes right before it.""" + assert isinstance(gram, str) and gram, "gram must be a non-empty string" + assert isinstance(tokens, list) and 0 <= i < len(tokens), "i must index tokens" + if gram in titled or (state and state in named): + return True + return i > 0 and tokens[i - 1] in PLACE_CUES + + class Resolver: """The one matcher over the pack's resolver tables (see the module docstring).""" @@ -108,7 +134,10 @@ def source_record(self, source_id: str) -> dict | None: row = self._con.execute("SELECT record FROM library_sources WHERE id = ?", [source_id]).fetchone() return json.loads(row[0]) if row else None - def by_name(self, resolver: str, ask: str, *, many: bool = False) -> dict: + def by_name(self, resolver: str, ask: str, *, many: bool = False, cued: bool = False) -> dict: + """``cued``: the caller vouches that ``ask`` names a place (the intent's own place text), so the + G1b bare-alias cue is not required of it.""" + assert isinstance(cued, bool), "cued must be a bool" tokens = norm(ask).split() grams = [] for i in range(len(tokens)): @@ -129,6 +158,9 @@ def by_name(self, resolver: str, ask: str, *, many: bool = False) -> dict: by_alias.setdefault(r[10], []).append((e["id"], bool(r[11]))) aliases_of.setdefault(e["id"], []).append(r[10]) upper = set(re.findall(r"\b[A-Z0-9$]{1,6}\b", ask or "")) + # "capitalised" (G1b) is Title case ("Many") or a spelled-out code ("NYC") — the same signal + # the two-letter rule already reads off `upper`, generalised to any length + titled = {m.lower() for m in re.findall(r"\b[A-Z][a-z]+\b", ask or "")} | {u.lower() for u in upper} states = states_in(ask, self) # a capitalised state code that is also a place's nickname ("LA", "DC") names that place only when # no other place is named: "LA weather" is Los Angeles, "Lafayette LA" is Lafayette, Louisiana @@ -143,6 +175,8 @@ def by_name(self, resolver: str, ask: str, *, many: bool = False) -> dict: spec = sum(1 for w in gram.split() if w not in ENGLISH) or 1 if n == 1 and gram in ENGLISH: continue + if resolver == "place" and gram in REGION_WORDS: + continue # G1c: a basin/ocean/region word is never a point place for eid, partial in by_alias.get(gram, []): e = entries[eid] if n == 1 and gram == norm(e["key"]) and len(gram) <= 5 and gram not in norm(e["name"]).split(): @@ -155,6 +189,15 @@ def by_name(self, resolver: str, ask: str, *, many: bool = False) -> dict: continue if n == 1 and e["kind"] == "place" and len(gram) <= 2 and gram.upper() not in upper: continue # a two-letter place nickname counts only in capitals ("LA", never the word "la") + # G1b: a longer one-word place alias written lowercase ("many") needs a cue too — the + # ask capitalises it, names its own state, or a place-cue word sits right before it. + # A prominent place (BARE_PLACE_RANK) is its own cue ("seattle weather tomorrow") unless + # the ask names some OTHER state (G1a: "mobile … across illinois" is not Mobile, AL); the + # caller can vouch for the text instead (``cued``: the intent named it as the place). + if n == 1 and e["kind"] == "place" and len(gram) > 2 and not cued \ + and not _place_cued(gram, i, tokens, titled, e["state"], states) \ + and ((e["rank"] or 0.0) < BARE_PLACE_RANK or (states and e["state"] not in states)): + continue score = (2.0 * spec - (0.5 if partial else 0.0) + (0.5 if gram == norm(e["name"]) else 0.0) + RANK_WEIGHT.get(resolver, 0.05) * e["rank"]) said = states - {gram.upper()} if n == 1 and gram in codes else states diff --git a/app/smartbrain_3000/ni_flow.py b/app/smartbrain_3000/ni_flow.py index 8d7b5607..fc03ae87 100644 --- a/app/smartbrain_3000/ni_flow.py +++ b/app/smartbrain_3000/ni_flow.py @@ -452,6 +452,9 @@ def _parse_json_reply(text: str) -> dict: _INTENT_NAME_WORD_RE = re.compile(r"[A-Za-z0-9]+") _MAX_INTENT_NAMES = 5 _MAX_INTENT_NAME_CHARS = 60 +# G2: a compound ask ("Phoenix or Miami") can make the model name both places as a list +_MAX_COMPOUND_PLACES = 3 +_MAX_COMPOUND_PLACE_CHARS = 60 def _clean_intent_names(raw: object, request: str) -> list[str]: @@ -506,10 +509,17 @@ def _validate_intent(reply: dict, request: str = "") -> dict: # geocode-consent (2026-09-15): ``place`` is optional and bounded — it only # ever becomes a percent-encoded geocode query behind the confirm card. place = reply.get("place") + if isinstance(place, list) and len(place) <= _MAX_COMPOUND_PLACES \ + and all(isinstance(p, str) and len(p) <= _MAX_COMPOUND_PLACE_CHARS for p in place): + # G2: a compound ask ("Phoenix or Miami") can make the model name both places as a list; + # the ask's own words, not this field, drive locate's compound honest stop (named_place) — + # never a stage failure over the model's shape choice + place = " / ".join(p.strip() for p in place if p.strip()) or None if place is not None and not isinstance(place, str): raise ValueError("intent.place must be a string or null") if isinstance(place, str) and len(place) > 120: - reply["place"] = place[:120] + place = place[:120] + reply["place"] = place reply["names"] = _clean_intent_names(reply.get("names"), request) return reply diff --git a/app/tests/test_library_resolve_g1g2.py b/app/tests/test_library_resolve_g1g2.py new file mode 100644 index 00000000..6fe48004 --- /dev/null +++ b/app/tests/test_library_resolve_g1g2.py @@ -0,0 +1,427 @@ +"""Round 20 resolver G1+G2 (blind50g review, 2026-10-12): three silent-wrong classes in +``library_resolve.Resolver.by_name("place", ask)`` / ``library_index.named_place``. + +G1a. A state named in the ask eclipses an out-of-state candidate: the state becomes the place so a + state-scoped source serves and a point source refuses honestly (no new code — the existing + ``named_place`` state-words fallback fires once G1b stops the false candidate). +G1b. A one-word place alias written lowercase ("many") counts only with a cue: the ask capitalises + it, names its own state, or a place-cue word sits right before it ("near Many"). +G1c. A basin/ocean/region word (Atlantic, Pacific, Midwest, ...) is never a point place, closed list. +G2. Two independently-cued places (or a place and a non-containing state) joined by a comparison + word make the ask "compound": locate offers no candidate, closed reason, honest stop. + +A small synthetic pack (resolver tables only, built in-memory) stands in for the pinned pack; the +population figures approximate pack_f's own (log10(pop) facts gathered for this round). No network, +no model except where a flow-level test stubs one. +""" + +from __future__ import annotations + +import gzip +import hashlib +import json +import math +from pathlib import Path + +import duckdb +import pytest + +from smartbrain_3000 import db as dbmod +from smartbrain_3000 import library_index, ni_flow +from smartbrain_3000 import ni as nimod +from smartbrain_3000.library_resolve import Resolver +from smartbrain_3000.secrets import gen_master_key + +# id, key, name, lat, lon, state, pop, aliases — the ruling's probe towns + a few ordinary big cities +_PLACES = [ + ("place:many", "many", "Many", 31.57, -93.48, "LA", 2470, ["many"]), + ("place:normal", "normal", "Normal", 40.51, -88.99, "IL", 53700, ["normal"]), + ("place:hope", "hope", "Hope", 33.67, -93.59, "AR", 8700, ["hope"]), + ("place:surprise", "surprise", "Surprise", 33.63, -112.37, "AZ", 147000, ["surprise"]), + ("place:mobile", "mobile", "Mobile", 30.69, -88.04, "AL", 187000, ["mobile"]), + ("place:reading", "reading", "Reading", 40.34, -75.93, "PA", 95000, ["reading"]), + ("place:why", "why", "Why", 31.96, -112.74, "AZ", 116, ["why"]), + ("place:nice", "nice", "Nice", 39.09, -122.83, "CA", 500, ["nice"]), + ("place:midwest-wy", "midwest-wy", "Midwest", 43.42, -106.27, "WY", 400, ["midwest"]), + ("place:midwest-city", "midwest-city", "Midwest City", 35.46, -97.40, "OK", 58000, ["midwest city"]), + ("place:atlantic-ia", "atlantic-ia", "Atlantic", 41.40, -94.98, "IA", 6760, ["atlantic"]), + ("place:pacific-mo", "pacific-mo", "Pacific", 38.48, -90.75, "MO", 7000, ["pacific"]), + ("place:pacific-wa", "pacific-wa", "Pacific", 46.50, -122.25, "WA", 7100, ["pacific"]), + ("place:new-england-nd", "new-england-nd", "New England", 46.53, -102.87, "ND", 692, ["new england"]), + ("place:gulf-nc", "gulf-nc", "Gulf", 35.38, -79.30, "NC", 100, ["gulf"]), + ("place:phoenix", "phoenix", "Phoenix", 33.45, -112.07, "AZ", 1650000, ["phoenix"]), + ("place:miami", "miami", "Miami", 25.76, -80.19, "FL", 450000, ["miami"]), + ("place:houston", "houston", "Houston", 29.76, -95.37, "TX", 2300000, ["houston"]), + ("place:new-orleans", "new-orleans", "New Orleans", 29.95, -90.07, "LA", 370000, ["new orleans"]), + ("place:chicago", "chicago", "Chicago", 41.88, -87.63, "IL", 2700000, ["chicago"]), + ("place:boston", "boston", "Boston", 42.36, -71.06, "MA", 675000, ["boston"]), + ("place:denver", "denver", "Denver", 39.74, -104.99, "CO", 715000, ["denver"]), + ("place:dallas", "dallas", "Dallas", 32.78, -96.80, "TX", 1300000, ["dallas"]), + ("place:fort-worth", "fort-worth", "Fort Worth", 32.75, -97.33, "TX", 935000, ["fort worth"]), + ("place:charleston", "charleston", "Charleston", 32.78, -79.93, "SC", 150000, ["charleston"]), + # lead probes (2026-10-13): a prominent city is its own cue in lowercase; a mid-size one is not + ("place:seattle", "seattle", "Seattle", 47.61, -122.33, "WA", 780995, ["seattle"]), + ("place:galveston", "galveston", "Galveston", 29.30, -94.80, "TX", 53538, ["galveston"]), + # G1c control: a real, longer place name that merely starts with a region word — untouched + ("place:atlantic-city", "atlantic-city", "Atlantic City", 39.36, -74.42, "NJ", 38500, ["atlantic city"]), +] +# id, name, code — every state the places above sit in, plus Alaska (named, never a town here) +_STATES = [ + ("AK", "Alaska"), ("AZ", "Arizona"), ("IL", "Illinois"), ("AR", "Arkansas"), ("AL", "Alabama"), + ("PA", "Pennsylvania"), ("CA", "California"), ("WY", "Wyoming"), ("OK", "Oklahoma"), ("IA", "Iowa"), + ("MO", "Missouri"), ("WA", "Washington"), ("ND", "North Dakota"), ("NC", "North Carolina"), + ("FL", "Florida"), ("TX", "Texas"), ("LA", "Louisiana"), ("MA", "Massachusetts"), ("CO", "Colorado"), + ("SC", "South Carolina"), +] + + +def _build_resolver_tables(con: duckdb.DuckDBPyConnection) -> None: + """Just the two resolver tables ``Resolver.by_name``/``named_place`` read.""" + con.execute("CREATE TABLE library_resolver_entries(id VARCHAR PRIMARY KEY, resolver VARCHAR, kind VARCHAR, " + "key VARCHAR, name VARCHAR, lat DOUBLE, lon DOUBLE, state VARCHAR, attrs JSON, rank DOUBLE)") + con.execute("CREATE TABLE library_resolver_aliases(alias VARCHAR, entry_id VARCHAR, partial BOOLEAN)") + for eid, key, name, lat, lon, state, pop, aliases in _PLACES: + rank = round(math.log10(pop), 2) if pop > 0 else 0.0 # the pack's own rank is ~log10(population) + con.execute("INSERT INTO library_resolver_entries VALUES (?,'place','place',?,?,?,?,?,?,?)", + (eid, key, name, lat, lon, state, json.dumps({"pop": pop}), rank)) + for a in aliases: + con.execute("INSERT INTO library_resolver_aliases VALUES (?,?,FALSE)", (a, eid)) + for code, name in _STATES: + eid = f"us_state:{code}" + con.execute("INSERT INTO library_resolver_entries VALUES (?,'us_state','us_state',?,?,NULL,NULL,?,'{}',0.0)", + (eid, code, name, code)) + for a in (name.lower(), code.lower()): + con.execute("INSERT INTO library_resolver_aliases VALUES (?,?,FALSE)", (a, eid)) + + +@pytest.fixture(scope="module") +def con() -> duckdb.DuckDBPyConnection: + c = duckdb.connect(":memory:") + _build_resolver_tables(c) + return c + + +@pytest.fixture(scope="module") +def res(con: duckdb.DuckDBPyConnection) -> Resolver: + return Resolver(con) + + +# --- G1b: a one-word place alias needs a cue ----------------------------------------------------- + +@pytest.mark.parametrize("ask,expect_name", [ + # lowercase, no state, no cue — a bare common-word alias of a town under BARE_PLACE_RANK + # (100,000 people) is never a matched candidate (the "many" bug, #36) + ("how many quakes hit the region today", None), + ("the city is normal sized for its state", None), + ("we still hope for better news tomorrow", None), + ("i was reading a book all day", None), + ("why did the market drop today", None), + ("that view is nice this time of year", None), + # a prominent place is its own cue, even written lowercase and bare (lead probe 2026-10-13: + # "seattle weather tomorrow" must never lose its place) — Surprise and Mobile are cities of + # 100,000+ and read as places too + ("seattle weather tomorrow", "Seattle"), + ("is phoenix hot right now", "Phoenix"), + ("the weather was a surprise to everyone", "Surprise"), + ("my phone service is mobile only", "Mobile"), + # a mid-size place written lowercase and bare still needs a cue (the intent's own place text + # supplies one through named_place — see test_the_intents_place_text_needs_no_cue) + ("galveston water temp", None), + ("water temp at galveston", "Galveston"), + # lowercase, a place-cue word right before it (the full set: in/near/at/around/for/from/to/of) + ("quakes near many right now", "Many"), + ("weather in normal this week", "Normal"), + ("flood warning for reading today", "Reading"), + ("tornado watch at mobile tonight", "Mobile"), + ("tide chart around nice this week", "Nice"), + ("directions to why today", "Why"), # "to" — only this round's broader by_name gate + ("stories of surprise this week", "Surprise"), # "of" — ditto + # lowercase, its own state named — admits it even with no cue word + ("many louisiana flood watch", "Many"), + ("normal illinois weather report", "Normal"), + ("hope arkansas storm warning", "Hope"), + # lowercase, a DIFFERENT state named — the wrong state never rescues it (#36's actual shape) + ("normal weather reported across texas", None), + ("mobile conditions improving across illinois", None), + # capitalised — the ask's own spelling is itself enough to be a matched candidate + ("Many is a small town", "Many"), + ("Normal gets cold in winter", "Normal"), + ("Hope sits near the river", "Hope"), + ("Surprise sits in the desert", "Surprise"), + ("Mobile is a coastal city", "Mobile"), + ("Reading gets crowded on weekends", "Reading"), + ("Why sits along a desert highway", "Why"), + ("Nice had record rainfall", "Nice"), +]) +def test_g1b_by_name_requires_a_cue_for_a_bare_one_word_alias( + res: Resolver, ask: str, expect_name: str | None) -> None: + r = res.by_name("place", ask) + if expect_name is None: + assert r["status"] == "none", (ask, r) + else: + assert r["status"] == "resolved" and r["best"]["name"] == expect_name, (ask, r) + + +def test_the_intents_place_text_needs_no_cue(res: Resolver) -> None: + """Lead probe 2026-10-13: the flow resolves the intent's own place text through named_place; that + text is the cue (``by_name(..., cued=True)``), so a lowercase mid-size place the model copied as + written ("galveston") still resolves — the bare ask alone does not.""" + words, has_place, status = library_index.named_place(res, "galveston water temp", None) + assert (words, has_place, status) == (set(), False, "none") + words, has_place, status = library_index.named_place(res, "galveston water temp", "galveston") + assert words == {"galveston"} and has_place and status == "resolved" + # a prominent place named by the intent in another state's ask is still not the place (G1a) + r = res.by_name("place", "mobile conditions improving across illinois") + assert r["status"] == "none" + + +def test_g1b_an_all_caps_short_code_counts_as_capitalised(res: Resolver) -> None: + """Live blind50g: "any delays on the NYC subway" first regressed under this round's own fix — a + Title-case regex alone missed "NYC" (all capitals), dropping the place and letting an unrelated + city's transit source through. The existing two-letter rule's own ``upper`` signal (ALL-CAPS, + 1-6 chars) must count as "capitalised" for a longer one-word alias too.""" + r = res.by_name("place", "storms moving over MOBILE tonight") + assert r["status"] == "resolved" and r["best"]["name"] == "Mobile" + + +@pytest.mark.parametrize("ask,expect", [ + # a small town (pop < 100k) stays unnamed without a cue even when the ask capitalises it — the + # SAME precedent as the pack's own "Melbourne"/"Annapolis" (test_of_is_not_a_place_cue, + # test_named_place_starts_from_the_resolvers_decision): a candidate match alone isn't a NAMED + # place; relevance still wants a cue, a state, size or a full multi-word name + ("Many is a small town", set()), + ("Hope sits near the river", set()), + ("Why sits along a desert highway", set()), + # a BIG same-named city (pop >= 100k) is unaffected either way — already-working, not this fix + ("Surprise sits in the desert", {"surprise"}), + ("Mobile is a coastal city", {"mobile"}), + # a cue in named_place's own narrower set admits it for relevance too + ("quakes near many right now", {"many"}), + ("weather in normal this week", {"normal"}), + # its own state admits it (the union includes the state's words, as it already does for any place) + ("many louisiana flood watch", {"many", "louisiana"}), +]) +def test_g1b_named_place_relevance_matches_the_pinned_precedent(res: Resolver, ask: str, expect: set[str]) -> None: + words, named, _status = library_index.named_place(res, ask) + assert words == expect, (ask, words) + assert named == bool(expect), (ask, named) + + +def test_g1b_a_wrong_named_state_does_not_rescue_a_cueless_alias(res: Resolver) -> None: + """"how many quakes ... hit Alaska" (#36): "many" (Louisiana) still has no cue, and Alaska is a + DIFFERENT state — the state-named branch must check the CANDIDATE's own state, not just that + some state was named anywhere.""" + ask = "how many quakes above magnitude 4 hit Alaska in the last 24 hours" + words, named, status = library_index.named_place(res, ask) + assert words == {"alaska"} and named and status == "resolved" # G1a: the state is the place + + +def test_g1b_midwest_city_stays_resolvable(res: Resolver) -> None: + """A region word ("midwest") is vetoed outright (G1c); its own longer, different alias + ("midwest city") is untouched and still resolves.""" + words, named, _status = library_index.named_place(res, "weather in Midwest City this weekend") + assert words == {"midwest", "city"} and named + + +# --- G1c: a region/basin word is never a point place ---------------------------------------------- + +@pytest.mark.parametrize("ask", [ + "number of active hurricanes in the Atlantic right now", # capitalised — still never a point (#37) + "any storms forming in the pacific this week", + "oil rigs in the gulf of mexico today", + "water temperature in the caribbean this week", + "sea ice extent in the arctic this month", + "shipping lanes in the indian ocean today", + "ice cover on the great lakes today", + "storm surge along the gulf coast tonight", + "flights delayed along the east coast today", + "wildfire smoke over the west coast today", + "snowfall across the midwest this week", + "storms moving through the northeast tonight", + "heat advisory for the southeast today", + "dust storm warning for the southwest today", + "rain moving into the pacific northwest today", + "nor'easter forming over new england tonight", +]) +def test_g1c_region_words_are_never_a_point_place(res: Resolver, ask: str) -> None: + words, named, status = library_index.named_place(res, ask) + assert words == set() and named is False and status == "none", (ask, words, status) + + +def test_g1c_atlantic_city_is_a_different_longer_alias(res: Resolver) -> None: + """The veto matches only the bare region word's exact alias text; a real, longer place name + ("Atlantic City") is a different alias and resolves normally.""" + r = res.by_name("place", "a trip to Atlantic City this weekend") + assert r["status"] == "resolved" and r["best"]["name"] == "Atlantic City" + + +# --- G2: a compound ask seals no single-place card ------------------------------------------------- + +@pytest.mark.parametrize("ask", [ + "is it warmer in Phoenix or Miami right now", + "humidity in Houston compared with New Orleans at the moment", + "wind speed in Chicago versus Denver today", + "rainfall in Dallas compared to Boston this month", + "snowfall in Denver against Charleston this winter", + "temperature in Miami than in Phoenix right now", +]) +def test_g2_a_compound_ask_resolves_no_candidate_place(res: Resolver, ask: str) -> None: + words, named, status = library_index.named_place(res, ask) + assert words == set() and named is False and status == "compound", (ask, words, status) + + +@pytest.mark.parametrize("ask", [ + "Lake Michigan water temperature in Chicago", # no comparison word at all + "Dodgers vs Giants score", # neither side is a place + "Bitcoin price today versus a week ago", # one subject, two times + "weather in Dallas-Fort Worth", # one place name + "flights from Boston to Denver", # a route, not a comparison + "Charleston harbor tides", # one place +]) +def test_g2_controls_stay_single_subject(res: Resolver, ask: str) -> None: + _words, _named, status = library_index.named_place(res, ask) + assert status != "compound", (ask, status) + + +def test_g2_candidates_returns_a_closed_reason_for_a_compound_ask(lib: library_index.LibraryIndex) -> None: + rows, skipped = lib.candidates("is it warmer in Phoenix or Miami right now") + assert rows == [] and skipped == ["this compares two places; one source answers one place"] + + +def test_g2_a_non_compound_ask_still_builds(lib: library_index.LibraryIndex) -> None: + rows, _skipped = lib.candidates("weather in Phoenix right now") + assert rows and rows[0]["source_id"] == "open-meteo-forecast" + + +# --- G2: the intent validator takes a compound place as a list, never a stage failure ------------- + +def test_intent_validator_accepts_a_short_place_list() -> None: + reply = {"kind": "external_data", "cadence_minutes": 15, "wants": ["temperature"], + "place": ["Phoenix", "Miami"]} + out = ni_flow._validate_intent(reply) + assert out["place"] == "Phoenix / Miami" + + +def test_intent_validator_still_refuses_a_too_long_place_list() -> None: + reply = {"kind": "external_data", "cadence_minutes": 15, "wants": ["temperature"], + "place": ["Phoenix", "Miami", "Houston", "Denver"]} + with pytest.raises(ValueError, match="intent.place must be a string or null"): + ni_flow._validate_intent(reply) + + +def test_intent_validator_still_refuses_a_non_string_non_list_place() -> None: + reply = {"kind": "external_data", "cadence_minutes": 15, "wants": ["temperature"], "place": 42} + with pytest.raises(ValueError, match="intent.place must be a string or null"): + ni_flow._validate_intent(reply) + + +# --- the pinned-pack-shaped fixture (candidates()/run_flow level) ---------------------------------- + +_TAXONOMY = [("weather", "current_value", ["current_value"], ["temperature", "weather"], + {"prefer": ["official"], "match": "geo", "resolvers": ["place"], "max_km": 30, "differ_on": []})] +_LATLON = [{"name": "lat", "fill": {"from": "resolver", "resolver": "place", "field": "lat"}}, + {"name": "lon", "fill": {"from": "resolver", "resolver": "place", "field": "lon"}}] +_SOURCE = {"id": "open-meteo-forecast", "name": "Open-Meteo forecast", "cat": "weather/current_value", + "kinds": ["current_value"], "url": "https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}", + "params": _LATLON, "desc": "Open-Meteo forecast", "authority": "primary", "prior": 1.0, "entity": "", + "audience": "", "answers": [{"name": "a", "label": "Temperature", "words": ["temperature"]}], + "examples": ["weather in a city"]} + + +def _build_pack(path: Path) -> None: + con_ = duckdb.connect(str(path)) + con_.execute("""CREATE TABLE library_sources( + id VARCHAR PRIMARY KEY, name VARCHAR, description VARCHAR, provider_id VARCHAR, provider_name VARCHAR, + authority VARCHAR, tier VARCHAR, geo VARCHAR, entity VARCHAR, access_kind VARCHAR, url_template VARCHAR, + docs_url VARCHAR, auth VARCHAR, terms_status VARCHAR, cadence VARCHAR, validation_status VARCHAR, + robots VARCHAR, votes_yes INTEGER, votes_no INTEGER, prior DOUBLE, record JSON, kinds VARCHAR[], + role VARCHAR, audience VARCHAR)""") + con_.execute("CREATE TABLE library_source_categories(source_id VARCHAR, category VARCHAR, subcategory VARCHAR)") + con_.execute("CREATE TABLE library_terms(term VARCHAR, source_id VARCHAR, weight DOUBLE)") + con_.execute("CREATE TABLE library_taxonomy(category VARCHAR, subcategory VARCHAR, label VARCHAR, " + "kinds VARCHAR[], params VARCHAR[], keywords VARCHAR[], policy JSON)") + con_.execute("CREATE TABLE library_meta(key VARCHAR, value VARCHAR)") + con_.execute("CREATE TABLE library_source_resolvers(source_id VARCHAR, resolver VARCHAR)") + _build_resolver_tables(con_) # the resolver_entries/aliases tables, shared with the unit-test pack + for cat, sub, kinds, kw, pol in _TAXONOMY: + con_.execute("INSERT INTO library_taxonomy VALUES (?,?,?,?,?,?,?)", + (cat, sub, f"{cat.title()} › {sub}", kinds, [], kw, json.dumps(pol))) + s = _SOURCE + cat, sub = s["cat"].split("/") + params = [{"kind": "x", "required": True, **p} for p in s["params"]] + acc = {"kind": "http_json", "auth": "none", "headers": {}, "params": params, "url_template": s["url"]} + rec = {"id": s["id"], "name": s["name"], "description": s["desc"], "tier": "curated", "categories": [s["cat"]], + "kinds": s["kinds"], "access": acc, "examples": s["examples"], "answers": s["answers"], + "coverage": {"entity": s["entity"], "geo": "US"}, + "provider": {"id": "p", "name": "Provider", "authority": s["authority"]}} + con_.execute("INSERT INTO library_sources VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + (s["id"], s["name"], s["desc"], "p", "Provider", s["authority"], "curated", "US", s["entity"], + "http_json", s["url"], "https://example.org", "none", "public_domain", "hourly", "ok", "allow", + 0, 0, s["prior"], json.dumps(rec), s["kinds"], "", s["audience"])) + con_.execute("INSERT INTO library_source_categories VALUES (?,?,?)", (s["id"], cat, sub)) + words = library_index.tokens(" ".join([s["name"], s["desc"], *s["examples"], + *(w for a in s["answers"] for w in a["words"])])) + for t in dict.fromkeys(words): + con_.execute("INSERT INTO library_terms VALUES (?,?,?)", (t, s["id"], 2.0)) + for p in params: + con_.execute("INSERT INTO library_source_resolvers VALUES (?,?)", (s["id"], p["fill"]["resolver"])) + con_.execute("INSERT INTO library_meta VALUES ('built_at','2026-10-12T00:00:00Z'), ('records','1')") + con_.close() + + +class _Net: + def __init__(self, payload: bytes) -> None: + self.payload = payload + + def safe_fetch_library_pack(self, url: str, max_bytes: int) -> bytes: + return self.payload + + +@pytest.fixture(scope="module") +def lib(tmp_path_factory) -> library_index.LibraryIndex: + tmp = tmp_path_factory.mktemp("g1g2") + _build_pack(tmp / "src.duckdb") + payload = gzip.compress((tmp / "src.duckdb").read_bytes()) + idx = library_index.LibraryIndex(tmp / "data", netguard_mod=_Net(payload), + pack={"tag": "vtest", "url": "https://example.org/l.gz", + "sha256": hashlib.sha256(payload).hexdigest()}) + idx.install() + return idx + + +# --- the flow-level honest stop (through run_flow, a stubbed model) -------------------------------- + +def _store() -> nimod.NIStore: + conn = duckdb.connect(":memory:") + dbmod.run_migrations(conn) + return nimod.NIStore(conn, gen_master_key()) + + +def test_a_compound_ask_through_run_flow_reaches_the_honest_stop(lib, monkeypatch) -> None: + """"is it warmer in Phoenix or Miami" never seals Phoenix's card alone: run_flow (intent, a + stubbed model; locate, the real G2 rule) lands the SAME honest stop as "no source answers yet".""" + monkeypatch.setattr(ni_flow, "_LIBRARY_PROVIDER", lambda: lib) + monkeypatch.setattr(ni_flow, "_SEARCH_PROVIDER", None) + store = _store() + ask = "is it warmer in Phoenix or Miami right now" + item_id = ni_flow.create_shell_item(store, ask) + intent_json = json.dumps({"kind": "external_data", "subject": "temperature", "cadence_minutes": 15, + "wants": ["temperature"], "place": None}) + out = ni_flow.run_flow(store, item_id, gateway_call=lambda _m, _p: intent_json, ni_route_model="m") + assert out["state"] == "source" and out["error"] == ni_flow.AWAITING_SOURCE_PICK + assert "no SmartBrain Library source answers this yet" in out["notes"][-1] + + +def test_a_place_list_intent_reaches_the_same_stop_not_a_stage_failure(lib, monkeypatch) -> None: + """A model that answers ``place`` as a list (set G live: Phoenix/Miami) must not fail the intent + stage after its retry — it reaches the ordinary source-pick pause, exactly like a clean intent.""" + monkeypatch.setattr(ni_flow, "_LIBRARY_PROVIDER", lambda: lib) + monkeypatch.setattr(ni_flow, "_SEARCH_PROVIDER", None) + store = _store() + ask = "is it warmer in Phoenix or Miami right now" + item_id = ni_flow.create_shell_item(store, ask) + intent_json = json.dumps({"kind": "external_data", "subject": "temperature", "cadence_minutes": 15, + "wants": ["temperature"], "place": ["Phoenix", "Miami"]}) + out = ni_flow.run_flow(store, item_id, gateway_call=lambda _m, _p: intent_json, ni_route_model="m") + assert out["state"] == "source" and out["error"] == ni_flow.AWAITING_SOURCE_PICK + assert out["state"] != "failed" diff --git a/docs/internal/ni-format.md b/docs/internal/ni-format.md index f8cb2f23..5dd3fb3b 100644 --- a/docs/internal/ni-format.md +++ b/docs/internal/ni-format.md @@ -1810,10 +1810,40 @@ is FIXED and pinned: state CTA, duplicate-title message, pause note). Known-and-accepted for the round (documented, not hidden): compound asks build the best single card with the unserved half disclosed at consent -(true decomposition = PLAN, the next milestone); model-ish asks land the +(true decomposition = PLAN, the next milestone) — superseded 2026-10-13 by +the G2 honest stop below; model-ish asks land the pick pause rather than an honest "cards can't write content yet" (PLAN's route classes); deeper build-slot queueing beyond honest copy. +**Locate: places and compound asks (Round 20, G1+G2 — 2026-10-13, after the +sealed set G read).** Four silent wrongs came from place resolution ("how many +quakes … hit Alaska" ran at Many, Louisiana; "hurricanes in the Atlantic" at +Atlantic, Iowa; two-place compares sealed one city). The rules, all in +`library_resolve.Resolver.by_name` / `library_index.named_place`, closed data: +G1b a bare ONE-WORD place alias written lowercase names a place only with a +cue — the ask capitalises it (ALL-CAPS codes count: "NYC"), names its own +state, or a place-cue word (in, near, at, around, for, from, to, of) sits +right before it; a prominent place (`BARE_PLACE_RANK` 5.0: the pack's rank is +~log10 population, so 100,000 people or more) is its own cue ("seattle +weather tomorrow") unless the ask names some other state (G1a: "mobile … +across illinois" is not Mobile, AL, and the named state is the place — the +state-scoped sources serve it); the intent's own place text is resolved with +`cued=True` (the model named it, so a lowercase "galveston" hint still +resolves). G1c a closed `REGION_WORDS` table (Atlantic, Pacific, Gulf of +Mexico, Caribbean, Arctic, Great Lakes, Gulf Coast, East/West Coast, Midwest, +Northeast, Southeast, Southwest, Pacific Northwest, New England …) is vetoed +before scoring — a region is never a point place; "Midwest City" and +"Atlantic City" are longer aliases and still resolve. G2 two independently +cued places (or a place and a state that does not contain it) joined by a +comparison word (or, vs, versus, compared with/to, against, than) give +`named_place` the status `compound`; `candidates()` offers nothing with the +closed reason "this compares two places; one source answers one place" and +the flow stops honestly with links; `_validate_intent` accepts `place` as a +list of ≤ 3 short strings and routes to the same stop (set F's compares had +failed at that stage). Measured on pack_f: the labeled locate set unchanged +(103 / 108), 25 of the 350 asks of sets A–G change and every one is a G1/G2 +class, 61 constructed probes 61 / 61, 14 lowercase big-city asks all resolve. + **G4b — the page door (2026-09-21, field round 2)**. The activity log rewrote the "web search is failing" report: EVERY URL the operator pasted was a normal webpage (nhc.noaa.gov/gtwo.php, spacinsider.com/news,