Skip to content

Commit

Permalink
feat: support searching where value is unknown (#75)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
jamesdabbs authored Nov 6, 2023
1 parent 2f510a5 commit 8a4bd46
Show file tree
Hide file tree
Showing 8 changed files with 106 additions and 65 deletions.
83 changes: 46 additions & 37 deletions packages/core/src/Formula.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,37 +31,40 @@ const orSchema = <P>(p: z.ZodSchema<P>) =>
export const formulaSchema = <P>(p: z.ZodSchema<P>): z.ZodSchema<F<P>> =>
z.union([atomSchema(p), andSchema(p), orSchema(p)])

export interface Atom<P> {
export interface Atom<P, X = never> {
kind: 'atom'
property: P
value: boolean
value: boolean | X
}

export interface And<P> {
export interface And<P, X = never> {
kind: 'and'
subs: Formula<P>[]
subs: Formula<P, X>[]
}

export interface Or<P> {
export interface Or<P, X = never> {
kind: 'or'
subs: Formula<P>[]
subs: Formula<P, X>[]
}

export type Formula<P> = And<P> | Or<P> | Atom<P>
export type Formula<P, X = never> = And<P, X> | Or<P, X> | Atom<P, X>

export function and<P>(...subs: Formula<P>[]): And<P> {
export function and<P, X = never>(...subs: Formula<P, X>[]): And<P, X> {
return { kind: 'and', subs: subs }
}

export function or<P>(...subs: Formula<P>[]): Or<P> {
export function or<P, X = never>(...subs: Formula<P, X>[]): Or<P, X> {
return { kind: 'or', subs: subs }
}

export function atom<P>(p: P, v = true): Atom<P> {
return { kind: 'atom', property: p, value: v }
export function atom<P, X = never>(
property: P,
value: boolean | X = true,
): Atom<P, X> {
return { kind: 'atom', property, value }
}

export function properties<P>(f: Formula<P>): Set<P> {
export function properties<P, X>(f: Formula<P, X>): Set<P> {
switch (f.kind) {
case 'atom':
return new Set([f.property])
Expand Down Expand Up @@ -94,10 +97,10 @@ export function negate<P>(formula: Formula<P>): Formula<P> {
}
}

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

export function mapProperty<P, Q>(
export function mapProperty<P, Q, X = never>(
func: (p: P) => Q,
formula: Formula<P>,
): Formula<Q> {
function mapAtom(a: Atom<P>): Atom<Q> {
formula: Formula<P, X>,
): Formula<Q, X> {
function mapAtom(a: Atom<P, X>): Atom<Q, X> {
return { ...a, property: func(a.property) }
}
return map<P, Q>(mapAtom, formula)
return map<P, Q, X>(mapAtom, formula)
}

export function compact<P>(f: Formula<P | undefined>): Formula<P> | undefined {
return properties(f).has(undefined) ? undefined : (f as Formula<P>)
export function compact<P, X>(
f: Formula<P | undefined, X>,
): Formula<P, X> | undefined {
return properties(f).has(undefined) ? undefined : (f as Formula<P, X>)
}

export function evaluate<T>(
f: Formula<T>,
export function evaluate<T, V extends boolean | null = boolean>(
f: Formula<T, V>,
traits: Map<T, boolean>,
): 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 => {
Expand Down Expand Up @@ -170,7 +179,7 @@ export function evaluate<T>(
}
}

export function parse(q?: string): Formula<string> | undefined {
export function parse(q?: string): Formula<string, null> | undefined {
if (!q) {
return
}
Expand All @@ -190,19 +199,19 @@ export function parse(q?: string): Formula<string> | undefined {
return fromJSON(parsed as any)
}

type Serialized =
type Serialized<X = never> =
| { and: Serialized[] }
| { or: Serialized[] }
| { property: string; value: boolean }
| Record<string, boolean>
| { property: string; value: boolean | X }
| Record<string, boolean | X>

export function fromJSON(json: Serialized): Formula<string> {
export function fromJSON(json: Serialized): Formula<string, null> {
if ('and' in json && typeof json.and === 'object') {
return and<string>(...json.and.map(fromJSON))
return and<string, null>(...json.and.map(fromJSON))
} else if ('or' in json && typeof json.or === 'object') {
return or<string>(...json.or.map(fromJSON))
return or<string, null>(...json.or.map(fromJSON))
} else if ('property' in json && typeof json.property === 'string') {
return atom<string>(json.property, json.value)
return atom<string, null>(json.property, json.value)
}

const entries = Object.entries(json)
Expand All @@ -214,7 +223,7 @@ export function fromJSON(json: Serialized): Formula<string> {
throw `cannot cast object with non-boolean value`
}

return atom<string>(...entries[0])
return atom<string, null>(...entries[0])
}

export function toJSON(f: Formula<string>): Serialized {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/Formula/Grammar.pegjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
64 changes: 48 additions & 16 deletions packages/core/test/Formula.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> = and(
atom('compact', true),
or(atom('connected', true), atom('separable', false)),
const compound = and<string>(
atom('compact'),
or(atom('connected'), atom('separable', false)),
atom('first countable', false),
)

Expand All @@ -28,7 +31,7 @@ describe('Formula', () => {
const f = compound

expect(f.subs[0]).toEqual(atom('compact'))
expect((f.subs[1] as F.Or<string>).subs[1]).toEqual(
expect((f.subs[1] as Or<string>).subs[1]).toEqual(
atom('separable', false),
)
})
Expand Down Expand Up @@ -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<string>(term.property.slice(0, 2), !term.value),
compound,
)

Expand All @@ -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([
Expand All @@ -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)
})
})
})

Expand All @@ -135,16 +154,18 @@ 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)),
)
})

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', () => {
Expand Down Expand Up @@ -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', () => {})
})
10 changes: 5 additions & 5 deletions packages/core/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
},
})
2 changes: 1 addition & 1 deletion packages/viewer/src/components/Shared/Formula.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import Atom from './Formula/Atom.svelte'
import Compound from './Formula/Compound.svelte'
export let value: Formula<Property>
export let value: Formula<Property, null>
export let link = true
</script>

Expand Down
4 changes: 2 additions & 2 deletions packages/viewer/src/components/Shared/Formula/Atom.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
import { Link, Typeset } from '@/components/Shared'
import type { Property } from '@/models'
export let value: Atom<Property>
export let value: Atom<Property, null>
export let link: boolean = true
</script>

{value.value ? '' : '¬'}
{value.value === null ? '?' : value.value ? '' : '¬'}
{#if link}
<Link.Property property={value.property} />
{:else}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import Formula from '../Formula.svelte'
export let value: And<Property> | Or<Property>
export let value: And<Property, null> | Or<Property, null>
export let link = true
$: connector = value.kind === 'and' ? '' : ''
Expand Down
4 changes: 2 additions & 2 deletions packages/viewer/src/components/Shared/Formula/Input/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function create({
limit = 10,
}: {
raw: Writable<string>
formula: Writable<Formula<Property> | undefined>
formula: Writable<Formula<Property, null> | undefined>
properties: Readable<Collection<Property>>
limit?: number
}): Store {
Expand Down Expand Up @@ -103,7 +103,7 @@ export function create({
function resolve(
index: Fuse<Property>,
str: string,
): Formula<Property> | undefined {
): Formula<Property, null> | undefined {
const parsed = F.parse(str)
if (!parsed) {
return
Expand Down

0 comments on commit 8a4bd46

Please sign in to comment.