@conform-ed/qti-react 0.0.12 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/index.js +4566 -212
  2. package/package.json +3 -1
  3. package/src/capability.ts +24 -0
  4. package/src/content-model.ts +104 -5
  5. package/src/graphic.ts +103 -0
  6. package/src/index.ts +139 -3
  7. package/src/interactions/associate.ts +22 -0
  8. package/src/interactions/drawing.ts +24 -0
  9. package/src/interactions/end-attempt.ts +19 -0
  10. package/src/interactions/extended-text.ts +21 -0
  11. package/src/interactions/gap-match.ts +22 -0
  12. package/src/interactions/graphic.ts +104 -0
  13. package/src/interactions/hottext.ts +21 -0
  14. package/src/interactions/index.ts +57 -2
  15. package/src/interactions/match.ts +27 -0
  16. package/src/interactions/media.ts +24 -0
  17. package/src/interactions/order.ts +21 -0
  18. package/src/interactions/slider.ts +24 -0
  19. package/src/interactions/upload.ts +19 -0
  20. package/src/normalized-item.ts +561 -0
  21. package/src/pci/index.ts +22 -0
  22. package/src/pci/interaction.ts +42 -0
  23. package/src/pci/markup.ts +102 -0
  24. package/src/pci/mount.ts +135 -0
  25. package/src/pci/registry.ts +240 -0
  26. package/src/pci/response.ts +138 -0
  27. package/src/pci/skin.ts +87 -0
  28. package/src/reference-skin/associate.ts +98 -0
  29. package/src/reference-skin/choice.ts +44 -0
  30. package/src/reference-skin/content.ts +30 -0
  31. package/src/reference-skin/drawing.ts +150 -0
  32. package/src/reference-skin/end-attempt.ts +27 -0
  33. package/src/reference-skin/extended-text.ts +35 -0
  34. package/src/reference-skin/gap-match.ts +69 -0
  35. package/src/reference-skin/graphic-associate.ts +123 -0
  36. package/src/reference-skin/graphic-base.ts +142 -0
  37. package/src/reference-skin/graphic-gap-match.ts +143 -0
  38. package/src/reference-skin/graphic-order.ts +76 -0
  39. package/src/reference-skin/hotspot.ts +43 -0
  40. package/src/reference-skin/hottext.ts +42 -0
  41. package/src/reference-skin/index.ts +75 -0
  42. package/src/reference-skin/inline-choice.ts +42 -0
  43. package/src/reference-skin/match.ts +80 -0
  44. package/src/reference-skin/media.ts +74 -0
  45. package/src/reference-skin/order.ts +79 -0
  46. package/src/reference-skin/position-object.ts +84 -0
  47. package/src/reference-skin/select-point.ts +87 -0
  48. package/src/reference-skin/slider.ts +41 -0
  49. package/src/reference-skin/text-entry.ts +31 -0
  50. package/src/reference-skin/upload.ts +46 -0
  51. package/src/response-processing.ts +178 -29
  52. package/src/rp/evaluate.ts +828 -0
  53. package/src/rp/index.ts +30 -0
  54. package/src/rp/interpreter.ts +251 -0
  55. package/src/rp/template-processing.ts +295 -0
  56. package/src/rp/templates.ts +190 -0
  57. package/src/rp/types.ts +161 -0
  58. package/src/rp/values.ts +198 -0
  59. package/src/runtime.ts +474 -28
  60. package/src/store.ts +155 -5
  61. package/src/test/controller.ts +806 -0
  62. package/src/test/index.ts +25 -0
  63. package/src/test/session-store.ts +244 -0
  64. package/src/test/types.ts +203 -0
  65. package/src/types.ts +27 -1
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The QTI standard response-processing templates as built-in canonical rule trees
3
+ * (ADR-0004): templates are interpreter inputs, not a separate scoring path. URIs are
4
+ * matched by their final path segment, so both the QTI 2.x and 3.0 purl forms resolve.
5
+ */
6
+
7
+ import type { RpRuleView } from "./types";
8
+
9
+ const matchCorrectRules: readonly RpRuleView[] = [
10
+ {
11
+ kind: "responseCondition",
12
+ responseIf: {
13
+ expression: {
14
+ kind: "match",
15
+ expressions: [
16
+ { kind: "variable", identifier: "RESPONSE" },
17
+ { kind: "correct", identifier: "RESPONSE" },
18
+ ],
19
+ },
20
+ rules: [
21
+ {
22
+ kind: "setOutcomeValue",
23
+ identifier: "SCORE",
24
+ expression: { kind: "baseValue", baseType: "float", value: 1 },
25
+ },
26
+ ],
27
+ },
28
+ responseElse: {
29
+ rules: [
30
+ {
31
+ kind: "setOutcomeValue",
32
+ identifier: "SCORE",
33
+ expression: { kind: "baseValue", baseType: "float", value: 0 },
34
+ },
35
+ ],
36
+ },
37
+ },
38
+ ];
39
+
40
+ const mapResponseRules: readonly RpRuleView[] = [
41
+ {
42
+ kind: "responseCondition",
43
+ responseIf: {
44
+ expression: { kind: "isNull", expressions: [{ kind: "variable", identifier: "RESPONSE" }] },
45
+ rules: [
46
+ {
47
+ kind: "setOutcomeValue",
48
+ identifier: "SCORE",
49
+ expression: { kind: "baseValue", baseType: "float", value: 0 },
50
+ },
51
+ ],
52
+ },
53
+ responseElse: {
54
+ rules: [
55
+ { kind: "setOutcomeValue", identifier: "SCORE", expression: { kind: "mapResponse", identifier: "RESPONSE" } },
56
+ ],
57
+ },
58
+ },
59
+ ];
60
+
61
+ const mapResponsePointRules: readonly RpRuleView[] = [
62
+ {
63
+ kind: "responseCondition",
64
+ responseIf: {
65
+ expression: { kind: "isNull", expressions: [{ kind: "variable", identifier: "RESPONSE" }] },
66
+ rules: [
67
+ {
68
+ kind: "setOutcomeValue",
69
+ identifier: "SCORE",
70
+ expression: { kind: "baseValue", baseType: "float", value: 0 },
71
+ },
72
+ ],
73
+ },
74
+ responseElse: {
75
+ rules: [
76
+ {
77
+ kind: "setOutcomeValue",
78
+ identifier: "SCORE",
79
+ expression: { kind: "mapResponsePoint", identifier: "RESPONSE" },
80
+ },
81
+ ],
82
+ },
83
+ },
84
+ ];
85
+
86
+ /**
87
+ * The Common Cartridge profile templates (CC2_*), transcribed from the published
88
+ * template XML shipped in CC packages. CC2_match is match_correct under another URI.
89
+ */
90
+ const cc2MatchBasicRules: readonly RpRuleView[] = [
91
+ {
92
+ kind: "responseCondition",
93
+ responseIf: {
94
+ expression: {
95
+ kind: "match",
96
+ expressions: [
97
+ { kind: "variable", identifier: "RESPONSE" },
98
+ { kind: "correct", identifier: "RESPONSE" },
99
+ ],
100
+ },
101
+ rules: [
102
+ { kind: "setOutcomeValue", identifier: "SCORE", expression: { kind: "variable", identifier: "MAXSCORE" } },
103
+ {
104
+ kind: "setOutcomeValue",
105
+ identifier: "FEEDBACKBASIC",
106
+ expression: { kind: "baseValue", baseType: "identifier", value: "correct" },
107
+ },
108
+ ],
109
+ },
110
+ responseElse: {
111
+ rules: [
112
+ {
113
+ kind: "setOutcomeValue",
114
+ identifier: "FEEDBACKBASIC",
115
+ expression: { kind: "baseValue", baseType: "identifier", value: "incorrect" },
116
+ },
117
+ ],
118
+ },
119
+ },
120
+ ];
121
+
122
+ const cc2MapResponseRules: readonly RpRuleView[] = [
123
+ {
124
+ kind: "responseCondition",
125
+ responseIf: {
126
+ expression: { kind: "isNull", expressions: [{ kind: "variable", identifier: "RESPONSE" }] },
127
+ rules: [
128
+ {
129
+ kind: "setOutcomeValue",
130
+ identifier: "SCORE",
131
+ expression: { kind: "baseValue", baseType: "float", value: 0 },
132
+ },
133
+ ],
134
+ },
135
+ responseElse: {
136
+ rules: [
137
+ { kind: "setOutcomeValue", identifier: "SCORE", expression: { kind: "mapResponse", identifier: "RESPONSE" } },
138
+ ],
139
+ },
140
+ },
141
+ {
142
+ kind: "responseCondition",
143
+ responseIf: {
144
+ expression: {
145
+ kind: "equal",
146
+ toleranceMode: "exact",
147
+ expressions: [
148
+ { kind: "variable", identifier: "MAXSCORE" },
149
+ { kind: "variable", identifier: "SCORE" },
150
+ ],
151
+ },
152
+ rules: [
153
+ {
154
+ kind: "setOutcomeValue",
155
+ identifier: "FEEDBACK",
156
+ expression: { kind: "baseValue", baseType: "identifier", value: "correct" },
157
+ },
158
+ ],
159
+ },
160
+ responseElse: {
161
+ rules: [
162
+ {
163
+ kind: "setOutcomeValue",
164
+ identifier: "FEEDBACK",
165
+ expression: { kind: "baseValue", baseType: "identifier", value: "incorrect" },
166
+ },
167
+ ],
168
+ },
169
+ },
170
+ ];
171
+
172
+ const templatesBySegment: ReadonlyMap<string, readonly RpRuleView[]> = new Map([
173
+ ["match_correct", matchCorrectRules],
174
+ ["map_response", mapResponseRules],
175
+ ["map_response_point", mapResponsePointRules],
176
+ ["CC2_match", matchCorrectRules],
177
+ ["CC2_match_basic", cc2MatchBasicRules],
178
+ ["CC2_map_response", cc2MapResponseRules],
179
+ ]);
180
+
181
+ /** Resolve a standard-template URI to its canonical rules, or null when unknown. */
182
+ export function resolveTemplate(uri: string): readonly RpRuleView[] | null {
183
+ const segment =
184
+ uri
185
+ .split("/")
186
+ .at(-1)
187
+ ?.replace(/\.xml$/u, "") ?? "";
188
+
189
+ return templatesBySegment.get(segment) ?? null;
190
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Structural views of QTI 3 `responseProcessing` and `outcomeDeclaration` (ADR-0004).
3
+ * Like the rest of the runtime's views, these are narrowed shapes of the
4
+ * `@conform-ed/contracts` schemas: validation happens upstream, the interpreter works
5
+ * against these types and reports anything it does not understand via the Capability
6
+ * Report instead of guessing.
7
+ */
8
+
9
+ import type { CapabilityIssue } from "../capability";
10
+ import type { Cardinality, ResponseDeclarationView, ResponseValue } from "../types";
11
+
12
+ export type RpScalar = string | number | boolean;
13
+
14
+ /** An outcome variable's value as exposed to consumers after response processing. */
15
+ export type OutcomeValue = RpScalar | readonly RpScalar[] | null;
16
+
17
+ /** One named member of a record-cardinality value (QTI record containers). */
18
+ export interface RpRecordField {
19
+ readonly name: string;
20
+ readonly baseType?: string;
21
+ readonly value: RpScalar;
22
+ }
23
+
24
+ /** The interpreter's typed value model: (baseType, cardinality, members); NULL is null. */
25
+ export interface RpValue {
26
+ readonly cardinality: Cardinality;
27
+ readonly baseType?: string;
28
+ readonly values: readonly RpScalar[];
29
+ /** Present only when cardinality is "record"; `values` mirrors the field values. */
30
+ readonly fields?: readonly RpRecordField[];
31
+ }
32
+
33
+ export type MaybeRpValue = RpValue | null;
34
+
35
+ export interface OutcomeDeclarationView {
36
+ readonly identifier: string;
37
+ readonly cardinality: Cardinality;
38
+ readonly baseType?: string;
39
+ readonly defaultValue?: { readonly values: ReadonlyArray<{ readonly value: RpScalar }> };
40
+ }
41
+
42
+ /**
43
+ * One expression node. Deliberately loose (`kind: string`): kinds the interpreter
44
+ * does not implement yet flow through and surface as `unsupported-rp` issues.
45
+ */
46
+ export interface RpExpressionView {
47
+ readonly kind: string;
48
+ readonly identifier?: string;
49
+ readonly baseType?: string;
50
+ readonly value?: RpScalar;
51
+ readonly expressions?: readonly RpExpressionView[];
52
+ /** Bounds/step for the random operators (template processing). */
53
+ readonly min?: number;
54
+ readonly max?: number;
55
+ readonly step?: number;
56
+ /** `equal` tolerance window; string entries are template references (unsupported). */
57
+ readonly toleranceMode?: "exact" | "absolute" | "relative";
58
+ readonly tolerance?: ReadonlyArray<number | string>;
59
+ readonly includeLowerBound?: boolean;
60
+ readonly includeUpperBound?: boolean;
61
+ /** 1-based position for `index`. */
62
+ readonly n?: number | string;
63
+ /** Function/constant name for `mathOperator`, `mathConstant`, `statsOperator`. */
64
+ readonly name?: string;
65
+ /** Rounding controls for `roundTo` and `equalRounded`. */
66
+ readonly roundingMode?: "decimalPlaces" | "significantFigures";
67
+ readonly figures?: number | string;
68
+ /** Pass count for `repeat`. */
69
+ readonly numberRepeats?: number | string;
70
+ /** String comparison controls for `stringMatch` and `substring`. */
71
+ readonly caseSensitive?: boolean;
72
+ readonly substring?: boolean;
73
+ /** Area for `inside` (QTI shape + coords string). */
74
+ readonly shape?: string;
75
+ readonly coords?: string;
76
+ /** Test-level subset selection (`testVariables` and the `number*` aggregates). */
77
+ readonly variableIdentifier?: string;
78
+ readonly weightIdentifier?: string;
79
+ readonly sectionIdentifier?: string;
80
+ readonly includeCategory?: string | readonly string[];
81
+ readonly excludeCategory?: string | readonly string[];
82
+ /** Vendor identification for `customOperator` (implementation registered by class). */
83
+ readonly class?: string;
84
+ readonly definition?: string;
85
+ /** Named-field selector for `fieldValue` over record-cardinality values. */
86
+ readonly fieldIdentifier?: string;
87
+ }
88
+
89
+ /**
90
+ * A consumer-registered `customOperator` implementation, keyed by its `class`
91
+ * attribute. Receives the already-evaluated child values; returns NULL-or-value like
92
+ * any expression. Vendor operators are by definition engine-specific (the spec leaves
93
+ * them implementation-defined), so nothing ships registered — same opt-in stance as
94
+ * PCI modules.
95
+ */
96
+ export type CustomOperatorImplementation = (
97
+ args: readonly MaybeRpValue[],
98
+ expression: RpExpressionView,
99
+ ) => MaybeRpValue;
100
+
101
+ export interface RpConditionBranch {
102
+ readonly expression: RpExpressionView;
103
+ readonly rules: readonly RpRuleView[];
104
+ }
105
+
106
+ /** One response rule: responseCondition, setOutcomeValue, or exitResponse. */
107
+ export interface RpRuleView {
108
+ readonly kind: string;
109
+ readonly identifier?: string;
110
+ readonly expression?: RpExpressionView;
111
+ readonly responseIf?: RpConditionBranch;
112
+ readonly responseElseIfs?: readonly RpConditionBranch[];
113
+ readonly responseElse?: { readonly rules: readonly RpRuleView[] };
114
+ }
115
+
116
+ /** Either a standard-template URI or an explicit rule tree (rules win if both). */
117
+ export interface ResponseProcessingView {
118
+ readonly template?: string;
119
+ readonly rules?: readonly RpRuleView[];
120
+ }
121
+
122
+ /**
123
+ * Response Normalization (ADR-0004): an opt-in, consumer-configured transform of
124
+ * candidate string input applied at comparison time. Off by default; always off in
125
+ * conformance runs. Applied to both sides of string comparisons so keys and candidate
126
+ * input normalize identically.
127
+ */
128
+ export type ResponseNormalization = (value: string, declaration?: ResponseDeclarationView) => string;
129
+
130
+ export interface TemplateDeclarationView {
131
+ readonly identifier: string;
132
+ readonly cardinality: Cardinality;
133
+ readonly baseType?: string;
134
+ readonly defaultValue?: { readonly values: ReadonlyArray<{ readonly value: RpScalar }> };
135
+ }
136
+
137
+ export interface ResponseProcessingContext {
138
+ readonly responseDeclarations: readonly ResponseDeclarationView[];
139
+ readonly outcomeDeclarations: readonly OutcomeDeclarationView[];
140
+ readonly responses: Readonly<Record<string, ResponseValue>>;
141
+ readonly normalization?: ResponseNormalization;
142
+ /** Template variables for this clone (read by `variable` lookups). */
143
+ readonly templateDeclarations?: readonly TemplateDeclarationView[];
144
+ readonly templateValues?: Readonly<Record<string, OutcomeValue>>;
145
+ /** Adaptive carry-over: outcome values from earlier attempts replace declared defaults. */
146
+ readonly priorOutcomes?: Readonly<Record<string, OutcomeValue>>;
147
+ /**
148
+ * Random source for `random`/`randomInteger`/`randomFloat` in RP (adaptive items,
149
+ * e.g. Monty Hall door reveals). Seed it from the attempt seed: the seed plus the
150
+ * submission sequence then replays the exact same outcomes (ADR-0004 determinism).
151
+ */
152
+ readonly random?: () => number;
153
+ /** Registered vendor `customOperator` implementations by class (opt-in). */
154
+ readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>>;
155
+ }
156
+
157
+ export interface ResponseProcessingResult {
158
+ readonly outcomes: Readonly<Record<string, OutcomeValue>>;
159
+ /** Non-empty means execution aborted to declared defaults (never partial scoring). */
160
+ readonly issues: readonly CapabilityIssue[];
161
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The interpreter's typed value model. QTI runtime values are (baseType, cardinality,
3
+ * members); NULL is represented as `null`. Comparison is baseType-aware: pairs are
4
+ * unordered within the pair, directedPairs ordered, strings normalize through the
5
+ * consumer's Response Normalization hook (identity by default), numbers compare
6
+ * numerically.
7
+ */
8
+
9
+ import { parsePoint } from "../graphic";
10
+ import { isResponseRecord } from "../types";
11
+ import type { Cardinality, ResponseDeclarationView, ResponseValue } from "../types";
12
+ import type { MaybeRpValue, OutcomeValue, ResponseNormalization, RpScalar, RpValue } from "./types";
13
+
14
+ export type { MaybeRpValue, RpValue } from "./types";
15
+
16
+ const numericBaseTypes = new Set(["float", "integer", "duration"]);
17
+
18
+ export function isNumericBaseType(baseType: string | undefined): boolean {
19
+ return baseType !== undefined && numericBaseTypes.has(baseType);
20
+ }
21
+
22
+ /** Coerce a raw scalar to its declared baseType (numbers and booleans may arrive as strings). */
23
+ export function coerceScalar(value: RpScalar, baseType: string | undefined): RpScalar {
24
+ if (isNumericBaseType(baseType) && typeof value === "string" && value.trim() !== "") {
25
+ const parsed = Number(value);
26
+
27
+ if (!Number.isNaN(parsed)) {
28
+ return parsed;
29
+ }
30
+ }
31
+
32
+ if (baseType === "boolean" && typeof value === "string") {
33
+ return value === "true";
34
+ }
35
+
36
+ return value;
37
+ }
38
+
39
+ /** A record field's base type is carried by its runtime type (no declaration exists). */
40
+ function fieldBaseType(value: string | number | boolean): string {
41
+ return typeof value === "boolean" ? "boolean" : typeof value === "number" ? "float" : "string";
42
+ }
43
+
44
+ /** Lift a store ResponseValue into the typed model using its declaration. */
45
+ export function fromResponse(declaration: ResponseDeclarationView, response: ResponseValue): MaybeRpValue {
46
+ if (response === null) {
47
+ return null;
48
+ }
49
+
50
+ if (isResponseRecord(response)) {
51
+ const fields = Object.entries(response).flatMap(([name, member]) =>
52
+ member === null ? [] : [{ name, baseType: fieldBaseType(member), value: member }],
53
+ );
54
+
55
+ return fields.length === 0 ? null : { cardinality: "record", fields, values: fields.map((field) => field.value) };
56
+ }
57
+
58
+ const raw = typeof response === "string" ? [response] : [...response];
59
+
60
+ if (raw.length === 0) {
61
+ return null;
62
+ }
63
+
64
+ return {
65
+ cardinality: declaration.cardinality,
66
+ baseType: declaration.baseType,
67
+ values: raw.map((value) => coerceScalar(value, declaration.baseType)),
68
+ };
69
+ }
70
+
71
+ export function singleNumber(value: MaybeRpValue): number | null {
72
+ if (value === null || value.values.length !== 1) {
73
+ return null;
74
+ }
75
+
76
+ const member = value.values[0];
77
+
78
+ return typeof member === "number" ? member : null;
79
+ }
80
+
81
+ export function singleBoolean(value: MaybeRpValue): boolean | null {
82
+ if (value === null || value.values.length !== 1) {
83
+ return null;
84
+ }
85
+
86
+ const member = value.values[0];
87
+
88
+ return typeof member === "boolean" ? member : null;
89
+ }
90
+
91
+ export function booleanValue(value: boolean): RpValue {
92
+ return { cardinality: "single", baseType: "boolean", values: [value] };
93
+ }
94
+
95
+ export function floatValue(value: number): RpValue {
96
+ return { cardinality: "single", baseType: "float", values: [value] };
97
+ }
98
+
99
+ function pairsEqual(a: string, b: string, directed: boolean): boolean {
100
+ const [a1, a2] = a.trim().split(/\s+/u);
101
+ const [b1, b2] = b.trim().split(/\s+/u);
102
+
103
+ if (a1 === undefined || a2 === undefined || b1 === undefined || b2 === undefined) {
104
+ return false;
105
+ }
106
+
107
+ if (a1 === b1 && a2 === b2) {
108
+ return true;
109
+ }
110
+
111
+ return !directed && a1 === b2 && a2 === b1;
112
+ }
113
+
114
+ export function scalarsEqual(
115
+ a: RpScalar,
116
+ b: RpScalar,
117
+ baseType: string | undefined,
118
+ normalize?: ResponseNormalization,
119
+ ): boolean {
120
+ if (baseType === "pair" || baseType === "directedPair") {
121
+ return typeof a === "string" && typeof b === "string" && pairsEqual(a, b, baseType === "directedPair");
122
+ }
123
+
124
+ if (baseType === "point") {
125
+ if (typeof a !== "string" || typeof b !== "string") {
126
+ return false;
127
+ }
128
+
129
+ const pointA = parsePoint(a);
130
+ const pointB = parsePoint(b);
131
+
132
+ return pointA !== null && pointB !== null && pointA.x === pointB.x && pointA.y === pointB.y;
133
+ }
134
+
135
+ if (typeof a === "number" || typeof b === "number") {
136
+ return Number(a) === Number(b);
137
+ }
138
+
139
+ if (normalize && baseType === "string" && typeof a === "string" && typeof b === "string") {
140
+ return normalize(a) === normalize(b);
141
+ }
142
+
143
+ return a === b;
144
+ }
145
+
146
+ /** QTI `match`: same cardinality; ordered compares sequences, containers compare multisets. */
147
+ export function valuesMatch(a: RpValue, b: RpValue, normalize?: ResponseNormalization): boolean {
148
+ const baseType = a.baseType ?? b.baseType;
149
+
150
+ if (a.values.length !== b.values.length) {
151
+ return false;
152
+ }
153
+
154
+ if (a.cardinality === "ordered" || b.cardinality === "ordered") {
155
+ return a.values.every((value, index) => scalarsEqual(value, b.values[index]!, baseType, normalize));
156
+ }
157
+
158
+ const remaining = [...b.values];
159
+
160
+ for (const value of a.values) {
161
+ const matchIndex = remaining.findIndex((candidate) => scalarsEqual(candidate, value, baseType, normalize));
162
+
163
+ if (matchIndex === -1) {
164
+ return false;
165
+ }
166
+
167
+ remaining.splice(matchIndex, 1);
168
+ }
169
+
170
+ return true;
171
+ }
172
+
173
+ /** Rebuild a typed value from a flattened OutcomeValue (templates, adaptive carry-over). */
174
+ export function fromFlatValue(value: OutcomeValue, cardinality: Cardinality, baseType?: string): MaybeRpValue {
175
+ if (value === null) {
176
+ return null;
177
+ }
178
+
179
+ const values = Array.isArray(value) ? [...value] : [value as RpScalar];
180
+
181
+ if (values.length === 0) {
182
+ return null;
183
+ }
184
+
185
+ return { cardinality, baseType, values: values.map((member) => coerceScalar(member, baseType)) };
186
+ }
187
+
188
+ export function toOutcomeValue(value: MaybeRpValue): OutcomeValue {
189
+ if (value === null || value.values.length === 0) {
190
+ return null;
191
+ }
192
+
193
+ if (value.cardinality === "single") {
194
+ return value.values[0] ?? null;
195
+ }
196
+
197
+ return value.values;
198
+ }