@lokascript/planner 2.4.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.
- package/dist/index.cjs +395 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +205 -0
- package/dist/index.d.ts +205 -0
- package/dist/index.js +358 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -0
- package/src/__tests__/planner.test.ts +605 -0
- package/src/index.ts +36 -0
- package/src/planner.ts +586 -0
- package/src/types.ts +88 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createPlanStep,
|
|
4
|
+
createAffordanceDef,
|
|
5
|
+
applyEffects,
|
|
6
|
+
inferHeuristicMappings,
|
|
7
|
+
inferConditions,
|
|
8
|
+
parseConditionMap,
|
|
9
|
+
buildDefsFromActions,
|
|
10
|
+
plan,
|
|
11
|
+
planWithCost,
|
|
12
|
+
planForAction,
|
|
13
|
+
planFromEntity,
|
|
14
|
+
} from '../planner.js';
|
|
15
|
+
import type { SirenAction, ConditionMapEntry } from '../types.js';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Fixtures: order workflow (matches Go demo)
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const ORDER_CONDITION_MAP: Record<string, ConditionMapEntry> = {
|
|
22
|
+
'order.mutable': {
|
|
23
|
+
description: 'Order is mutable',
|
|
24
|
+
producedBy: [],
|
|
25
|
+
requiredBy: ['add-item', 'update-order', 'delete-order'],
|
|
26
|
+
},
|
|
27
|
+
'order.status.pending': {
|
|
28
|
+
description: 'Order status is pending',
|
|
29
|
+
producedBy: ['update-order'],
|
|
30
|
+
requiredBy: [],
|
|
31
|
+
},
|
|
32
|
+
'order.status.processing': {
|
|
33
|
+
description: 'Order status is processing',
|
|
34
|
+
producedBy: ['update-order'],
|
|
35
|
+
requiredBy: ['ship-order'],
|
|
36
|
+
},
|
|
37
|
+
'order.status.shipped': {
|
|
38
|
+
description: 'Order status is shipped',
|
|
39
|
+
producedBy: ['ship-order'],
|
|
40
|
+
requiredBy: [],
|
|
41
|
+
},
|
|
42
|
+
'order.items.nonempty': {
|
|
43
|
+
description: 'Order has items',
|
|
44
|
+
producedBy: ['add-item'],
|
|
45
|
+
requiredBy: ['ship-order'],
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const ORDER_ACTIONS: SirenAction[] = [
|
|
50
|
+
{
|
|
51
|
+
name: 'add-item',
|
|
52
|
+
href: '/orders/1/items',
|
|
53
|
+
method: 'POST',
|
|
54
|
+
preconditions: ['order.mutable'],
|
|
55
|
+
effects: ['order.items.nonempty'],
|
|
56
|
+
cost: 1,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'update-order',
|
|
60
|
+
href: '/orders/1',
|
|
61
|
+
method: 'PUT',
|
|
62
|
+
preconditions: ['order.mutable'],
|
|
63
|
+
effects: ['order.status.pending', 'order.status.processing', 'order.status.shipped'],
|
|
64
|
+
cost: 1,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'ship-order',
|
|
68
|
+
href: '/orders/1/ship',
|
|
69
|
+
method: 'POST',
|
|
70
|
+
preconditions: ['order.status.processing', 'order.items.nonempty'],
|
|
71
|
+
effects: ['order.status.shipped'],
|
|
72
|
+
cost: 2,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'delete-order',
|
|
76
|
+
href: '/orders/1',
|
|
77
|
+
method: 'DELETE',
|
|
78
|
+
preconditions: ['order.mutable'],
|
|
79
|
+
effects: [],
|
|
80
|
+
cost: 1,
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// PlanStep
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
describe('PlanStep', () => {
|
|
89
|
+
it('label() returns name without params', () => {
|
|
90
|
+
const step = createPlanStep('ship-order');
|
|
91
|
+
expect(step.label()).toBe('ship-order');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('label() includes params', () => {
|
|
95
|
+
const step = createPlanStep('update-order', { status: 'processing' });
|
|
96
|
+
expect(step.label()).toBe('update-order(status=processing)');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('key() is stable regardless of param insertion order', () => {
|
|
100
|
+
const a = createPlanStep('action', { b: '2', a: '1' });
|
|
101
|
+
const b = createPlanStep('action', { a: '1', b: '2' });
|
|
102
|
+
expect(a.key()).toBe(b.key());
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('key() without params returns just name', () => {
|
|
106
|
+
const step = createPlanStep('ship-order');
|
|
107
|
+
expect(step.key()).toBe('ship-order');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// AffordanceDef
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
describe('AffordanceDef', () => {
|
|
116
|
+
it('defaults cost to 1 and preferred to false', () => {
|
|
117
|
+
const def = createAffordanceDef({
|
|
118
|
+
name: 'test',
|
|
119
|
+
preconditions: [],
|
|
120
|
+
effects: ['a'],
|
|
121
|
+
});
|
|
122
|
+
expect(def.cost).toBe(1);
|
|
123
|
+
expect(def.preferred).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('preserves cost and preferred', () => {
|
|
127
|
+
const def = createAffordanceDef({
|
|
128
|
+
name: 'test',
|
|
129
|
+
preconditions: [],
|
|
130
|
+
effects: [],
|
|
131
|
+
cost: 5,
|
|
132
|
+
preferred: true,
|
|
133
|
+
});
|
|
134
|
+
expect(def.cost).toBe(5);
|
|
135
|
+
expect(def.preferred).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('freezes arrays', () => {
|
|
139
|
+
const def = createAffordanceDef({
|
|
140
|
+
name: 'test',
|
|
141
|
+
preconditions: ['a'],
|
|
142
|
+
effects: ['b'],
|
|
143
|
+
});
|
|
144
|
+
expect(Object.isFrozen(def.preconditions)).toBe(true);
|
|
145
|
+
expect(Object.isFrozen(def.effects)).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// applyEffects
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
describe('applyEffects', () => {
|
|
154
|
+
it('adds simple effect', () => {
|
|
155
|
+
const def = createAffordanceDef({
|
|
156
|
+
name: 'add-item',
|
|
157
|
+
preconditions: [],
|
|
158
|
+
effects: ['order.items.nonempty'],
|
|
159
|
+
});
|
|
160
|
+
const state = new Set(['order.mutable']);
|
|
161
|
+
const result = applyEffects(state, def, {}, new Set());
|
|
162
|
+
expect(result.has('order.items.nonempty')).toBe(true);
|
|
163
|
+
expect(result.has('order.mutable')).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('resolves template effect and clears mutex', () => {
|
|
167
|
+
const def = createAffordanceDef({
|
|
168
|
+
name: 'update-order',
|
|
169
|
+
preconditions: [],
|
|
170
|
+
effects: ['order.status.pending', 'order.status.processing'],
|
|
171
|
+
templateEffect: 'order.status.{status}',
|
|
172
|
+
paramName: 'status',
|
|
173
|
+
validValues: ['pending', 'processing'],
|
|
174
|
+
});
|
|
175
|
+
const state = new Set(['order.status.pending', 'order.mutable']);
|
|
176
|
+
const result = applyEffects(state, def, { status: 'processing' }, new Set(['order.status.']));
|
|
177
|
+
expect(result.has('order.status.processing')).toBe(true);
|
|
178
|
+
expect(result.has('order.status.pending')).toBe(false);
|
|
179
|
+
expect(result.has('order.mutable')).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('clears mutex prefix for non-template effects', () => {
|
|
183
|
+
const def = createAffordanceDef({
|
|
184
|
+
name: 'ship',
|
|
185
|
+
preconditions: [],
|
|
186
|
+
effects: ['order.status.shipped'],
|
|
187
|
+
});
|
|
188
|
+
const state = new Set(['order.status.processing']);
|
|
189
|
+
const result = applyEffects(state, def, {}, new Set(['order.status.']));
|
|
190
|
+
expect(result.has('order.status.shipped')).toBe(true);
|
|
191
|
+
expect(result.has('order.status.processing')).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// inferHeuristicMappings
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
describe('inferHeuristicMappings', () => {
|
|
200
|
+
it('maps 3-segment eq condition', () => {
|
|
201
|
+
const mappings = inferHeuristicMappings(new Set(['order.status.pending']));
|
|
202
|
+
expect(mappings['order.status.pending']).toEqual({
|
|
203
|
+
property: 'status',
|
|
204
|
+
op: 'eq',
|
|
205
|
+
value: 'pending',
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('maps 3-segment nonempty condition', () => {
|
|
210
|
+
const mappings = inferHeuristicMappings(new Set(['order.items.nonempty']));
|
|
211
|
+
expect(mappings['order.items.nonempty']).toEqual({
|
|
212
|
+
property: 'itemCount',
|
|
213
|
+
op: 'gt',
|
|
214
|
+
value: 0,
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('maps 2-segment to derived', () => {
|
|
219
|
+
const mappings = inferHeuristicMappings(new Set(['order.mutable']));
|
|
220
|
+
expect(mappings['order.mutable'].property).toBe('_derived');
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// inferConditions
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
describe('inferConditions', () => {
|
|
229
|
+
it('prefers x-conditions when present', () => {
|
|
230
|
+
const entity = {
|
|
231
|
+
properties: { status: 'pending' },
|
|
232
|
+
'x-conditions': ['order.status.processing', 'order.items.nonempty'],
|
|
233
|
+
};
|
|
234
|
+
const result = inferConditions(
|
|
235
|
+
entity,
|
|
236
|
+
new Set(['order.status.pending', 'order.status.processing'])
|
|
237
|
+
);
|
|
238
|
+
expect(result.has('order.status.processing')).toBe(true);
|
|
239
|
+
expect(result.has('order.status.pending')).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('infers 3-segment eq from properties', () => {
|
|
243
|
+
const entity = { properties: { status: 'pending' } };
|
|
244
|
+
const result = inferConditions(
|
|
245
|
+
entity,
|
|
246
|
+
new Set(['order.status.pending', 'order.status.processing'])
|
|
247
|
+
);
|
|
248
|
+
expect(result.has('order.status.pending')).toBe(true);
|
|
249
|
+
expect(result.has('order.status.processing')).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('infers nonempty from count property', () => {
|
|
253
|
+
const entity = { properties: { itemCount: 3 } };
|
|
254
|
+
const result = inferConditions(entity, new Set(['order.items.nonempty']));
|
|
255
|
+
expect(result.has('order.items.nonempty')).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('infers derived condition from action presence', () => {
|
|
259
|
+
const entity = { properties: {}, actions: [{ name: 'foo' }] };
|
|
260
|
+
const result = inferConditions(entity, new Set(['order.mutable']));
|
|
261
|
+
expect(result.has('order.mutable')).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('does not infer derived when no actions', () => {
|
|
265
|
+
const entity = { properties: {}, actions: [] };
|
|
266
|
+
const result = inferConditions(entity, new Set(['order.mutable']));
|
|
267
|
+
expect(result.has('order.mutable')).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// parseConditionMap
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
describe('parseConditionMap', () => {
|
|
276
|
+
it('produces correct AffordanceDefs from order condition map', () => {
|
|
277
|
+
const { defs, conditionNames, mutexPrefixes } = parseConditionMap(ORDER_CONDITION_MAP);
|
|
278
|
+
|
|
279
|
+
expect(conditionNames.size).toBe(5);
|
|
280
|
+
expect(defs.length).toBe(4); // add-item, delete-order, ship-order, update-order
|
|
281
|
+
|
|
282
|
+
const updateDef = defs.find(d => d.name === 'update-order')!;
|
|
283
|
+
expect(updateDef.templateEffect).toBe('order.status.{status}');
|
|
284
|
+
expect(updateDef.paramName).toBe('status');
|
|
285
|
+
// update-order produces pending and processing (shipped is produced by ship-order)
|
|
286
|
+
expect(updateDef.validValues).toContain('pending');
|
|
287
|
+
expect(updateDef.validValues).toContain('processing');
|
|
288
|
+
|
|
289
|
+
expect(mutexPrefixes.has('order.status.')).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('handles empty condition map', () => {
|
|
293
|
+
const { defs, conditionNames } = parseConditionMap({});
|
|
294
|
+
expect(defs).toEqual([]);
|
|
295
|
+
expect(conditionNames.size).toBe(0);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// buildDefsFromActions
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
describe('buildDefsFromActions', () => {
|
|
304
|
+
it('builds defs from SirenAction array', () => {
|
|
305
|
+
const { defs, conditionNames } = buildDefsFromActions(ORDER_ACTIONS);
|
|
306
|
+
expect(defs.length).toBe(4);
|
|
307
|
+
expect(conditionNames.has('order.mutable')).toBe(true);
|
|
308
|
+
expect(conditionNames.has('order.status.shipped')).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('preserves cost from SirenAction', () => {
|
|
312
|
+
const { defs } = buildDefsFromActions(ORDER_ACTIONS);
|
|
313
|
+
const shipDef = defs.find(d => d.name === 'ship-order')!;
|
|
314
|
+
expect(shipDef.cost).toBe(2);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('defaults cost to 1 when not specified', () => {
|
|
318
|
+
const actions: SirenAction[] = [
|
|
319
|
+
{ name: 'test', href: '/test', preconditions: [], effects: ['a'] },
|
|
320
|
+
];
|
|
321
|
+
const { defs } = buildDefsFromActions(actions);
|
|
322
|
+
expect(defs[0].cost).toBe(1);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('detects template effects', () => {
|
|
326
|
+
const { defs } = buildDefsFromActions(ORDER_ACTIONS);
|
|
327
|
+
const updateDef = defs.find(d => d.name === 'update-order')!;
|
|
328
|
+
expect(updateDef.templateEffect).toBe('order.status.{status}');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('handles actions without preconditions/effects', () => {
|
|
332
|
+
const actions: SirenAction[] = [{ name: 'noop', href: '/noop' }];
|
|
333
|
+
const { defs, conditionNames } = buildDefsFromActions(actions);
|
|
334
|
+
expect(defs.length).toBe(1);
|
|
335
|
+
expect(defs[0].preconditions).toEqual([]);
|
|
336
|
+
expect(defs[0].effects).toEqual([]);
|
|
337
|
+
expect(conditionNames.size).toBe(0);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// plan (BFS)
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
describe('plan (BFS)', () => {
|
|
346
|
+
const { defs, mutexPrefixes } = parseConditionMap(ORDER_CONDITION_MAP);
|
|
347
|
+
|
|
348
|
+
it('returns [[]] when goal already satisfied', () => {
|
|
349
|
+
const start = new Set(['order.status.shipped']);
|
|
350
|
+
const result = plan(start, 'order.status.shipped', defs, mutexPrefixes);
|
|
351
|
+
expect(result).toEqual([[]]);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('finds single-step path', () => {
|
|
355
|
+
const start = new Set(['order.mutable']);
|
|
356
|
+
const result = plan(start, 'order.items.nonempty', defs, mutexPrefixes);
|
|
357
|
+
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
358
|
+
expect(result[0].length).toBe(1);
|
|
359
|
+
expect(result[0][0].name).toBe('add-item');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('finds multi-step path to ship-order', () => {
|
|
363
|
+
// Need: order.status.processing + order.items.nonempty
|
|
364
|
+
// From: order.mutable + order.status.pending
|
|
365
|
+
const start = new Set(['order.mutable', 'order.status.pending']);
|
|
366
|
+
const result = plan(start, 'order.status.shipped', defs, mutexPrefixes);
|
|
367
|
+
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
368
|
+
// Should be 3 steps: add-item, update-order(processing), ship-order
|
|
369
|
+
const path = result[0];
|
|
370
|
+
expect(path.length).toBe(3);
|
|
371
|
+
const names = path.map(s => s.name);
|
|
372
|
+
expect(names).toContain('add-item');
|
|
373
|
+
expect(names).toContain('update-order');
|
|
374
|
+
expect(names).toContain('ship-order');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('returns [] when goal unreachable', () => {
|
|
378
|
+
// No way to reach ship-order without order.mutable
|
|
379
|
+
const start = new Set<string>();
|
|
380
|
+
const result = plan(start, 'order.status.shipped', defs, mutexPrefixes);
|
|
381
|
+
expect(result).toEqual([]);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('respects blocked steps', () => {
|
|
385
|
+
const start = new Set(['order.mutable']);
|
|
386
|
+
const blocked = [createPlanStep('add-item')];
|
|
387
|
+
const result = plan(start, 'order.items.nonempty', defs, mutexPrefixes, {
|
|
388
|
+
blockedSteps: blocked,
|
|
389
|
+
});
|
|
390
|
+
// add-item is the only way to reach order.items.nonempty, so no path
|
|
391
|
+
expect(result).toEqual([]);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('respects maxDepth', () => {
|
|
395
|
+
const start = new Set(['order.mutable', 'order.status.pending']);
|
|
396
|
+
const result = plan(start, 'order.status.shipped', defs, mutexPrefixes, {
|
|
397
|
+
maxDepth: 2,
|
|
398
|
+
});
|
|
399
|
+
// Needs 3 steps, but maxDepth is 2
|
|
400
|
+
expect(result).toEqual([]);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('returns all shortest paths', () => {
|
|
404
|
+
// With the update-order template, there may be multiple paths of same length
|
|
405
|
+
const start = new Set(['order.mutable', 'order.status.pending']);
|
|
406
|
+
const result = plan(start, 'order.status.shipped', defs, mutexPrefixes);
|
|
407
|
+
// All paths should be the same length
|
|
408
|
+
if (result.length > 1) {
|
|
409
|
+
const len = result[0].length;
|
|
410
|
+
for (const path of result) {
|
|
411
|
+
expect(path.length).toBe(len);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
// planWithCost
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
describe('planWithCost', () => {
|
|
422
|
+
it('selects lowest-cost path', () => {
|
|
423
|
+
// Create two paths to the same goal with different costs
|
|
424
|
+
const cheapDef = createAffordanceDef({
|
|
425
|
+
name: 'cheap',
|
|
426
|
+
preconditions: [],
|
|
427
|
+
effects: ['goal'],
|
|
428
|
+
cost: 1,
|
|
429
|
+
});
|
|
430
|
+
const expensiveDef = createAffordanceDef({
|
|
431
|
+
name: 'expensive',
|
|
432
|
+
preconditions: [],
|
|
433
|
+
effects: ['goal'],
|
|
434
|
+
cost: 10,
|
|
435
|
+
});
|
|
436
|
+
const start = new Set<string>();
|
|
437
|
+
const result = planWithCost(start, 'goal', [cheapDef, expensiveDef], new Set());
|
|
438
|
+
expect(result.steps.length).toBe(1);
|
|
439
|
+
expect(result.steps[0].name).toBe('cheap');
|
|
440
|
+
expect(result.totalCost).toBe(1);
|
|
441
|
+
expect(result.alternativeCount).toBe(2);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('prefers preferred steps at equal cost', () => {
|
|
445
|
+
const regular = createAffordanceDef({
|
|
446
|
+
name: 'regular',
|
|
447
|
+
preconditions: [],
|
|
448
|
+
effects: ['goal'],
|
|
449
|
+
cost: 1,
|
|
450
|
+
preferred: false,
|
|
451
|
+
});
|
|
452
|
+
const preferred = createAffordanceDef({
|
|
453
|
+
name: 'preferred',
|
|
454
|
+
preconditions: [],
|
|
455
|
+
effects: ['goal'],
|
|
456
|
+
cost: 1,
|
|
457
|
+
preferred: true,
|
|
458
|
+
});
|
|
459
|
+
const start = new Set<string>();
|
|
460
|
+
const result = planWithCost(start, 'goal', [regular, preferred], new Set());
|
|
461
|
+
expect(result.steps[0].name).toBe('preferred');
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('returns empty result when no paths', () => {
|
|
465
|
+
const def = createAffordanceDef({
|
|
466
|
+
name: 'blocked',
|
|
467
|
+
preconditions: ['impossible'],
|
|
468
|
+
effects: ['goal'],
|
|
469
|
+
});
|
|
470
|
+
const result = planWithCost(new Set(), 'goal', [def], new Set());
|
|
471
|
+
expect(result.steps).toEqual([]);
|
|
472
|
+
expect(result.alternativeCount).toBe(0);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('returns empty steps when already satisfied', () => {
|
|
476
|
+
const def = createAffordanceDef({
|
|
477
|
+
name: 'unused',
|
|
478
|
+
preconditions: [],
|
|
479
|
+
effects: ['goal'],
|
|
480
|
+
});
|
|
481
|
+
const result = planWithCost(new Set(['goal']), 'goal', [def], new Set());
|
|
482
|
+
expect(result.steps).toEqual([]);
|
|
483
|
+
expect(result.totalCost).toBe(0);
|
|
484
|
+
expect(result.alternativeCount).toBe(1);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
// planForAction (condition map path)
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
describe('planForAction', () => {
|
|
493
|
+
it('returns just the goal when all preconditions met', () => {
|
|
494
|
+
const entity = {
|
|
495
|
+
'x-conditions': ['order.status.processing', 'order.items.nonempty'],
|
|
496
|
+
};
|
|
497
|
+
const result = planForAction(entity, 'ship-order', ORDER_CONDITION_MAP);
|
|
498
|
+
expect(result).not.toBeNull();
|
|
499
|
+
expect(result!.length).toBe(1);
|
|
500
|
+
expect(result![0].name).toBe('ship-order');
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('plans prerequisite chain', () => {
|
|
504
|
+
const entity = {
|
|
505
|
+
'x-conditions': ['order.mutable', 'order.status.pending'],
|
|
506
|
+
};
|
|
507
|
+
const result = planForAction(entity, 'ship-order', ORDER_CONDITION_MAP);
|
|
508
|
+
expect(result).not.toBeNull();
|
|
509
|
+
// Should include add-item, update-order(status=processing), ship-order
|
|
510
|
+
const names = result!.map(s => s.name);
|
|
511
|
+
expect(names).toContain('add-item');
|
|
512
|
+
expect(names).toContain('update-order');
|
|
513
|
+
expect(names[names.length - 1]).toBe('ship-order');
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('produces parameterized steps from template effects', () => {
|
|
517
|
+
const entity = {
|
|
518
|
+
'x-conditions': ['order.mutable', 'order.items.nonempty'],
|
|
519
|
+
};
|
|
520
|
+
const result = planForAction(entity, 'ship-order', ORDER_CONDITION_MAP);
|
|
521
|
+
expect(result).not.toBeNull();
|
|
522
|
+
const updateStep = result!.find(s => s.name === 'update-order');
|
|
523
|
+
expect(updateStep).toBeDefined();
|
|
524
|
+
expect(updateStep!.params.status).toBe('processing');
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('returns null for unknown action', () => {
|
|
528
|
+
const entity = { 'x-conditions': ['order.mutable'] };
|
|
529
|
+
const result = planForAction(entity, 'nonexistent', ORDER_CONDITION_MAP);
|
|
530
|
+
expect(result).toBeNull();
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('returns null when precondition unreachable', () => {
|
|
534
|
+
// ship-order needs order.status.processing + order.items.nonempty
|
|
535
|
+
// But order.mutable is not active, so add-item and update-order are blocked
|
|
536
|
+
const entity = { 'x-conditions': [] as string[] };
|
|
537
|
+
const result = planForAction(entity, 'ship-order', ORDER_CONDITION_MAP);
|
|
538
|
+
expect(result).toBeNull();
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
// planFromEntity (simple path)
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
describe('planFromEntity', () => {
|
|
547
|
+
it('plans from entity actions directly', () => {
|
|
548
|
+
const entity = {
|
|
549
|
+
actions: ORDER_ACTIONS,
|
|
550
|
+
'x-conditions': ['order.mutable', 'order.status.pending'] as string[],
|
|
551
|
+
};
|
|
552
|
+
const result = planFromEntity(entity, 'ship-order');
|
|
553
|
+
expect(result).not.toBeNull();
|
|
554
|
+
const names = result!.steps.map(s => s.name);
|
|
555
|
+
expect(names).toContain('add-item');
|
|
556
|
+
expect(names[names.length - 1]).toBe('ship-order');
|
|
557
|
+
expect(result!.totalCost).toBeGreaterThan(0);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('returns null when goal action not found', () => {
|
|
561
|
+
const entity = { actions: ORDER_ACTIONS };
|
|
562
|
+
const result = planFromEntity(entity, 'nonexistent');
|
|
563
|
+
expect(result).toBeNull();
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('returns null when entity has no actions', () => {
|
|
567
|
+
const entity = { actions: [] as SirenAction[] };
|
|
568
|
+
const result = planFromEntity(entity, 'ship-order');
|
|
569
|
+
expect(result).toBeNull();
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('returns single-step when all preconditions met', () => {
|
|
573
|
+
const entity = {
|
|
574
|
+
actions: ORDER_ACTIONS,
|
|
575
|
+
'x-conditions': ['order.status.processing', 'order.items.nonempty'] as string[],
|
|
576
|
+
};
|
|
577
|
+
const result = planFromEntity(entity, 'ship-order');
|
|
578
|
+
expect(result).not.toBeNull();
|
|
579
|
+
expect(result!.steps.length).toBe(1);
|
|
580
|
+
expect(result!.steps[0].name).toBe('ship-order');
|
|
581
|
+
expect(result!.totalCost).toBe(2); // ship-order cost
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('returns null when preconditions unreachable', () => {
|
|
585
|
+
// ship-order needs order.status.processing + order.items.nonempty
|
|
586
|
+
// add-item and update-order need order.mutable which is not active
|
|
587
|
+
const entity = {
|
|
588
|
+
actions: ORDER_ACTIONS,
|
|
589
|
+
'x-conditions': [] as string[],
|
|
590
|
+
};
|
|
591
|
+
const result = planFromEntity(entity, 'ship-order');
|
|
592
|
+
expect(result).toBeNull();
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('includes cost in result', () => {
|
|
596
|
+
const entity = {
|
|
597
|
+
actions: ORDER_ACTIONS,
|
|
598
|
+
'x-conditions': ['order.mutable', 'order.status.pending'] as string[],
|
|
599
|
+
};
|
|
600
|
+
const result = planFromEntity(entity, 'ship-order');
|
|
601
|
+
expect(result).not.toBeNull();
|
|
602
|
+
// add-item(1) + update-order(1) + ship-order(2) = 4
|
|
603
|
+
expect(result!.totalCost).toBe(4);
|
|
604
|
+
});
|
|
605
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lokascript/planner — BFS planner over affordance graphs.
|
|
3
|
+
*
|
|
4
|
+
* Pure module — no DOM, no fetch, no side effects.
|
|
5
|
+
* Used by GRAIL MCP tools and Siren hypermedia clients.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Types (Siren/GRAIL primitives)
|
|
9
|
+
export type {
|
|
10
|
+
SirenEntity,
|
|
11
|
+
SirenAction,
|
|
12
|
+
SirenField,
|
|
13
|
+
SirenLink,
|
|
14
|
+
SirenSubEntity,
|
|
15
|
+
BlockedProperties,
|
|
16
|
+
BlockedResponse,
|
|
17
|
+
ConditionMapEntry,
|
|
18
|
+
} from './types.js';
|
|
19
|
+
|
|
20
|
+
// Planner types
|
|
21
|
+
export type { PlanStep, AffordanceDef, PlanResult, PlanOptions } from './planner.js';
|
|
22
|
+
|
|
23
|
+
// Planner functions
|
|
24
|
+
export {
|
|
25
|
+
createPlanStep,
|
|
26
|
+
createAffordanceDef,
|
|
27
|
+
applyEffects,
|
|
28
|
+
inferHeuristicMappings,
|
|
29
|
+
inferConditions,
|
|
30
|
+
parseConditionMap,
|
|
31
|
+
buildDefsFromActions,
|
|
32
|
+
plan,
|
|
33
|
+
planWithCost,
|
|
34
|
+
planForAction,
|
|
35
|
+
planFromEntity,
|
|
36
|
+
} from './planner.js';
|