Skip to content

Commit 3ca0534

Browse files
jamie-simularclaude
andcommitted
feat(voice): brevity becomes a rule with a budget, and something that grades it
Brevity was the one headline property of a voice-only interface that the prompt asserted and nothing measured. It lived as an adjective clause at block 2 ("one or two sentences"), repeated with no added constraint at block 4 ("extra short"), competing with 36KB of correctness rules that mostly push the other way: name the specific action, echo the detail you heard, account for each outstanding task separately, give the reversal beat before the result. Length pressure here is structural, and the prompt never said which side yields. **A budget, not an adjective.** The new LENGTH block sits after the hands-free context that motivates it and gives a countable default — one sentence, under about fifteen words, roughly six seconds — because a sentence can be forty words and the ear counts time, not punctuation. It names what to cut, which nothing did before: openers, restating the request, announcing what it is about to do, unsolicited offers, and a closing "anything else?" — the call stays open, so it never has to ask. A worked too-long/right pair carries more than the adjectives did. **And what it yields to.** LENGTH YIELDS TO ACCURACY, AND TO NOTHING ELSE. Without that paragraph a tightened brevity rule quietly erodes the honesty rules the prompt is built around, so the four cases that genuinely need words are named: a verbatim read-back, two tasks accounted for separately, presenting a choice's options, why a capture failed. Blocks 2 and 4 now defer to it instead of stating weaker versions of it. **`no-filler` is what makes it hold.** Thirty-eight behaviours in this prompt hold because a rule grades them; this one did not, and the rubric had no rule about length or filler at all. The first run proved the point in the other direction: the rule as first written flagged "I'm currently checking your unread emails and Slack messages, and after that I'll book your table" — the exact line the LENGTH block exists to protect, and a line anyone would be happy to hear on the glasses. The rule keyed on repeating the request when what matters is whether the words are an ANSWER. It now says naming a task inside an answer about that task is content, and carries an operational test for the judge: flag a line only if words could be deleted with nothing the user asked for lost. Both rows pass. Transcripts: a plain calendar check, where nothing needs elaborating and filler is all there is to add; and `no-filler` added to the existing "one running, one waiting" row, pointing the other way — that reply legitimately needs two clauses, so it is where a brevity rule would do its damage. Evals (gemini-3.1-flash-lite-preview, judge gemini-3.5-flash-lite): transcript tier 54/56 effect choice, 74/76 judged, 1 ungraded on a 503; loop tier 6/6 structural, 8/9 judged. Both `no-filler` rows pass. The remaining flags are the documented lite-tier ones (`queued-not-underway` twice, `no-fabricated-timing` once). The two attachLatestImage misses are NOT this change: A/B'd two runs per prompt on those transcripts and the pre-change prompt fails the same row the same way. Counts that had drifted with the catalogue: 41 -> 42 blocks, 31 -> 32 rules, 32 -> 33 transcripts in README, DIRECTORY, SAI_GLASSES_APP and LoopEvalTest's header. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8e110d5 commit 3ca0534

8 files changed

Lines changed: 39 additions & 11 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ SAI_LIVE_AGENT=1 ./gradlew :app:testDebugUnitTest --tests "*LiveQueueTest*" --re
9191
# the real model through the real FSM, graded against the rubric (a full run takes minutes)
9292
SAI_CONVERSATION_EVAL=1 GEMINI_API_KEY=… ./gradlew :app:testDebugUnitTest --tests "*LoopEvalTest*" --rerun
9393

94-
# the real model over 32 fixed transcripts, no FSM — phrasing and effect choice, graded the same way
94+
# the real model over 33 fixed transcripts, no FSM — phrasing and effect choice, graded the same way
9595
SAI_TRANSCRIPT_EVAL=1 GEMINI_API_KEY=… ./gradlew :app:testDebugUnitTest --tests "*TranscriptEvalTest*" --rerun
9696

9797
# a real model AND a real agent, end to end; add SAI_PRESENTER=1 to watch it in the dashboard
@@ -100,7 +100,7 @@ SAI_DEMO=1 GEMINI_API_KEY=… ./gradlew :app:testDebugUnitTest --tests "*DemoFlo
100100

