Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support searching where value is unknown #75

Merged
merged 3 commits into from
Nov 6, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Comment on lines +34 to +37
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

X represents the set of extended (non-boolean) allowed values.

}

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', () => {})
})
6 changes: 1 addition & 5 deletions packages/core/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,9 @@ export default defineConfig({
},
test: {
coverage: {
lines: 92.52,
branches: 95.32,
statements: 92.52,
functions: 83.33,
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