Let the garden keep what she was given, not only what she sought

Two halves of the same idea, both read out of work Petal already
records.

Planting: an accepted collocation is a learnable chunk, so it becomes a
phrase card. The scheduler didn't need to know — a three-word chunk
climbs the ladder exactly like a looked-up word. What needed care was
deciding what *isn't* a chunk (single words are word choice; a
six-word-plus "collocation" is a rewritten sentence, and sentences make
miserable flashcards), and that the example must be the *corrected*
sentence — the stored draft still holds the phrasing she just left
behind. Re-accepting the same chunk leaves the existing card alone
rather than resetting a schedule it has been climbing. The whole thing
is best-effort: accepting an edit must never fail because a flashcard
couldn't be made.

The growth journal: kept this month beside kept the month before, the
phrasing that stuck, the patterns that faded. The queries were the easy
part; the honesty is the feature. "Stuck" needs the phrase in a *second*
document, because one document is just the edit where she left it.
"Faded" says nothing at all unless she has been writing lately —
otherwise a month away from Petal comes back to her as progress, which
is the one way this could lie. And a suggestion had to start recording
when she *decided* it, not when the model proposed it, so 0012 adds
resolved_at and backfills the old rows to their created_at.

It lives as a second tab in the garden, and it feeds the kitten: after
an accept she now sometimes hears something true of her alone, once per
line, half the time, never waited for.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-27 14:16:59 -07:00
parent 7b845644be
commit e9b8595456
19 changed files with 1328 additions and 5 deletions
+74
View File
@@ -0,0 +1,74 @@
package vocab
import (
"path/filepath"
"testing"
"gitea.parodia.dev/drwily/petal/internal/db"
)
func TestPhraseKey(t *testing.T) {
cases := []struct {
in, want string
}{
{"make a decision", "make a decision"},
{"Make A Decision", "make a decision"}, // shares one namespace with lookups
{" make a\ndecision ", "make a decision"}, // the editor's whitespace
{"“make a decision.”", "make a decision"}, // punctuation from the sentence around it
{"cant afford it", "cant afford it"}, // inner marks are part of the chunk
{"decision", ""}, // word choice, not a chunk — lookup's job
{"", ""}, //
{"...", ""}, //
{"12 000", ""}, // digits aren't vocabulary
{"a b c d e f g", ""}, // a clause wearing a chunk's label
{"in ones own good time again", "in ones own good time again"}, // six words is still a chunk
}
for _, tc := range cases {
if got := PhraseKey(tc.in); got != tc.want {
t.Errorf("PhraseKey(%q) = %q, want %q", tc.in, got, tc.want)
}
}
// The length cap counts runes, not bytes — otherwise a Portuguese chunk well
// inside the limit would be dropped for being accented.
accented := "ãããããã ãããããã ãããããã ãããããã ãããããã" // 34 runes, 64 bytes
if got := PhraseKey(accented); got != accented {
t.Errorf("PhraseKey(%d runes / %d bytes) = %q, want it kept", len([]rune(accented)), len(accented), got)
}
long := "ãããããããããããã ãããããããããããã ãããããããããããã ãããããããããããã ãããããããããããã ãããããããããããã"
if got := PhraseKey(long); got != "" {
t.Errorf("PhraseKey(%d runes) = %q, want \"\"", len([]rune(long)), got)
}
}
func TestPlantCreatesOnceAndReportsIt(t *testing.T) {
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
p := Phrase{Text: "make a decision", Meaning: "why", Example: "I had to make a decision."}
created, err := Plant(database, db.LocalUserID, p)
if err != nil || !created {
t.Fatalf("first plant: created=%v err=%v", created, err)
}
created, err = Plant(database, db.LocalUserID, p)
if err != nil || created {
t.Fatalf("second plant: created=%v err=%v, want false", created, err)
}
// A card that isn't plantable is a silent no-op, not an error: planting hangs
// off accepting an edit, and that accept must never fail for a flashcard.
created, err = Plant(database, db.LocalUserID, Phrase{Text: "decision"})
if err != nil || created {
t.Fatalf("unplantable: created=%v err=%v", created, err)
}
var n int
if err := database.QueryRow(`SELECT count(*) FROM vocab_words WHERE user_id = ?`, db.LocalUserID).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
if n != 1 {
t.Fatalf("garden has %d cards, want 1", n)
}
}