Osy#betaRun it on Osyrin: osyrin.com

Reference / Testing

Testing search by meaning — TestEmbedder.Use and a deterministic embedder

`TestEmbedder.Use(embedder)` makes a test run embed with an embedder written in Osy# — any class implementing `Osysharp.Memory.IEmbedder` — so a suite can assert what `Memory.Search` and `[Searchable(Memory)]` find without calling a model. `Osysharp.Memory.Testing` ships one: `WordsEmbedder`, free, offline and the same on every machine.

preview1 example compiled by CItestingmemorysearch

Source on GitHub: Osysharp.Memory.Testing — github.com/osysharp/memory/testing ↗

Summary#

A field marked [Searchable(Memory)] is found by meaning once the app has an embedder. In a test there is no model to call — and there should not be: a real embedder costs money, needs a key, and answers slightly differently from one release to the next. TestEmbedder.Use hands the test run an embedder of your own instead, so every row the test writes is embedded the moment it is committed, and Memory.Search on the next line finds it.

[TestFixture]
void Kitchen() {
  TestEmbedder.Use(new WordsEmbedder());   // Osysharp.Memory.Testing
  new Recipe { Title = "Lentil soup", Method = "Simmer red lentils with cumin until soft." };
}

Signature#

TestEmbedder.Use(IEmbedder embedder);      // in a [Test] or [TestFixture] only

// Osysharp.Memory — what an embedder is
public interface IEmbedder {
  string Model();                                     // recorded with each vector
  List<double> Embed(string text, int dimensions);    // `dimensions` numbers, the app's vector width
}

// Osysharp.Memory.Testing — a hashed bag of words
public class WordsEmbedder : IEmbedder {
  public int MinLength = 2;                 // shorter words count for nothing
  public List<string> Ignore = [ … ];       // "the", "and", "of" … — the commonest English words
  public List<string> WordsOf(string text); // the words a text is embedded by
}

Description#

What it changes#

For the rest of the test (a fixture's call reaches every test that declares it):

  • Every searchable row is embedded at commit, with your embedder, instead of by the background worker an app uses in production. A row written on one line is found on the next.
  • Memory.Search embeds its query with the same embedder, so the two are comparable.
  • Nothing is metered. A test embedder is not a model call: no budget is spent and no key is read.

The width is the app's own (app.Embedding.Dimensions, or the platform's default), passed to Embed as dimensions — an embedder that answers a different number of values is refused with a message saying so.

What WordsEmbedder finds#

It turns a text into its words (lower-cased, without the commonest ones), hashes each word into one of dimensions slots, and scales the result to length one. Two texts are as close as the words they share. That makes it lexical, not semantic — "invoice" is not near "bill" — so a test written against it searches with words its rows contain, and asserts the things that matter about a search: which rows are found, in which order, and which are never found (a row the searcher may not read, a draft, something erased).

To test meaning across words or languages, write an embedder that knows them — any class implementing IEmbedder will do, and a small table of "these words mean the same" is often all a test needs.

Why it is refused outside a test#

An embedder decides what a search finds. In app code, TestEmbedder.Use would let one line replace the app's real embedder for everyone. So it is a compile error anywhere but a [Test] or [TestFixture], and the platform refuses it on a run that is not a test, saying so. The app needs use Osysharp.Memory; for it to resolve at all.

Examples#

A recipe book searched by what each method says — and a private recipe its owner's friends never find:

app RecipeBox {
  use Osysharp.Memory;
  use Osysharp.Memory.Testing@0;
  model "model/**/*.osy";
}

using Osysharp.Memory;
using Osysharp.Memory.Testing;

[Principal]
entity Cook {
  [Required, Unique, MaxLength(200)] string Email;
  security { allow read when IsAuthenticated; }
}

entity Recipe {
  [Required, MaxLength(120)] string Title;
  [Searchable(Memory), MaxLength(4000)] string Method;
  Cook? KeptBy;
  security {
    allow read where KeptBy == null || KeptBy == user;
    allow create when IsAuthenticated;
  }
}

public List<string> RecipesAbout(string words) {
  List<string> titles = [];
  foreach (var hit in Memory.Search(words, of: [Recipe], limit: 5)) {
    var r = Recipe.FirstOrDefault(x => x.Id == hit.SourceEntityId);
    if (r != null && !titles.Contains(r.Title)) { titles.Add(r.Title); }
  }
  return titles;
}

[TestFixture]
void Kitchen() {
  TestEmbedder.Use(new WordsEmbedder());
  var mia = new Cook { Email = "[email protected]" };
  new Cook { Email = "[email protected]" };
  new Recipe { Title = "Lentil soup", Method = "Simmer red lentils with cumin and onion until soft, then blend." };
  new Recipe { Title = "Flatbread", Method = "Knead flour, yoghurt and salt; rest the dough, then cook in a dry pan." };
  new Recipe { Title = "Grandma's stew", Method = "Brown the beef, add lentils and red wine, simmer three hours.", KeptBy = mia };
}

principal Mia => Cook.Single(c => c.Email == "[email protected]");
principal Leo => Cook.Single(c => c.Email == "[email protected]");

[Test(Kitchen)]
[runas(Leo)]
void a_recipe_is_found_by_what_its_method_says() {
  Assert.Equal("Lentil soup", RecipesAbout("simmer red lentils cumin")[0]);
}

[Test(Kitchen)]
void a_kept_recipe_is_found_only_by_its_cook() {
  runas (Leo) { Assert.False(RecipesAbout("beef lentils red wine simmer").Contains("Grandma's stew")); }
  runas (Mia) { Assert.Equal("Grandma's stew", RecipesAbout("beef lentils red wine simmer")[0]); }
}

See also#

Related

using Memory (semantic search)

Opt into semantic (vector) search over your app's content. `using Memory;` adds a searchable store to the app…

[Searchable]

Mark a text field searchable. `[Searchable]` gives a String or Markdown property the best relevance search the app can…

Search

How you make text findable in Osy#. You never touch a vector, an index, or an embedder — you mark a text field…

[Test] / [TestFixture]

A test is an ordinary function marked [Test]. It runs against a throwaway clone of the app, so it may create rows and…

Stubbing your own functions in a test (fault injection)

`<Function>.Stub(λ)` makes one of the app's own functions answer a value, or throw, for the rest of a `[Test]` —…

Outbound calls in a test

A `[Test]` answers an outbound call from a stub — `Http.<Verb>.Stub(url => …)` — or, when the test says so with…