Skip to content

Commit 77540f0

Browse files
kubeclaude
andcommitted
FE-1129: Token encoding playground
Storybook dev playground: dimension editor, Monaco-typed token value editor (story-scoped TS service), and a bit-level memory view of the encoded token buffer. Exports the token value codec from petrinaut-core and reuses the typed defaults in the editors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5cec3ca commit 77540f0

12 files changed

Lines changed: 1485 additions & 20 deletions

File tree

.changeset/token-codec-exports.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@hashintel/petrinaut-core": patch
3+
"@hashintel/petrinaut": patch
4+
---
5+
6+
Export the token value codec and `compileUserCode` from `@hashintel/petrinaut-core`.

libs/@hashintel/petrinaut-core/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,15 @@ export {
331331
type ScenarioParameterValues,
332332
} from "./simulation/authoring/scenario/compile-scenario";
333333
export { buildMetricState } from "./simulation/frames/metric-state";
334+
export {
335+
coerceTokenAttributeValue,
336+
coerceTokenRecord,
337+
decodeTokenAttributeValue,
338+
decodeTokenRecord,
339+
defaultTokenAttributeValue,
340+
encodeTokenAttributeValue,
341+
} from "./simulation/engine/token-values";
342+
export { compileUserCode } from "./simulation/authoring/user-code/compile-user-code";
334343
export {
335344
displayNameSchema,
336345
validateDisplayName,

libs/@hashintel/petrinaut/src/ui/components/spreadsheet.tsx

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useRef, useState } from "react";
22

33
import { css, cva } from "@hashintel/ds-helpers/css";
4+
import { defaultTokenAttributeValue } from "@hashintel/petrinaut-core";
45

