Skip to content

Commit faa80e3

Browse files
author
Michal Warda
committed
feat: Introduce uniqueOneOf function for reusable unique selection in schema generation. Update README and documentation to reflect new option structure, including support for weighted items and improved error handling for exhausted collections.
1 parent da1baeb commit faa80e3

File tree

4 files changed

+173
-15
lines changed

4 files changed

+173
-15
lines changed

README.md

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -223,26 +223,43 @@ const schema = () => [
223223

224224
#### Unique draws across a dataset
225225

226-
Pass a `uniqueBy` configuration when you need each option to be used at most once across every row/schema during generation:
226+
Pass a `uniqueBy` configuration when you need each option to be used at most once across every row/schema during generation. When using `uniqueBy`, each option must be an object with `id`, `value`, and optionally `weight`:
227227

228228
```ts
229229
const toolOptions = [
230-
weatherTool.toolFunction(),
231-
calendarTool.toolFunction(),
232-
flightTool.toolFunction(),
233-
] as const;
230+
{ id: "weather", value: weatherTool.toolFunction() },
231+
{ id: "calendar", value: calendarTool.toolFunction() },
232+
{ id: "flight", value: flightTool.toolFunction() },
233+
];
234+
235+
const schema = () => [
236+
oneOf(toolOptions, {
237+
uniqueBy: {
238+
collection: "tools",
239+
},
240+
}),
241+
];
242+
```
243+
244+
You can also combine `uniqueBy` with weighted options:
245+
246+
```ts
247+
const toolOptions = [
248+
{ id: "weather", value: weatherTool.toolFunction(), weight: 0.5 },
249+
{ id: "calendar", value: calendarTool.toolFunction(), weight: 0.3 },
250+
{ id: "flight", value: flightTool.toolFunction(), weight: 0.2 },
251+
];
234252

235253
const schema = () => [
236254
oneOf(toolOptions, {
237255
uniqueBy: {
238256
collection: "tools",
239-
itemId: "name",
240257
},
241258
}),
242259
];
243260
```
244261

245-
The `collection` name identifies the shared pool (so multiple `oneOf` calls can coordinate), and `itemId` can be either a property key or a function that returns a stable identifier. Omit `itemId` to default to the common `id` field. Torque throws if the pool is exhausted, making it easy to guarantee perfect round-robin coverage.
262+
The `collection` name identifies the shared pool (so multiple `oneOf` calls can coordinate). The `id` property must be a string, number, or boolean and is used to track uniqueness. Torque throws if the pool is exhausted, making it easy to guarantee perfect round-robin coverage.
246263

247264
> 💡 See weighted example: [`examples/weighted-one-of.ts`](examples/weighted-one-of.ts)
248265
> 💡 Full utilities demo: [`examples/composition-utilities.ts`](examples/composition-utilities.ts) | [▶️ Try in Browser](https://stackblitz.com/github/qforge-dev/torque/tree/main/stackblitz-templates/composition-utilities)

packages/torque/README.md

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -232,26 +232,43 @@ const schema = () => [
232232

233233
#### Unique draws across a dataset
234234

235-
Pass a `uniqueBy` configuration when you need each option to be used at most once across every row/schema during generation:
235+
Pass a `uniqueBy` configuration when you need each option to be used at most once across every row/schema during generation. When using `uniqueBy`, each option must be an object with `id`, `value`, and optionally `weight`:
236236

237237
```ts
238238
const toolOptions = [
239-
weatherTool.toolFunction(),
240-
calendarTool.toolFunction(),
241-
flightTool.toolFunction(),
242-
] as const;
239+
{ id: "weather", value: weatherTool.toolFunction() },
240+
{ id: "calendar", value: calendarTool.toolFunction() },
241+
{ id: "flight", value: flightTool.toolFunction() },
242+
];
243+
244+
const schema = () => [
245+
oneOf(toolOptions, {
246+
uniqueBy: {
247+
collection: "tools",
248+
},
249+
}),
250+
];
251+
```
252+
253+
You can also combine `uniqueBy` with weighted options:
254+
255+
```ts
256+
const toolOptions = [
257+
{ id: "weather", value: weatherTool.toolFunction(), weight: 0.5 },
258+
{ id: "calendar", value: calendarTool.toolFunction(), weight: 0.3 },
259+
{ id: "flight", value: flightTool.toolFunction(), weight: 0.2 },
260+
];
243261

244262
const schema = () => [
245263
oneOf(toolOptions, {
246264
uniqueBy: {
247265
collection: "tools",
248-
itemId: "name",
249266
},
250267
}),
251268
];
252269
```
253270

254-
The `collection` name identifies the shared pool (so multiple `oneOf` calls can coordinate), and `itemId` can be either a property key or a function that returns a stable identifier. Omit `itemId` to default to the common `id` field. Torque throws if the pool is exhausted, making it easy to guarantee perfect round-robin coverage.
271+
The `collection` name identifies the shared pool (so multiple `oneOf` calls can coordinate). The `id` property must be a string, number, or boolean and is used to track uniqueness. Torque throws if the pool is exhausted, making it easy to guarantee perfect round-robin coverage.
255272

256273
> 💡 See weighted example: [`examples/weighted-one-of.ts`](examples/weighted-one-of.ts)
257274
> 💡 Full utilities demo: [`examples/composition-utilities.ts`](examples/composition-utilities.ts) | [▶️ Try in Browser](https://stackblitz.com/github/qforge-dev/torque/tree/main/stackblitz-templates/composition-utilities)

packages/torque/src/schema-rng.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,76 @@ export function randomSample<T>(n: number, array: T[]): T[] {
5959
return result;
6060
}
6161

62+
function generateRandomId(): string {
63+
const randA = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
64+
const randB = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
65+
return `${randA.toString(36)}${randB.toString(36)}`;
66+
}
67+
68+
function generateRandomCollectionName(): string {
69+
const randA = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
70+
const randB = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
71+
return `col_${randA.toString(36)}${randB.toString(36)}`;
72+
}
73+
74+
/**
75+
* Factory function that creates a reusable oneOf function with unique selection.
76+
* Converts an array of items (or weighted items) into a collection of objects with random IDs,
77+
* and returns a function that can be called during schema generation to select
78+
* unique items from the collection.
79+
*
80+
* @param items - Array of items or weighted items to create unique selection from
81+
* @returns A function that when called, returns a unique item from the collection
82+
*
83+
* @example
84+
* ```ts
85+
* // Plain items
86+
* const tools = [weatherTool, calendarTool, flightTool];
87+
* const oneOfTools = uniqueOneOf(tools);
88+
*
89+
* // Weighted items
90+
* const weightedTools = [
91+
* { value: weatherTool, weight: 0.5 },
92+
* { value: calendarTool, weight: 0.3 },
93+
* flightTool, // unweighted, gets remaining weight
94+
* ];
95+
* const oneOfWeightedTools = uniqueOneOf(weightedTools);
96+
*
97+
* const schema = () => [
98+
* oneOfTools(), // Returns a unique tool each time
99+
* ];
100+
* ```
101+
*/
102+
export function uniqueOneOf<T>(items: Array<WeightedOneOfOption<T>>): () => T {
103+
if (items.length === 0) {
104+
throw new Error("uniqueOneOf requires at least one item");
105+
}
106+
107+
const collection = generateRandomCollectionName();
108+
const optionsWithIds: OneOfOptionWithId<T>[] = items.map((item) => {
109+
if (isWeightedOption(item)) {
110+
return {
111+
id: generateRandomId(),
112+
value: item.value,
113+
weight: item.weight,
114+
};
115+
} else {
116+
return {
117+
id: generateRandomId(),
118+
value: item,
119+
};
120+
}
121+
});
122+
123+
return () => {
124+
return oneOf(optionsWithIds, {
125+
uniqueBy: {
126+
collection,
127+
},
128+
});
129+
};
130+
}
131+
62132
export function oneOf<T>(
63133
options: Array<OneOfOptionWithId<T>>,
64134
config: { uniqueBy: OneOfUniqueBy }

packages/torque/src/schema.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
generatedAssistant,
77
assistant,
88
} from "./schema";
9-
import { oneOf } from "./schema-rng";
9+
import { oneOf, uniqueOneOf } from "./schema-rng";
1010
import type { IMessageSchemaContext } from "./types";
1111
import { withSeed } from "./utils";
1212
import { MockLanguageModelV2 } from "ai/test";
@@ -140,6 +140,60 @@ describe("oneOf", () => {
140140
});
141141
});
142142
});
143+
144+
it("uniqueOneOf creates a reusable oneOf function with unique selection", async () => {
145+
const tools = ["weather", "calendar", "flight"];
146+
147+
const oneOfTools = uniqueOneOf(tools);
148+
149+
await runWithUniqueSelectionScope(async () => {
150+
const picks = [oneOfTools(), oneOfTools(), oneOfTools()];
151+
152+
expect(new Set(picks).size).toBe(3);
153+
picks.forEach((pick) => {
154+
expect(tools).toContain(pick);
155+
});
156+
});
157+
});
158+
159+
it("uniqueOneOf throws when collection is exhausted", async () => {
160+
const tools = ["only"];
161+
162+
const oneOfTools = uniqueOneOf(tools);
163+
164+
await runWithUniqueSelectionScope(async () => {
165+
oneOfTools(); // First call succeeds
166+
167+
expect(() => {
168+
oneOfTools(); // Second call should throw
169+
}).toThrow("oneOf uniqueBy collection");
170+
});
171+
});
172+
173+
it("uniqueOneOf works with weighted options", async () => {
174+
const weightedTools = [
175+
{ value: "weather", weight: 0.5 },
176+
{ value: "calendar", weight: 0.3 },
177+
"flight", // unweighted, gets remaining weight
178+
];
179+
180+
const oneOfTools = uniqueOneOf(weightedTools);
181+
182+
await runWithUniqueSelectionScope(async () => {
183+
await withSeed(100, async () => {
184+
const first = oneOfTools();
185+
expect(["weather", "calendar", "flight"]).toContain(first);
186+
187+
const second = oneOfTools();
188+
expect(["weather", "calendar", "flight"]).toContain(second);
189+
expect(second).not.toBe(first);
190+
191+
const third = oneOfTools();
192+
expect(["weather", "calendar", "flight"]).toContain(third);
193+
expect(new Set([first, second, third]).size).toBe(3);
194+
});
195+
});
196+
});
143197
});
144198

145199
describe("metadata helper", () => {

0 commit comments

Comments
 (0)