Skip to content

Commit f75507c

Browse files
hyperpolymathclaude
andcommitted
Wire A2ML metadata envelopes into all data interchange points
Every export/import/sync path now speaks A2ML: level export wraps levelConfig in an A2ML envelope with seed/template provenance, portfolio and campaign graph encode/decode handle both A2ML-wrapped and raw JSON (graceful fallback), and sync server payloads carry A2ML metadata for downstream tool inspection (Hypatia, VerisimDB). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f4a404c commit f75507c

File tree

7 files changed

+114
-14
lines changed

7 files changed

+114
-14
lines changed

idaptik-ums/src/generator/A2mlWrapper.res

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,47 @@ type a2mlMeta = {
2828
designerNotes: string,
2929
}
3030

31+
// ---------------------------------------------------------------------------
32+
// Timestamp binding
33+
// ---------------------------------------------------------------------------
34+
35+
@val external dateNow: unit => float = "Date.now"
36+
37+
// ---------------------------------------------------------------------------
38+
// Convenience constructors
39+
// ---------------------------------------------------------------------------
40+
41+
/// Build standard A2ML metadata from a generated level's seed and template.
42+
/// Uses "idaptik-ums" as the generator identifier and the current timestamp.
43+
let fromLevel = (seed: int, templateName: string): a2mlMeta => {
44+
version: "1.0",
45+
generator: "idaptik-ums",
46+
seed: seed,
47+
templateName: templateName,
48+
generatedAt: Belt.Float.toString(dateNow()),
49+
designerNotes: "",
50+
}
51+
52+
/// Build A2ML metadata for a portfolio envelope.
53+
let fromPortfolio = (name: string): a2mlMeta => {
54+
version: "1.0",
55+
generator: "idaptik-ums-portfolio",
56+
seed: 0,
57+
templateName: name,
58+
generatedAt: Belt.Float.toString(dateNow()),
59+
designerNotes: "",
60+
}
61+
62+
/// Build A2ML metadata for a campaign graph envelope.
63+
let fromCampaign = (portfolioId: string): a2mlMeta => {
64+
version: "1.0",
65+
generator: "idaptik-ums-campaign",
66+
seed: 0,
67+
templateName: portfolioId,
68+
generatedAt: Belt.Float.toString(dateNow()),
69+
designerNotes: "",
70+
}
71+
3172
// ---------------------------------------------------------------------------
3273
// Wrap / Unwrap
3374
// ---------------------------------------------------------------------------

idaptik-ums/src/generator/CampaignGraph.res

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,27 @@ let encodeCampaignGraph = (graph: campaignGraph): string => {
204204
}`
205205
}
206206

207-
/// Decode a campaign graph from a JSON string.
207+
/// Encode a campaign graph to an A2ML-wrapped JSON string. The A2ML envelope
208+
/// carries generator metadata (version, portfolio reference) alongside the
209+
/// campaign graph payload.
210+
let encodeCampaignGraphA2ml = (graph: campaignGraph): string => {
211+
let json = encodeCampaignGraph(graph)
212+
let meta = A2mlWrapper.fromCampaign(graph.portfolioId)
213+
A2mlWrapper.wrapInA2ml(json, meta)
214+
}
215+
216+
/// Decode a campaign graph from a JSON string. Accepts both A2ML-wrapped
217+
/// and raw campaign graph JSON. If the input has an "a2ml" envelope,
218+
/// unwraps it first and uses the inner payload; otherwise treats it as
219+
/// raw campaign graph JSON.
208220
let decodeCampaignGraph = (jsonStr: string): result<campaignGraph, string> => {
221+
// Try A2ML unwrap first — if it's an A2ML envelope, use the inner payload
222+
let rawJson = switch A2mlWrapper.unwrapA2ml(jsonStr) {
223+
| Ok((payload, _meta)) => payload
224+
| Error(_) => jsonStr // Not A2ML — treat as raw campaign graph JSON
225+
}
209226
try {
210-
let json = JSON.parseExn(jsonStr)
227+
let json = JSON.parseExn(rawJson)
211228
let obj = json->Js.Json.decodeObject->Belt.Option.getWithDefault(Js.Dict.empty())
212229
let portfolioId = Js.Dict.get(obj, "portfolioId")
213230
->Belt.Option.flatMap(Js.Json.decodeString)

idaptik-ums/src/generator/GeneratorDemo.res

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -918,7 +918,7 @@ let make = () => {
918918
| Some(gen) =>
919919
switch (topology, powerMap) {
920920
| (Some(topo), Some(pwr)) =>
921-
let json = LevelExport.exportToJson(gen, topo, pwr)
921+
let json = LevelExport.exportToA2ml(gen, topo, pwr)
922922
copyToClipboard(json)
923923
| _ => ()
924924
}

idaptik-ums/src/generator/LevelExport.res

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,21 @@ let exportToJson = (
129129
let config = exportToConfig(level, topology, power)
130130
LevelConfigCodec.toJsonString(config)
131131
}
132+
133+
// ---------------------------------------------------------------------------
134+
// Export to A2ML-wrapped JSON
135+
// ---------------------------------------------------------------------------
136+
137+
/// Export a generated level to an A2ML-wrapped JSON string. The A2ML envelope
138+
/// carries generator metadata (version, seed, template) alongside the
139+
/// levelConfig payload, enabling downstream tools to inspect provenance
140+
/// without parsing the level data itself.
141+
let exportToA2ml = (
142+
level: LevelGen.generatedLevel,
143+
topology: NetworkGen.generatedTopology,
144+
power: PowerGen.powerMap,
145+
): string => {
146+
let json = exportToJson(level, topology, power)
147+
let meta = A2mlWrapper.fromLevel(level.seed, level.templateName)
148+
A2mlWrapper.wrapInA2ml(json, meta)
149+
}

idaptik-ums/src/generator/LevelImport.res

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,17 @@ let importFromConfig = (config: LevelConfigTypes.levelConfig): LevelGen.generate
116116
// Import from JSON string
117117
// ---------------------------------------------------------------------------
118118

119-
/// Import a level from a JSON string. Parses the JSON, decodes to levelConfig,
120-
/// then converts to generatedLevel. Returns Error if JSON is malformed or
121-
/// doesn't match the expected schema.
119+
/// Import a level from a JSON string. Accepts both A2ML-wrapped and raw
120+
/// levelConfig JSON. If the input has an "a2ml" envelope, unwraps it first
121+
/// and uses the inner payload; otherwise treats it as raw levelConfig JSON.
122+
/// Returns Error if JSON is malformed or doesn't match the expected schema.
122123
let importFromJson = (jsonStr: string): result<LevelGen.generatedLevel, string> => {
123-
switch LevelConfigCodec.fromJsonString(jsonStr) {
124+
// Try A2ML unwrap first — if it's an A2ML envelope, use the inner payload
125+
let rawJson = switch A2mlWrapper.unwrapA2ml(jsonStr) {
126+
| Ok((payload, _meta)) => payload
127+
| Error(_) => jsonStr // Not A2ML — treat as raw levelConfig JSON
128+
}
129+
switch LevelConfigCodec.fromJsonString(rawJson) {
124130
| Ok(config) => Ok(importFromConfig(config))
125131
| Error(msg) => Error(msg)
126132
}

idaptik-ums/src/generator/Portfolio.res

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,28 @@ let encodePortfolio = (p: portfolio): string => {
131131
}`
132132
}
133133