101101
The two judged tiers grade against the same rubric and see different failures, which is why both
102102
exist: `LoopEvalTest` runs a handful of conversations through a queue that really exists;
103-
`TranscriptEvalTest` runs 32 fixed transcripts with no FSM, so it can grade whether she SAYS the right
103+
`TranscriptEvalTest` runs 33 fixed transcripts with no FSM, so it can grade whether she SAYS the right
104104
thing about a waiting task but not whether the task was really waiting. Narrow either with
105105
`EVAL_ONLY="<name fragment>"`, and read `EVAL_MODEL` before reading a red — the default is a tier
106106
below what the glasses run.

docs/DIRECTORY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ environment variable and skipping itself otherwise. Five kinds:
206206
nothing else, joined by `LiveQueueTest`, which is the only place the queue is admitted behind a task
207207
that is genuinely still running and the only place `abort` / `new-session` reach a real endpoint; `eval/LoopEvalTest` (`SAI_CONVERSATION_EVAL=1`) runs the real model through the real
208208
FSM and grades the transcript against `eval/rubric.json`; `eval/TranscriptEvalTest`
209-
(`SAI_TRANSCRIPT_EVAL=1`) runs it over the 32 fixed transcripts in `eval/Transcripts.kt` with no FSM,
209+
(`SAI_TRANSCRIPT_EVAL=1`) runs it over the 33 fixed transcripts in `eval/Transcripts.kt` with no FSM,
210210
grading phrasing by judge and effect choice deterministically; `DemoFlowTest` (`SAI_DEMO=1`) drives a
211211
real model and a real agent end to end, paced for the presenter so a demo can be rehearsed without
212212
hardware.

docs/SAI_GLASSES_APP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,7 @@ Layered the same way the server's own suite is — deterministic first, by-ear l
363363
status the mapper does not know — because behaviour belongs in the scripted tier where it is
364364
reproducible and free. `SAI_CONVERSATION_EVAL=1` (`eval/LoopEvalTest`) puts the real model through
365365
the real FSM and grades the transcript rule by rule against `eval/rubric.json`.
366-
`SAI_TRANSCRIPT_EVAL=1` (`eval/TranscriptEvalTest`) runs it over 32 fixed transcripts with no FSM,
366+
`SAI_TRANSCRIPT_EVAL=1` (`eval/TranscriptEvalTest`) runs it over 33 fixed transcripts with no FSM,
367367
grading phrasing by judge and effect choice deterministically — the two share the rubric and catch
368368
different things, since this one can grade whether she SAYS the right thing about a waiting task but
369369
not whether the task was really waiting. `SAI_DEMO=1` (`DemoFlowTest`) drives a real model **and** a

meta-android-app/app/src/main/assets/voice-profile.json

Lines changed: 4 additions & 3 deletions
Large diffs are not rendered by default.

