Skip to content

Commit a1ef364

Browse files
committed
Apply transformations lazily for math fields
1 parent 32246a2 commit a1ef364

5 files changed

Lines changed: 110 additions & 116 deletions

File tree

apps/test/src/Exercise.tsx

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,69 @@
1-
import { createView, defineFeedback, defineSchema, expr, useExerciseContext } from '@learning/core'
1+
import {
2+
createView,
3+
defineFeedback,
4+
defineField,
5+
defineSchema,
6+
expr,
7+
useExerciseContext,
8+
} from '@learning/core'
29
import { createMemo, Show } from 'solid-js'
310
import * as v from 'valibot'
411

12+
const Math = defineField({
13+
base: v.string(),
14+
feedback: v.pipe(v.string(), v.transform(expr)),
15+
})
16+
517
const schema = defineSchema({
618
name: 'math/factor',
7-
question: { expr: v.string() },
19+
question: { expr: Math },
820
transform: async (question) => {
9-
return { expr: await expr(question.expr).expand().latex() }
21+
return { expr: await question.expr.expand().latex() }
1022
},
1123
steps: {
1224
start: {
1325
previous: [],
1426
state: {
15-
attempt: v.string(),
27+
attempt: Math,
1628
},
1729
},
1830
root: {
1931
previous: ['start'],
2032
state: {
21-
root: v.string(),
33+
root: Math,
2234
},
2335
},
2436
},
2537
})
2638