134-
/// Decode a portfolio from a JSON string. Rebuilds levels by regenerating
135-
/// from the stored seeds (deterministic generation ensures identical output).
134+
/// Encode a portfolio to an A2ML-wrapped JSON string. The A2ML envelope
135+
/// carries generator metadata (version, portfolio name) alongside the
136+
/// portfolio payload, enabling downstream tools to inspect provenance.
137+
let encodePortfolioA2ml = (p: portfolio): string => {
138+
let json = encodePortfolio(p)
139+
let meta = A2mlWrapper.fromPortfolio(p.name)
140+
A2mlWrapper.wrapInA2ml(json, meta)
141+
}
142+
143+
/// Decode a portfolio from a JSON string. Accepts both A2ML-wrapped and raw
144+
/// portfolio JSON. If the input has an "a2ml" envelope, unwraps it first
145+
/// and uses the inner payload; otherwise treats it as raw portfolio JSON.
146+
/// Rebuilds levels by regenerating from the stored seeds (deterministic
147+
/// generation ensures identical output).
136148
let decodePortfolio = (jsonStr: string): result<portfolio, string> => {
149+
// Try A2ML unwrap first — if it's an A2ML envelope, use the inner payload
150+
let rawJson = switch A2mlWrapper.unwrapA2ml(jsonStr) {
151+
| Ok((payload, _meta)) => payload
152+
| Error(_) => jsonStr // Not A2ML — treat as raw portfolio JSON
153+
}
137154
try {
138-
let json = JSON.parseExn(jsonStr)
155+
let json = JSON.parseExn(rawJson)
139156
let obj = json->Js.Json.decodeObject->Belt.Option.getWithDefault(Js.Dict.empty())
140157
let getString = (d, k) => Js.Dict.get(d, k)->Belt.Option.flatMap(Js.Json.decodeString)->Belt.Option.getWithDefault("")
141158
let getFloat = (d, k) => Js.Dict.get(d, k)->Belt.Option.flatMap(Js.Json.decodeNumber)->Belt.Option.getWithDefault(0.0)

idaptik-ums/src/generator/PortfolioSync.res

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,10 @@ let parseResponse = %raw(`
6565
// ---------------------------------------------------------------------------
6666

6767
/// Save a portfolio to VerisimDB via the sync server.
68-
/// The portfolio is serialized to JSON and sent as a POST to /db/portfolios.
68+
/// The portfolio is serialized to A2ML-wrapped JSON and sent as a POST to
69+
/// /db/portfolios. The A2ML envelope carries portfolio provenance metadata.
6970
let savePortfolio = (portfolio: Portfolio.portfolio): promise<result<string, string>> => {
70-
let json = Portfolio.encodePortfolio(portfolio)
71+
let json = Portfolio.encodePortfolioA2ml(portfolio)
7172
let url = `${syncServerUrl}/db/portfolios`
7273
let response = fetchJson(url, "POST", json)
7374
response->Js.Promise.then_(r => {
@@ -91,9 +92,9 @@ let loadPortfolio = (portfolioId: string): promise<result<string, string>> => {
9192

9293
/// Save a campaign graph to ArangoDB via the sync server.
9394
/// Campaign edges are stored as an ArangoDB edge collection for efficient
94-
/// graph traversal.
95+
/// graph traversal. The graph is wrapped in an A2ML envelope for provenance.
9596
let saveCampaignGraph = (graph: CampaignGraph.campaignGraph): promise<result<string, string>> => {
96-
let json = CampaignGraph.encodeCampaignGraph(graph)
97+
let json = CampaignGraph.encodeCampaignGraphA2ml(graph)
9798
let url = `${syncServerUrl}/db/campaigns`
9899
let response = fetchJson(url, "POST", json)
99100
response->Js.Promise.then_(r => {

0 commit comments

Comments
 (0)