From 71c8ba50ecf5c8ca640978b281c66c0b1c30b511 Mon Sep 17 00:00:00 2001 From: Michael Heller Date: Mon, 3 Aug 2026 05:05:14 -0400 Subject: [PATCH] feat(lampstand): semantic parse + fail-closed router (campaign gap #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Spotlight replacement's core logic: NL query → typed tokens + resolved intent (lampstand_parse) → governed routing (lampstand_route). Discover routes to sherlock (IR); operate/egress needs an owner grant; destructive/unrecognized fails closed to the Governor. to_turn() projects a query into a Turn Witness feed turn, closing the loop to the surface. 12 tests + CI workflow. Rule/lexicon v0 → learned App-Intents next. --- .github/workflows/lampstand.yml | 27 +++++ ...mpstand_parse.cpython-312-pytest-8.3.4.pyc | Bin 0 -> 10734 bytes ...mpstand_route.cpython-312-pytest-8.3.4.pyc | Bin 0 -> 7513 bytes tests/test_lampstand_parse.py | 50 ++++++++ tests/test_lampstand_route.py | 34 ++++++ .../lampstand_parse.cpython-312.pyc | Bin 0 -> 4322 bytes .../lampstand_route.cpython-312.pyc | Bin 0 -> 3390 bytes tools/lampstand_parse.py | 113 ++++++++++++++++++ tools/lampstand_route.py | 69 +++++++++++ 9 files changed, 293 insertions(+) create mode 100644 .github/workflows/lampstand.yml create mode 100644 tests/__pycache__/test_lampstand_parse.cpython-312-pytest-8.3.4.pyc create mode 100644 tests/__pycache__/test_lampstand_route.cpython-312-pytest-8.3.4.pyc create mode 100644 tests/test_lampstand_parse.py create mode 100644 tests/test_lampstand_route.py create mode 100644 tools/__pycache__/lampstand_parse.cpython-312.pyc create mode 100644 tools/__pycache__/lampstand_route.cpython-312.pyc create mode 100644 tools/lampstand_parse.py create mode 100644 tools/lampstand_route.py diff --git a/.github/workflows/lampstand.yml b/.github/workflows/lampstand.yml new file mode 100644 index 0000000..5010cd1 --- /dev/null +++ b/.github/workflows/lampstand.yml @@ -0,0 +1,27 @@ +name: lampstand +on: + push: + paths: + - 'tools/lampstand_parse.py' + - 'tools/lampstand_route.py' + - 'tests/test_lampstand_parse.py' + - 'tests/test_lampstand_route.py' + - '.github/workflows/lampstand.yml' + pull_request: + paths: + - 'tools/lampstand_parse.py' + - 'tools/lampstand_route.py' + - 'tests/test_lampstand_parse.py' + - 'tests/test_lampstand_route.py' + - '.github/workflows/lampstand.yml' +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - run: python -m pip install pytest + - name: Run lampstand parser + router tests + run: python -m pytest tests/test_lampstand_parse.py tests/test_lampstand_route.py -q diff --git a/tests/__pycache__/test_lampstand_parse.cpython-312-pytest-8.3.4.pyc b/tests/__pycache__/test_lampstand_parse.cpython-312-pytest-8.3.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d26ea942009a859ae3335db052e78965c3eaf0c5 GIT binary patch literal 10734 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_lampstand_route.cpython-312-pytest-8.3.4.pyc b/tests/__pycache__/test_lampstand_route.cpython-312-pytest-8.3.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c6d66404fb19e2e3f647e9b16a018446ddc64144 GIT binary patch literal 7513 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 literal 0 HcmV?d00001 diff --git a/tests/test_lampstand_parse.py b/tests/test_lampstand_parse.py new file mode 100644 index 0000000..3e2a5b3 --- /dev/null +++ b/tests/test_lampstand_parse.py @@ -0,0 +1,50 @@ +from tools.lampstand_parse import parse + + +def test_discover_contact_lists_in_org(): + p = parse("show me all contact lists in my org") + assert p["intent"] == "ActionShow" + assert p["object"] == "ContactLists" + assert p["purpose"] == "discover" + assert p["space"] == "user-space" + assert "Own" in p["scope"] + assert p["admissible"] is True + + +def test_operate_launch_needs_consent(): + p = parse("open terminal on my laptop") + assert p["intent"] == "ActionLaunch" + assert p["purpose"] == "operate" + assert p["admissible"] == "consent" + + +def test_destructive_delete_is_inadmissible(): + p = parse("delete last week's logs") + assert p["intent"] == "ActionDelete" + assert p["destructive"] is True + assert p["space"] == "system-space" + assert p["admissible"] is False + + +def test_egress_send_needs_consent(): + p = parse("send my contact lists to partners") + assert p["purpose"] == "egress" + assert p["admissible"] == "consent" + + +def test_unrecognized_query_is_fail_closed(): + p = parse("the quick brown fox") + assert p["intent"] is None + assert p["admissible"] is False + + +def test_empty_query_never_raises(): + p = parse("") + assert p["intent"] is None and p["admissible"] is False + + +def test_every_content_token_is_annotated_for_known_query(): + p = parse("delete last week's logs") + # each token carries at least its recognized annotation + assert all(isinstance(t["ann"], list) for t in p["tokens"]) + assert any(a[0] == "Destructive" for t in p["tokens"] for a in t["ann"]) diff --git a/tests/test_lampstand_route.py b/tests/test_lampstand_route.py new file mode 100644 index 0000000..24d628a --- /dev/null +++ b/tests/test_lampstand_route.py @@ -0,0 +1,34 @@ +from tools.lampstand_route import route, to_turn + + +def test_discover_routes_to_ir_allowed(): + r = route("show me all contact lists in my org") + assert r["verdict"] == "allow" + assert "sherlock (IR)" in r["route"] + + +def test_operate_routes_to_consent_plane(): + r = route("open terminal on my laptop") + assert r["verdict"] == "consent" + assert "consent-plane" in r["route"] + + +def test_destructive_routes_to_governor_denied(): + r = route("delete last week's logs") + assert r["verdict"] == "deny" + assert "Governor queue" in r["route"] + + +def test_unrecognized_query_fails_closed_to_deny(): + r = route("the quick brown fox") + assert r["verdict"] == "deny" + assert "no recognized action" in r["reason"] + + +def test_to_turn_projects_a_witness_turn(): + t = to_turn("show me all contact lists in my org") + assert t["user"].startswith("show me") + assert t["purpose"] == "discover" + assert t["admissible"] is True + assert t["tokens"] and all("w" in tok and "ann" in tok for tok in t["tokens"]) + assert t["id"].startswith("turn:") diff --git a/tools/__pycache__/lampstand_parse.cpython-312.pyc b/tools/__pycache__/lampstand_parse.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c6c1eb7464b9b20d192a99c76a89259b94895083 GIT binary patch literal 4322 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 literal 0 HcmV?d00001 diff --git a/tools/__pycache__/lampstand_route.cpython-312.pyc b/tools/__pycache__/lampstand_route.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d994df5667cee187ae0b67d8ddb0b7b01039b069 GIT binary patch literal 3390 zcmai0TWl2989sBLz1iN2Z`?GlaqA7n3#I1b5DEemQ4pnITHyq(hCRpY!Lu{VnOSUa z)`_D=l~Jf-E0JsqBe{8Ul^3F_3YGfc;2IpZlN)^=hC4I8vR%&^n|C#ZwO%>FU z=FC6meCI#c@B9Bh$6{dw&-}IG&0m7`H}+F|#K7P-C>XN{qd|l@%$r<+ALKb^3#L#I z2gQOkC>7*EnL{B~E1F6{9aI4k!qR)xFu>a)R1nN`*WtvytBa! zE3aXaGaPW%57@Ad3x?wuH%tQkog5oAn3cok;SLk++-0rX879=Ub0e8y+bj?#{VH_T z>>{DMOEM%+iQ{M%AsB|V!ep#y8acz&>`@E6<*9DDT6fO29Adfqi>7W7<^bm5xU>Z8 zx=l1#M#-WiXXh=WT;B=9fu#(UVDe$MEaK#J6Gp_fslD!Rl1+QHi?%z=*5+@hKXo!j zaMM z99{j1fF&TrZLC4+u7W<<#9CYhO>hsWBa;F0JJ%Dg&2W;(DNEVrulYVd zAytHmSdm_`x_aN2w71lJzofmsJ1=RE{h$7=c~m<|+_s=zLFx93-Zs_@c#>|K_Nb>6 zOSA|)^VAxT;6jD!bT7wh9MZbaT~2#)z$6-jV|fVv8DYX+qR z_~%Pa#;~^O4imx`s9kgK$@Yz#gpm=1sYueQr))CVlYF9kG9|iWTeJy=pea@`Zqhg_ znxROGUKq5_Vu}l!B!J*h#yC&N6E`h;4ZezIZJPC2?r}v=fSJ)2hLLi^x?#~aupJo+ z&3~R<$Xs<`9hrgw?Zg}=rb%c9liQizQJapqln^Hahi#hs>!+V_ZQFD*_5Z9t{odj@ zZ3V}E!RGAj1$mf6Uv=zTy7KW;f8<`{ncYYIvI5Gk0sTZT_vf z!qV}F+{5%Aq7U9)9(dI8==DFn@@Use{O5P%YA8A#dJ<}{CU#CKUejw+QZ>~)ZOmMl zKCzfu)R&as++2Kf<>2L&_?0{I(?lBsod!_f;m@vqdiCMa-(^?&E<8>gtnS!3cW%CC zZqLdO-dt(D&c<(CQ&DPXHQrK9v{e(y*{+$cIrYQLb~Blp?VIVFYhO)vR^y4;=9%W{ z(GU03%|(7S-nkwRMddXVlH?88ER?HjE&Q;!?`!fuz<5!UYYd;@T|fj+g|F8NnA=il zx30%qT3th8z6{4xhN}RYC{|E~8{?b^hul!zt5Cy&k_t>&&!7*a9G{1|UT=VYQbh{X z7c)y{2&^m!GyL5q5irosW0lo{XO@$M;NMqn_(ptmbV0cSNX|!(qI9hM(@WH5EP?nI z4?%F&SW*D7yhMjUF*K~ZdWKc)hXBbyy*GhM>E3dPRrYrs zI67vs{d)e+^LGT~$*w&@EXU(UJ)jT7P0)wE(Cl$}`hEEN%=46zHDW>B4=BhsCark9 zfjtqTI0XWZJG8Tg-UgC=!Wf*pji*#&O^R!0*>dQQ;O_vu&aa`EM1NOfQ{?^VQ? z_ug1-PcQxCar@!N;UmDbhIqB9x!TzJr16!7<4fGVA6L(uT^|18{A%LT=jR{Znv2gJ znr~UW@gV-7@6)!`WdHK{$B9eTWNP}_%<(75p2cHuiKmau?5MW1&P8YJ^+uIlVpWp= z^R0@KKjZv4f37A^9SFaWWiW+@{pU7%z<_)eG6u~1cfh|{_t1SFix_Uvf&eWFYhf=$ zDD`4F;tLGcZX3Gh@9HQv}mb3G(RQ)@_+QnUm5T}+E~7ktY*{A}zEknbLirDuT!+1mB%EC#R>e%A0} z15B(7x~65Cfe+om3U&bk>nO91K>;JM`TI8YYZ)@#;3?TGwsYC67tUsfN-zS+W+~$X zFk!q1xh7=?L|JY}S#Yk<4re3oj273P&=*IwdL4&P3!YsD@ z;0mk93pOs9Lqq literal 0 HcmV?d00001 diff --git a/tools/lampstand_parse.py b/tools/lampstand_parse.py new file mode 100644 index 0000000..6fa8941 --- /dev/null +++ b/tools/lampstand_parse.py @@ -0,0 +1,113 @@ +"""lampstand — deterministic v0 semantic parse for the launcher. + +Turns a natural-language query into typed tokens (the annotation tree the launcher +and Turn Witness surfaces render) plus a resolved intent: {intent, object, scope, +purpose, space, admissible}. This is the rule/lexicon v0 — a transparent baseline to +be superseded by the learned App-Intents model, not a permanent dictionary. Pure +stdlib so it parses identically in CI, on device, and in tests. + +Purposes follow the consent plane: discover / implement / verify / ship / operate / +egress / administer. A destructive operate is marked so it can never self-authorize. +""" +from __future__ import annotations +from typing import Any, Dict, List + +# verb -> (intent concept, purpose) +ACTIONS = { + "show": ("ActionShow", "discover"), "list": ("ActionShow", "discover"), + "find": ("ActionShow", "discover"), "get": ("ActionShow", "discover"), + "search": ("ActionShow", "discover"), + "open": ("ActionLaunch", "operate"), "launch": ("ActionLaunch", "operate"), + "start": ("ActionLaunch", "operate"), "run": ("ActionLaunch", "operate"), + "delete": ("ActionDelete", "operate·destructive"), + "remove": ("ActionDelete", "operate·destructive"), + "wipe": ("ActionDelete", "operate·destructive"), + "send": ("ActionSend", "egress"), "share": ("ActionSend", "egress"), + "email": ("ActionSend", "egress"), + "create": ("ActionCreate", "implement"), "add": ("ActionCreate", "implement"), +} +# noun -> (concept, annotation class, space) +ENTITIES = { + "contact": ("ContactLists", "intent", "user-space"), + "contacts": ("ContactLists", "intent", "user-space"), + "list": ("ContactLists", "intent", "user-space"), + "lists": ("Lists", "type", "user-space"), + "terminal": ("TurtleTerm", "intent", "user-space"), + "log": ("LogData", "entity", "system-space"), + "logs": ("LogData", "entity", "system-space"), + "org": ("Organization", "entity", "user-space"), + "organization": ("Organization", "entity", "user-space"), + "laptop": ("Device", "entity", "user-space"), + "device": ("Device", "entity", "user-space"), +} +RELATIONS = {"in": "Contains", "on": "OnDevice", "to": "SendTo", "from": "From"} +SCOPE = {"my": "Own", "mine": "Own", "last": "TimeWindow", "week": "TimeWindow", + "week's": "TimeWindow", "today": "TimeWindow", "yesterday": "TimeWindow"} + + +def _norm(tok: str) -> str: + return tok.strip().lower().strip(".,!?;:") + + +def parse(query: str) -> Dict[str, Any]: + """Parse a query into annotated tokens + a resolved intent (never raises).""" + words = [w for w in (query or "").split() if w.strip()] + tokens: List[Dict[str, Any]] = [] + intent = obj = None + purpose = None + scopes: List[str] = [] + space = "user-space" + destructive = False + + for w in words: + n = _norm(w) + ann: List[List[str]] = [] + if n in ACTIONS: + concept, pur = ACTIONS[n] + ann.append([concept, "intent"]) + if intent is None: + intent, purpose = concept, pur + if "destructive" in pur: + destructive = True + ann.append(["Destructive", "type"]) + if n in ENTITIES: + concept, cls, sp = ENTITIES[n] + ann.append([concept, cls]) + if cls in ("intent", "entity") and obj is None: + obj, space = concept, sp + if n in RELATIONS: + ann.append([RELATIONS[n], "relation"]) + if n in SCOPE: + s = SCOPE[n] + ann.append([s, "scope"]) + if s not in scopes: + scopes.append(s) + tokens.append({"w": w, "ann": ann}) + + admissible = _admissible(purpose, space, destructive) + return { + "query": query, + "tokens": tokens, + "intent": intent, + "object": obj, + "scope": scopes, + "purpose": purpose, + "space": space, + "destructive": destructive, + "admissible": admissible, + } + + +def _admissible(purpose, space, destructive): + """Fail-closed admissibility verdict: True | 'consent' | False.""" + if purpose is None: + return False # no recognized action → nothing to admit + if destructive: + return False # irreversible acts never self-authorize + if purpose == "discover": + return True + if purpose == "implement": + return True if space in ("user-space", "agent-space") else "consent" + if purpose in ("operate", "egress", "administer"): + return "consent" # explicit owner grant required + return False diff --git a/tools/lampstand_route.py b/tools/lampstand_route.py new file mode 100644 index 0000000..7fc3206 --- /dev/null +++ b/tools/lampstand_route.py @@ -0,0 +1,69 @@ +"""lampstand — route a parsed query to a governed, typed action. + +Takes a query (or a parse dict) and returns a fail-closed routing decision: an +admissible discover routes to the IR (sherlock/holmes); an operate/egress needs an +explicit owner grant (consent-plane); a destructive or unrecognized query is refused +and escalated to the Governor. Nothing routes to execution without a verdict. +""" +from __future__ import annotations +from typing import Any, Dict, Union + +try: # importable both as `tools.lampstand_route` (from repo root) and standalone + from lampstand_parse import parse +except ImportError: # pragma: no cover + from tools.lampstand_parse import parse + + +def route(query_or_parse: Union[str, Dict[str, Any]]) -> Dict[str, Any]: + p = parse(query_or_parse) if isinstance(query_or_parse, str) else query_or_parse + adm = p.get("admissible") + + if adm is True: + verdict = "allow" + chain = (["lampstand", "sherlock (IR)", "holmes"] + if p.get("purpose") == "discover" else ["lampstand", "app-intents"]) + reason = f"admitted: :{p['intent']} · purpose={p['purpose']} · {p['space']}" + elif adm == "consent": + verdict = "consent" + chain = ["lampstand", "app-intents", "consent-plane"] + reason = f"{p['purpose']} on {p['space']} requires an explicit owner grant" + else: # False → fail closed + verdict = "deny" + chain = ["lampstand", "guardrail-fabric", "Governor queue"] + reason = ("no recognized action in query" if p.get("intent") is None + else f"{p['purpose']}: irreversible/inadmissible — escalated to Governor") + + return { + "verdict": verdict, + "route": chain, + "reason": reason, + "intent": p.get("intent"), + "object": p.get("object"), + "purpose": p.get("purpose"), + "space": p.get("space"), + "scope": p.get("scope", []), + } + + +def to_turn(query: str, sys_text: str = "") -> Dict[str, Any]: + """Project a query into a Turn Witness feed turn (surface data/turn-witness.json).""" + p = parse(query) + r = route(p) + tid = "turn:%08x" % (abs(hash(query)) & 0xFFFFFFFF) + return { + "id": tid, + "user": query, + "sys": sys_text or _default_sys(r), + "tokens": [{"w": t["w"], "g": "", "ann": t["ann"]} for t in p["tokens"]], + "purpose": p["purpose"] or "unknown", + "space": p["space"], + "admissible": p["admissible"], + } + + +def _default_sys(r: Dict[str, Any]) -> str: + return { + "allow": f"routed via {r['route'][-1]}", + "consent": "awaiting owner consent", + "deny": "refused — routed to Governor", + }[r["verdict"]]