56
export interface SpreadsheetColumn {
67
id: string;
@@ -192,16 +193,8 @@ const booleanCellStyle = css({
192193

193194
const getDefaultCellValue = (
194195
column: SpreadsheetColumn | undefined,
195-
): SpreadsheetCellValue => {
196-
switch (column?.type) {
197-
case "boolean":
198-
return false;
199-
case "integer":
200-
case "real":
201-
default:
202-
return 0;
203-
}
204-
};
196+
): SpreadsheetCellValue =>
197+
column?.type ? defaultTokenAttributeValue(column.type) : 0;
205198

206199
const formatCellValue = (value: SpreadsheetCellValue): string => String(value);
207200

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { Button, Select, TextInput } from "@hashintel/ds-components";
2+
import { css } from "@hashintel/ds-helpers/css";
3+
4+
import type { PlaygroundDimension } from "./physical-layout";
5+
import type { SelectItem } from "@hashintel/ds-components";
6+
import type { ColorElementType } from "@hashintel/petrinaut-core";
7+
8+
export type DimensionEditorProps = {
9+
dimensions: PlaygroundDimension[];
10+
onChange: (dimensions: PlaygroundDimension[]) => void;
11+
};
12+
13+
const typeOptions: SelectItem<ColorElementType>[] = [
14+
{ value: "real", text: "Real" },
15+
{ value: "integer", text: "Integer" },
16+
{ value: "boolean", text: "Boolean" },
17+
];
18+
19+
const listStyle = css({
20+
display: "flex",
21+
flexDirection: "column",
22+
gap: "1.5",
23+
});
24+
25+
const rowStyle = css({
26+
display: "flex",
27+
alignItems: "center",
28+
gap: "1",
29+
});
30+
31+
const nameInputStyle = css({
32+
display: "flex",
33+
flex: "[1]",
34+
minWidth: "[0]",
35+
36+
"& input": {
37+
fontFamily: "mono",
38+
},
39+
});
40+
41+
const typeSelectStyle = css({
42+
width: "[110px]",
43+
flexShrink: 0,
44+
});
45+
46+
/**
47+
* Store-free version of the type-properties dimension list: local state only,
48+
* for the playground story.
49+
*/
50+
export const DimensionEditor: React.FC<DimensionEditorProps> = ({
51+
dimensions,
52+
onChange,
53+
}) => {
54+
const updateAt = (index: number, update: Partial<PlaygroundDimension>) => {
55+
onChange(
56+
dimensions.map((dimension, dimensionIndex) =>
57+
dimensionIndex === index ? { ...dimension, ...update } : dimension,
58+
),
59+
);
60+
};
61+
62+
return (
63+
<div className={listStyle}>
64+
{dimensions.map((dimension, index) => (
65+
// eslint-disable-next-line react/no-array-index-key -- rows are positional
66+
<div key={index} className={rowStyle}>
67+
<TextInput
68+
value={dimension.name}
69+
size="sm"
70+
width="fullWidth"
71+
className={nameInputStyle}
72+
placeholder="dimension_name"
73+
onChange={(name) => updateAt(index, { name })}
74+
connectToRightInput
75+
/>
76+
<Select
77+
required
78+
value={dimension.type}
79+
onChange={(type) => updateAt(index, { type })}
80+
items={typeOptions}
81+
size="sm"
82+
className={typeSelectStyle}
83+
connectToLeftInput
84+
/>
85+
<Button
86+
onClick={() =>
87+
onChange(dimensions.filter((_, other) => other !== index))
88+
}
89+
size="xxs"
90+
variant="ghost"
91+
iconName="close"
92+
aria-label={`Remove dimension ${dimension.name}`}
93+
/>
94+
</div>
95+
))}
96+
<Button
97+
onClick={() =>
98+
onChange([
99+
...dimensions,
100+
// First free dim_N: length alone can collide after removals.
101+
{
102+
name: (() => {
103+
let index = dimensions.length;
104+
while (dimensions.some((d) => d.name === `dim_${index}`)) {
105+
index++;
106+
}
107+
return `dim_${index}`;
108+
})(),
109+
type: "real",
110+
},
111+
])
112+
}
113+
size="xs"
114+
variant="ghost"
115+
iconName="plus"
116+
>
117+
Add dimension
118+
</Button>
119+
</div>
120+
);
121+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* monaco-editor 0.55 ships its TypeScript language service untyped — the
3+
* contribution's `.d.ts` is an empty `export {}` and the old
4+
* `monaco.languages.typescript` namespace types were removed. Declare the
5+
* minimal surface the playground uses.
6+
*/
7+
declare module "monaco-editor/esm/vs/language/typescript/monaco.contribution.js" {
8+
export type PlaygroundExtraLib = { content: string; filePath?: string };
9+
10+
export const typescriptDefaults: {
11+
setCompilerOptions(options: Record<string, unknown>): void;
12+
setEagerModelSync(enabled: boolean): void;
13+
setExtraLibs(libs: PlaygroundExtraLib[]): void;
14+
setModeConfiguration(config: Record<string, boolean>): void;
15+
};
16+
17+
export const ScriptTarget: { ES2020: number };
18+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
computeTokenLayout,
5+
decodeToken,
6+
encodeToken,
7+
getFieldBits,
8+
getFieldHex,
9+
} from "./physical-layout";
10+
11+
import type { PlaygroundDimension } from "./physical-layout";
12+
13+
const DIMENSIONS: PlaygroundDimension[] = [
14+
{ name: "active", type: "boolean" },
15+
{ name: "amount", type: "real" },
16+
{ name: "count", type: "integer" },
17+
];
18+
19+
describe("computeTokenLayout", () => {
20+
it("v1 keeps declaration order with one f64 slot per dimension", () => {
21+
const layout = computeTokenLayout(DIMENSIONS, "v1");
22+
23+
expect(layout.strideBytes).toBe(24);
24+
expect(layout.paddingRanges).toEqual([]);
25+
expect(
26+
layout.fields.map((f) => [f.name, f.physical.kind, f.byteOffset]),
27+
).toEqual([
28+
["active", "f64", 0],
29+
["amount", "f64", 8],
30+
["count", "f64", 16],
31+
]);
32+
});
33+
34+
it("v2 orders by decreasing alignment and pads the stride to 8", () => {
35+
const layout = computeTokenLayout(DIMENSIONS, "v2");
36+
37+
// amount and count (f64, align 8) first — stable relative order — then
38+
// the u8 boolean, then 7 bytes of tail padding.
39+
expect(
40+
layout.fields.map((f) => [f.name, f.physical.kind, f.byteOffset]),
41+
).toEqual([
42+
["amount", "f64", 0],
43+
["count", "f64", 8],
44+
["active", "u8", 16],
45+
]);
46+
expect(layout.strideBytes).toBe(24);
47+
expect(layout.paddingRanges).toEqual([{ start: 17, end: 24 }]);
48+
});
49+
50+
it("returns an empty layout for no dimensions", () => {
51+
const layout = computeTokenLayout([], "v2");
52+
expect(layout.strideBytes).toBe(0);
53+
expect(layout.fields).toEqual([]);
54+
});
55+
});
56+
57+
describe("encodeToken / decodeToken", () => {
58+
it.each(["v1", "v2"] as const)(
59+
"round-trips with product coercion in %s mode",
60+
(mode) => {
61+
const layout = computeTokenLayout(DIMENSIONS, mode);
62+
const { stored, decoded } = encodeToken(layout, {
63+
active: true,
64+
amount: 1.25,
65+
count: 2.7,
66+
});
67+
68+
expect(stored).toEqual({ active: true, amount: 1.25, count: 3 });
69+
expect(decoded).toEqual({ active: true, amount: 1.25, count: 3 });
70+
},
71+
);
72+
73+
it("applies typed defaults for missing values", () => {
74+
const layout = computeTokenLayout(DIMENSIONS, "v2");
75+
const { decoded } = encodeToken(layout, {});
76+
expect(decoded).toEqual({ active: false, amount: 0, count: 0 });
77+
});
78+
79+
it("stores booleans as a single byte in v2", () => {
80+
const layout = computeTokenLayout(
81+
[{ name: "flag", type: "boolean" }],
82+
"v2",
83+
);
84+
const { buffer } = encodeToken(layout, { flag: true });
85+
expect(layout.strideBytes).toBe(8);
86+
expect(new Uint8Array(buffer)[0]).toBe(1);
87+
expect(decodeToken(layout, buffer)).toEqual({ flag: true });
88+
});
89+
});
90+
91+
describe("bit inspection", () => {
92+
it("exposes IEEE-754 bits MSB-first for f64 fields", () => {
93+
const layout = computeTokenLayout([{ name: "x", type: "real" }], "v1");
94+
const { buffer } = encodeToken(layout, { x: -2 });
95+
const bits = getFieldBits(buffer, layout.fields[0]!);
96+
97+
// -2 = sign 1, exponent 0x400 (10000000000), mantissa all zero.
98+
expect(bits).toHaveLength(64);
99+
expect(bits[0]).toBe(1);
100+
expect(bits.slice(1, 12).join("")).toBe("10000000000");
101+
expect(bits.slice(12).every((bit) => bit === 0)).toBe(true);
102+
expect(getFieldHex(buffer, layout.fields[0]!)).toBe("0xc000000000000000");
103+
});
104+
});

0 commit comments

Comments
 (0)