Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,39 @@ var doc = new OssieDocument();
new OssieYamlWriter().write(doc, Paths.get("my-model.ossie.yaml"));
```

Resolve AI-context synonyms to canonical entity names, without writing
your own tree walk:

```java
import bi.saiku.ossie.OssieSynonymIndex;

var model = doc.getSemanticModel().get(0);
var metrics = OssieSynonymIndex.buildMetricIndex(model);
// If the LLM (or the user) said "revenue" or "top-line" or "REVENUE",
// resolve it to the canonical metric name declared in the YAML.
String canonical = OssieSynonymIndex.resolve("revenue", metrics);
// → "net_revenue" (assuming the metric declared that synonym)
```

Reject documents at unknown spec versions — useful in CI or when you
want to fail fast on drift:

```java
var doc = new OssieYamlReader()
.setStrictVersion(true)
.read(Paths.get("model.ossie.yaml"));
// Throws UnsupportedOssieVersionException if the version isn't in
// OssieYamlReader.SUPPORTED_VERSIONS.
```

## Compatibility

- **OSI spec versions** — DTOs cover the v0.1.x and v0.2.x wire
formats. Version strings on documents are not validated at read time;
future spec additions are ignored via
`@JsonIgnoreProperties(ignoreUnknown = true)`.
- **OSI spec versions** — the DTO tree covers the v0.1.x and v0.2.x
wire formats. `OssieYamlReader.SUPPORTED_VERSIONS` names the exact
versions the reader knows about. Unknown top-level and per-entity
fields are silently ignored (`@JsonIgnoreProperties(ignoreUnknown =
true)`), so additive spec changes round-trip without breaking older
consumers.
- **JDK** — 21+.
- **dbt Core** — reads `target/osi_document.json` from dbt 1.12 and up
verbatim.
Expand Down
118 changes: 118 additions & 0 deletions src/main/java/bi/saiku/ossie/OssieSynonymIndex.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* Copyright 2026 Spicule Ltd
* Apache License, Version 2.0.
*/
package bi.saiku.ossie;

import bi.saiku.ossie.model.AiContext;
import bi.saiku.ossie.model.Dataset;
import bi.saiku.ossie.model.Field;
import bi.saiku.ossie.model.Metric;
import bi.saiku.ossie.model.SemanticModel;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;

/**
* Canonicalises AI-context synonyms to their entity names.
*
* <p>The Ossie / OSI spec attaches {@code AIContext.synonyms} to fields, metrics, and datasets so
* LLM agents can refer to them by natural aliases: "revenue" resolves to the canonical
* {@code net_revenue} metric; "sales transactions" resolves to the {@code store_sales} dataset.
* The synonyms are declared once in the YAML but the lookup logic ("case-insensitive walk of every
* dataset's fields, collect their AI-context synonyms") is otherwise identical across consumers.
*
* <p>Each index maps <em>lowercased synonym</em> to the corresponding entity's canonical name.
* Lookups against the returned map should also lowercase the key ({@link Locale#ROOT}). Callers
* are typically:
*
* <ul>
* <li>Query validators that accept either canonical names or synonyms
* <li>Natural-language layers ({@code /ask}) that need to resolve the LLM's word choice
* <li>MCP tool implementations exposing the same canonicalisation to agent clients
* </ul>
*
* <p>Insertion order in the returned map is preserved (dataset → field → metric traversal order in
* the source model), which helps a UI display "revenue → net_revenue (from Sales.net_revenue)"
* predictably.
*
* <p>Ambiguous synonyms are resolved with a <em>first-declared-wins</em> policy — if two fields
* declare {@code "id"} as a synonym, the first one encountered stays in the map and later
* declarations are silently dropped. That matches the pragmatic behaviour most consumers want:
* make the synonym resolvable to <em>something</em> rather than throw at build time. Consumers
* that need strict ambiguity detection should walk the source model themselves.
*/
public final class OssieSynonymIndex {

private OssieSynonymIndex() {}

/**
* Field-name synonym index. Keys are lowercased synonyms; values are the canonical
* {@code "<datasetName>.<fieldName>"} handle. Returns an empty map if the model declares no
* synonyms on any field.
*/
public static Map<String, String> buildFieldIndex(SemanticModel model) {
Map<String, String> out = new LinkedHashMap<>();
if (model == null) return out;
for (Dataset ds : model.getDatasets()) {
for (Field f : ds.getFields()) {
for (String syn : synonymsOf(f.getAiContext())) {
out.putIfAbsent(syn.toLowerCase(Locale.ROOT), ds.getName() + "." + f.getName());
}
}
}
return out;
}

/**
* Metric-name synonym index. Keys are lowercased synonyms; values are the canonical metric
* name. Metrics are model-scoped in the OSI spec so no dataset qualifier is needed.
*/
public static Map<String, String> buildMetricIndex(SemanticModel model) {
Map<String, String> out = new LinkedHashMap<>();
if (model == null) return out;
for (Metric m : model.getMetrics()) {
for (String syn : synonymsOf(m.getAiContext())) {
out.putIfAbsent(syn.toLowerCase(Locale.ROOT), m.getName());
}
}
return out;
}

/** Dataset-name synonym index. Keys are lowercased synonyms; values are the canonical dataset name. */
public static Map<String, String> buildDatasetIndex(SemanticModel model) {
Map<String, String> out = new LinkedHashMap<>();
if (model == null) return out;
for (Dataset ds : model.getDatasets()) {
for (String syn : synonymsOf(ds.getAiContext())) {
out.putIfAbsent(syn.toLowerCase(Locale.ROOT), ds.getName());
}
}
return out;
}

/**
* Resolves a caller-supplied name against a synonym index. Returns the canonical name (from
* the map) or the input unchanged if no synonym matches. Case-insensitive.
*
* <p>Reads best as a validator escape hatch:
*
* <pre>{@code
* Map<String, String> aliases = OssieSynonymIndex.buildMetricIndex(model);
* String resolved = OssieSynonymIndex.resolve(request.getMetric(), aliases);
* // proceed with `resolved` — validation now sees the canonical name
* }</pre>
*/
public static String resolve(String maybeSynonym, Map<String, String> index) {
if (maybeSynonym == null || index == null || index.isEmpty()) return maybeSynonym;
String canonical = index.get(maybeSynonym.toLowerCase(Locale.ROOT));
return canonical == null ? maybeSynonym : canonical;
}

private static Iterable<String> synonymsOf(AiContext ctx) {
if (ctx == null || ctx.getSynonyms() == null || ctx.getSynonyms().isEmpty()) {
return java.util.List.of();
}
return ctx.getSynonyms();
}
}
73 changes: 61 additions & 12 deletions src/main/java/bi/saiku/ossie/OssieYamlReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,43 +13,92 @@
import java.io.Reader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;

/**
* Load an {@link OssieDocument} from Ossie-flavoured YAML.
* Loads an {@link OssieDocument} from OSI-flavoured YAML or JSON.
*
* <p>Symmetric with {@code OssieYamlWriter} — the pair is the round-trip. Lives in
* saiku-service alongside the writer + POJOs so any module can consume it (previously kept in
* saiku-sql; promoted here when {@code OssieDiscoverService} in this module needed to read the
* same YAML the Calcite adapter loads).
* <p>YAML is a strict superset of JSON so the same reader accepts either. dbt Core 1.12's
* {@code target/osi_document.json} feeds through this class verbatim.
*
* <p>{@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} is disabled so Ossie documents that
* carry vendor extensions or future spec keys we don't yet model won't blow up the loader — the
* spec is draft and additive fields are the whole point of {@code custom_extensions}.
* <p>{@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} is disabled: additive spec fields
* that this library version doesn't yet model round-trip silently rather than failing the load.
* That's the right default for a draft-spec ecosystem where consumers routinely lag emitters.
*
* <p>By default, the {@code version:} field is accepted as-is. Callers that need to reject
* documents at unknown spec versions can opt in with {@link #setStrictVersion(boolean)} — the
* reader then throws {@link UnsupportedOssieVersionException} on a mismatch. The set of
* recognised versions is exposed via {@link #getSupportedVersions()} for surfacing in errors and
* documentation.
*/
public final class OssieYamlReader {

/**
* Ossie / OSI spec versions this library understands. Widens as new drafts land — additive
* changes bump the last suffix (e.g. {@code 0.2.0.dev0} → {@code 0.2.0.dev1}); breaking
* changes require a new major version of ossie-core itself.
*
* <p>Included today:
*
* <ul>
* <li>{@code 0.1.0} — initial public draft
* <li>{@code 0.1.1} — the version dbt Core 1.12 emits at
* {@code target/osi_document.json}
* <li>{@code 0.2.0.dev0} — current apache/ossie draft head
* </ul>
*/
public static final Set<String> SUPPORTED_VERSIONS = Set.of("0.1.0", "0.1.1", "0.2.0.dev0");

private final ObjectMapper yaml;
private boolean strictVersion = false;

public OssieYamlReader() {
this.yaml = new ObjectMapper(new YAMLFactory());
this.yaml.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
}

/**
* When {@code true}, {@link #read} throws {@link UnsupportedOssieVersionException} if the
* document's {@code version} field is not in {@link #SUPPORTED_VERSIONS}. Default {@code false}
* — permissive mode for tools that want to attempt a best-effort parse against unfamiliar spec
* versions (Jackson's {@code ignoreUnknown} handles additive changes gracefully).
*
* @return {@code this} for fluent chaining
*/
public OssieYamlReader setStrictVersion(boolean strict) {
this.strictVersion = strict;
return this;
}

/** Returns the versions this reader recognises. Equivalent to {@link #SUPPORTED_VERSIONS}. */
public Set<String> getSupportedVersions() {
return SUPPORTED_VERSIONS;
}

public OssieDocument read(Path yamlPath) throws IOException {
try (InputStream in = Files.newInputStream(yamlPath)) {
return yaml.readValue(in, OssieDocument.class);
return checkVersion(yaml.readValue(in, OssieDocument.class));
}
}

public OssieDocument read(InputStream stream) throws IOException {
return yaml.readValue(stream, OssieDocument.class);
return checkVersion(yaml.readValue(stream, OssieDocument.class));
}

public OssieDocument read(Reader reader) throws IOException {
return yaml.readValue(reader, OssieDocument.class);
return checkVersion(yaml.readValue(reader, OssieDocument.class));
}

public OssieDocument readString(String yamlText) throws IOException {
return yaml.readValue(yamlText, OssieDocument.class);
return checkVersion(yaml.readValue(yamlText, OssieDocument.class));
}

private OssieDocument checkVersion(OssieDocument doc) {
if (!strictVersion) return doc;
String version = doc.getVersion();
if (version == null || !SUPPORTED_VERSIONS.contains(version)) {
throw new UnsupportedOssieVersionException(version, SUPPORTED_VERSIONS);
}
return doc;
}
}
52 changes: 52 additions & 0 deletions src/main/java/bi/saiku/ossie/UnsupportedOssieVersionException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright 2026 Spicule Ltd
* Apache License, Version 2.0.
*/
package bi.saiku.ossie;

import java.util.Set;
import java.util.TreeSet;

/**
* Thrown by {@link OssieYamlReader} when {@code setStrictVersion(true)} is enabled and the parsed
* document's {@code version:} field is not in {@link OssieYamlReader#SUPPORTED_VERSIONS}.
*
* <p>Runtime exception because callers using strict-version-check are opting in to fail-fast
* behaviour on version drift, not routine error handling. Extends {@link RuntimeException} so
* existing method signatures ({@code read(...) throws IOException}) don't need widening.
*
* <p>The message names the offending version and lists the supported set so operators can decide
* whether to widen this library's version constant or downgrade the document's version.
*/
public class UnsupportedOssieVersionException extends RuntimeException {

private static final long serialVersionUID = 1L;

private final String observedVersion;
private final Set<String> supportedVersions;

public UnsupportedOssieVersionException(String observed, Set<String> supported) {
super(buildMessage(observed, supported));
this.observedVersion = observed;
this.supportedVersions = supported == null ? Set.of() : Set.copyOf(supported);
}

/** @return the version string carried by the document (may be {@code null} if none declared) */
public String getObservedVersion() {
return observedVersion;
}

/** @return the set of versions the reader was configured to accept */
public Set<String> getSupportedVersions() {
return supportedVersions;
}

private static String buildMessage(String observed, Set<String> supported) {
String obs = observed == null ? "(no version declared)" : "'" + observed + "'";
Set<String> sortedSupported = supported == null ? Set.of() : new TreeSet<>(supported);
return "Ossie document declares version " + obs
+ " which is not in the reader's supported set: " + sortedSupported
+ ". Enable permissive mode by leaving OssieYamlReader.setStrictVersion(false) — "
+ "the default — or bump this library to a release that lists the observed version.";
}
}
Loading
Loading