Skip to content
Open
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
24 changes: 24 additions & 0 deletions spec/schemas/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@
"format": "uri",
"description": "Path to original file, when this is a copy"
},
"compatible": {
"description": "Declares that this configuration is compatible with one or more other configurations — meaning there exists at least one concrete hardware implementation that satisfies both this config and each listed config simultaneously. Each pointer is either the name of a config in the repository cfgs/ directory or a path to a config YAML file (absolute, or relative to this file). Compatibility is checked transitively: if config B lists config C as compatible, then this config must also be compatible with C.",
"examples": [
"profile/RVA23U64",
["profile/RVA23U64", "profile/RVB23U64"],
"../other/my_config.yaml"
],
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" } }
]
},
"params": {
"type": "object"
},
Expand Down Expand Up @@ -111,6 +123,18 @@
"format": "uri",
"description": "Path to original file, when this is a copy"
},
"compatible": {
"description": "Declares that this configuration is compatible with one or more other configurations — meaning there exists at least one concrete hardware implementation that satisfies both this config and each listed config simultaneously. Each pointer is either the name of a config in the repository cfgs/ directory or a path to a config YAML file (absolute, or relative to this file). Compatibility is checked transitively: if config B lists config C as compatible, then this config must also be compatible with C.",
"examples": [
"profile/RVA23U64",
["profile/RVA23U64", "profile/RVB23U64"],
"../other/my_config.yaml"
],
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" } }
]
},
"mandatory_extensions": {
"description": "Extensions mandatory in this architecture",
"type": "array",
Expand Down
57 changes: 57 additions & 0 deletions tools/ruby-gems/udb/lib/udb/cfg_arch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,8 @@ def full_config_valid?
reasons += missing_params.map { |p| "Parameter is required but missing: '#{p.name}'" }
end

validate_compatible(reasons)

if reasons.empty?
raise "bad validity check" unless to_condition.satisfiable?
ValidationResult.new(valid: true, reasons:)
Expand Down Expand Up @@ -466,6 +468,8 @@ def partial_config_valid?
end
end

validate_compatible(reasons)

if reasons.empty?
raise "Bad validation" unless to_condition.satisfiable?
return ValidationResult.new(valid: true, reasons: [])
Expand Down Expand Up @@ -1722,5 +1726,58 @@ def convert_monospace_to_links(adoc)
end
end

sig { params(pointer: String).returns(ConfiguredArchitecture) }
def resolve_compatible_pointer(pointer)
@config.info.resolver.cfg_arch_for_pointer(
pointer,
relative_dir: Pathname.new(@config.info.path).dirname
)
end
private :resolve_compatible_pointer

sig { params(other: ConfiguredArchitecture, reasons: T::Array[String], visited: T::Set[String]).void }
def check_compatible_with(other, reasons, visited)
return if visited.include?(other.name)
visited.add(other.name)

combined = to_condition & other.to_condition
unless combined.satisfiable_by_cfg_arch?(self)
combined.to_logic_tree(expand: true).minimal_unsat_subsets.each do |min|
reasons << "Config '#{name}' is not compatible with '#{other.name}': not satisfiable: #{min.to_s(format: LogicNode::LogicSymbolFormat::C)}"
end
end

# Resolve transitive pointers relative to `other`'s directory, not self's.
Array(other.config.compatible).each do |pointer|
begin
trans = other.send(:resolve_compatible_pointer, pointer)
check_compatible_with(trans, reasons, visited)
rescue => e
reasons << "Cannot resolve transitive compatible pointer '#{pointer}': #{e.message}"
end
end
end
private :check_compatible_with

sig { params(reasons: T::Array[String]).void }
def validate_compatible(reasons)
return if @config.compatible.nil?

# Use dup per top-level pointer so sibling pointers each get a fresh visited set —
# each branch independently validates against any shared transitive targets. Cycle
# detection within a single chain is still enforced because visited mutates in-place
# during the recursive descent.
visited = T.let(Set.new([name]), T::Set[String])
Array(@config.compatible).each do |pointer|
begin
other = resolve_compatible_pointer(pointer)
check_compatible_with(other, reasons, visited.dup)
rescue => e
reasons << "Cannot resolve compatible pointer '#{pointer}': #{e.message}"
end
Comment thread
dhower-qc marked this conversation as resolved.
end
end
private :validate_compatible

end
end
4 changes: 4 additions & 0 deletions tools/ruby-gems/udb/lib/udb/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ def arch_overlay_abs
sig { returns(String) }
def description = @data["description"]

sig { returns(T.nilable(T.any(String, T::Array[String]))) }
def compatible = @data["compatible"]

sig { abstract.returns(T.nilable(Integer)) }
def mxlen; end

Expand Down Expand Up @@ -359,5 +362,6 @@ def implemented_extensions
end
end
end

end
end
30 changes: 30 additions & 0 deletions tools/ruby-gems/udb/lib/udb/resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
module Udb
# resolves the specification in the context of a config, and writes to a generation folder
#
# Raised by Resolver#cfg_info when a config name or path cannot be found.
class ConfigNotFoundError < StandardError; end
Comment on lines 17 to +19
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConfigNotFoundError is documented as being raised by Resolver#cfg_info, but cfg_info still logs + exits on missing config names, and this exception class isn’t referenced anywhere. Either update cfg_info to raise ConfigNotFoundError (and stop calling exit from library code), or remove/adjust the comment and class to reflect actual behavior.

Suggested change
#
# Raised by Resolver#cfg_info when a config name or path cannot be found.
class ConfigNotFoundError < StandardError; end

Copilot uses AI. Check for mistakes.

# The primary interface for users will be #cfg_arch_for
class Resolver
extend T::Sig
Expand Down Expand Up @@ -301,6 +304,33 @@ def cfg_info(config_path_or_name)
end
end

# Resolve a config pointer (name or file path) to a ConfiguredArchitecture.
# Resolution order:
# 1. If <cfgs_path>/<pointer>.yaml exists on disk, treat as a repo config name
# (handles names that contain '/', e.g. "profile/RVA23U64").
# 2. If the pointer ends in .yaml/.yml, is absolute, or starts with ./ or ../,
# treat as a file path resolved relative to +relative_dir+.
# 3. Otherwise raise ArgumentError — the pointer is neither a known config name
# nor a recognisable file path.
sig { params(pointer: String, relative_dir: Pathname).returns(Udb::ConfiguredArchitecture) }
def cfg_arch_for_pointer(pointer, relative_dir:)
if (@cfgs_path / "#{pointer}.yaml").file?
# Repo config name — may contain '/' for nested configs (e.g. "profile/RVA23U64").
cfg_arch_for(pointer)
elsif pointer.end_with?(".yaml", ".yml") ||
Pathname.new(pointer).absolute? ||
pointer.start_with?("./", "../")
# Explicit file path — resolve relative to the caller's directory.
path = Pathname.new(pointer)
cfg_arch_for(path.absolute? ? path : (relative_dir / path).cleanpath)
else
raise ArgumentError,
"Cannot resolve config pointer '#{pointer}': not a known config name " \
"under '#{@cfgs_path}' and not a recognisable file path " \
"(.yaml/.yml extension, absolute, or starting with ./ or ../)"
end
end
Comment thread
dhower-qc marked this conversation as resolved.

# resolve the specification for a config, and return a ConfiguredArchitecture
sig { params(config_path_or_name: T.any(Pathname, String)).returns(Udb::ConfiguredArchitecture) }
def cfg_arch_for(config_path_or_name)
Expand Down
202 changes: 202 additions & 0 deletions tools/ruby-gems/udb/test/test_cfg_arch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -555,4 +555,206 @@ def test_partial_config_parameter_defined_by_unknown_terms
assert param_reasons.first.include?("{unknown}"), "Expected {unknown} annotations for possible-but-not-mandatory extensions"
end
end

def test_compatible
base_64_yaml = <<~YAML
$schema: config_schema.json#
kind: architecture configuration
type: partially configured
name: compat_test_base_64
description: Base 64-bit config for compatibility tests
params:
MXLEN: 64
mandatory_extensions:
- name: "I"
version: ">= 0"
- name: "Sm"
version: ">= 0"
YAML

compat_64_yaml = <<~YAML
$schema: config_schema.json#
kind: architecture configuration
type: partially configured
name: compat_test_compat_64
description: Compatible 64-bit config
params:
MXLEN: 64
mandatory_extensions:
- name: "I"
version: ">= 0"
- name: "Sm"
version: ">= 0"
- name: "M"
version: ">= 0"
YAML

incompat_32_yaml = <<~YAML
$schema: config_schema.json#
kind: architecture configuration
type: partially configured
name: compat_test_incompat_32
description: Incompatible 32-bit config (MXLEN conflict)
params:
MXLEN: 32
mandatory_extensions:
- name: "I"
version: ">= 0"
- name: "Sm"
version: ">= 0"
YAML

also_compat_64_yaml = <<~YAML
$schema: config_schema.json#
kind: architecture configuration
type: partially configured
name: compat_test_also_compat_64
description: Another compatible 64-bit config
params:
MXLEN: 64
mandatory_extensions:
- name: "I"
version: ">= 0"
- name: "Sm"
version: ">= 0"
YAML

Tempfile.create(%w/compat_64 .yaml/) do |compat_64_file|
compat_64_file.write(compat_64_yaml)
compat_64_file.flush

Tempfile.create(%w/incompat_32 .yaml/) do |incompat_32_file|
incompat_32_file.write(incompat_32_yaml)
incompat_32_file.flush

Tempfile.create(%w/also_compat_64 .yaml/) do |also_compat_64_file|
also_compat_64_file.write(also_compat_64_yaml)
also_compat_64_file.flush

# 1. Valid single compatible pointer
Tempfile.create(%w/cfg .yaml/) do |f|
f.write(base_64_yaml.sub("mandatory_extensions:", "compatible: #{compat_64_file.path}\nmandatory_extensions:"))
f.flush
result = @resolver.cfg_arch_for(Pathname.new(f.path)).valid?
assert result.valid, "Expected valid for compatible with matching MXLEN, got: #{result.reasons}"
end

# Name-based pointer: use a known repo config name (no '/' → name lookup branch)
Tempfile.create(%w/cfg .yaml/) do |f|
f.write(base_64_yaml.sub("mandatory_extensions:", "compatible: rv64\nmandatory_extensions:"))
f.flush
result = @resolver.cfg_arch_for(Pathname.new(f.path)).valid?
# rv64 is a known repo config; compatibility check should not raise
assert [true, false].include?(result.valid),
"Expected cfg_arch_for_pointer to resolve name-based pointer 'rv64' without error"
Comment on lines +647 to +649
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion is effectively a no-op: result.valid will always be either true or false, and the test will already fail if pointer resolution raises. Consider asserting an expected outcome (e.g., that the config is compatible with rv64), or assert on the absence/presence of specific compatibility reasons so this case actually verifies name-based pointer resolution behavior.

Suggested change
# rv64 is a known repo config; compatibility check should not raise
assert [true, false].include?(result.valid),
"Expected cfg_arch_for_pointer to resolve name-based pointer 'rv64' without error"
assert result.valid,
"Expected valid when compatible pointer is the known config name 'rv64', got: #{result.reasons}"

Copilot uses AI. Check for mistakes.
end

# Relative-path pointer: reference the compatible config via a relative path
Dir.mktmpdir do |dir|
dir_path = Pathname.new(dir)
rel_compat_path = dir_path / "compat_64.yaml"
rel_compat_path.write(compat_64_yaml)

cfg_path = dir_path / "cfg.yaml"
cfg_path.write(base_64_yaml.sub("mandatory_extensions:", "compatible: ./compat_64.yaml\nmandatory_extensions:"))

result = @resolver.cfg_arch_for(cfg_path).valid?
assert result.valid,
"Expected valid when compatible pointer is a relative path './compat_64.yaml', got: #{result.reasons}"
end

# 2. Invalid single compatible pointer
Tempfile.create(%w/cfg .yaml/) do |f|
f.write(base_64_yaml.sub("mandatory_extensions:", "compatible: #{incompat_32_file.path}\nmandatory_extensions:"))
f.flush
result = @resolver.cfg_arch_for(Pathname.new(f.path)).valid?
refute result.valid, "Expected invalid for compatible with conflicting MXLEN"
assert result.reasons.any? { |r| r.include?("not compatible with") },
"Expected 'not compatible with' in reasons, got: #{result.reasons}"
end

# 3. Valid multiple compatible pointers (non-overlapping)
Tempfile.create(%w/cfg .yaml/) do |f|
f.write(base_64_yaml.sub("mandatory_extensions:", "compatible:\n - #{compat_64_file.path}\n - #{also_compat_64_file.path}\nmandatory_extensions:"))
f.flush
result = @resolver.cfg_arch_for(Pathname.new(f.path)).valid?
assert result.valid, "Expected valid for compatible with multiple matching configs, got: #{result.reasons}"
end

# 4. Invalid multiple compatible pointers (one incompatible)
Tempfile.create(%w/cfg .yaml/) do |f|
f.write(base_64_yaml.sub("mandatory_extensions:", "compatible:\n - #{compat_64_file.path}\n - #{incompat_32_file.path}\nmandatory_extensions:"))
f.flush
result = @resolver.cfg_arch_for(Pathname.new(f.path)).valid?
refute result.valid, "Expected invalid when one of multiple compatible configs conflicts"
assert result.reasons.any? { |r| r.include?("not compatible with") },
"Expected 'not compatible with' in reasons, got: #{result.reasons}"
end
Comment thread
dhower-qc marked this conversation as resolved.

# 5. Valid transitive compatible (base_64 -> transitive_b -> compat_64, all satisfiable)
transitive_compat_b_yaml = <<~YAML
$schema: config_schema.json#
kind: architecture configuration
type: partially configured
name: compat_test_transitive_compat_b
description: Transitive-compatible 64-bit config
compatible: #{compat_64_file.path}
params:
MXLEN: 64
mandatory_extensions:
- name: "I"
version: ">= 0"
- name: "Sm"
version: ">= 0"
- name: "M"
version: ">= 0"
YAML
Tempfile.create(%w/transitive_compat_b .yaml/) do |trans_compat_file|
trans_compat_file.write(transitive_compat_b_yaml)
trans_compat_file.flush

Tempfile.create(%w/cfg .yaml/) do |f|
f.write(base_64_yaml.sub("mandatory_extensions:", "compatible: #{trans_compat_file.path}\nmandatory_extensions:"))
f.flush
result = @resolver.cfg_arch_for(Pathname.new(f.path)).valid?
assert result.valid, "Expected valid for transitive compatible chain where all are satisfiable, got: #{result.reasons}"
end
end

# 6. Invalid transitive compatible (base_64 -> transitive_b -> incompat_32, unsatisfiable transitively)
transitive_incompat_b_yaml = <<~YAML
$schema: config_schema.json#
kind: architecture configuration
type: partially configured
name: compat_test_transitive_incompat_b
description: Config that is directly compatible with base_64 but transitively points to an incompatible config
compatible: #{incompat_32_file.path}
params:
MXLEN: 64
mandatory_extensions:
- name: "I"
version: ">= 0"
- name: "Sm"
version: ">= 0"
- name: "M"
version: ">= 0"
YAML
Tempfile.create(%w/transitive_incompat_b .yaml/) do |trans_incompat_file|
trans_incompat_file.write(transitive_incompat_b_yaml)
trans_incompat_file.flush

Tempfile.create(%w/cfg .yaml/) do |f|
f.write(base_64_yaml.sub("mandatory_extensions:", "compatible: #{trans_incompat_file.path}\nmandatory_extensions:"))
f.flush
result = @resolver.cfg_arch_for(Pathname.new(f.path)).valid?
refute result.valid, "Expected invalid for transitive chain that leads to an incompatible config"
assert result.reasons.any? { |r| r.include?("not compatible with") },
"Expected 'not compatible with' in reasons, got: #{result.reasons}"
end
end

end
end
end
end
end
Loading