Skip to content

Commit 8a4bd46

Browse files
authored
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
1 parent 2f510a5 commit 8a4bd46

File tree

8 files changed

+106
-65
lines changed

8 files changed

+106
-65
lines changed

packages/core/src/Formula.ts

+46-37
Original file line numberDiff line numberDiff line change
@@ -31,37 +31,40 @@ const orSchema = <P>(p: z.ZodSchema<P>) =>
3131
export const formulaSchema = <P>(p: z.ZodSchema<P>): z.ZodSchema<F<P>> =>
3232
z.union([atomSchema(p), andSchema(p), orSchema(p)])
3333

34-
export interface Atom<P> {
34+
export interface Atom<P, X = never> {
3535
kind: 'atom'
3636
property: P
37-
value: boolean
37+
value: boolean | X
3838
}
3939

40-
export interface And<P> {
40+
export interface And<P, X = never> {
4141
kind: 'and'
42-
subs: Formula<P>[]
42+
subs: Formula<P, X>[]
4343
}
4444

45-
export interface Or<P> {
45+
export interface Or<P, X = never> {
4646
kind: 'or'
47-
subs: Formula<P>[]
47+
subs: Formula<P, X>[]
4848
}
4949

50-
export type Formula<P> = And<P> | Or<P> | Atom<P>
50+
export type Formula<P, X = never> = And<P, X> | Or<P, X> | Atom<P, X>
5151

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

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

60-
export function atom<P>(p: P, v = true): Atom<P> {
61-
return { kind: 'atom', property: p, value: v }
60+
export function atom<P, X = never>(
61+
property: P,
62+
value: boolean | X = true,
63+
): Atom<P, X> {
64+
return { kind: 'atom', property, value }
6265
}
6366

64-
export function properties<P>(f: Formula<P>): Set<P> {
67+
export function properties<P, X>(f: Formula<P, X>): Set<P> {
6568
switch (f.kind) {
6669
case 'atom':
6770
return new Set([f.property])
@@ -94,10 +97,10 @@ export function negate<P>(formula: Formula<P>): Formula<P> {
9497
}
9598
}
9699

97-
export function map<P, Q>(
98-
func: (p: Atom<P>) => Atom<Q>,
99-
formula: Formula<P>,
100-
): Formula<Q> {
100+
export function map<P, Q, X = never>(
101+
func: (p: Atom<P, X>) => Atom<Q, X>,
102+
formula: Formula<P, X>,
103+
): Formula<Q, X> {
101104
switch (formula.kind) {
102105
case 'atom':
103106
return func(formula)
@@ -109,32 +112,38 @@ export function map<P, Q>(
109112
}
110113
}
111114

112-
export function mapProperty<P, Q>(
115+
export function mapProperty<P, Q, X = never>(
113116
func: (p: P) => Q,
114-
formula: Formula<P>,
115-
): Formula<Q> {
116-
function mapAtom(a: Atom<P>): Atom<Q> {
117+
formula: Formula<P, X>,
118+
): Formula<Q, X> {
119+
function mapAtom(a: Atom<P, X>): Atom<Q, X> {
117120
return { ...a, property: func(a.property) }
118121
}
119-
return map<P, Q>(mapAtom, formula)
122+
return map<P, Q, X>(mapAtom, formula)
120123
}
121124

122-
export function compact<P>(f: Formula<P | undefined>): Formula<P> | undefined {
123-
return properties(f).has(undefined) ? undefined : (f as Formula<P>)
125+
export function compact<P, X>(
126+
f: Formula<P | undefined, X>,
127+
): Formula<P, X> | undefined {
128+
return properties(f).has(undefined) ? undefined : (f as Formula<P, X>)
124129
}
125130

126-
export function evaluate<T>(
127-
f: Formula<T>,
131+
export function evaluate<T, V extends boolean | null = boolean>(
132+
f: Formula<T, V>,
128133
traits: Map<T, boolean>,
129134
): boolean | undefined {
130135
let result: boolean | undefined
131136

132137
switch (f.kind) {
133138
case 'atom':
134-
if (traits.has(f.property)) {
135-
return traits.get(f.property) === f.value
139+
const known = traits.has(f.property)
140+
if (f.value === null) {
141+
return !known
136142
}
137-
return undefined
143+
if (!known) {
144+
return undefined
145+
}
146+
return traits.get(f.property) === f.value
138147
case 'and':
139148
result = true // by default
140149
f.subs.forEach(sub => {
@@ -170,7 +179,7 @@ export function evaluate<T>(
170179
}
171180
}
172181

173-
export function parse(q?: string): Formula<string> | undefined {
182+
export function parse(q?: string): Formula<string, null> | undefined {
174183
if (!q) {
175184
return
176185
}
@@ -190,19 +199,19 @@ export function parse(q?: string): Formula<string> | undefined {
190199
return fromJSON(parsed as any)
191200
}
192201

193-
type Serialized =
202+
type Serialized<X = never> =
194203
| { and: Serialized[] }
195204
| { or: Serialized[] }
196-
| { property: string; value: boolean }
197-
| Record<string, boolean>
205+
| { property: string; value: boolean | X }
206+
| Record<string, boolean | X>
198207

199-
export function fromJSON(json: Serialized): Formula<string> {
208+
export function fromJSON(json: Serialized): Formula<string, null> {
200209
if ('and' in json && typeof json.and === 'object') {
201-
return and<string>(...json.and.map(fromJSON))
210+
return and<string, null>(...json.and.map(fromJSON))
202211
} else if ('or' in json && typeof json.or === 'object') {
203-
return or<string>(...json.or.map(fromJSON))
212+
return or<string, null>(...json.or.map(fromJSON))
204213
} else if ('property' in json && typeof json.property === 'string') {
205-
return atom<string>(json.property, json.value)
214+
return atom<string, null>(json.property, json.value)
206215
}
207216

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

217-
return atom<string>(...entries[0])
226+
return atom<string, null>(...entries[0])
218227
}
219228

220229
export function toJSON(f: Formula<string>): Serialized {

packages/core/src/Formula/Grammar.pegjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Or = _ "(" _ head:Formula tail:(_ Disjunction _ Formula)+ _ ")" _ {
1313
Atom = mod:Modifier? _ prop:Property {
1414
let value;
1515
if (mod === '?') {
16-
value = undefined
16+
value = null
1717
} else if (mod) {
1818
value = false
1919
} else {

packages/core/test/Formula.test.ts

+48-16
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
import { describe, expect, it } from 'vitest'
2-
import * as F from '../src/Formula'
32
import {
43
Formula,
4+
Or,
55
and,
66
atom,
7+
compact,
78
evaluate,
89
fromJSON,
910
negate,
1011
or,
12+
map,
13+
mapProperty,
1114
parse,
1215
properties,
1316
render,
1417
toJSON,
1518
} from '../src/Formula'
1619

17-
const compound: Formula<string> = and(
18-
atom('compact', true),
19-
or(atom('connected', true), atom('separable', false)),
20+
const compound = and<string>(
21+
atom('compact'),
22+
or(atom('connected'), atom('separable', false)),
2023
atom('first countable', false),
2124
)
2225

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

3033
expect(f.subs[0]).toEqual(atom('compact'))
31-
expect((f.subs[1] as F.Or<string>).subs[1]).toEqual(
34+
expect((f.subs[1] as Or<string>).subs[1]).toEqual(
3235
atom('separable', false),
3336
)
3437
})
@@ -70,8 +73,8 @@ describe('Formula', () => {
7073

7174
describe('map', () => {
7275
it('maps over entire atoms', () => {
73-
const result = F.map(
74-
term => atom(term.property.slice(0, 2), !term.value),
76+
const result = map(
77+
term => atom<string>(term.property.slice(0, 2), !term.value),
7578
compound,
7679
)
7780

@@ -81,12 +84,20 @@ describe('Formula', () => {
8184

8285
describe('mapProperty', () => {
8386
it('only maps over properties', () => {
84-
const result = F.mapProperty(property => property.slice(0, 2), compound)
87+
const result = mapProperty(property => property.slice(0, 2), compound)
8588

8689
expect(render_(result)).toEqual('(co ∧ (co ∨ ¬se) ∧ ¬fi)')
8790
})
8891
})
8992

93+
describe('compact', () => {
94+
it('preserves null-valued atoms', () => {
95+
const f = and(atom('A'), atom('B', null), atom('C', false))
96+
97+
expect(compact(f)).toEqual(f)
98+
})
99+
})
100+
90101
describe('evaluate', () => {
91102
it('is true if all subs are true', () => {
92103
const traits = new Map([
@@ -109,14 +120,22 @@ describe('Formula', () => {
109120
expect(evaluate(compound, traits)).toEqual(false)
110121
})
111122

112-
it('is undefined if a sub is undefined', () => {
113-
const traits = new Map([
114-
['compact', true],
115-
['first countable', false],
116-
])
123+
const traits = new Map([
124+
['compact', true],
125+
['first countable', false],
126+
])
117127

128+
it('is undefined if a sub is undefined', () => {
118129
expect(evaluate(compound, traits)).toEqual(undefined)
119130
})
131+
132+
it('can match null', () => {
133+
expect(evaluate(parse('?other')!, traits)).toEqual(true)
134+
})
135+
136+
it('can fail to match null', () => {
137+
expect(evaluate(parse('?compact')!, traits)).toEqual(false)
138+
})
120139
})
121140
})
122141

@@ -135,16 +154,18 @@ describe('parsing', () => {
135154
expect(parse('not compact')).toEqual(atom('compact', false))
136155
})
137156

157+
it('can mark properties unknown', () => {
158+
expect(parse('?compact')).toEqual(atom('compact', null))
159+
})
160+
138161
it('inserts parens', () => {
139162
expect(parse('compact + connected + ~t_2')).toEqual(
140163
and(atom('compact', true), atom('connected', true), atom('t_2', false)),
141164
)
142165
})
143166

144167
it('allows parens', () => {
145-
expect(F.parse('(foo + bar)')).toEqual(
146-
F.and(F.atom('foo', true), F.atom('bar', true)),
147-
)
168+
expect(parse('(foo + bar)')).toEqual(and(atom('foo'), atom('bar')))
148169
})
149170

150171
it('handles errors with parens', () => {
@@ -183,4 +204,15 @@ describe('serialization', () => {
183204
expect(fromJSON(toJSON(formula))).toEqual(formula)
184205
})
185206
})
207+
208+
it('throws when given multiple keys', () => {
209+
expect(() =>
210+
fromJSON({
211+
P1: true,
212+
P2: false,
213+
}),
214+
).toThrowError('cast')
215+
})
216+
217+
it('throws when given multiple keys', () => {})
186218
})

packages/core/vite.config.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@ export default defineConfig({
1717
},
1818
test: {
1919
coverage: {
20-
lines: 92.52,
21-
branches: 95.32,
22-
statements: 92.52,
23-
functions: 83.33,
20+
lines: 91.27,
21+
branches: 94.01,
22+
statements: 91.27,
23+
functions: 83.55,
2424
skipFull: true,
2525
thresholdAutoUpdate: true,
26-
exclude: ['src/Formula/Grammar.ts'],
26+
exclude: ['src/Formula/Grammar.ts', 'test'],
2727
},
2828
},
2929
})

packages/viewer/src/components/Shared/Formula.svelte

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import Atom from './Formula/Atom.svelte'
55
import Compound from './Formula/Compound.svelte'
66
7-
export let value: Formula<Property>
7+
export let value: Formula<Property, null>
88
export let link = true
99
</script>
1010

packages/viewer/src/components/Shared/Formula/Atom.svelte

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
import { Link, Typeset } from '@/components/Shared'
55
import type { Property } from '@/models'
66
7-
export let value: Atom<Property>
7+
export let value: Atom<Property, null>
88
export let link: boolean = true
99
</script>
1010

11-
{value.value ? '' : '¬'}
11+
{value.value === null ? '?' : value.value ? '' : '¬'}
1212
{#if link}
1313
<Link.Property property={value.property} />
1414
{:else}

packages/viewer/src/components/Shared/Formula/Compound.svelte

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
66
import Formula from '../Formula.svelte'
77
8-
export let value: And<Property> | Or<Property>
8+
export let value: And<Property, null> | Or<Property, null>
99
export let link = true
1010
1111
$: connector = value.kind === 'and' ? '' : ''

packages/viewer/src/components/Shared/Formula/Input/store.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export function create({
2525
limit = 10,
2626
}: {
2727
raw: Writable<string>
28-
formula: Writable<Formula<Property> | undefined>
28+
formula: Writable<Formula<Property, null> | undefined>
2929
properties: Readable<Collection<Property>>
3030
limit?: number
3131
}): Store {
@@ -103,7 +103,7 @@ export function create({
103103
function resolve(
104104
index: Fuse<Property>,
105105
str: string,
106-
): Formula<Property> | undefined {
106+
): Formula<Property, null> | undefined {
107107
const parsed = F.parse(str)
108108
if (!parsed) {
109109
return

0 commit comments

Comments
 (0)