2739
const feedback = defineFeedback<typeof schema>({
28-
start: async ({ question, state }) => {
29-
const [equal, factored] = await Promise.all([
30-
expr(state.attempt).isEqual(question.expr),
31-
expr(state.attempt).isFactored(),
32-
])
40+
start: async ({ question: { expr }, state: { attempt } }) => {
41+
const [equal, factored] = await Promise.all([attempt.isEqual(expr), attempt.isFactored()])
3342
const correct = equal && factored
3443
return { correct, score: [Number(correct), 1], next: correct ? null : 'root' }
3544
},
3645
root: async ({ question, state }) => {
37-
const correct = await expr(question.expr).checkRoot(state.root)
46+
const correct = await question.expr.checkRoot(state.root)
3847
return { correct, score: [0, 0], next: null }
3948
},
4049
})
4150

4251
export const Factor = createView(schema, feedback, {
4352
start: (props) => {
44-
const question = () => expr(props.question.expr)
45-
const attempt = () => expr(props.state?.attempt)
53+
const question = createMemo(() => props.question.expr)
54+
const attempt = createMemo(() => props.state?.attempt)
4655
const answer = createMemo(() => attempt() && question().factor().latex())
4756
const equal = createMemo(() => attempt()?.isEqual(question()))
4857
const factored = createMemo(() => attempt()?.isFactored())
4958
const correct = () => equal() && factored()
5059
const exercise = useExerciseContext()
5160
return (
5261
<>
53-
<p>Factorisez l'expression suivante: {props.question.expr}</p>
62+
<p>Factorisez l'expression suivante: {props.question.expr.rawInput}</p>
5463
<p>
5564
Tentative:{' '}
5665
<input
57-
value={props.state?.attempt ?? ''}
66+
value={props.state?.attempt.rawInput ?? ''}
5867
readonly={props.state !== undefined}
5968
onInput={(e) => {
6069
exercise?.setState((state) => {
@@ -74,14 +83,12 @@ export const Factor = createView(schema, feedback, {
7483
},
7584
root: (props) => {
7685
const exercise = useExerciseContext()
77-
const correct = createMemo(
78-
() => props.state && expr(props.question.expr).checkRoot(props.state?.root ?? ''),
79-
)
86+
const correct = createMemo(() => props.state && props.question.expr.checkRoot(props.state.root))
8087
return (
8188
<>
82-
<p>Trouvez une racine de {props.question.expr}</p>
89+
<p>Trouvez une racine de {props.question.expr.rawInput}</p>
8390
<input
84-
value={props.state?.root ?? ''}
91+
value={props.state?.root.rawInput ?? ''}
8592
onInput={(e) => {
8693
exercise?.setState((state) => {
8794
state.root = e.target.value

packages/core/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
export { createView, defineFeedback, defineSchema } from './src/exercise/base'
1+
export { createView, defineFeedback, defineField, defineSchema } from './src/exercise/base'
22
export { useExerciseContext } from './src/exercise/context'
3-
export { field } from './src/exercise/field'
43
export { expr } from './src/expr'

packages/core/src/exercise/base.tsx

Lines changed: 81 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Dynamic } from '@solidjs/web'
2-
import { mapAsync } from 'es-toolkit'
2+
import { mapAsync, mapValues } from 'es-toolkit'
33
import {
44
createMemo,
55
createStore,
@@ -14,8 +14,24 @@ import * as v from 'valibot'
1414
import { ExerciseContext } from './context'
1515

1616
type MaybeAsync<T> = T | Promise<T>
17-
type RawShape = Record<string, v.BaseSchema<any, any, any>>
18-
type InferFromShape<T extends RawShape> = v.InferOutput<v.ObjectSchema<T, undefined>>
17+
18+
type Field<T = any, U = any> = {
19+
base: v.GenericSchema<any, T>
20+
feedback: v.GenericSchema<T, U>
21+
}
22+
23+
export function defineField<T, U>(field: Field<T, U>) {
24+
return field
25+
}
26+
27+
type RawShape = Record<string, Field>
28+
function RawShapeSchema<T extends RawShape, S extends 'base' | 'feedback'>(shape: T, stage: S) {
29+
return v.object(mapValues(shape, (field) => field[stage]) as { [K in keyof T]: T[K][S] })
30+
}
31+
32+
type InferFromShape<T extends Record<string, Field>, S extends 'base' | 'feedback'> = v.InferOutput<
33+
ReturnType<typeof RawShapeSchema<T, S>>
34+
>
1935

2036
/**
2137
* Infer the type associated with a schema factory
@@ -43,7 +59,7 @@ type Infer<
4359
export function defineSchema<
4460
const N extends string,
4561
Q extends RawShape,
46-
T extends (question: InferFromShape<Q>) => Promise<InferFromShape<Q>>,
62+
T extends (question: InferFromShape<Q, 'feedback'>) => Promise<InferFromShape<Q, 'base'>>,
4763
const S extends Record<string, { previous: (keyof S)[]; state: RawShape }> & {
4864
start: { previous: []; state: RawShape }
4965
},
@@ -54,53 +70,63 @@ export function defineSchema<
5470
type Schema<
5571
N extends string = any,
5672
Q extends RawShape = any,
57-
T extends (question: InferFromShape<Q>) => Promise<InferFromShape<Q>> = any,
73+
T extends (question: InferFromShape<Q, 'feedback'>) => Promise<InferFromShape<Q, 'base'>> = any,
5874
S extends Record<string, { previous: (keyof S)[]; state: RawShape }> & {
5975
start: { previous: []; state: RawShape }
6076
} = any,
6177
> = ReturnType<typeof defineSchema<N, Q, T, S>>
6278

63-
function Part<T extends Schema, K extends keyof T['steps'], S extends boolean = false>(
64-
schema: T,
65-
step: K,
66-
withState?: S,
67-
) {
79+
function Part<
80+
T extends Schema,
81+
K extends keyof T['steps'],
82+
S extends boolean = false,
83+
V extends 'base' | 'feedback' = 'base',
84+
>(schema: T, step: K, withState?: S, stage?: V) {
6885
const base = v.object({
6986
step: v.literal(step as K),
7087
})
7188
const extended = v.object({
7289
...base.entries,
73-
state: v.object(schema.steps[step].state as T['steps'][K]['state']),
90+
state: RawShapeSchema(schema.steps[step].state as T['steps'][K]['state'], stage ?? 'base'),
7491
})
7592
return (withState ? extended : base) as S extends true ? typeof extended : typeof base
7693
}
7794

78-
type Part<T extends Schema, K extends keyof T['steps'], S extends boolean = false> = Infer<
79-
typeof Part<T, K, S>
80-
>
95+
type Part<
96+
T extends Schema,
97+
K extends keyof T['steps'],
98+
S extends boolean = false,
99+
V extends 'base' | 'feedback' = 'base',
100+
> = Infer<typeof Part<T, K, S, V>>
81101

82102
function PartUnion<
83103
T extends Schema,
84104
K extends readonly (keyof T['steps'])[],
85105
S extends boolean = false,
86-
>(schema: T, steps: K, withState?: S) {
106+
V extends 'base' | 'feedback' = 'base',
107+
>(schema: T, steps: K, withState?: S, stage?: V) {
87108
return v.variant(
88109
'step',
89-
steps.map((step) => Part(schema, step, withState)),
90-
) as v.VariantSchema<'step', { [I in keyof K]: ReturnType<typeof Part<T, K[I], S>> }, undefined>
110+
steps.map((step) => Part(schema, step, withState, stage)),
111+
) as v.VariantSchema<
112+
'step',
113+
{ [I in keyof K]: ReturnType<typeof Part<T, K[I], S, V>> },
114+
undefined
115+
>
91116
}
92117

93118
type PartUnion<
94119
T extends Schema,
95120
K extends readonly (keyof T['steps'])[] = readonly (keyof T['steps'])[],
96121
S extends boolean = false,
97-
> = Infer<typeof PartUnion<T, K, S>>
122+
V extends 'base' | 'feedback' = 'base',
123+
> = Infer<typeof PartUnion<T, K, S, V>>
98124

99125
type Props<T extends Schema, K extends keyof T['steps'], F extends boolean = true> = {
100-
question: InferFromShape<T['question']>
101-
state?: InferFromShape<T['steps'][K]['state']>
126+
question: InferFromShape<T['question'], 'feedback'>
127+
state?: InferFromShape<T['steps'][K]['state'], 'feedback'>
102128
previous: {
103-
[S in T['steps'][K]['previous'][number]]: InferFromShape<T['steps'][S]['state']>
129+
[S in T['steps'][K]['previous'][number]]: InferFromShape<T['steps'][S]['state'], 'feedback'>
104130
}[T['steps'][K]['previous'][number]][]
105131
} & (F extends true ? Partial<{ correct: boolean; score: [number, number] }> : {})
106132

@@ -114,32 +140,41 @@ export function defineFeedback<T extends Schema>(data: {
114140
return data
115141
}
116142

143+
function Attempt<T extends Schema, V extends 'base' | 'feedback'>(schema: T, stage: V) {
144+
const steps = Object.keys(schema.steps) as T['steps'][keyof T['steps']]
145+
return v.array(
146+
v.union([PartUnion(schema, steps, true, stage), PartUnion(schema, steps, false, stage)]),
147+
)
148+
}
149+
117150
export function buildSchemas<T extends Schema>(
118151
schema: T,
119152
feedback: ReturnType<typeof defineFeedback<T>>,
120153
) {
121-
const steps = Object.keys(schema.steps) as T['steps'][keyof T['steps']]
122154
return {
123155
Student: v.pipeAsync(
124156
v.object({
125157
name: v.literal(schema.name as T['name']),
126-
question: v.object(schema.question as T['question']),
127-
attempt: v.array(
128-
v.union([PartUnion(schema, steps, true), PartUnion(schema, steps, false)]),
129-
),
158+
question: RawShapeSchema(schema.question as T['question'], 'base'),
159+
attempt: Attempt(schema, 'base'),
130160
}),
131161
// TODO: calls need to be deduped, waiting for solid-router release?
132162
v.transformAsync(async ({ attempt, question, ...exercise }) => {
163+
const parsedQuestion = v.parse(
164+
RawShapeSchema(schema.question as T['question'], 'feedback'),
165+
question,
166+
)
167+
const parsedAttempt = v.parse(Attempt(schema, 'feedback'), attempt)
133168
let modifiedAttempt = attempt
134169
if (attempt.length === 0 && schema.transform) {
135-
question = await schema.transform(question)
170+
question = await schema.transform(parsedQuestion)
136171
}
137172
modifiedAttempt = await mapAsync(modifiedAttempt, async (part, i) => {
138-
if ('state' in part && part.state) {
173+
if ('state' in part && 'state' in parsedAttempt[i]! && part.state) {
139174
const result = await feedback[part.step]({
140-
question: question,
141-
state: part.state,
142-
previous: modifiedAttempt.slice(0, i).toReversed() as any,
175+
question: parsedQuestion,
176+
state: parsedAttempt[i]!.state ?? part.state,
177+
previous: parsedAttempt.slice(0, i).toReversed() as any,
143178
})
144179
return { ...part, ...result }
145180
}
@@ -176,10 +211,13 @@ export function createView<T extends Schema>(
176211
const { Student, grade } = buildSchemas(schema, feedback)
177212
return function Component(props: FinalViewProps<T>) {
178213
const exercise = createMemo(async () => grade(await props.fetch()))
214+
const parsedQuestion = createMemo(() =>
215+
v.parse(RawShapeSchema(schema.question as T['question'], 'feedback'), exercise().question),
216+
)
217+
const parsedAttempt = createMemo(() => v.parse(Attempt(schema, 'feedback'), exercise().attempt))
179218
return (
180219
<Loading fallback="Génération de l'exercice...">
181-
<p>{exercise().attempt.length} étapes</p>
182-
<For each={exercise().attempt}>
220+
<For each={parsedAttempt()}>
183221
{<K extends keyof T['steps']>(
184222
part: () =>
185223
| Part<T, K, true>
@@ -195,23 +233,22 @@ export function createView<T extends Schema>(
195233
)
196234
const submit = async () => {
197235
if (!validated().success) return
198-
await props.save(
199-
await grade({
200-
...exercise(),
201-
attempt: [
202-
...exercise().attempt.toSpliced(-1),
203-
validated().output as Part<T, K, true>,
204-
],
205-
}),
206-
)
236+
const graded = await grade({
237+
...exercise(),
238+
attempt: [
239+
...exercise().attempt.toSpliced(-1),
240+
validated().output as Part<T, K, true>,
241+
],
242+
})
243+
await props.save(graded)
207244
refresh(() => exercise)
208245
}
209246
return (
210-
<ExerciseContext value={{ state, setState }}>
247+
<ExerciseContext value={{ readOnly: !!part().state, state, setState }}>
211248
<Dynamic
212249
component={view[part().step]}
213250
{...({
214-
question: exercise().question,
251+
question: parsedQuestion(),
215252
state: part().state,
216253
previous: exercise().attempt.slice(0, i()).toReversed() as any,
217254
} satisfies Props<T, K> as ComponentProps<View<T>[K]>)}

packages/core/src/exercise/context.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createContext, createStore, useContext } from 'solid-js'
22

33
type ContextType = {
4+
readOnly?: boolean
45
state: Record<string, any>
56
setState: ReturnType<typeof createStore<Record<string, any>>>[1]
67
}

packages/core/src/exercise/field.ts

Lines changed: 0 additions & 50 deletions
This file was deleted.

0 commit comments

Comments
 (0)