cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Add JEV functions to the engine

Dami committed b54bf5e
JEV.TEST, JEV.PROB, JEV.CLASSIFY and JEV.SCORE describe TypeSafe
questions (yes/no, choice, score) from their arguments and look the
answers up in a pluggable sheet.Remote, so the engine never touches the
network. They are volatile: until an answer arrives the cell shows
Loading…, and RecalcVolatile picks answers up without making an undo
step. TEST and PROB ask the same question. Errors in the inputs are
never sent, and without a source the functions are #N/A.
commit
b54bf5e1a810e456a6981f9826b9690fda4caea2
parent
9424a35a8dfbe0c7494f05f9f25a3bf8dfb41479
author
Dami <dami.etoile@gmail.com>
4 files changed, +423 -0
Minternal/sheet/functions.go2+
Ainternal/sheet/functions_jev.go217++++++++++++++++++++++++++++++
Ainternal/sheet/functions_jev_test.go119++++++++++++++++
Ainternal/sheet/remote.go85++++++++++++
M internal/sheet/functions.go +2 -0
A internal/sheet/functions_jev.go +217 -0
@@ -0,0 +1,217 @@
1+package sheet2+3+import (4+ "strings"5+)6+7+// JEV functions ask TypeSafe's hosted model (jev) typed questions about a8+// value. They share one shape, like Sheets' own functions: the value to9+// ask about (a cell, a range or text), the question in plain words, then10+// what the answers mean. Each maps to a familiar spreadsheet or dataframe11+// idea:12+//13+// JEV.TEST yes/no, like IF's condition or a boolean mask14+// JEV.PROB the probability of yes, like predict_proba15+// JEV.CLASSIFY one label from a list, like SWITCH or pd.cut16+// JEV.SCORE a score on an ordered rubric, like a rating scale17+18+func init() {19+ define(20+ &FuncDef{Name: "JEV.TEST", Args: "value, question, [yes_means], [no_means]",21+ Desc: "TRUE or FALSE, answered by the JEV model", Min: 2, Max: 4, Volatile: true,22+ remote: yesNoCall, eval: remoteEval(yesNoCall, func(a RemoteAnswer) Value { return boolean(a.Noul >= 0.5) })},23+ &FuncDef{Name: "JEV.PROB", Args: "value, question, [yes_means], [no_means]",24+ Desc: "The probability the answer is yes, from the JEV model", Min: 2, Max: 4, Volatile: true,25+ remote: yesNoCall, eval: remoteEval(yesNoCall, func(a RemoteAnswer) Value { return num(a.Noul) }),26+ format: func([]Node, func(Node) Format) Format { return Format{Kind: FmtPercent} }},27+ &FuncDef{Name: "JEV.CLASSIFY", Args: "value, question, labels, [descriptions]",28+ Desc: "The label that fits best, chosen by the JEV model", Min: 3, Max: 4, Volatile: true,29+ remote: choiceCall, eval: remoteEval(choiceCall, func(a RemoteAnswer) Value { return Value{Kind: Text, Str: a.Choice} })},30+ &FuncDef{Name: "JEV.SCORE", Args: "value, question, levels",31+ Desc: "A score on an ordered rubric, from the JEV model", Min: 3, Max: 3, Volatile: true,32+ remote: scoreCall, eval: remoteEval(scoreCall, func(a RemoteAnswer) Value { return num(a.Score) })},33+ )34+}35+36+// ErrRemote is a question the model couldn't answer, e.g. a network37+// failure; the context line says why.38+var ErrRemote = Value{Kind: Error, Str: "#ERROR!"}39+40+// remoteEval evaluates a JEV function through Remote, converting the41+// answer with result. Errors in the inputs come back as-is so they can be42+// fixed; they're never sent.43+func remoteEval(build func([]Node, lookup) (RemoteCall, error), result func(RemoteAnswer) Value) func([]Node, lookup) Value {44+ return func(args []Node, get lookup) Value {45+ call, err := build(args, get)46+ if err != nil {47+ return err.(inputError).v48+ }49+ if Remote == nil {50+ return ErrNoRemote51+ }52+ ans, ok := Remote.Lookup(call)53+ switch {54+ case !ok:55+ return Pending56+ case ans.Failed != "":57+ return ErrRemote58+ }59+ return result(ans)60+ }61+}62+63+// inputError carries a spreadsheet error found in a JEV function's inputs.64+type inputError struct{ v Value }65+66+func (e inputError) Error() string { return e.v.Str }67+68+// jevState turns the value argument into what the model sees: text,69+// numbers and booleans as themselves, a range as rows of them.70+func jevState(n Node, get lookup) (any, error) {71+ if rn, ok := n.(rangeNode); ok {72+ var rows [][]any73+ for r := rn.r.From.Row; r <= rn.r.To.Row; r++ {74+ var row []any75+ for c := rn.r.From.Col; c <= rn.r.To.Col; c++ {76+ v := get(Addr{Col: c, Row: r})77+ if v.Kind == Error {78+ return nil, inputError{v}79+ }80+ row = append(row, plain(v))81+ }82+ rows = append(rows, row)83+ }84+ return rows, nil85+ }86+ v := eval(n, get)87+ if v.Kind == Error {88+ return nil, inputError{v}89+ }90+ return plain(v), nil91+}92+93+// plain converts a value to a JSON-friendly Go value.94+func plain(v Value) any {95+ switch v.Kind {96+ case Number:97+ return v.Num98+ case Bool:99+ return v.Num != 0100+ case Text:101+ return v.Str102+ }103+ return ""104+}105+106+// jevText evaluates an argument that must be text, such as the question.107+func jevText(n Node, get lookup) (string, error) {108+ v := eval(n, get)109+ if v.Kind == Error {110+ return "", inputError{v}111+ }112+ return strings.TrimSpace(text(v)), nil113+}114+115+// jevList reads a list argument: a range of cells, or one text of116+// comma-separated items like "positive, negative, neutral". Blanks are117+// skipped.118+func jevList(n Node, get lookup) ([]string, error) {119+ var out []string120+ if rn, ok := n.(rangeNode); ok {121+ for r := rn.r.From.Row; r <= rn.r.To.Row; r++ {122+ for c := rn.r.From.Col; c <= rn.r.To.Col; c++ {123+ v := get(Addr{Col: c, Row: r})124+ if v.Kind == Error {125+ return nil, inputError{v}126+ }127+ if s := strings.TrimSpace(text(v)); s != "" {128+ out = append(out, s)129+ }130+ }131+ }132+ return out, nil133+ }134+ s, err := jevText(n, get)135+ if err != nil {136+ return nil, err137+ }138+ for _, item := range strings.Split(s, ",") {139+ if item = strings.TrimSpace(item); item != "" {140+ out = append(out, item)141+ }142+ }143+ return out, nil144+}145+146+// yesNoCall is shared by JEV.TEST and JEV.PROB, so asking both about the147+// same thing is one request.148+func yesNoCall(args []Node, get lookup) (RemoteCall, error) {149+ state, err := jevState(args[0], get)150+ if err != nil {151+ return RemoteCall{}, err152+ }153+ q, err := jevText(args[1], get)154+ if err != nil {155+ return RemoteCall{}, err156+ }157+ var means [2]string158+ for i := range 2 {159+ if len(args) > 2+i {160+ if means[i], err = jevText(args[2+i], get); err != nil {161+ return RemoteCall{}, err162+ }163+ }164+ }165+ return RemoteCall{Kind: "noul", State: state, Instructions: q, Criteria: means}, nil166+}167+168+func choiceCall(args []Node, get lookup) (RemoteCall, error) {169+ state, err := jevState(args[0], get)170+ if err != nil {171+ return RemoteCall{}, err172+ }173+ q, err := jevText(args[1], get)174+ if err != nil {175+ return RemoteCall{}, err176+ }177+ labels, err := jevList(args[2], get)178+ if err != nil {179+ return RemoteCall{}, err180+ }181+ var descs []string182+ if len(args) > 3 {183+ if descs, err = jevList(args[3], get); err != nil {184+ return RemoteCall{}, err185+ }186+ }187+ if len(labels) < 2 {188+ return RemoteCall{}, inputError{ErrValue} // nothing to choose between189+ }190+ criteria := make(map[string]string, len(labels))191+ for i, l := range labels {192+ criteria[l] = l193+ if i < len(descs) {194+ criteria[l] = descs[i]195+ }196+ }197+ return RemoteCall{Kind: "choice", State: state, Instructions: q, Criteria: criteria}, nil198+}199+200+func scoreCall(args []Node, get lookup) (RemoteCall, error) {201+ state, err := jevState(args[0], get)202+ if err != nil {203+ return RemoteCall{}, err204+ }205+ q, err := jevText(args[1], get)206+ if err != nil {207+ return RemoteCall{}, err208+ }209+ levels, err := jevList(args[2], get)210+ if err != nil {211+ return RemoteCall{}, err212+ }213+ if len(levels) < 2 {214+ return RemoteCall{}, inputError{ErrValue}215+ }216+ return RemoteCall{Kind: "score", State: state, Instructions: q, Criteria: levels}, nil217+}
A internal/sheet/functions_jev_test.go +119 -0
@@ -0,0 +1,119 @@
1+package sheet2+3+import (4+ "reflect"5+ "testing"6+)7+8+// fakeRemote answers from a table and records every call it's asked.9+type fakeRemote struct {10+ answers map[string]RemoteAnswer // by Instructions11+ asked []RemoteCall12+}13+14+func (f *fakeRemote) Lookup(c RemoteCall) (RemoteAnswer, bool) {15+ f.asked = append(f.asked, c)16+ a, ok := f.answers[c.Instructions]17+ return a, ok18+}19+20+func useRemote(t *testing.T, r RemoteSource) {21+ t.Helper()22+ prev := Remote23+ Remote = r24+ t.Cleanup(func() { Remote = prev })25+}26+27+func TestJEVFunctions(t *testing.T) {28+ f := &fakeRemote{answers: map[string]RemoteAnswer{29+ "Is this a complaint?": {Noul: 0.83},30+ "Sentiment": {Choice: "negative", Confidence: 0.9},31+ "Urgency": {Score: 1.6, Confidence: 0.7},32+ "Broken": {Failed: "HTTP 500"},33+ }}34+ useRemote(t, f)35+ s := New()36+ s.Set(at("A1"), "The package arrived crushed and support never replied.")37+ s.Set(at("B1"), "positive")38+ s.Set(at("B2"), "negative")39+ s.Set(at("B3"), "neutral")40+41+ tests := []struct {42+ formula string43+ want Value44+ }{45+ {`=JEV.TEST(A1, "Is this a complaint?")`, boolean(true)},46+ {`=JEV.PROB(A1, "Is this a complaint?")`, num(0.83)},47+ {`=JEV.CLASSIFY(A1, "Sentiment", B1:B3)`, Value{Kind: Text, Str: "negative"}},48+ {`=JEV.CLASSIFY(A1, "Sentiment", "positive, negative, neutral")`, Value{Kind: Text, Str: "negative"}},49+ {`=JEV.SCORE(A1, "Urgency", "low, medium, high")`, num(1.6)},50+ {`=JEV.TEST(A1, "Not answered yet")`, Pending},51+ {`=JEV.TEST(A1, "Broken")`, ErrRemote},52+ {`=JEV.TEST(1/0, "Is this a complaint?")`, ErrDiv0}, // bad input isn't sent53+ {`=JEV.CLASSIFY(A1, "Sentiment", "only one")`, ErrValue}, // nothing to choose54+ {`=IF(JEV.TEST(A1, "Is this a complaint?"), "escalate", "ok")`, Value{Kind: Text, Str: "escalate"}},55+ }56+ for _, tt := range tests {57+ if err := s.Set(at("C1"), tt.formula); err != nil {58+ t.Fatalf("Set(%q): %v", tt.formula, err)59+ }60+ if got := s.Value(at("C1")); got != tt.want {61+ t.Errorf("%s = %+v, want %+v", tt.formula, got, tt.want)62+ }63+ }64+ // JEV.PROB shows as a percent.65+ s.Set(at("C1"), `=JEV.PROB(A1, "Is this a complaint?")`)66+ if got := s.DisplayFormat(at("C1")).Kind; got != FmtPercent {67+ t.Errorf("JEV.PROB format %v", got)68+ }69+}70+71+func TestJEVCalls(t *testing.T) {72+ f := &fakeRemote{}73+ useRemote(t, f)74+ s := New()75+ s.Set(at("A1"), "text")76+ s.Set(at("A2"), "42")77+ s.Set(at("B1"), "good")78+ s.Set(at("B2"), "bad")79+ s.Set(at("C1"), "It works")80+ s.Set(at("C2"), "It doesn't")81+ s.Set(at("D1"), `=JEV.CLASSIFY(A1:A2, "Quality", B1:B2, C1:C2)`)82+ calls := s.RemoteCalls(at("D1"))83+ want := RemoteCall{84+ Kind: "choice",85+ State: [][]any{{"text"}, {42.0}},86+ Instructions: "Quality",87+ Criteria: map[string]string{"good": "It works", "bad": "It doesn't"},88+ }89+ if len(calls) != 1 || !reflect.DeepEqual(calls[0], want) {90+ t.Errorf("calls %#v", calls)91+ }92+ s.Set(at("D2"), `=JEV.TEST(A1, "Q", "yes means this")`)93+ if c := s.RemoteCalls(at("D2")); len(c) != 1 || c[0].Criteria != [2]string{"yes means this", ""} {94+ t.Errorf("noul criteria %#v", c)95+ }96+ // Answers arriving later show up after RecalcVolatile.97+ f.answers = map[string]RemoteAnswer{"Q": {Noul: 0.1}}98+ if s.Value(at("D2")) != Pending {99+ t.Fatalf("D2 = %+v before answers", s.Value(at("D2")))100+ }101+ s.Set(at("E1"), "=IF(D2, 1, 2)")102+ state := s.StateID()103+ s.RecalcVolatile()104+ if s.Value(at("D2")) != boolean(false) || s.Value(at("E1")).Num != 2 {105+ t.Errorf("after answers: D2 %+v E1 %+v", s.Value(at("D2")), s.Value(at("E1")))106+ }107+ if s.StateID() != state {108+ t.Error("answers arriving counted as an edit")109+ }110+}111+112+func TestJEVWithoutKey(t *testing.T) {113+ useRemote(t, nil)114+ s := New()115+ s.Set(at("A1"), `=JEV.TEST("x", "Q")`)116+ if s.Value(at("A1")) != ErrNoRemote {117+ t.Errorf("no key: %+v", s.Value(at("A1")))118+ }119+}
A internal/sheet/remote.go +85 -0
@@ -0,0 +1,85 @@
1+package sheet2+3+// Some functions (JEV.*) are answered by a hosted model. The engine never4+// talks to the network: it describes each question as a RemoteCall and5+// asks Remote for the answer. Until the answer arrives the cell shows6+// Loading…, and the source queues the call; when answers arrive the UI7+// calls RecalcVolatile, and the functions, being volatile, look again.8+9+// RemoteCall is one question for the model, built from a function's10+// arguments. It is comparable through its JSON form, which callers use as11+// a cache key.12+type RemoteCall struct {13+ Kind string `json:"kind"` // "noul", "choice" or "score"14+ State any `json:"state"`15+ Instructions string `json:"instructions"`16+ // Criteria depends on Kind: noul is [2]string{yes, no} (either may be17+ // empty), choice is map[label]description, score is []string levels.18+ Criteria any `json:"criteria"`19+}20+21+// RemoteAnswer is the model's answer. Which fields are set depends on the22+// call's Kind; Failed explains an answer that couldn't be had.23+type RemoteAnswer struct {24+ Noul float64 // probability of yes25+ Choice string26+ Score float6427+ Confidence float64 // in Choice or Score, not a probability of truth28+ Failed string29+}30+31+// RemoteSource answers RemoteCalls. Lookup returns false while an answer32+// isn't known, and queues the call.33+type RemoteSource interface {34+ Lookup(RemoteCall) (RemoteAnswer, bool)35+}36+37+// Remote answers JEV functions. It is nil when no API key is configured,38+// and the functions then evaluate to ErrNoRemote.39+var Remote RemoteSource40+41+var (42+ // Pending is shown while an answer is on its way.43+ Pending = Value{Kind: Error, Str: "Loading…"}44+ // ErrNoRemote means JEV functions can't run: there is no API key.45+ ErrNoRemote = Value{Kind: Error, Str: "#N/A"}46+)47+48+// IsPending reports whether v is waiting for a remote answer.49+func IsPending(v Value) bool { return v == Pending }50+51+// RecalcVolatile recomputes volatile formulas and their dependents, e.g.52+// after remote answers arrive. It doesn't touch the undo history.53+func (s *Sheet) RecalcVolatile() { s.recalc(nil) }54+55+// RemoteCalls returns the questions the formula at a asks, with their56+// current inputs, so the UI can show details or re-ask them.57+func (s *Sheet) RemoteCalls(a Addr) []RemoteCall {58+ c := s.cells[a]59+ if c == nil || c.expr == nil {60+ return nil61+ }62+ get := func(a Addr) Value { return s.Value(a) }63+ var calls []RemoteCall64+ var walk func(Node)65+ walk = func(n Node) {66+ switch n := n.(type) {67+ case unaryNode:68+ walk(n.x)69+ case binaryNode:70+ walk(n.l)71+ walk(n.r)72+ case callNode:73+ if n.fn.remote != nil {74+ if call, err := n.fn.remote(n.args, get); err == nil {75+ calls = append(calls, call)76+ }77+ }78+ for _, arg := range n.args {79+ walk(arg)80+ }81+ }82+ }83+ walk(c.expr)84+ return calls85+}