From 8a4bd46d7515a0dd2ab2aa197378ed9f578ced75 Mon Sep 17 00:00:00 2001 From: James Dabbs Date: Mon, 6 Nov 2023 09:16:51 -0700 Subject: [PATCH] feat: support searching where value is unknown (#75) * feat: support searching where value is unknown Adds minimal support for 3-valued logic. Formula types support arbitrarily many extended types with the default (X = never) type, but parsing and evaluating is currently only well-defined for value types that extend (boolean | null). Note that we're using null to represent unknown here in part so that helpers like atom(property, value = true) can distinguish between being called with an unknown and being called without providing the default argument. Fixes #70 * fix: formula summary display for unknowns * chore: restore test coverage thresholds --- packages/core/src/Formula.ts | 83 ++++++++++--------- packages/core/src/Formula/Grammar.pegjs | 2 +- packages/core/test/Formula.test.ts | 64 ++++++++++---- packages/core/vite.config.ts | 10 +-- .../src/components/Shared/Formula.svelte | 2 +- .../src/components/Shared/Formula/Atom.svelte | 4 +- .../components/Shared/Formula/Compound.svelte | 2 +- .../components/Shared/Formula/Input/store.ts | 4 +- 8 files changed, 106 insertions(+), 65 deletions(-) diff --git a/packages/core/src/Formula.ts b/packages/core/src/Formula.ts index 3ebab32f..a6131367 100644 --- a/packages/core/src/Formula.ts +++ b/packages/core/src/Formula.ts @@ -31,37 +31,40 @@ const orSchema =

(p: z.ZodSchema

) => export const formulaSchema =

(p: z.ZodSchema

): z.ZodSchema> => z.union([atomSchema(p), andSchema(p), orSchema(p)]) -export interface Atom

{ +export interface Atom { kind: 'atom' property: P - value: boolean + value: boolean | X } -export interface And

{ +export interface And { kind: 'and' - subs: Formula

[] + subs: Formula[] } -export interface Or

{ +export interface Or { kind: 'or' - subs: Formula

[] + subs: Formula[] } -export type Formula

= And

| Or

| Atom

+export type Formula = And | Or | Atom -export function and

(...subs: Formula

[]): And

{ +export function and(...subs: Formula[]): And { return { kind: 'and', subs: subs } } -export function or

(...subs: Formula

[]): Or

{ +export function or(...subs: Formula[]): Or { return { kind: 'or', subs: subs } } -export function atom

(p: P, v = true): Atom

{ - return { kind: 'atom', property: p, value: v } +export function atom( + property: P, + value: boolean | X = true, +): Atom { + return { kind: 'atom', property, value } } -export function properties

(f: Formula

): Set

{ +export function properties(f: Formula): Set

{ switch (f.kind) { case 'atom': return new Set([f.property]) @@ -94,10 +97,10 @@ export function negate

(formula: Formula

): Formula

{ } } -export function map( - func: (p: Atom

) => Atom, - formula: Formula

, -): Formula { +export function map( + func: (p: Atom) => Atom, + formula: Formula, +): Formula { switch (formula.kind) { case 'atom': return func(formula) @@ -109,32 +112,38 @@ export function map( } } -export function mapProperty( +export function mapProperty( func: (p: P) => Q, - formula: Formula

, -): Formula { - function mapAtom(a: Atom

): Atom { + formula: Formula, +): Formula { + function mapAtom(a: Atom): Atom { return { ...a, property: func(a.property) } } - return map(mapAtom, formula) + return map(mapAtom, formula) } -export function compact

(f: Formula

): Formula

| undefined { - return properties(f).has(undefined) ? undefined : (f as Formula

) +export function compact( + f: Formula

, +): Formula | undefined { + return properties(f).has(undefined) ? undefined : (f as Formula) } -export function evaluate( - f: Formula, +export function evaluate( + f: Formula, traits: Map, ): boolean | undefined { let result: boolean | undefined switch (f.kind) { case 'atom': - if (traits.has(f.property)) { - return traits.get(f.property) === f.value + const known = traits.has(f.property) + if (f.value === null) { + return !known } - return undefined + if (!known) { + return undefined + } + return traits.get(f.property) === f.value case 'and': result = true // by default f.subs.forEach(sub => { @@ -170,7 +179,7 @@ export function evaluate( } } -export function parse(q?: string): Formula | undefined { +export function parse(q?: string): Formula | undefined { if (!q) { return } @@ -190,19 +199,19 @@ export function parse(q?: string): Formula | undefined { return fromJSON(parsed as any) } -type Serialized = +type Serialized = | { and: Serialized[] } | { or: Serialized[] } - | { property: string; value: boolean } - | Record + | { property: string; value: boolean | X } + | Record -export function fromJSON(json: Serialized): Formula { +export function fromJSON(json: Serialized): Formula { if ('and' in json && typeof json.and === 'object') { - return and(...json.and.map(fromJSON)) + return and(...json.and.map(fromJSON)) } else if ('or' in json && typeof json.or === 'object') { - return or(...json.or.map(fromJSON)) + return or(...json.or.map(fromJSON)) } else if ('property' in json && typeof json.property === 'string') { - return atom(json.property, json.value) + return atom(json.property, json.value) } const entries = Object.entries(json) @@ -214,7 +223,7 @@ export function fromJSON(json: Serialized): Formula { throw `cannot cast object with non-boolean value` } - return atom(...entries[0]) + return atom(...entries[0]) } export function toJSON(f: Formula): Serialized { diff --git a/packages/core/src/Formula/Grammar.pegjs b/packages/core/src/Formula/Grammar.pegjs index b6efc090..8d31edff 100644 --- a/packages/core/src/Formula/Grammar.pegjs +++ b/packages/core/src/Formula/Grammar.pegjs @@ -13,7 +13,7 @@ Or = _ "(" _ head:Formula tail:(_ Disjunction _ Formula)+ _ ")" _ { Atom = mod:Modifier? _ prop:Property { let value; if (mod === '?') { - value = undefined + value = null } else if (mod) { value = false } else { diff --git a/packages/core/test/Formula.test.ts b/packages/core/test/Formula.test.ts index 5c3bb971..d871a5b8 100644 --- a/packages/core/test/Formula.test.ts +++ b/packages/core/test/Formula.test.ts @@ -1,22 +1,25 @@ import { describe, expect, it } from 'vitest' -import * as F from '../src/Formula' import { Formula, + Or, and, atom, + compact, evaluate, fromJSON, negate, or, + map, + mapProperty, parse, properties, render, toJSON, } from '../src/Formula' -const compound: Formula = and( - atom('compact', true), - or(atom('connected', true), atom('separable', false)), +const compound = and( + atom('compact'), + or(atom('connected'), atom('separable', false)), atom('first countable', false), ) @@ -28,7 +31,7 @@ describe('Formula', () => { const f = compound expect(f.subs[0]).toEqual(atom('compact')) - expect((f.subs[1] as F.Or).subs[1]).toEqual( + expect((f.subs[1] as Or).subs[1]).toEqual( atom('separable', false), ) }) @@ -70,8 +73,8 @@ describe('Formula', () => { describe('map', () => { it('maps over entire atoms', () => { - const result = F.map( - term => atom(term.property.slice(0, 2), !term.value), + const result = map( + term => atom(term.property.slice(0, 2), !term.value), compound, ) @@ -81,12 +84,20 @@ describe('Formula', () => { describe('mapProperty', () => { it('only maps over properties', () => { - const result = F.mapProperty(property => property.slice(0, 2), compound) + const result = mapProperty(property => property.slice(0, 2), compound) expect(render_(result)).toEqual('(co ∧ (co ∨ ¬se) ∧ ¬fi)') }) }) + describe('compact', () => { + it('preserves null-valued atoms', () => { + const f = and(atom('A'), atom('B', null), atom('C', false)) + + expect(compact(f)).toEqual(f) + }) + }) + describe('evaluate', () => { it('is true if all subs are true', () => { const traits = new Map([ @@ -109,14 +120,22 @@ describe('Formula', () => { expect(evaluate(compound, traits)).toEqual(false) }) - it('is undefined if a sub is undefined', () => { - const traits = new Map([ - ['compact', true], - ['first countable', false], - ]) + const traits = new Map([ + ['compact', true], + ['first countable', false], + ]) + it('is undefined if a sub is undefined', () => { expect(evaluate(compound, traits)).toEqual(undefined) }) + + it('can match null', () => { + expect(evaluate(parse('?other')!, traits)).toEqual(true) + }) + + it('can fail to match null', () => { + expect(evaluate(parse('?compact')!, traits)).toEqual(false) + }) }) }) @@ -135,6 +154,10 @@ describe('parsing', () => { expect(parse('not compact')).toEqual(atom('compact', false)) }) + it('can mark properties unknown', () => { + expect(parse('?compact')).toEqual(atom('compact', null)) + }) + it('inserts parens', () => { expect(parse('compact + connected + ~t_2')).toEqual( and(atom('compact', true), atom('connected', true), atom('t_2', false)), @@ -142,9 +165,7 @@ describe('parsing', () => { }) it('allows parens', () => { - expect(F.parse('(foo + bar)')).toEqual( - F.and(F.atom('foo', true), F.atom('bar', true)), - ) + expect(parse('(foo + bar)')).toEqual(and(atom('foo'), atom('bar'))) }) it('handles errors with parens', () => { @@ -183,4 +204,15 @@ describe('serialization', () => { expect(fromJSON(toJSON(formula))).toEqual(formula) }) }) + + it('throws when given multiple keys', () => { + expect(() => + fromJSON({ + P1: true, + P2: false, + }), + ).toThrowError('cast') + }) + + it('throws when given multiple keys', () => {}) }) diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index 6636104d..b9677e6d 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -17,13 +17,13 @@ export default defineConfig({ }, test: { coverage: { - lines: 92.52, - branches: 95.32, - statements: 92.52, - functions: 83.33, + lines: 91.27, + branches: 94.01, + statements: 91.27, + functions: 83.55, skipFull: true, thresholdAutoUpdate: true, - exclude: ['src/Formula/Grammar.ts'], + exclude: ['src/Formula/Grammar.ts', 'test'], }, }, }) diff --git a/packages/viewer/src/components/Shared/Formula.svelte b/packages/viewer/src/components/Shared/Formula.svelte index 1279dd96..6d6a12f2 100644 --- a/packages/viewer/src/components/Shared/Formula.svelte +++ b/packages/viewer/src/components/Shared/Formula.svelte @@ -4,7 +4,7 @@ import Atom from './Formula/Atom.svelte' import Compound from './Formula/Compound.svelte' - export let value: Formula + export let value: Formula export let link = true diff --git a/packages/viewer/src/components/Shared/Formula/Atom.svelte b/packages/viewer/src/components/Shared/Formula/Atom.svelte index c0b57530..c5fcf1f9 100644 --- a/packages/viewer/src/components/Shared/Formula/Atom.svelte +++ b/packages/viewer/src/components/Shared/Formula/Atom.svelte @@ -4,11 +4,11 @@ import { Link, Typeset } from '@/components/Shared' import type { Property } from '@/models' - export let value: Atom + export let value: Atom export let link: boolean = true -{value.value ? '' : '¬'} +{value.value === null ? '?' : value.value ? '' : '¬'} {#if link} {:else} diff --git a/packages/viewer/src/components/Shared/Formula/Compound.svelte b/packages/viewer/src/components/Shared/Formula/Compound.svelte index 184da754..8ca8c555 100644 --- a/packages/viewer/src/components/Shared/Formula/Compound.svelte +++ b/packages/viewer/src/components/Shared/Formula/Compound.svelte @@ -5,7 +5,7 @@ import Formula from '../Formula.svelte' - export let value: And | Or + export let value: And | Or export let link = true $: connector = value.kind === 'and' ? '∧' : '∨' diff --git a/packages/viewer/src/components/Shared/Formula/Input/store.ts b/packages/viewer/src/components/Shared/Formula/Input/store.ts index 704590e3..20ba0d63 100644 --- a/packages/viewer/src/components/Shared/Formula/Input/store.ts +++ b/packages/viewer/src/components/Shared/Formula/Input/store.ts @@ -25,7 +25,7 @@ export function create({ limit = 10, }: { raw: Writable - formula: Writable | undefined> + formula: Writable | undefined> properties: Readable> limit?: number }): Store { @@ -103,7 +103,7 @@ export function create({ function resolve( index: Fuse, str: string, -): Formula | undefined { +): Formula | undefined { const parsed = F.parse(str) if (!parsed) { return