// Package memorytest opens memory files a test can write to and read back // without a model or a network. package memorytest import ( "context" "encoding/json" "fmt" "strings" "testing" "github.com/yeomyeonggeori/bluememo" "github.com/yeomyeonggeori/bluememo/bluememotest" "github.com/yeomyeonggeori/blueclaw/internal/memory" "github.com/yeomyeonggeori/blueclaw/internal/policy" ) const defaultEmbeddingModel = "test-embed" // Open gives a Stores under the test's own directory, embedding by hash and // settling through a model that keeps each note as one statement. Nothing a // test writes relates to what is already held. func Open(t *testing.T) *memory.Stores { t.Helper() stores, _ := OpenWithRoot(t) return stores } // OpenWithRoot gives the same Stores and the workspace root it keeps its files // under, for a test that has to put a file somewhere itself. func OpenWithRoot(t *testing.T) (*memory.Stores, string) { t.Helper() root := t.TempDir() return openUnder(t, root, chooserAnswering(relationUnrelatedAnswer), defaultEmbeddingModel), root } // OpenEmbeddedBy gives the same Stores as OpenWithRoot, recording every vector // it writes as made by the named embedding model. func OpenEmbeddedBy(t *testing.T, embeddingModel string) (*memory.Stores, string) { t.Helper() root := t.TempDir() return openUnder(t, root, chooserAnswering(relationUnrelatedAnswer), embeddingModel), root } // OpenCorrecting gives a Stores whose judge reads every new statement as a // correction of what it is nearest to, so a test can claim a supersede. func OpenCorrecting(t *testing.T) *memory.Stores { t.Helper() return open(t, chooserAnswering(relationUpdatesAnswer)) } // OpenJudgingSame gives a Stores whose judge reads every new statement as // saying what its nearest candidate says, so a test can claim a statement was // recognized rather than written. func OpenJudgingSame(t *testing.T) *memory.Stores { t.Helper() return open(t, chooserAnswering(relationSameAnswer)) } func open(t *testing.T, chooser bluememo.Chooser) *memory.Stores { t.Helper() return openUnder(t, t.TempDir(), chooser, defaultEmbeddingModel) } // OpenEmbeddingWith gives the same Stores as Open, embedding through the given // embedder, for a test that decides how near two texts are. func OpenEmbeddingWith(t *testing.T, embedder bluememo.Embedder) *memory.Stores { t.Helper() return openEmbeddingUnder(t, t.TempDir(), chooserAnswering(relationUnrelatedAnswer), defaultEmbeddingModel, embedder) } func openUnder(t *testing.T, root string, chooser bluememo.Chooser, embeddingModel string) *memory.Stores { t.Helper() return openEmbeddingUnder(t, root, chooser, embeddingModel, &bluememotest.HashEmbedder{}) } func openEmbeddingUnder(t *testing.T, root string, chooser bluememo.Chooser, embeddingModel string, embedder bluememo.Embedder) *memory.Stores { t.Helper() stores := memory.NewStores(root, bluememo.Configuration{ Embedder: embedder, EmbeddingModel: embeddingModel, Model: EchoModel{}, Judge: bluememo.DistributionJudge{Chooser: chooser}, }, currentProcessActor{}) t.Cleanup(func() { _ = stores.Close() }) return stores } // fixedChooser answers each of the judge's questions the one way a test wants: // the given relation, an importance that is not the noise rating, and the // nearest candidate when a relation points at one. // The judge numbers its relation answers the way RelationInstruction lists // them. const ( relationSameAnswer = "1" relationUpdatesAnswer = "2" relationUnrelatedAnswer = "4" ) type fixedChooser struct { relation string } func (chooser fixedChooser) Choose(_ context.Context, request bluememo.ChoiceRequest) (map[string]float64, error) { answer := chooser.answerFor(request) distribution := make(map[string]float64, len(request.Answers)) for _, offered := range request.Answers { distribution[offered] = 0 } if _, isOffered := distribution[answer]; !isOffered { return nil, fmt.Errorf("the judge did not offer %q among %v", answer, request.Answers) } distribution[answer] = 1 return distribution, nil } func (chooser fixedChooser) answerFor(request bluememo.ChoiceRequest) string { switch request.Instruction { case bluememo.RelationInstruction: return chooser.relation case bluememo.ImportanceInstruction: return "3" } return request.Answers[0] } func chooserAnswering(relation string) bluememo.Chooser { return fixedChooser{relation: relation} } // Remember settles one sentence into a scope, so a test reads back what it // wrote. func Remember(t *testing.T, stores *memory.Stores, scope memory.Scope, sentences ...string) { t.Helper() for _, sentence := range sentences { note := bluememo.Note{GroupID: memory.NewIdentifier(), Body: sentence, IsExplicit: true} if _, errorValue := stores.Remember(context.Background(), []memory.Scope{scope}, note); errorValue != nil { t.Fatalf("remember %q into %s: %v", sentence, scope.Kind, errorValue) } } } type EchoModel struct{} func (EchoModel) GenerateStructured(_ context.Context, request bluememo.StructuredRequest) (string, error) { if request.SchemaName == "memory_trigger" { return `{"phrases":[]}`, nil } content := request.Subject if marker := strings.LastIndex(content, "\nText\n"); marker >= 0 { content = content[marker+len("\nText\n"):] } content = strings.TrimSpace(content) if runes := []rune(content); len(runes) > bluememo.ContentCharacterLimit { content = string(runes[:bluememo.ContentCharacterLimit]) } document, errorValue := json.Marshal(map[string]any{"propositions": []bluememo.Proposition{ {Content: content, Expiry: bluememo.ExpiryNone}, }}) if errorValue != nil { return "", errorValue } return string(document), nil } // Count is how many memories a scope's file holds, so a test can claim that // something wrote none of its own. func Count(t *testing.T, stores *memory.Stores, scope memory.Scope) int { t.Helper() store, errorValue := stores.Store(context.Background(), scope) if errorValue != nil { t.Fatalf("open %s: %v", scope.Kind, errorValue) } memories, errorValue := store.Memories(context.Background()) if errorValue != nil { t.Fatalf("list %s: %v", scope.Kind, errorValue) } return len(memories) } // Recall is what a reader is shown for a query, across the files their access // opens. func Recall(t *testing.T, stores *memory.Stores, scopes []memory.Scope, query string) []memory.MemoryFact { t.Helper() recalled, errorValue := stores.RecallAcross(context.Background(), policy.PersonAccess{PersonID: "reader"}, scopes, query, memory.DefaultRecallLimit) if errorValue != nil { t.Fatalf("recall %q: %v", query, errorValue) } return recalled.Facts }