quizwitz-validator 0.1.1 → 0.3.0

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.
@@ -58,3 +58,10 @@ export declare function validGroupId(value: unknown): number | undefined;
58
58
  * questions becomes one new group above the highest id.
59
59
  */
60
60
  export declare function normalizedGroupIds(questions: QuestionAggregate[]): number[];
61
+ /**
62
+ * A sound setting of an audio/video attachment as the game reads it
63
+ * (client SoundParameters.parse): `attributes.sound.{key}`, else a legacy
64
+ * `[key=value]` bracket in the name (key ignoring case). A volume above 1 is
65
+ * played at 1. Undefined when neither holds a number.
66
+ */
67
+ export declare function attachmentSoundNumber(attachment: Record<string, any>, key: string): number | undefined;
package/dist/aggregate.js CHANGED
@@ -145,3 +145,31 @@ export function normalizedGroupIds(questions) {
145
145
  return runId;
146
146
  });
147
147
  }
148
+ /**
149
+ * A sound setting of an audio/video attachment as the game reads it
150
+ * (client SoundParameters.parse): `attributes.sound.{key}`, else a legacy
151
+ * `[key=value]` bracket in the name (key ignoring case). A volume above 1 is
152
+ * played at 1. Undefined when neither holds a number.
153
+ */
154
+ export function attachmentSoundNumber(attachment, key) {
155
+ const read = (value) => {
156
+ const number = typeof value === 'number' ? value : parseFloat(String(value));
157
+ return isFinite(number) ? Math.min(1, number) : undefined;
158
+ };
159
+ const sound = attachment?.attributes?.sound;
160
+ if (sound && typeof sound === 'object' && sound[key] !== undefined && sound[key] !== null) {
161
+ const stored = read(sound[key]);
162
+ if (stored !== undefined) {
163
+ return stored;
164
+ }
165
+ }
166
+ let legacy;
167
+ const name = typeof attachment?.name === 'string' ? attachment.name : '';
168
+ for (const match of name.match(/\[(.*?)\]/g) ?? []) {
169
+ const parts = match.substring(1, match.length - 1).split('=');
170
+ if (parts.length === 2 && parts[0].toLowerCase() === key.toLowerCase()) {
171
+ legacy = read(parts[1]) ?? legacy;
172
+ }
173
+ }
174
+ return legacy;
175
+ }
package/dist/evaluate.js CHANGED
@@ -2,7 +2,7 @@ import { TypeCatalog } from './catalog.js';
2
2
  import { questionSetting, questionTypeToken, questionsOf, normalizedGroupIds, roundName, roundTypeToken, roundsOf } from './aggregate.js';
3
3
  import { ignoredBy } from './ignore.js';
4
4
  import { questionWarningId } from './ignore.js';
5
- import { COMPLETENESS_MESSAGES, evaluateConsistency, evaluateExplanationPair, evaluateGroupSize, evaluateOddOneOut, evaluateOptionCountMajority, evaluateOutlier, evaluateRequire, matchesLegacyWarning, matchesOptionCountOver, matchesSettingEquals, matchesTypeSetting, passesCheck, render } from './rules.js';
5
+ import { COMPLETENESS_MESSAGES, evaluateAttachmentVolume, evaluateConsistency, evaluateExplanationPair, evaluateGroupPosition, evaluateGroupSize, evaluateOddOneOut, evaluateOptionCountMajority, evaluateOutlier, evaluateRequire, matchesLegacyWarning, matchesOptionCountOver, matchesSettingEquals, matchesTypeSetting, passesCheck, render } from './rules.js';
6
6
  import { localizedText } from './text.js';
7
7
  import { COMPARATIVE_KINDS } from './types.js';
8
8
  export const ROUND_INCOMPLETE_MESSAGE = '{round}: scoring settings incomplete ({fields})';
