Symbolon packs

kausalflow/lightkone / components/search-query/versions/1/properties.py · raw

"""Behavioral properties for lightkone/search-query.

Each property states one rule from the description in symbolon.toml, checked
on small generated libraries whose vocabulary overlaps on purpose: shared
words, accents, mixed case, nested folders, ties in dates.
"""

import unicodedata

from hypothesis import given, strategies as st

from schema import Input, Paper

# Helpers that restate the description. They are the spec, not an implementation hint.


def fold(s: str) -> str:
    s = unicodedata.normalize("NFKD", s)
    return "".join(c for c in s if not 0x300 <= ord(c) <= 0x36F).lower()


def title_text(p: Paper) -> str:
    return fold(p.title)


def author_text(p: Paper) -> str:
    return fold(" ; ".join(p.authors))


def rest_text(p: Paper) -> str:
    parts = [p.abstract, p.venue, " ".join(p.tags), p.key, p.doi, p.arxiv, p.notes]
    return fold(" ".join(x for x in parts if x))


def word_score(p: Paper, word: str) -> int:
    w = fold(word)
    return 5 * (w in title_text(p)) + 4 * (w in author_text(p)) + (w in rest_text(p))


def render(word: str) -> str:
    return f'"{word}"' if " " in word else word


def run(impl, papers, query, sort="added") -> list[str]:
    return impl(Input(papers=papers, query=query, sort=sort)).ids


def newest_first(papers, key=lambda p: p.added):
    """Descending by key; Python's sort is stable, so equal papers keep their input order."""
    return sorted(papers, key=key, reverse=True)


# Generated libraries

TITLE_WORDS = ["alpha", "beta", "Gamma", "café", "Über", "naïve", "networks", "qft", "lattice", "ML"]
AUTHORS = ["Hinton, Geoffrey", "Gödel, Kurt", "Noether, Emmy", "Alpha, Ann", "Müller, Jörg"]
TAGS = ["ml", "ML", "physics", "Café", "to read", "qft"]
FOLDERS = ["Physics", "Physics/QFT", "physics/qft/Lattice", "Math", "Mathematics", "Café/Ébauche"]
TEXTS = ["", "about alpha and beta", "Lattice QFT on networks", "see re:search", "naive café notes", "Gödel"]
TIMES = [f"2024-0{m}-0{d}T10:00:00.000Z" for m in (1, 2, 3) for d in (1, 2)]


def opt(s):
    return st.none() | s


@st.composite
def papers_st(draw, min_size=0, max_size=8, titles=None):
    n = draw(st.integers(min_size, max_size))
    out = []
    for i in range(n):
        title = draw(titles) if titles is not None else " ".join(draw(st.lists(st.sampled_from(TITLE_WORDS), max_size=4)))
        out.append(Paper(
            id=f"p{i}",
            title=title,
            added=draw(st.sampled_from(TIMES)),
            key=draw(st.sampled_from(["", f"Key{i}", "Alpha2020-ab"])),
            authors=draw(st.lists(st.sampled_from(AUTHORS), max_size=3, unique=True)),
            year=draw(opt(st.integers(2015, 2022))),
            venue=draw(opt(st.sampled_from(TEXTS))),
            abstract=draw(opt(st.sampled_from(TEXTS))),
            doi=draw(opt(st.sampled_from(["", "10.1000/alpha", "10.2/xyz"]))),
            arxiv=draw(opt(st.sampled_from(["", "2101.00001"]))),
            tags=draw(st.lists(st.sampled_from(TAGS), max_size=3, unique=True)),
            file=draw(opt(st.sampled_from(["", "All Papers/A/a.pdf"]))),
            pdf=draw(opt(st.sampled_from(["", "pdfs/p.pdf"]))),
            notes=draw(opt(st.sampled_from(TEXTS))),
            folders=draw(opt(st.lists(st.sampled_from(FOLDERS), max_size=2, unique=True))),
            starred=draw(opt(st.booleans())),
            trashed=draw(opt(st.sampled_from(["", "2024-05-01T00:00:00.000Z"]))),
            opened=draw(opt(st.sampled_from(["", *TIMES]))),
        ))
    return out


WORDS = ["alpha", "ALPHA", "alp", "beta", "cafe", "CAFÉ", "uber", "naive", "naïve", "godel", "gödel", "hinton",
         "qft", "lattice", "ml", "net", "alpha beta", "n, g", "re:search", "10.1000", "2101", "key1", "zzz"]
FILTERS = ["tag:ml", "tag:Physics", 'tag:"to read"', "tag:cafe", "author:hinton", "author:godel", "author:\"l, a\"",
           "year:2018", "year:2016-2019", "year:2020-2017", "in:physics", "in:Physics/QFT/", "in:Math", 'in:"cafe/ebauche"',
           "has:pdf", "has:abstract", "has:arxiv", "has:notes", "has:nothing", "no:pdf", "no:abstract", "no:arxiv",
           "no:notes", "is:starred", "is:recent", "is:unknown"]
terms = st.sampled_from(FILTERS) | st.sampled_from(WORDS).map(render)
SORTS = ["added", "year", "title", "relevance", "opened"]


def is_trashed(p: Paper) -> bool:
    return bool(p.trashed)