meta-android-app/app/src/test/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/saispike/eval/EvalDataTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ class EvalDataTest {
2424
fun `the catalogue is all there`() {
2525
// Pinned counts, so a truncated file or a half-finished edit is a failure rather than a quiet
2626
// reduction in coverage. Update these deliberately when the catalogue grows.
27-
assertEquals("rules", 31, rubric.size)
28-
assertEquals("transcripts", 32, TRANSCRIPTS.size)
27+
assertEquals("rules", 32, rubric.size)
28+
assertEquals("transcripts", 33, TRANSCRIPTS.size)
2929
}
3030

3131
@Test

meta-android-app/app/src/test/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/saispike/eval/LoopEvalTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
// JUDGE_MODEL (grader, default gemini-3.5-flash-lite)
1414
// EVAL_PRINT=1 dumps each captured conversation
1515
//
16-
// **This complements `TranscriptEvalTest`; it does not replace it.** That one drives the model over 32
16+
// **This complements `TranscriptEvalTest`; it does not replace it.** That one drives the model over 33
1717
// fixed transcripts with no FSM: broad coverage of phrasing and classification, but its queue is a
1818
// fiction — `forwardToAgent` resolves to a canned `ok`, and the `session-state` a scenario reacts to
1919
// was written by hand. So it can grade whether the model SAYS the right thing about a waiting task,

meta-android-app/app/src/test/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/saispike/eval/Transcripts.kt

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,20 @@ val TRANSCRIPTS: List<Transcript> =
326326
user("how much longer will this take?"),
327327
user("what have you done so far?"),
328328
)),
329+
// Filler is what the model reaches for when a turn has almost nothing in it — an opener, the
330+
// request read straight back, a line about what it is about to do. On a screen that reads as
331+
// politeness; on the glasses it IS the reply, and the user sits through all of it to hear two
332+
// meetings. Nothing here needs elaborating, which is exactly what makes it the hard case.
333+
Transcript(
334+
name = "plain request — the answer, and nothing wrapped around it",
335+
targets = listOf("no-filler", "no-tool-narration", "first-person"),
336+
turns =
337+
listOf(
338+
user("check my calendar for tomorrow"),
339+
agent(status("processing")),
340+
agent(complete("Two meetings: standup at 9, design review at 2.")),
341+
),
342+
expectTools = ToolExpectation(includes = listOf("forwardToAgent"))),
329343
Transcript(
330344
name = "email draft — transparency + verbatim",
331345
targets = listOf("transparency", "no-re-ask", "first-person"),
@@ -643,9 +657,17 @@ val TRANSCRIPTS: List<Transcript> =
643657
// to react to.
644658
Transcript(
645659
name = "one running, one waiting — status accounts for each separately",
660+
// `no-filler` rides along here deliberately, pointing the other way. This is the reply
661+
// that legitimately needs two clauses, so it is where a brevity rule would do its damage
662+
// — one blended "I'm working on it" is shorter and wrong. Grading both on one transcript
663+
// is what keeps the length rule a ban on filler rather than on content.
646664
targets =
647665
listOf(
648-
"queued-not-underway", "no-fabricated-timing", "no-re-ask", "first-person"),
666+
"queued-not-underway",
667+
"no-fabricated-timing",
668+
"no-re-ask",
669+
"first-person",
670+
"no-filler"),
649671
turns =
650672
listOf(
651673
user("check my unread emails and Slack messages"),

meta-android-app/app/src/test/resources/eval/rubric.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,5 +155,10 @@
155155
"id": "no-invented-location",
156156
"rule": "Never states or implies where the user is. The concierge is not given the user's location, so it must not name a city, neighbourhood, address, or country as though it knew, and must not say a place is \"near you\" on its own authority. Repeating a place the USER just said is fine, and so is relaying a place that came back in an agent result. When a location was needed but unavailable, it says plainly that it couldn't get their location and asks roughly where they are, instead of guessing or falling back on where the computer is.",
157157
"failExample": "Told the location was unavailable, the concierge answers \"it looks like it's about 18 degrees and clear in San Francisco\" — a city nobody gave it. NOT a violation: \"I couldn't get your location — roughly whereabouts are you?\", or repeating a city the user themselves named."
158+
},
159+
{
160+
"id": "no-filler",
161+
"rule": "Keeps spoken turns free of filler. A turn must not open with a pleasantry (\"Sure\", \"Of course\", \"Absolutely\", \"Happy to\", \"Great question\"), repeat the request back before doing it, announce what it is about to do instead of doing it, tack on next steps nobody asked for, or close with \"anything else?\". Judge FILLER, not length, and never content. Naming a task inside an ANSWER about that task is content, not a restatement: a status reply must name what is running AND what is waiting, and saying what comes next is the answer to \"what are you working on?\", not an unsolicited offer. A long turn is fine — required, even — when the words carry what the user asked for: a verbatim read-back, the options in a choice, two outstanding tasks accounted for separately, why a capture failed. A single detail echoed back to catch a mishearing (\"on it — the March draft\") is content too. An empty turn is never a violation, and neither is a short natural acknowledgement (\"on it\", \"got it\", \"there we go\") — the reversal beat `natural-flip` requires is not filler. THE TEST: flag a line only if words could be deleted from it with nothing the user asked for lost.",
162+
"failExample": "\"Of course! Let me go ahead and take a look at your calendar for tomorrow, and I'll let you know what I find — anything else while I'm in there?\" — opener, pre-narration and an unsolicited offer, where \"On it.\" says everything it says. NOT a violation: \"on it — the March draft\"; a long turn that is entirely the verbatim text the user asked to hear; or \"I'm checking your unread emails and Slack messages, and after that I'll book your table for four at seven\" — asked what it is working on, every clause there is an answer."
158163
}
159164
]

0 commit comments

Comments
 (0)