@@ -220,6 +220,12 @@ function evaluateRound(catalog, scope, round, roundIndex) {
220
220
  case 'groupSize':
221
221
  evaluateGroupSize(rule, ctx).forEach(add);
222
222
  break;
223
+ case 'groupPosition':
224
+ evaluateGroupPosition(rule, ctx).forEach(add);
225
+ break;
226
+ case 'attachmentVolume':
227
+ evaluateAttachmentVolume(rule, ctx).forEach(add);
228
+ break;
223
229
  case 'consistency':
224
230
  evaluateConsistency(rule, ctx, quiet).forEach(add);
225
231
  break;
package/dist/rules.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CompletenessRule, ConsistencyRule, ExplanationPairRule, Finding, GroupSizeRule, LegacyWarning, LocalizedText, OddOneOutRule, OptionCountMajorityRule, OptionCountOverRule, OutlierRule, QuestionAggregate, RequireCheck, RequireRule, SettingEqualsRule, SettingsField, TypeSettingRule } from './types.js';
1
+ import type { AttachmentVolumeRule, CompletenessRule, ConsistencyRule, ExplanationPairRule, Finding, GroupPositionRule, GroupSizeRule, LegacyWarning, LocalizedText, OddOneOutRule, OptionCountMajorityRule, OptionCountOverRule, OutlierRule, QuestionAggregate, RequireCheck, RequireRule, SettingEqualsRule, SettingsField, TypeSettingRule } from './types.js';
2
2
  export interface QuestionContext {
3
3
  question: QuestionAggregate;
4
4
  uuid: string;
@@ -25,6 +25,28 @@ export declare function evaluateOutlier(rule: OutlierRule, ctx: RoundContext): F
25
25
  export declare function evaluateOddOneOut(rule: OddOneOutRule, ctx: RoundContext): Finding[];
26
26
  export declare function evaluateOptionCountMajority(rule: OptionCountMajorityRule, ctx: RoundContext): Finding[];
27
27
  export declare function evaluateGroupSize(rule: GroupSizeRule, ctx: RoundContext): Finding[];
28
+ /**
29
+ * The nth question of every group compared across the groups: where a strict
30
+ * majority (but not all) of the groups agree on the value, the nth questions
31
+ * that differ. Questions without a value (no positive setting, empty text)
32
+ * are left out of the count.
33
+ */
34
+ export declare function evaluateGroupPosition(rule: GroupPositionRule, ctx: RoundContext): Finding[];
35
+ /**
36
+ * A volume gain as the editor's slider shows it: `√gain` as a whole percent
37
+ * (the sliders are perceptual, docs/design.md 3.4). Also the comparison key,
38
+ * so gains the author cannot tell apart on the slider count as the same.
39
+ */
40
+ export declare function volumePercent(gain: number): number;
41
+ /**
42
+ * One sound setting (`mutedBackgroundVolume`) on the audio/video attachments
43
+ * of a round's questions: where at least `majority` of the questions with
44
+ * such attachments (but not all) play every one of them at the same value,
45
+ * and that value is not the default, the questions with an attachment that
46
+ * differs or leaves it unset. A round whose majority leaves the setting at
47
+ * the default is left alone: the few that set it did so on purpose.
48
+ */
49
+ export declare function evaluateAttachmentVolume(rule: AttachmentVolumeRule, ctx: RoundContext): Finding[];
28
50
  /** Explanation swaps; `quiet` names the `questionUuid:field` gaps the swap explains, for the consistency rules. */
29
51
  export declare function evaluateExplanationPair(rule: ExplanationPairRule, ctx: RoundContext, quiet: Set<string>): Finding[];
30
52
  export declare function evaluateConsistency(rule: ConsistencyRule, ctx: RoundContext, quiet: Set<string>): Finding[];
package/dist/rules.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * question-anomaly.ts and question-type-descriptor.ts so behaviour and ids
5
5
  * are identical.
6
6
  */
7
- import { filledOptionCount, fillsSlot, imageMapOf, optionsOf, questionSetting, resourceText, typeSettingValue } from './aggregate.js';
7
+ import { attachmentSoundNumber, attachmentsOf, filledOptionCount, fillsSlot, imageMapOf, optionsOf, questionSetting, resourceText, typeSettingValue } from './aggregate.js';
8
8
  import { groupSizeWarningId, questionWarningId } from './ignore.js';
9
9
  import { interpolate, localizedText } from './text.js';
10
10
  export function render(text, params, language) {
@@ -144,6 +144,141 @@ export function evaluateGroupSize(rule, ctx) {
144
144
  const first = groups.get(odd.id)[0];
145
145
  return [finding(ctx, first, rule, odd.count === 1 ? rule.messageSingle : rule.message, { count: odd.count, expected: odd.expected }, groupSizeWarningId(ctx.roundId, odd.id))];
146
146
  }
147
+ /**
148
+ * The nth question of every group compared across the groups: where a strict
149
+ * majority (but not all) of the groups agree on the value, the nth questions
150
+ * that differ. Questions without a value (no positive setting, empty text)
151
+ * are left out of the count.
152
+ */
153
+ export function evaluateGroupPosition(rule, ctx) {
154
+ const order = [];
155
+ const groups = new Map();
156
+ ctx.questions.forEach((q, i) => {
157
+ const id = ctx.groupIds[i];
158
+ if (!groups.has(id)) {
159
+ groups.set(id, []);
160
+ order.push(id);
161
+ }
162
+ groups.get(id).push(q);
163
+ });
164
+ const valueOf = (q) => {
165
+ if (rule.setting !== undefined) {
166
+ const value = questionSetting(q.question, rule.setting);
167
+ return typeof value === 'number' && value > 0 ? { key: String(value), value } : null;
168
+ }
169
+ const text = resourceText(q.question, rule.field);
170
+ return text !== '' ? { key: text.toLowerCase(), value: text } : null;
171
+ };
172
+ const longest = Math.max(0, ...order.map(id => groups.get(id).length));
173
+ const out = [];
174
+ for (let position = 0; position < longest; position++) {
175
+ const carriers = [];
176
+ for (const id of order) {
177
+ const q = groups.get(id)[position];
178
+ const value = q ? valueOf(q) : null;
179
+ if (q && value) {
180
+ carriers.push({ q, ...value });
181
+ }
182
+ }
183
+ if (carriers.length < (rule.minPopulation ?? 3)) {
184
+ continue;
185
+ }
186
+ const tally = new Map();
187
+ for (const c of carriers) {
188
+ tally.set(c.key, (tally.get(c.key) ?? 0) + 1);
189
+ }
190
+ let expected = '';
191
+ let agreeing = 0;
192
+ tally.forEach((count, key) => {
193
+ if (count > agreeing) {
194
+ expected = key;
195
+ agreeing = count;
196
+ }
197
+ });
198
+ if (agreeing === carriers.length || agreeing <= carriers.length * (rule.majority ?? 0.5)) {
199
+ continue;
200
+ }
201
+ const shown = carriers.find(c => c.key === expected).value;
202
+ for (const c of carriers) {
203
+ if (c.key !== expected) {
204
+ out.push(finding(ctx, c.q, rule, rule.message, { value: c.value, expected: shown, position: position + 1 }));
205
+ }
206
+ }
207
+ }
208
+ return out;
209
+ }
210
+ /**
211
+ * A volume gain as the editor's slider shows it: `√gain` as a whole percent
212
+ * (the sliders are perceptual, docs/design.md 3.4). Also the comparison key,
213
+ * so gains the author cannot tell apart on the slider count as the same.
214
+ */
215
+ export function volumePercent(gain) {
216
+ return gain > 0 ? Math.round(Math.sqrt(Math.min(1, gain)) * 100) : 0;
217
+ }
218
+ /**
219
+ * One sound setting (`mutedBackgroundVolume`) on the audio/video attachments
220
+ * of a round's questions: where at least `majority` of the questions with
221
+ * such attachments (but not all) play every one of them at the same value,
222
+ * and that value is not the default, the questions with an attachment that
223
+ * differs or leaves it unset. A round whose majority leaves the setting at
224
+ * the default is left alone: the few that set it did so on purpose.
225
+ */
226
+ export function evaluateAttachmentVolume(rule, ctx) {
227
+ const types = rule.types ?? ['audio', 'video'];
228
+ const excluded = rule.excludeSlots ?? [];
229
+ const defaultPercent = volumePercent(rule.default);
230
+ const carriers = [];
231
+ for (const q of ctx.questions) {
232
+ const percents = [];
233
+ const slots = attachmentsOf(q.question);
234
+ for (const slot of Object.keys(slots)) {
235
+ const items = slots[slot]?.items;
236
+ if (excluded.includes(slot) || !Array.isArray(items)) {
237
+ continue;
238
+ }
239
+ for (const attachment of items) {
240
+ if (attachment && types.includes(attachment.type)) {
241
+ const gain = attachmentSoundNumber(attachment, rule.setting);
242
+ percents.push(volumePercent(gain ?? rule.default));
243
+ }
244
+ }
245
+ }
246
+ if (percents.length > 0) {
247
+ carriers.push({ q, percents });
248
+ }
249
+ }
250
+ if (carriers.length < (rule.minPopulation ?? 4)) {
251
+ return [];
252
+ }
253
+ const tally = new Map();
254
+ for (const c of carriers) {
255
+ if (c.percents.every(p => p === c.percents[0])) {
256
+ tally.set(c.percents[0], (tally.get(c.percents[0]) ?? 0) + 1);
257
+ }
258
+ }
259
+ let expected = defaultPercent;
260
+ let agreeing = 0;
261
+ tally.forEach((count, percent) => {
262
+ if (count > agreeing) {
263
+ expected = percent;
264
+ agreeing = count;
265
+ }
266
+ });
267
+ if (expected === defaultPercent || agreeing === carriers.length || agreeing < carriers.length * (rule.majority ?? 0.75)) {
268
+ return [];
269
+ }
270
+ const out = [];
271
+ for (const c of carriers) {
272
+ const odd = c.percents.find(p => p !== expected);
273
+ if (odd === undefined) {
274
+ continue;
275
+ }
276
+ out.push(odd === defaultPercent
277
+ ? finding(ctx, c.q, rule, rule.messageUnset, { expected: expected + '%' })
278
+ : finding(ctx, c.q, rule, rule.message, { value: odd + '%', expected: expected + '%' }));
279
+ }
280
+ return out;
281
+ }
147
282
  /** Explanation swaps; `quiet` names the `questionUuid:field` gaps the swap explains, for the consistency rules. */
148
283
  export function evaluateExplanationPair(rule, ctx, quiet) {
149
284
  const fields = rule.fields;
package/dist/types.d.ts CHANGED
@@ -22,7 +22,7 @@ export interface SettingsField {
22
22
  };
23
23
  [key: string]: unknown;
24
24
  }
25
- export type RuleKind = 'outlier' | 'oddOneOut' | 'consistency' | 'explanationPair' | 'optionCountMajority' | 'groupSize' | 'require' | 'settingEquals' | 'optionCountOver' | 'typeSetting';
25
+ export type RuleKind = 'outlier' | 'oddOneOut' | 'consistency' | 'explanationPair' | 'optionCountMajority' | 'groupSize' | 'groupPosition' | 'attachmentVolume' | 'require' | 'settingEquals' | 'optionCountOver' | 'typeSetting';
26
26
  export declare const COMPARATIVE_KINDS: readonly RuleKind[];
27
27
  export declare const PER_QUESTION_KINDS: readonly RuleKind[];
28
28
  export type Severity = 'error' | 'warning';
@@ -70,6 +70,26 @@ export interface GroupSizeRule extends RuleBase {
70
70
  rule: 'groupSize';
71
71
  messageSingle: LocalizedText;
72
72
  }
73
+ export interface GroupPositionRule extends RuleBase {
74
+ rule: 'groupPosition';
75
+ /** A question setting (`points`), or else */
76
+ setting?: string;
77
+ /** a text on the primary resource (`label`), compared trimmed and ignoring case. */
78
+ field?: string;
79
+ }
80
+ export interface AttachmentVolumeRule extends RuleBase {
81
+ rule: 'attachmentVolume';
82
+ /** The sound setting compared: a linear gain under `attributes.sound` (`mutedBackgroundVolume`, `volume`). */
83
+ setting: string;
84
+ /** The gain an attachment that does not set it plays at (`mutedBackgroundVolume` 0, `volume` 1). */
85
+ default: number;
86
+ /** Attachment types that carry the setting (default `["audio", "video"]`). */
87
+ types?: string[];
88
+ /** Question attachment slots left out (`background`). */
89
+ excludeSlots?: string[];
90
+ /** For an attachment that leaves the setting at its default. */
91
+ messageUnset: LocalizedText;
92
+ }
73
93
  export type RequireCheck = 'correctOption' | 'allOptionsFilled' | 'answer' | 'imageMap';
74
94
  export interface RequireRule extends RuleBase {
75
95
  rule: 'require';
@@ -98,7 +118,7 @@ export interface TypeSettingRule extends RuleBase {
98
118
  min: number;
99
119
  };
100
120
  }
101
- export type FindingRule = OutlierRule | OddOneOutRule | ConsistencyRule | ExplanationPairRule | OptionCountMajorityRule | GroupSizeRule | RequireRule | SettingEqualsRule | OptionCountOverRule | TypeSettingRule;
121
+ export type FindingRule = OutlierRule | OddOneOutRule | ConsistencyRule | ExplanationPairRule | OptionCountMajorityRule | GroupSizeRule | GroupPositionRule | AttachmentVolumeRule | RequireRule | SettingEqualsRule | OptionCountOverRule | TypeSettingRule;
102
122
  /** The existing editor-facing fields the evaluator reads as well (docs/design.md, 3.2). */
103
123
  export type CompletenessRule = 'requireText' | 'requireCorrectOption' | 'requireAllOptionsFilled' | 'requireAnswer' | 'requireImageMap';
104
124
  export interface OptionCountWarning {
package/dist/types.js CHANGED
@@ -2,5 +2,5 @@
2
2
  * The served manifests (the part this package reads), the rule vocabulary
3
3
  * and the evaluator's input and output (docs/design.md, 3 and 4).
4
4
  */
5
- export const COMPARATIVE_KINDS = ['outlier', 'oddOneOut', 'consistency', 'explanationPair', 'optionCountMajority', 'groupSize'];
5
+ export const COMPARATIVE_KINDS = ['outlier', 'oddOneOut', 'consistency', 'explanationPair', 'optionCountMajority', 'groupSize', 'groupPosition', 'attachmentVolume'];
6
6
  export const PER_QUESTION_KINDS = ['require', 'settingEquals', 'optionCountOver', 'typeSetting'];
package/docs/design.md CHANGED
@@ -79,6 +79,8 @@ Messages are **English keys**: the editor translates them with its own catalogue
79
79
  | `consistency` | `field` (narration field on the primary resource: `long`, `tips`, `explanation`, `longExplanation`) **or** `slot` (question attachment slot: `before`, `during`, ...), `label`, `minPopulation` (4), `majority` (0.75) | Questions missing what at least `majority` of the round (but not all) has. Param `{field}` or `{slot}` = label. `during` counts as filled by an image map. A gap `explanationPair` already explains is skipped. |
80
80
  | `explanationPair` | `fields` (`["longExplanation", "explanation"]`), `labels` (`{field: label}`), `minPopulation` (4), `majority` (0.75) | Among questions that fill either field, the ones using the other half than the majority. Params `{expected, actual}` = labels. |
81
81
  | `optionCountMajority` | `types` (question type tokens), `minPopulation` (4), `majority` (0.5) | Same-type siblings (types listed) with a different number of filled options than the strict majority. Params `{count, expected}`. |
82
+ | `groupPosition` | exactly one of `setting` (a numeric question setting: `points`) or `field` (a text on the primary resource: `label`, compared trimmed and ignoring case), `minPopulation` (3, groups), `majority` (0.5) | In a grouped round, the nth question of every group is compared with the nth question of the other groups: where a strict majority of the groups (but not all) agree, the nth questions that differ. Questions without a value (setting not > 0, empty text) are left out. Params `{value, expected, position}` (position from 1). |
83
+ | `attachmentVolume` | `setting` (a sound setting under `attributes.sound`, a linear gain: `mutedBackgroundVolume`), `default` (the gain when unset: 0), `types` (`["audio", "video"]`), `excludeSlots` (`["background"]`), `messageUnset`, `minPopulation` (4, questions), `majority` (0.75) | The audio/video attachments of every question's slots (legacy `[key=value]` name brackets read too). Gains are compared as the editor's perceptual slider shows them (√gain as a whole percent). Where at least `majority` of the questions with such attachments (but not all) play all of them at one value that is not the default, the questions with an attachment that differs: `message` with `{value, expected}`, or `messageUnset` with `{expected}` when it leaves the setting at the default. A round whose majority leaves it at the default gets nothing. |
82
84
  | `groupSize` | `minPopulation` (3, groups), `message` (several), `messageSingle` (one question) | In a grouped round, the one group whose size differs when every other group agrees. Id `<roundUuid>:group:<groupId>:group-size`, anchored on the group's first question. Params `{count, expected}`. |
83
85
 
84
86
  **Per-question kinds** — checked on every question in scope: