Determinism
How reproducible template generation works
Determinism
Determinism ensures that running the same template with the same inputs produces identical outputs.
Why Determinism Matters
The LLM Analogy
Think of template generation like a conversation with an LLM:
- User answers questions → stored in state
- Template asks server with X answers → processes and returns more questions
- User answers X+1 questions → template asks server again
- This repeats → each round must produce identical results for the same inputs
If values like UUIDs change between rounds, the entire process becomes unreliable. Determinism ensures the template behaves consistently across multiple generation cycles.
This is especially important for template upgrades. When a user updates from template v1.0 to v2.0, the deterministic states enable a 3-way merge between the original files, user changes, and new template output.
Reproducible Builds
Generate the same project multiple times with identical results:
# First generationcyanprint create myorg/template:1.0.0 ./project-a# Second generation with same answerscyanprint create myorg/template:1.0.0 ./project-b# project-a and project-b are identical
Safe Updates
Update templates without breaking existing projects:
# Initial generationcyanprint create myorg/template:1.0.0 ./my-project# Later, update to new versioncyanprint update ./my-project myorg/template:2.0.0# Deterministic values remain consistent
How It Works
Deterministic States
Every generation stores deterministic states - a key-value cache that ensures reproducible value generation:
# Stored in .cyan_state.yamltemplates:myorg/template-name:active: truehistory:- version: 1time: '2024-01-15T10:30:00Z'answers:project-name: 'my-project'deterministic_states:project-id: '550e8400-e29b-41d4-a716-446655440000'timestamp: '1704067200000'
IDeterminism Interface
The d parameter provides deterministic value generation through the IDeterminism interface:
import { startTemplateWithFn } from 'cyanprintsdk';export default startTemplateWithFn(async (i, d) => {// d.get() returns cached value if exists, otherwise calls origin functionconst projectId = d.get('project-id', () => crypto.randomUUID());// Subsequent calls with same key return the cached valueconst timestamp = d.get('timestamp', () => Date.now().toString());return { /* ... */ };});
from cyanprintsdk.main import start_template_with_fnasync def template(i: IInquirer, d: IDeterminism) -> Cyan:# d.get() returns cached value if exists, otherwise calls origin functionproject_id = d.get('project-id', lambda: str(uuid.uuid4()))# Subsequent calls with same key return the cached valuetimestamp = d.get('timestamp', lambda: str(int(time.time() * 1000)))return { /* ... */ }template_main = start_template_with_fn(template)
[TemplateMain]public static async Task<Cyan> Run(IInquirer i, IDeterminism d){// d.Get() returns cached value if exists, otherwise calls origin functionvar projectId = d.Get("project-id", () => Guid.NewGuid().ToString());// Subsequent calls with same key return the cached valuevar timestamp = d.Get("timestamp", () => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString());return /* ... */;}
The get() Method
Signature
interface IDeterminism {get(key: string, origin: () => string): string;}
class IDeterminism(ABC):@abstractmethoddef get(self, key: str, origin: Callable[[], str]) -> str:pass
interface IDeterminism{string Get(string key, Func<string> origin);}
How It Works
The get() method implements a cache-through pattern:
- First generation: If the key doesn't exist, calls the
originfunction and stores the result - Subsequent generations: Returns the cached value for that key
const projectId = d.get('project-id', () => crypto.randomUUID());// First run: generates new UUID, caches it// Later runs: returns cached UUIDconst buildNumber = d.get('build-number', () => '1');// Always returns '1' after first generation
project_id = d.get('project-id', lambda: str(uuid.uuid4()))# First run: generates new UUID, caches it# Later runs: returns cached UUIDbuild_number = d.get('build-number', lambda: '1')# Always returns '1' after first generation
var projectId = d.Get("project-id", () => Guid.NewGuid().ToString());// First run: generates new UUID, caches it// Later runs: returns cached UUIDvar buildNumber = d.Get("build-number", () => "1");// Always returns '1' after first generation
The origin function is only called when the key doesn't exist in the deterministic states cache. This is where you can safely use Date.now(), Math.random(), or crypto.randomUUID() - the result gets cached for future runs.
Common Patterns
Deterministic UUIDs
import { startTemplateWithFn } from 'cyanprintsdk';export default startTemplateWithFn(async (i, d) => {const projectId = d.get('project-id', () => crypto.randomUUID());const apiKey = d.get('api-key', () => crypto.randomUUID());return {processors: [{name: 'cyan/default',files: [/* ... */],config: {vars: { projectId, apiKey }}}]};});
import uuidfrom cyanprintsdk.main import start_template_with_fnasync def template(i: IInquirer, d: IDeterminism) -> Cyan:project_id = d.get('project-id', lambda: str(uuid.uuid4()))api_key = d.get('api-key', lambda: str(uuid.uuid4()))return Cyan(processors=[CyanProcessor(name='cyan/default',files=[],config=ProcessorConfig(vars={'projectId': project_id, 'apiKey': api_key}))])template_main = start_template_with_fn(template)
using CyanPrintSDK;public class Template{[TemplateMain]public static async Task<Cyan> Run(IInquirer i, IDeterminism d){var projectId = d.Get("project-id", () => Guid.NewGuid().ToString());var apiKey = d.Get("api-key", () => Guid.NewGuid().ToString());return new Cyan{Processors = new[]{new CyanProcessor{Name = "cyan/default",Files = new List<CyanFile>(),Config = new ProcessorConfig{Vars = new Dictionary<string, string>{["projectId"] = projectId,["apiKey"] = apiKey}}}}};}}
Deterministic Timestamps
import { startTemplateWithFn } from 'cyanprintsdk';export default startTemplateWithFn(async (i, d) => {const createdAt = d.get('created-at', () => Date.now().toString());const date = new Date(parseInt(createdAt)).toISOString();return {processors: [{name: 'cyan/default',files: [/* ... */],config: {vars: { createdAt: date }}}]};});
import timefrom datetime import datetimefrom cyanprintsdk.main import start_template_with_fnasync def template(i: IInquirer, d: IDeterminism) -> Cyan:created_at = d.get('created-at', lambda: str(int(time.time() * 1000)))date = datetime.fromtimestamp(int(created_at) / 1000).isoformat()return Cyan(processors=[CyanProcessor(name='cyan/default',files=[],config=ProcessorConfig(vars={'createdAt': date}))])template_main = start_template_with_fn(template)
using CyanPrintSDK;public class Template{[TemplateMain]public static async Task<Cyan> Run(IInquirer i, IDeterminism d){var createdAt = d.Get("created-at", () => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString());var date = DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(createdAt)).ToString("o");return new Cyan{Processors = new[]{new CyanProcessor{Name = "cyan/default",Files = new List<CyanFile>(),Config = new ProcessorConfig{Vars = new Dictionary<string, string>{["createdAt"] = date}}}}};}}
Sequential Numbers
For sequential numbers, use a unique key for each:
import { startTemplateWithFn } from 'cyanprintsdk';export default startTemplateWithFn(async (i, d) => {const user1Id = d.get('user-id-1', () => '1');const user2Id = d.get('user-id-2', () => '2');const order1Id = d.get('order-id-1', () => '1');return { /* ... */ };});
from cyanprintsdk.main import start_template_with_fnasync def template(i: IInquirer, d: IDeterminism) -> Cyan:user1_id = d.get('user-id-1', lambda: '1')user2_id = d.get('user-id-2', lambda: '2')order1_id = d.get('order-id-1', lambda: '1')return { /* ... */ }template_main = start_template_with_fn(template)
using CyanPrintSDK;public class Template{[TemplateMain]public static async Task<Cyan> Run(IInquirer i, IDeterminism d){var user1Id = d.Get("user-id-1", () => "1");var user2Id = d.Get("user-id-2", () => "2");var order1Id = d.Get("order-id-1", () => "1");return /* ... */;}}
State Lifecycle
What Should Be Deterministic
Use deterministic values for:
- IDs: Database IDs, entity identifiers
- Keys: API keys (non-sensitive), tokens
- Timestamps: Creation dates, version dates
- References: Cross-reference identifiers
Always wrap non-deterministic functions in d.get(). Direct calls to Date.now(), Math.random(), crypto.randomUUID() (or equivalents in Python/C#) will produce different values on each generation.
What Should NOT Be Deterministic
Some values should vary per generation:
- User input - Varies based on answers
- External data - Fetch at generation time if you need current data
Verification
Verify determinism by generating twice:
# Generate twice with same answerscyanprint create myorg/template:1.0.0 ./test1cyanprint create myorg/template:1.0.0 ./test2# Compare outputsdiff -r ./test1 ./test2# Should show no differences
Related
- Pin Determinism - How-to guide
- 3-Way Merge - Update mechanics
- Client State - State management