metanorma-mko 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8d552e397dd8ed669ba8766736e4fd97621ef7b3203598b162ff989c84691a59
4
+ data.tar.gz: 0d1babd1f3cfcecf0aa61055a4798bb91edcd7847c9936a624cbbcfe9dec45a1
5
+ SHA512:
6
+ metadata.gz: bbe6e4797d6290ca828ed59c124d3b452d2b9a10f7ca59533e18c23be2f42316da55edbd14d2dbd88a8c9636659b6c15b7bde605c2cf1205a0f8a0909f9d034c
7
+ data.tar.gz: 6fa2e380044eecad731b5258b7c424651a815a3a0b6522ae47699d97e037ae121a49e770d8982bf5af89645ae5f2d0559f11a28d6ee31e5ed21074903406af07
data/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2026, Ribose Inc.
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.adoc ADDED
@@ -0,0 +1,44 @@
1
+ = metanorma-mko
2
+
3
+ Metanorma Knowledge Objects (MKO, MN 116) — the machine serialization
4
+ format for Metanorma documents, as code.
5
+
6
+ == What this gem owns
7
+
8
+ The FORMAT contract:
9
+
10
+ * the MN 116 wire schema (typed units, edges, document identity,
11
+ manifest) as lutaml-model classes — `Metanorma::Mko::Schema`;
12
+ * the bundle layout and manifest verification — `Mko::Bundle`,
13
+ `Mko::Writer`;
14
+ * hash-addressed assets (`Mko::Assets`);
15
+ * structured edition diffs (`Mko::Diff`) and interlingual alignment
16
+ (`Mko::Alignment`);
17
+ * the generated JSON Schemas — `Mko.json_schemas` (the schema classes
18
+ are the single source of truth; TS/Python consumers validate against
19
+ these, never hand-rolled copies);
20
+ * the reference MCP server over any conforming bundle —
21
+ `Mko::Mcp::Server` (search_units / get_unit / walk_edges /
22
+ edition_diff; JSON-RPC 2.0 over stdio).
23
+
24
+ == What it does not own
25
+
26
+ The model side — the projection walk, collection orchestration, flavor
27
+ resolution — lives in
28
+ https://github.com/metanorma/metanorma-document[metanorma-document],
29
+ which depends on this gem and reopens `Metanorma::Mko` with the export
30
+ entry points (`Mko.export`, `Mko::Collection.export`).
31
+
32
+ == Usage
33
+
34
+ Consumers validate against the published contract:
35
+
36
+ [source,ruby]
37
+ require "metanorma/mko"
38
+ Metanorma::Mko.json_schemas["unit"]
39
+ # => { "$schema" => "https://json-schema.org/draft/2020-12/schema", ... }
40
+
41
+ Agent consumers mount the reference MCP server:
42
+
43
+ [source,ruby]
44
+ Metanorma::Mko::Mcp::Server.new("oiml-r-60-1.mko").run
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Metanorma
6
+ module Mko
7
+ # Interlingual alignment (MN 116 §stability, P2): units across
8
+ # language editions of the same document are the same knowledge
9
+ # object when their anchors match — Metanorma translations carry
10
+ # the semantic anchors. Emits variant_of unit edges binding the
11
+ # editions: parallel corpora, cross-language retrieval with
12
+ # source-language provenance, and consistency diffs between
13
+ # translations.
14
+ module Alignment
15
+ class << self
16
+ # Align two bundles of the same document (different language
17
+ # editions) by stable unit anchor. Returns [Schema::Edge] with
18
+ # kind variant_of; content-hash anchors (h-…) and unmatched
19
+ # units are simply not aligned — never guessed.
20
+ def align(bundle_a, bundle_b)
21
+ a = units_by_anchor(bundle_a)
22
+ b = units_by_anchor(bundle_b)
23
+ a.keys.intersection(b.keys).filter_map do |anchor|
24
+ next if anchor.to_s.empty? || anchor.start_with?("h-")
25
+
26
+ Schema::Edge.new(from: a[anchor], to: b[anchor],
27
+ kind: "variant_of")
28
+ end
29
+ end
30
+
31
+ # Write alignment.jsonl into bundle_a's directory; returns the
32
+ # path. The alignment is a derived artifact — regeneration
33
+ # replaces it.
34
+ def export(bundle_a, bundle_b)
35
+ edges = align(bundle_a, bundle_b)
36
+ path = File.join(bundle_a, "alignment.jsonl")
37
+ File.write(path, edges.map(&:to_json).join("\n") + "\n")
38
+ path
39
+ end
40
+
41
+ private
42
+
43
+ def units_by_anchor(bundle)
44
+ file = File.join(bundle, "units.jsonl")
45
+ File.readlines(file).each_with_object({}) do |line, h|
46
+ unit = JSON.parse(line)
47
+ h[unit["anchor"]] = unit["id"]
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest"
5
+
6
+ module Metanorma
7
+ module Mko
8
+ # Hash-addressed figure assets (MN 116 §assets). Bytes come from
9
+ # the model when it carries them (data URIs) and from the caller's
10
+ # source directory for relative paths — never from the walk's
11
+ # environment. Every asset lands in the bundle as assets/<sha256>,
12
+ # manifest-verified like every other component.
13
+ class Assets
14
+ Entry = Struct.new(:name, :media_type, :data, keyword_init: true)
15
+
16
+ DATA_URI = /\Adata:([^;,]+)(?:;charset=[^;,]+)?;base64,(.*)\z/m.freeze
17
+ MEDIA_TYPES = {
18
+ ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg",
19
+ ".gif" => "image/gif", ".svg" => "image/svg+xml",
20
+ ".tif" => "image/tiff", ".tiff" => "image/tiff", ".webp" => "image/webp",
21
+ }.freeze
22
+
23
+ def initialize(source_dir: nil)
24
+ @source_dir = source_dir
25
+ @entries = {}
26
+ end
27
+
28
+ # Returns the asset reference ("assets/<sha256>") for a figure
29
+ # source, or nil when no bytes are available.
30
+ def attach(uri)
31
+ data, media_type = bytes_for(uri)
32
+ return nil unless data
33
+
34
+ sha = Digest::SHA256.hexdigest(data)
35
+ name = "assets/#{sha}"
36
+ @entries[name] ||= Entry.new(
37
+ name: name, media_type: media_type || media_type_for(uri), data: data
38
+ )
39
+ name
40
+ end
41
+
42
+ def entries
43
+ @entries.values
44
+ end
45
+
46
+ private
47
+
48
+ def bytes_for(uri)
49
+ text = uri.to_s
50
+ if (m = text.match(DATA_URI))
51
+ [Base64.decode64(m[2]), m[1]]
52
+ elsif @source_dir && !text.empty?
53
+ path = File.expand_path(text, @source_dir)
54
+ [File.binread(path), nil] if File.file?(path)
55
+ end
56
+ end
57
+
58
+ def media_type_for(uri)
59
+ MEDIA_TYPES[File.extname(uri.to_s).downcase] || "application/octet-stream"
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mko
5
+ # The on-disk bundle writer: one place that knows the file layout,
6
+ # the manifest bookkeeping, and the hash verification inputs. The
7
+ # projection Writer and the collection exporter both compose it —
8
+ # bundle mechanics are never duplicated.
9
+ class Bundle
10
+ attr_reader :dir
11
+
12
+ def self.open(short, to)
13
+ new(File.expand_path(File.join(to, "#{short}.mko")))
14
+ end
15
+
16
+ def initialize(dir)
17
+ @dir = dir
18
+ @manifest = Schema::Manifest.new(
19
+ schema: SCHEMA, schema_version: SCHEMA_VERSION,
20
+ generated: Schema::Generated.new(
21
+ tool: "metanorma-document", schema_version: SCHEMA_VERSION,
22
+ ),
23
+ components: []
24
+ )
25
+ FileUtils.rm_rf(dir)
26
+ FileUtils.mkdir_p(dir)
27
+ end
28
+
29
+ def add_json(name, file, object)
30
+ path = File.join(dir, file)
31
+ File.write(path, JSON.pretty_generate(JSON.parse(object.to_json)))
32
+ component(name: name, file: file, media_type: "application/json")
33
+ end
34
+
35
+ # Line-oriented component; the block serializes each item
36
+ # (framework to_json by default).
37
+ def add_lines(name, file, items, &serializer)
38
+ serializer ||= lambda(&:to_json)
39
+ path = File.join(dir, file)
40
+ File.write(path, "#{items.map(&serializer).join("\n")}\n")
41
+ component(name: name, file: file, media_type: "application/jsonl",
42
+ count: items.size)
43
+ end
44
+
45
+ def add_asset(entry)
46
+ path = File.join(dir, entry.name)
47
+ FileUtils.mkdir_p(File.dirname(path))
48
+ File.binwrite(path, entry.data)
49
+ component(name: entry.name, file: entry.name,
50
+ media_type: entry.media_type, count: 1)
51
+ end
52
+
53
+ def flavor=(flavor)
54
+ @manifest.generated.flavor = flavor
55
+ end
56
+
57
+ def source_file=(source)
58
+ @manifest.source_file = source
59
+ end
60
+
61
+ def write_manifest
62
+ @manifest.generated.timestamp = Time.now.utc.iso8601
63
+ File.write(File.join(dir, "manifest.json"),
64
+ JSON.pretty_generate(JSON.parse(@manifest.to_json)))
65
+ dir
66
+ end
67
+
68
+ def zip!
69
+ require "zip"
70
+ zip_path = "#{dir}.zip"
71
+ Zip::File.open(zip_path, Zip::File::CREATE) do |zipfile|
72
+ Dir[File.join(dir, "**", "*")].each do |f|
73
+ next if File.directory?(f)
74
+
75
+ zipfile.add(f.sub("#{dir}/", ""), f)
76
+ end
77
+ end
78
+ zip_path
79
+ end
80
+
81
+ private
82
+
83
+ def component(name:, file:, media_type:, count: nil)
84
+ @manifest.components << Schema::ManifestComponent.new(
85
+ name: name, file: file, media_type: media_type,
86
+ count: count, hash: "sha256:#{Digest::SHA256.file(File.join(dir, file)).hexdigest}"
87
+ )
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Metanorma
6
+ module Mko
7
+ # Structured edition diffs (MN 116 §diffs; issue #53 item 5):
8
+ # "what changed between the 2000 and the 2017 edition" becomes a
9
+ # machine answer, not an expert reading marathon. Built on the
10
+ # stable-id contract — units pair by anchor, content hashes say
11
+ # whether anything changed — so a diff is exact and incremental.
12
+ module Diff
13
+ class << self
14
+ # Two bundles of the same document (different editions).
15
+ # Returns a change set:
16
+ # { from:, to:, added: [unit summaries], removed: [...],
17
+ # changed: [{ anchor, type, number, fields: [...] }] }
18
+ # fields carries field-level detail (text, title, number, and
19
+ # typed payload fields: designations, definition, rows,
20
+ # statement, …).
21
+ def between(bundle_a, bundle_b)
22
+ a = units_by_anchor(bundle_a)
23
+ b = units_by_anchor(bundle_b)
24
+ {
25
+ "from" => document_id(bundle_a),
26
+ "to" => document_id(bundle_b),
27
+ "added" => (b.keys - a.keys).map { |k| summary(b[k]) },
28
+ "removed" => (a.keys - b.keys).map { |k| summary(a[k]) },
29
+ "changed" => (a.keys & b.keys).filter_map do |k|
30
+ next if a[k]["hash"] == b[k]["hash"]
31
+
32
+ changed_unit(a[k], b[k])
33
+ end,
34
+ }
35
+ end
36
+
37
+ # Write <dir>/<from>-to-<to>.diff.json; returns the path.
38
+ def export(bundle_a, bundle_b, to:)
39
+ diff = between(bundle_a, bundle_b)
40
+ name = "#{Mko.slug(diff["from"])}-to-#{Mko.slug(diff["to"])}.diff.json"
41
+ path = File.join(to, name)
42
+ File.write(path, JSON.pretty_generate(diff) + "\n")
43
+ path
44
+ end
45
+
46
+ private
47
+
48
+ def units_by_anchor(bundle)
49
+ File.readlines(File.join(bundle, "units.jsonl"))
50
+ .each_with_object({}) do |line, h|
51
+ unit = JSON.parse(line)
52
+ h[unit["anchor"]] = unit
53
+ end
54
+ end
55
+
56
+ def document_id(bundle)
57
+ JSON.parse(File.read(File.join(bundle, "document.json")))
58
+ .dig("ids", "canonical")
59
+ end
60
+
61
+ def summary(unit)
62
+ { "anchor" => unit["anchor"], "type" => unit["type"],
63
+ "number" => unit["number"], "title" => unit["title"] }
64
+ end
65
+
66
+ # Field-level detail: top-level fields first, then the payload
67
+ # sub-fields consumers act on (terminology changes, table row
68
+ # deltas, requirement restatements).
69
+ def changed_unit(old, new)
70
+ fields = %w[text title number obligation].select do |f|
71
+ old[f] != new[f]
72
+ end
73
+ fields.concat(payload_changes(old, new))
74
+ summary(new).merge("fields" => fields.uniq)
75
+ end
76
+
77
+ def payload_changes(old, new)
78
+ op = old["payload"] || {}
79
+ np = new["payload"] || {}
80
+ return [] if op == np
81
+
82
+ changed = []
83
+ %w[designations admitted deprecated definition statement
84
+ subject identifier klass caption columns].each do |f|
85
+ changed << f if op[f] != np[f]
86
+ end
87
+ if op["rows"] != np["rows"]
88
+ changed << "rows" \
89
+ "(+#{(np["rows"] || []).size - (op["rows"] || []).size})"
90
+ end
91
+ changed << "payload" if changed.empty? && op != np
92
+ changed
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mko
5
+ # The value an export returns: the bundle path (String-ducktyped,
6
+ # so existing callers keep working) plus the projection Result,
7
+ # so composite exporters (collections, alignments) derive from
8
+ # the same truth the bundle was written from — never by reading
9
+ # their own output back.
10
+ class Export
11
+ attr_reader :path, :result
12
+
13
+ def initialize(path, result)
14
+ @path = path
15
+ @result = result
16
+ end
17
+
18
+ def to_s
19
+ @path.to_s
20
+ end
21
+
22
+ def to_str
23
+ @path.to_s
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Metanorma
6
+ module Mko
7
+ # Reference MCP server over an MKO bundle (issue #53 item 9; the
8
+ # MN 116 contract: search_units / get_unit / walk_edges /
9
+ # edition_diff). Every tool is a read over bundle files — an agent
10
+ # consumes a Metanorma corpus without anyone building a RAG first.
11
+ # JSON-RPC 2.0 over stdio; drive via #handle lines or #run(io).
12
+ module Mcp
13
+ class Server
14
+ PROTOCOL = "2025-06-18"
15
+ TOOLS = [
16
+ { "name" => "search_units",
17
+ "description" => "Search the document's units by term " \
18
+ "overlap over title and text; optional type filter",
19
+ "inputSchema" => {
20
+ "type" => "object",
21
+ "properties" => {
22
+ "query" => { "type" => "string" },
23
+ "type" => { "type" => "string",
24
+ "enum" => %w[clause annex term table figure formula
25
+ note example sourcecode requirement
26
+ reference] },
27
+ },
28
+ "required" => %w[query],
29
+ } },
30
+ { "name" => "get_unit",
31
+ "description" => "Full unit by stable id (text + typed payload)",
32
+ "inputSchema" => {
33
+ "type" => "object",
34
+ "properties" => { "id" => { "type" => "string" } },
35
+ "required" => %w[id],
36
+ } },
37
+ { "name" => "walk_edges",
38
+ "description" => "Edges touching a unit (any kind, or one kind)",
39
+ "inputSchema" => {
40
+ "type" => "object",
41
+ "properties" => {
42
+ "unit_id" => { "type" => "string" },
43
+ "kind" => { "type" => "string" },
44
+ },
45
+ } },
46
+ { "name" => "edition_diff",
47
+ "description" => "Structured change set between two bundles of " \
48
+ "the same document (anchors pair; hashes decide)",
49
+ "inputSchema" => {
50
+ "type" => "object",
51
+ "properties" => {
52
+ "bundle_a" => { "type" => "string" },
53
+ "bundle_b" => { "type" => "string" },
54
+ },
55
+ "required" => %w[bundle_a bundle_b],
56
+ } },
57
+ ].freeze
58
+
59
+ def initialize(bundle)
60
+ @bundle = bundle
61
+ end
62
+
63
+ # One JSON-RPC request line -> response line (or nil for
64
+ # notifications). The test surface: drive the protocol without
65
+ # spawning a process.
66
+ def handle(line)
67
+ req = JSON.parse(line)
68
+ return nil if req["id"].nil?
69
+
70
+ result =
71
+ case req["method"]
72
+ when "initialize"
73
+ { "protocolVersion" => PROTOCOL,
74
+ "capabilities" => { "tools" => {} },
75
+ "serverInfo" => { "name" => "metanorma-mko",
76
+ "version" => SCHEMA_VERSION } }
77
+ when "tools/list" then { "tools" => TOOLS }
78
+ when "tools/call" then call_tool(req["params"] || {})
79
+ end
80
+ return JSON.generate(
81
+ { "jsonrpc" => "2.0", "id" => req["id"],
82
+ "error" => { "code" => -32_601,
83
+ "message" => "method not found: #{req['method']}" } }
84
+ ) if result.nil?
85
+
86
+ JSON.generate({ "jsonrpc" => "2.0", "id" => req["id"],
87
+ "result" => result })
88
+ rescue StandardError => e
89
+ JSON.generate({ "jsonrpc" => "2.0", "id" => (req && req["id"]),
90
+ "error" => { "code" => -32_603,
91
+ "message" => "#{e.class}: #{e.message}" } })
92
+ end
93
+
94
+ def run(io = $stdin)
95
+ io.each_line { |line| (out = handle(line)) && puts(out) }
96
+ end
97
+
98
+ private
99
+
100
+ def units
101
+ @units ||= File.readlines(File.join(@bundle, "units.jsonl"))
102
+ .map { |l| JSON.parse(l) }
103
+ end
104
+
105
+ def edges
106
+ @edges ||= File.readlines(File.join(@bundle, "edges.jsonl"))
107
+ .map { |l| JSON.parse(l) }
108
+ end
109
+
110
+ def call_tool(params)
111
+ name = params["name"]
112
+ args = params["arguments"] || {}
113
+ case name
114
+ when "search_units" then search_units(args)
115
+ when "get_unit" then get_unit(args)
116
+ when "walk_edges" then walk_edges(args)
117
+ when "edition_diff" then edition_diff(args)
118
+ else raise ArgumentError, "unknown tool: #{name}"
119
+ end
120
+ end
121
+
122
+ def search_units(args)
123
+ terms = args["query"].to_s.downcase.split(/[^\p{L}\p{N}]+/)
124
+ .reject { |t| t.length < 2 }
125
+ pool = args["type"] ? units.select { |u| u["type"] == args["type"] } : units
126
+ scored = pool.filter_map do |u|
127
+ hay = "#{u['title']} #{u['text']}".downcase
128
+ score = terms.count { |t| hay.include?(t) }
129
+ { "id" => u["id"], "type" => u["type"], "anchor" => u["anchor"],
130
+ "number" => u["number"], "title" => u["title"],
131
+ "score" => score } if score.positive?
132
+ end
133
+ text(JSON.generate(scored.sort_by { |s| -s["score"] }.first(10)))
134
+ end
135
+
136
+ def get_unit(args)
137
+ unit = units.find { |u| u["id"] == args["id"] }
138
+ raise ArgumentError, "no unit #{args['id']}" unless unit
139
+
140
+ text(JSON.generate(unit))
141
+ end
142
+
143
+ def walk_edges(args)
144
+ picked = edges.select do |e|
145
+ (args["unit_id"].nil? || e["from"] == args["unit_id"] ||
146
+ e["to"] == args["unit_id"]) &&
147
+ (args["kind"].nil? || e["kind"] == args["kind"])
148
+ end
149
+ text(JSON.generate(picked.first(50)))
150
+ end
151
+
152
+ def edition_diff(args)
153
+ text(JSON.generate(Diff.between(args["bundle_a"], args["bundle_b"])))
154
+ end
155
+
156
+ def text(body)
157
+ { "content" => [{ "type" => "text", "text" => body }] }
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mko
5
+ # The in-memory bundle: what a projection produces and the Writer
6
+ # serializes. Format-shaped — the component set of MN 116 — so it
7
+ # lives with the format, constructed by the model-side walk in
8
+ # metanorma-document.
9
+ class Result
10
+ attr_reader :document, :units, :edges, :glossary, :bibdata,
11
+ :bibliography, :identifiers, :assets, :unitsml, :flavor
12
+
13
+ def initialize(document:, units:, edges:, glossary:, bibdata:,
14
+ bibliography:, identifiers:, assets: [], unitsml: [],
15
+ flavor:)
16
+ @document = document
17
+ @units = units
18
+ @edges = edges
19
+ @glossary = glossary
20
+ @bibdata = bibdata
21
+ @bibliography = bibliography
22
+ @identifiers = identifiers
23
+ @assets = assets
24
+ @unitsml = unitsml
25
+ @flavor = flavor
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mko
5
+ module Schema
6
+ # collection.json — the family contract for collection bundles:
7
+ # membership and cross-document edges between documents that a
8
+ # metanorma collection.yml declares together (OIML R 60 parts,
9
+ # R 129/138/144 families).
10
+ class CollectionMember < Lutaml::Model::Serializable
11
+ attribute :bundle, :string
12
+ attribute :docidentifier, :string
13
+ attribute :identifier, :string
14
+ attribute :title, :string
15
+ attribute :edition, :string
16
+
17
+ json do
18
+ map "bundle", to: :bundle
19
+ map "docidentifier", to: :docidentifier
20
+ map "identifier", to: :identifier
21
+ map "title", to: :title
22
+ map "edition", to: :edition
23
+ end
24
+ end
25
+
26
+ class Collection < Lutaml::Model::Serializable
27
+ attribute :canonical, :string
28
+ attribute :short, :string
29
+ attribute :title, :string
30
+ attribute :edition, :string
31
+ attribute :members, CollectionMember, collection: true,
32
+ default: -> { [] }
33
+
34
+ json do
35
+ map "canonical", to: :canonical
36
+ map "short", to: :short
37
+ map "title", to: :title
38
+ map "edition", to: :edition
39
+ map "members", to: :members
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end