Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions MASTER_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,3 +362,12 @@ on ours. License check before every download.
`.aiassistant` blobs, 5MB wasm). These are mined feature-by-feature from the tag with
tests during Phases 1–2, not merged wholesale. Mining checklist lives here until
each item ships or is explicitly rejected.
- 2026-07-05 — Phase 1 golden harness landed: 24 translation fixtures + 6
reconstruction fixtures, word-order-aware scorer (`tests/golden/scoring.py`), CI
gate (criticals must pass 100%). First run caught and fixed four real grammar bugs
('need'→'NE' stemming, false FINISH from -s/-ing forms, 'walking'→'WALKE',
RIGHTS→RIGHT library-sign mangling) and one vocabulary hole (NEED absent from the
avatar — added as a review-flagged placeholder, conf 2). **Phase 3 vocabulary
debt (measured):** 16 transformer signs the avatar cannot play — BOOK, BUY, FEEL,
HE, LEARN, LIKE, LIVE, MAKE, MILK, MY, NAME, PHONE, SOUTH AFRICA, TEACH, THINK,
YOUR. Record real signer data for these (plus CALL, HERE) before expanding further.
20 changes: 20 additions & 0 deletions sasl_transformer/grammar_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,26 @@
"came": "come",
"saw": "see",
"seen": "see",
# Common silent-e progressives — suffix stripping cannot recover the 'e'
# (walking→walk is right, but making→mak is not), so map them explicitly.
# NOTE: these are progressive (-ing) forms, NOT past tense; the caller
# must not derive a FINISH marker from them.
"making": "make",
"taking": "take",
"coming": "come",
"having": "have",
"giving": "give",
"writing": "write",
"living": "live",
"moving": "move",
"using": "use",
"losing": "lose",
"choosing": "choose",
"driving": "drive",
"hoping": "hope",
"closing": "close",
"sharing": "share",
"leaving": "leave",
"took": "take",
"taken": "take",
"gave": "give",
Expand Down
42 changes: 32 additions & 10 deletions sasl_transformer/transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,9 +398,16 @@ def _translate_with_rules(
question_markers.append(clean.upper())
continue

# Convert verbs to base form
# Convert verbs to base form. Only -ed forms and irregular past
# verbs signal past tense — stripping plural/3rd-person 's' or
# progressive '-ing' must NOT add a FINISH aspect marker
# (was: 'My chest hurts' → '... HURT FINISH').
base = self._to_base_form(clean)
if base != clean:
if (
base != clean
and not clean.endswith("ing") # progressives are not past tense
and (clean.endswith("ed") or clean in IRREGULAR_VERB_BASE_FORMS)
):
has_past_tense = True

content_words.append(base.upper())
Expand Down Expand Up @@ -468,23 +475,38 @@ def _to_base_form(self, word: str) -> str:
if clean in IRREGULAR_VERB_BASE_FORMS:
return IRREGULAR_VERB_BASE_FORMS[clean]

# If the word as-typed is already a known sign, never mangle it —
# suffix stripping would turn the library sign RIGHTS into RIGHT
# (not a sign) and silently downgrade it to fingerspelling.
if self._sign_library.has_sign(clean.upper()):
return clean

# Regular verb suffix stripping
if clean.endswith("ing"):
# running → run (double consonant)
stem = clean[:-3]
if len(stem) >= 2 and stem[-1] == stem[-2]:
if len(stem) < 2:
return clean
# running → run (double consonant)
if stem[-1] == stem[-2]:
return stem[:-1]
# driving → drive (silent e)
if stem and stem[-1] not in "aeiou":
# Prefer whichever candidate the sign library actually knows;
# otherwise the bare stem is the safer fingerspell (walking→walk,
# not 'walke'). Silent-e verbs are handled by the irregular map.
if self._sign_library.has_sign(stem.upper()):
return stem
if self._sign_library.has_sign((stem + "e").upper()):
return stem + "e"
return stem if stem else clean
return stem

if clean.endswith("ed"):
stem = clean[:-2]
if stem and stem[-1] == stem[-2]:
return stem[:-1]
if not stem:
# 'need'/'feed'/'speed' are not past tenses — the 'ed' belongs to
# the stem. Require a plausible stem length so we never emit
# fragments like 'ne' (was: 'I need help' → 'I NE HELP').
if len(stem) < 3 or clean.endswith("eed"):
return clean
if len(stem) >= 2 and stem[-1] == stem[-2]:
return stem[:-1]
return stem

if clean.endswith("ies"):
Expand Down
9 changes: 9 additions & 0 deletions signs_library.js
Original file line number Diff line number Diff line change
Expand Up @@ -1374,6 +1374,15 @@ const SIGN_LIBRARY = {
IL.sh,IL.el,IL.wr, NL,
{j:'R_el', ax:'x', amp:0.18, freq:2.0}),

// PLACEHOLDER pending Deaf review (MASTER_PLAN D15/D16): NEED shares the
// downward X-hand family with MUST, signed softer/single motion. Added
// because NEED is the highest-frequency clinic verb and was missing from
// the avatar vocabulary entirely (caught by the golden fixture harness).
'NEED': sign('NEED','X-hand bends downward, single soft motion','Right X-hand dips downward once — need (placeholder, review required)',2,
{x:-0.45,y:0,z:-0.22},{x:-0.60,y:0,z:0},{x:0,y:0,z:0}, HS.xhand,
IL.sh,IL.el,IL.wr, NL,
{j:'R_el', ax:'x', amp:0.12, freq:1.2}),

'VERY': sign('VERY','Both V-hands spread apart','V-hands separate from each other — intensifier',4,
{x:-0.52,y:0,z:-0.35},{x:-0.28,y:0,z:0},{x:0,y:0,z:0}, HS.vhand,
{x:-0.52,y:0,z:0.35},{x:-0.28,y:0,z:0},{x:0,y:0,z:0}, HS.vhand,
Expand Down
Empty file added tests/golden/__init__.py
Empty file.
142 changes: 142 additions & 0 deletions tests/golden/scoring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""
Deterministic scorer for the golden translation fixtures (MASTER_PLAN Phase 1).

Scores a gloss-token sequence against a fixture case on five axes:

1. required-sign recall — every expected sign present
2. forbidden signs — banned glosses absent
3. marker preservation — WILL/MUST/CAN/FINISH survive
4. word order — [before, after] pairs hold (the SASL axis a
bag-of-signs metric cannot see)
5. avatar-library compliance — unknown-sign count vs the signs the avatar
can actually play (signs_library.js is ground
truth, NOT the transformer's JSON library)

No network, no Ollama, no Node — the JS library is parsed with a regex.
"""

import json
import re
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]
SIGNS_LIBRARY_JS = REPO_ROOT / "signs_library.js"
GOLDEN_DIR = Path(__file__).resolve().parent

# Matches library entries like 'HELLO': sign( or "I'M FINE": signWithFrames(
_SIGN_KEY_RE = re.compile(r"""^\s*(['"])([A-Z][A-Z0-9 '\-]*)\1\s*:\s*sign""", re.MULTILINE)


def load_known_signs(js_path=SIGNS_LIBRARY_JS):
"""Return the set of sign names the avatar can actually play."""
text = Path(js_path).read_text(encoding="utf-8")
return {m.group(2) for m in _SIGN_KEY_RE.finditer(text)}


def load_cases(filename):
with open(GOLDEN_DIR / filename, encoding="utf-8") as fh:
return json.load(fh)["cases"]


def _contains_sign(glosses, name):
"""True if `name` appears in the gloss sequence.

Multi-word library signs (e.g. 'THANK YOU') match either as a single
token or as a consecutive run of tokens.
"""
if name in glosses:
return True
words = name.split(" ")
if len(words) > 1:
for i in range(len(glosses) - len(words) + 1):
if glosses[i:i + len(words)] == words:
return True
return False


def _first_index(glosses, name):
"""Index of the first occurrence of `name` (multi-word aware); -1 if absent."""
words = name.split(" ")
if len(words) == 1:
return glosses.index(name) if name in glosses else -1
for i in range(len(glosses) - len(words) + 1):
if glosses[i:i + len(words)] == words:
return i
return glosses.index(name) if name in glosses else -1


def score_case(case, glosses):
"""Score one fixture case against a produced gloss sequence.

Returns a dict with per-axis results and an overall `passed` flag.
A `critical` case passes only when every axis is clean.
"""
required = case.get("expected_required_signs", [])
forbidden = case.get("expected_forbidden_signs", [])
markers = case.get("expected_markers", [])
order_pairs = case.get("expected_order", [])
max_unknown = case.get("max_unknown_signs", 0)
allow_fs = case.get("allow_fingerspell", False)

missing_required = [s for s in required if not _contains_sign(glosses, s)]
forbidden_hits = [s for s in forbidden if _contains_sign(glosses, s)]
missing_markers = [m for m in markers if not _contains_sign(glosses, m)]

order_violations = []
for before, after in order_pairs:
i, j = _first_index(glosses, before), _first_index(glosses, after)
if i >= 0 and j >= 0 and i >= j:
order_violations.append([before, after])

known = load_known_signs()
covered = set()
for name in known:
idx = _first_index(glosses, name)
if idx >= 0:
covered.update(range(idx, idx + len(name.split(" "))))
unknown_signs = [g for i, g in enumerate(glosses) if i not in covered]
unknown_ok = len(unknown_signs) <= max_unknown if allow_fs else len(unknown_signs) <= max_unknown

recall = 1.0 if not required else (len(required) - len(missing_required)) / len(required)

passed = (
not missing_required
and not forbidden_hits
and not missing_markers
and not order_violations
and unknown_ok
)

return {
"id": case["id"],
"priority": case.get("priority", "normal"),
"passed": passed,
"required_recall": round(recall, 3),
"missing_required": missing_required,
"forbidden_hits": forbidden_hits,
"missing_markers": missing_markers,
"order_violations": order_violations,
"unknown_signs": unknown_signs,
"glosses": glosses,
}


def summarize(results):
"""Aggregate a result list into the baseline report shape."""
total = len(results)
passed = sum(1 for r in results if r["passed"])
critical = [r for r in results if r["priority"] == "critical"]
critical_passed = sum(1 for r in critical if r["passed"])
mean_recall = round(sum(r["required_recall"] for r in results) / total, 3) if total else 1.0
return {
"cases_total": total,
"cases_passed": passed,
"critical_total": len(critical),
"critical_passed": critical_passed,
"mean_required_recall": mean_recall,
"failures": [
{k: r[k] for k in ("id", "missing_required", "forbidden_hits",
"missing_markers", "order_violations", "unknown_signs", "glosses")}
for r in results if not r["passed"]
],
}
60 changes: 60 additions & 0 deletions tests/golden/sign_reconstruction_cases.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"description": "Golden deaf→hearing sign-reconstruction fixtures (MASTER_PLAN Phase 1). Scored at INTENT level against the deterministic fallback (simple_signs_to_english) — English fluency may vary, meaning may not.",
"review_status": "draft — awaiting SASL interpreter review (D15/D16)",
"cases": [
{
"id": "recon-help-001",
"group": "medical",
"priority": "critical",
"input_signs": ["ME", "NEED", "HELP"],
"expected_text_contains": ["help"],
"forbidden_text_contains": ["fine", "no help", "not"],
"notes": "Urgent help intent must survive; wording may vary."
},
{
"id": "recon-hungry-001",
"group": "daily",
"priority": "high",
"input_signs": ["ME", "HUNGRY"],
"expected_text_contains": ["hungry"],
"forbidden_text_contains": ["not"],
"notes": "State report."
},
{
"id": "recon-doctor-where-001",
"group": "medical",
"priority": "critical",
"input_signs": ["DOCTOR", "WHERE"],
"expected_text_contains": ["doctor", "where"],
"forbidden_text_contains": [],
"notes": "Location question about a doctor — both concepts must appear."
},
{
"id": "recon-water-001",
"group": "daily",
"priority": "high",
"input_signs": ["ME", "WANT", "WATER"],
"expected_text_contains": ["water"],
"forbidden_text_contains": ["no water"],
"notes": "Request for water. KNOWN QUIRK: fallback currently phrases WANT as 'need' — intent preserved, flagged for interpreter review."
},
{
"id": "recon-yes-001",
"group": "daily",
"priority": "high",
"input_signs": ["YES"],
"expected_text_contains": ["yes"],
"forbidden_text_contains": ["no"],
"notes": "Single-sign answer must never invert."
},
{
"id": "recon-single-help-001",
"group": "emergency",
"priority": "critical",
"input_signs": ["HELP"],
"expected_text_contains": ["help"],
"forbidden_text_contains": ["not", "fine"],
"notes": "Bare HELP is an emergency signal — must expand to a help request, never soften."
}
]
}
Loading
Loading