def satisfies(p: Paper, f: str) -> bool:
    field, _, value = f.partition(":")
    value = value.strip('"')
    if field == "tag":
        return any(fold(t) == fold(value) for t in p.tags)
    if field == "author":
        return fold(value) in author_text(p)
    if field == "year":
        a, _, b = value.partition("-")
        lo, hi = int(a), int(b or a)
        return bool(p.year) and lo <= p.year <= hi
    if field == "in":
        target = fold(value).rstrip("/")
        return any(fold(x) == target or fold(x).startswith(target + "/") for x in p.folders or [])
    if field == "has":
        return {"pdf": bool(p.pdf or p.file), "abstract": bool(p.abstract), "arxiv": bool(p.arxiv),
                "notes": bool(p.notes)}.get(value, True)
    if field == "no":
        return {"pdf": not p.pdf, "abstract": not p.abstract, "arxiv": not p.arxiv,
                "notes": not p.notes}.get(value, True)
    if field == "is":
        return {"starred": bool(p.starred), "recent": bool(p.opened)}.get(value, True)
    raise AssertionError(f)


# Properties


@given(papers=papers_st(), query=st.lists(terms, max_size=3).map(" ".join), sort=st.sampled_from(SORTS))
def test_results_are_distinct_papers_from_the_library(impl, papers, query, sort):
    ids = run(impl, papers, query, sort)
    assert len(ids) == len(set(ids))
    assert set(ids) <= {p.id for p in papers}


@given(papers=papers_st(), query=st.lists(terms, max_size=2).map(" ".join), trash=st.booleans())
def test_trash_shows_only_with_is_trash(impl, papers, query, trash):
    by_id = {p.id: p for p in papers}
    ids = run(impl, papers, ("is:trash " if trash else "") + query)
    assert all(is_trashed(by_id[i]) == trash for i in ids)


@given(papers=papers_st(), a=terms, b=terms, trash=st.booleans())
def test_terms_are_conjunctive(impl, papers, a, b, trash):
    if a.startswith("year:") and b.startswith("year:"):
        return  # only the last year: term counts, see test_only_the_last_year_filter_counts
    prefix = "is:trash " if trash else ""
    both = set(run(impl, papers, f"{prefix}{a} {b}"))
    assert both == set(run(impl, papers, prefix + a)) & set(run(impl, papers, prefix + b))


@given(papers=papers_st(min_size=1), query=st.sampled_from(["", " ", "  \t "]))
def test_empty_query_lists_the_library_newest_first(impl, papers, query):
    expected = [p.id for p in newest_first([p for p in papers if not is_trashed(p)])]
    assert run(impl, papers, query) == expected


@given(papers=papers_st(min_size=1), f=st.sampled_from(FILTERS), trash=st.booleans())
def test_each_filter_selects_what_it_says(impl, papers, f, trash):
    got = set(run(impl, papers, ("is:trash " if trash else "") + f))
    assert got == {p.id for p in papers if is_trashed(p) == trash and satisfies(p, f)}


@given(papers=papers_st(min_size=1), words=st.lists(st.sampled_from(WORDS), min_size=1, max_size=2),
       sort=st.sampled_from(["added", "relevance"]))
def test_words_rank_title_over_author_over_rest(impl, papers, words, sort):
    scored = []
    for p in papers:
        scores = [word_score(p, w) for w in words]
        if not is_trashed(p) and all(scores):
            scored.append((p, sum(scores)))
    by_added = newest_first([p for p, _ in scored])
    rank = {p.id: i for i, p in enumerate(by_added)}
    total = {p.id: s for p, s in scored}
    expected = sorted(rank, key=lambda i: (-total[i], rank[i]))
    assert run(impl, papers, " ".join(render(w) for w in words), sort) == expected


@given(papers=papers_st(), query=st.lists(terms, min_size=1, max_size=3).map(" ".join), sort=st.sampled_from(SORTS))
def test_case_and_accents_in_the_query_do_not_matter(impl, papers, query, sort):
    assert run(impl, papers, query.upper(), sort) == run(impl, papers, query, sort)


@given(papers=papers_st(min_size=1), f=st.sampled_from([""] + FILTERS))
def test_year_sort_puts_later_years_first(impl, papers, f):
    ids = run(impl, papers, f, "year")
    keep = set(ids)
    expected = [p.id for p in newest_first([p for p in papers if p.id in keep], key=lambda p: p.year or 0)]
    assert ids == expected


ASCII_TITLES = st.lists(st.sampled_from(["alpha", "beta", "b", "a1", "zeta 2", "a beta", ""]), max_size=3).map(" ".join)


@given(papers=papers_st(min_size=1, titles=ASCII_TITLES), f=st.sampled_from([""] + FILTERS))
def test_title_sort_is_ascending_and_stable(impl, papers, f):
    ids = run(impl, papers, f, "title")
    keep = set(ids)
    assert ids == [p.id for p in sorted((p for p in papers if p.id in keep), key=lambda p: p.title)]


@given(papers=papers_st(min_size=1), how=st.sampled_from([("", "opened"), ("is:recent", "added"), ("is:recent", "opened")]))
def test_opened_order_and_is_recent(impl, papers, how):
    query, sort = how
    ids = run(impl, papers, query, sort)
    keep = set(ids)
    by_added = newest_first([p for p in papers if p.id in keep])
    assert ids == [p.id for p in newest_first(by_added, key=lambda p: p.opened or "")]


YEARS = [f for f in FILTERS if f.startswith("year:")]


@given(papers=papers_st(min_size=1), first=st.sampled_from(YEARS), last=st.sampled_from(YEARS), word=st.sampled_from(["", "alpha"]))
def test_only_the_last_year_filter_counts(impl, papers, first, last, word):
    assert run(impl, papers, f"{first} {word} {last}") == run(impl, papers, f"{word} {last}")