workbench(vault): filter entries by status, metadata text, and facets (#763)

Make a populated vault navigable beyond opaque digests:

- status facet: 'all' + the distinct epistemic_status values present in
  the loaded entries (derived, never a hardcoded closed set — the status
  vocabulary is engine-owned)
- text search now reaches metadata keys/values, not just the epistemic
  labels + index
- boolean facets: 'Has proposition' (propositional_form) and 'Has
  promotion digest' (promotion_certificate_digest); dropped 'has replay
  hash' — no such field exists
- filter-empty stays filter-empty (not persistence guidance)

No new endpoint; filters operate on already-loaded entries.
This commit is contained in:
Shay 2026-06-15 01:38:09 -07:00 committed by GitHub
parent 74dc7c7a3e
commit 3e8f40b685
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 190 additions and 18 deletions

View file

@ -1,5 +1,5 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -22,14 +22,14 @@ const entries: VaultEntry[] = [
entry_index: 0,
epistemic_status: "coherent",
epistemic_state: "verified",
metadata: { concept: "truth" },
metadata: { concept: "truth", propositional_form: "alpha causes beta" },
versor_digest: "sha256:aaaaaaaaaaaaaaaaaaaa",
},
{
entry_index: 1,
epistemic_status: "speculative",
epistemic_state: "inferred",
metadata: { concept: "beauty" },
metadata: { concept: "beauty", promotion_certificate_digest: "sha256:certcert" },
versor_digest: "sha256:bbbbbbbbbbbbbbbbbbbb",
},
];
@ -184,9 +184,12 @@ describe("VaultRoute", () => {
expect(await screen.findByText("entry_count")).toBeInTheDocument();
expect(screen.getByText("reproject_interval")).toBeInTheDocument();
expect(screen.getByText("coherent")).toBeInTheDocument();
expect(screen.getByText("verified")).toBeInTheDocument();
expect(screen.getByText("speculative")).toBeInTheDocument();
// Scope row pills to the listbox — the status <select> also renders
// "coherent"/"speculative" as <option> text.
const list = screen.getByRole("listbox", { name: "Vault entries" });
expect(within(list).getByText("coherent")).toBeInTheDocument();
expect(within(list).getByText("verified")).toBeInTheDocument();
expect(within(list).getByText("speculative")).toBeInTheDocument();
});
it("never invents a similarity / relevance score (exact-recall doctrine)", async () => {
@ -202,7 +205,9 @@ describe("VaultRoute", () => {
const user = userEvent.setup();
renderRoute();
await user.click(await screen.findByText("speculative"));
// Click entry 1's state pill ("inferred") — unambiguous; "speculative"
// now also appears as a status <select> option.
await user.click(await screen.findByText("inferred"));
await waitFor(() =>
expect(screen.getByTestId("subject")).toHaveTextContent("vault_entry:1"),
@ -216,9 +221,97 @@ describe("VaultRoute", () => {
const list = await screen.findByRole("listbox", { name: "Vault entries" });
list.focus();
expect(screen.getAllByRole("option")[0]).toHaveAttribute("aria-selected", "true");
// Scope to the listbox: the status <select> also owns role="option" nodes.
expect(within(list).getAllByRole("option")[0]).toHaveAttribute("aria-selected", "true");
await user.keyboard("j");
expect(screen.getAllByRole("option")[1]).toHaveAttribute("aria-selected", "true");
expect(within(list).getAllByRole("option")[1]).toHaveAttribute("aria-selected", "true");
});
// Scope row counting to the listbox — the status <select> also owns options.
function visibleRows() {
return within(
screen.getByRole("listbox", { name: "Vault entries" }),
).getAllByRole("option");
}
it("status facet narrows entries to the chosen epistemic_status", async () => {
stubVault();
const user = userEvent.setup();
renderRoute();
await screen.findByText("entry_count");
expect(visibleRows()).toHaveLength(2);
await user.selectOptions(
screen.getByRole("combobox", { name: "Filter by epistemic status" }),
"coherent",
);
const rows = visibleRows();
expect(rows).toHaveLength(1);
expect(rows[0]).toHaveTextContent("verified"); // entry 0 (coherent/verified)
});
it("'Has proposition' facet keeps only entries carrying propositional_form", async () => {
stubVault();
const user = userEvent.setup();
renderRoute();
await screen.findByText("entry_count");
await user.click(screen.getByRole("switch", { name: "Has proposition" }));
const rows = visibleRows();
expect(rows).toHaveLength(1);
expect(rows[0]).toHaveTextContent("verified"); // entry 0 has propositional_form
});
it("'Has promotion digest' facet keeps only certified entries", async () => {
stubVault();
const user = userEvent.setup();
renderRoute();
await screen.findByText("entry_count");
await user.click(screen.getByRole("switch", { name: "Has promotion digest" }));
const rows = visibleRows();
expect(rows).toHaveLength(1);
expect(rows[0]).toHaveTextContent("inferred"); // entry 1 (speculative/inferred) has the digest
});
it("text search reaches metadata values, not just epistemic labels", async () => {
stubVault();
const user = userEvent.setup();
renderRoute();
await screen.findByText("entry_count");
await user.type(
screen.getByPlaceholderText("Filter by status, state, index, or metadata"),
"beauty",
);
// SearchInput debounces ~150ms before it propagates onChange.
await waitFor(() => expect(visibleRows()).toHaveLength(1));
expect(visibleRows()[0]).toHaveTextContent("inferred"); // entry 1 metadata.concept = "beauty"
});
it("a filter that matches nothing stays filter-empty, not persistence guidance", async () => {
stubVault();
const user = userEvent.setup();
renderRoute();
await screen.findByText("entry_count");
await user.type(
screen.getByPlaceholderText("Filter by status, state, index, or metadata"),
"no-such-entry",
);
// SearchInput debounces ~150ms before it propagates onChange.
await waitFor(() =>
expect(screen.getByText("No vault entries match this filter.")).toBeInTheDocument(),
);
// NOT the absence/persistence guidance
expect(
screen.queryByText(/No persisted vault snapshot is available\./),
).not.toBeInTheDocument();
});
});

View file

@ -148,9 +148,54 @@ function EntryCountToolbar({ count }: { count: number }) {
);
}
// True when an entry's open metadata carries a non-empty value at `key`.
function metaPresent(entry: VaultEntry, key: string): boolean {
const value = entry.metadata?.[key];
return value !== undefined && value !== null && value !== "";
}
// Flatten an entry's metadata to a searchable string (keys + values), so the
// text filter reaches stored content, not just the epistemic labels.
function metadataText(metadata: Record<string, unknown> | undefined): string {
if (!metadata) return "";
return Object.entries(metadata)
.map(([k, v]) => `${k} ${v !== null && typeof v === "object" ? JSON.stringify(v) : String(v)}`)
.join(" ");
}
function FacetToggle({
label,
active,
onToggle,
}: {
label: string;
active: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={active}
aria-label={label}
onClick={onToggle}
className={`inline-flex h-6 items-center rounded-md border px-2 text-xs transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)] ${
active
? "border-[var(--color-selected-border)] bg-[var(--color-selected-bg)] text-[var(--color-text-primary)]"
: "border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] text-[var(--color-text-secondary)]"
}`}
>
{label}
</button>
);
}
export function VaultRoute() {
const { subject, setSubject, setInspectorOpen } = useEvidenceSubject();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [hasPropForm, setHasPropForm] = useState(false);
const [hasPromotionDigest, setHasPromotionDigest] = useState(false);
const summaryQuery = useVaultSummary();
const summary = summaryQuery.data;
@ -161,16 +206,26 @@ export function VaultRoute() {
subject.kind === "vault_entry" ? subject.entryIndex : null;
const entries = entriesQuery.data ?? [];
// Status options are derived from the loaded entries, never a hardcoded set —
// the epistemic_status vocabulary is engine-owned.
const statusOptions = useMemo(() => {
const present = new Set(entries.map((entry) => entry.epistemic_status));
return ["all", ...Array.from(present).sort()];
}, [entries]);
const filteredEntries = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return entries;
return entries.filter(
(entry) =>
entry.epistemic_status.toLowerCase().includes(q) ||
entry.epistemic_state.toLowerCase().includes(q) ||
String(entry.entry_index).includes(q),
);
}, [search, entries]);
return entries.filter((entry) => {
if (statusFilter !== "all" && entry.epistemic_status !== statusFilter) return false;
if (hasPropForm && !metaPresent(entry, "propositional_form")) return false;
if (hasPromotionDigest && !metaPresent(entry, "promotion_certificate_digest")) return false;
if (!q) return true;
const haystack =
`${entry.epistemic_status} ${entry.epistemic_state} ${entry.entry_index} ${metadataText(entry.metadata)}`.toLowerCase();
return haystack.includes(q);
});
}, [search, statusFilter, hasPropForm, hasPromotionDigest, entries]);
// Hydrate a URL-restored (identity-only) vault_entry subject with its data.
useEffect(() => {
@ -253,10 +308,34 @@ export function VaultRoute() {
<div className="grid min-h-0 gap-3">
<VaultSummaryStrip summary={summary} />
<SearchInput
placeholder="Filter by epistemic status, state, or index"
placeholder="Filter by status, state, index, or metadata"
value={search}
onChange={setSearch}
/>
<div className="flex flex-wrap items-center gap-2">
<select
aria-label="Filter by epistemic status"
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] px-2 py-1 text-sm text-[var(--color-text-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
>
{statusOptions.map((status) => (
<option key={status} value={status}>
{status === "all" ? "all statuses" : status}
</option>
))}
</select>
<FacetToggle
label="Has proposition"
active={hasPropForm}
onToggle={() => setHasPropForm((value) => !value)}
/>
<FacetToggle
label="Has promotion digest"
active={hasPromotionDigest}
onToggle={() => setHasPromotionDigest((value) => !value)}
/>
</div>
{entriesQuery.isLoading ? (
<LoadingState label="Loading vault entries..." />
) : entriesQuery.isError ? (