- 1. Purpose
- 2. Overview
- 3. System architecture diagram
- 4. Layer 1: Preprocessing
- 5. Layer 2: Algorithm selection
- 6. Layer 3: Match options
- 7. Layer 4: Diff formatting
- 8. Complete example: All 4 layers
- 9. Configuration precedence
- 10. Benefits of 4-layer architecture
- 11. Profile DSL and Dimension System
- 12. See also
1. Purpose
This document explains Canon’s 4-layer comparison architecture and how documents flow through preprocessing, algorithm selection, semantic matching, and diff rendering.
For a guided walkthrough of choosing configurations, see Choosing Configuration.
For detailed 4-layer pipeline documentation, see Comparison Pipeline.
2. Overview
Canon uses a 4-layer architecture that separates concerns for clean, maintainable comparison logic:
-
Layer 1 - Preprocessing: Optional document normalization
-
Layer 2 - Algorithm Selection: Choose comparison strategy (DOM vs Semantic)
-
Layer 3 - Match Options: Content comparison with configurable dimensions (algorithm-specific)
-
Layer 4 - Diff Formatting: Formatted output with visualization (algorithm-specific)
Each layer is independent and configurable, allowing fine-grained control over comparison behavior.
Key Insight: Layers 3 and 4 are algorithm-specific - they behave differently depending on which algorithm (DOM or Semantic) is chosen in Layer 2.
3. System architecture diagram
graph TD
A[Input Documents] --> B[Layer 1: Preprocessing]
B --> C[Layer 2: Algorithm Selection]
C --> D[Layer 3: Match Options]
D --> E[Layer 4: Diff Formatting]
E --> F[Output]
C -->|DOM Algorithm| D1[Positional Matching]
C -->|Semantic Algorithm| D2[Signature Matching]
D1 --> E1[Line-based Diff]
D2 --> E2[Operation-based Diff]
style B fill:#e1f5ff
style C fill:#fff4e1
style D fill:#ffe1f5
style E fill:#e1ffe1
4. Layer 1: Preprocessing
4.1. Purpose
Transform documents into a normalized form before comparison. This eliminates format-specific variations that should not affect semantic equivalence.
4.2. Options
none(default)-
No preprocessing - compare documents as-is
c14n-
Canonical form:
-
XML: W3C Canonical XML 1.1
-
HTML: Normalized HTML structure
-
JSON: Sorted keys, normalized whitespace
-
YAML: Sorted keys, standard format
-
normalize-
Normalize whitespace:
-
Collapse multiple whitespace to single space
-
Trim leading/trailing whitespace
-
Normalize line endings
-
format-
Pretty-print with standard formatting:
-
2-space indentation
-
One element/property per line
-
Consistent structure
-
4.3. Usage
Canon::Comparison.equivalent?(doc1, doc2,
preprocessing: :normalize
)
$ canon diff file1.xml file2.xml --preprocessing normalize
See Preprocessing documentation for details.
5. Layer 2: Algorithm selection
5.1. Purpose
Choose the comparison strategy. This is a critical decision because it determines how Layers 3 and 4 behave.
5.2. Options
dom(default)-
DOM-based positional comparison
-
Fast, stable, well-tested
-
Position-based element matching
-
No move detection
-
Best for similar documents
-
semantic(experimental)-
Tree-based semantic diff
-
Slower but more intelligent
-
Signature-based matching
-
Detects moves, merges, splits
-
Best for restructured documents
-
5.3. Algorithm characteristics
| Feature | DOM | Semantic |
|---|---|---|
Stability |
Stable (production-ready) |
Experimental |
Performance |
Fast (linear) |
Slower (quadratic worst case) |
Move Detection |
No |
Yes |
Match Strategy |
Positional |
Signature-based |
Layer 3 Behavior |
Element-by-element comparison |
Signature calculation |
Layer 4 Behavior |
Line-based differences |
Operation-based (INSERT, DELETE, UPDATE, MOVE) |
Best For |
Similar documents |
Restructured documents |
5.4. Usage
# DOM algorithm (default)
Canon::Comparison.equivalent?(doc1, doc2,
diff_algorithm: :dom
)
# Semantic algorithm
Canon::Comparison.equivalent?(doc1, doc2,
diff_algorithm: :semantic
)
# DOM algorithm
$ canon diff file1.xml file2.xml --diff-algorithm dom
# Semantic algorithm
$ canon diff file1.xml file2.xml --diff-algorithm semantic
See Algorithm documentation for details.
6. Layer 3: Match options
6.1. Purpose
Configure what to compare and how strictly. Match options are format-specific - each format (XML, HTML, JSON, YAML) has its own set of dimensions based on its structure.
6.2. Key architectural principle
Dimensions are format-specific, NOT algorithm-specific.
The comparison architecture works as follows:
| Aspect | Description | Examples |
|---|---|---|
Format |
Determines which dimensions exist |
XML has attributes, JSON has keys |
Dimensions |
WHAT to compare (format-specific) |
|
Profile |
Configures dimension behaviors for a format |
|
Algorithm |
HOW nodes are matched (format-independent) |
DOM: position-based, Semantic: signature-based |
Critical distinction:
-
Format → Dimensions: XML has
attribute_values, JSON haskey_order -
Profile → Behaviors: Configures HOW dimensions are compared (
:strict,:normalize,:ignore) -
Algorithm → Matching Strategy: DOM (position) vs Semantic (signature) - works with ANY format
6.3. Format-specific dimensions
Different formats have different dimensions based on their structure:
XML/HTML dimensions:
text_content-
Text within elements
structural_whitespace-
Whitespace between elements
attribute_presence-
Which attributes exist
attribute_order-
Order of attributes
attribute_values-
Attribute value content
element_position-
Position in tree
comments-
Comment nodes
JSON dimensions:
text_content-
Value text
structural_whitespace-
Whitespace
key_order-
Order of object keys
YAML dimensions:
text_content-
Value text
structural_whitespace-
Whitespace
key_order-
Order of keys
comments-
Comments
6.4. Dimension behaviors
Each dimension supports behaviors:
-
:strict- Must match exactly -
:normalize- Match after normalization -
:ignore- Don’t compare
6.5. Match profiles
Profiles are predefined combinations of dimension settings for common scenarios.
Important: Profiles are format-specific. Each format (Xml, Html, Json, Yaml) has its own set of profiles configured for its dimensions.
See Available Preset Profiles for complete profile reference.
6.6. Available preset profiles
Canon provides preset profiles optimized for different comparison scenarios. Each format has its own set of profiles with appropriate dimension configurations.
6.6.1. XML/HTML profiles
Profile: :strict
Exact matching - all dimensions use :strict behavior (XML default).
| Dimension | Behavior | Description |
|---|---|---|
preprocessing |
|
No preprocessing - compare as-is |
text_content |
|
Must match exactly |
structural_whitespace |
|
Whitespace must match exactly |
attribute_presence |
|
All attributes must be present |
attribute_order |
|
Attribute order must match |
attribute_values |
|
Attribute values must match exactly |
element_position |
|
Element positions must match |
comments |
|
Comments must match exactly |
Use when: You need exact byte-for-byte matching (e.g., validating serialization).
Profile: :rendered
Browser rendering - ignores formatting that doesn’t affect display (HTML default).
| Dimension | Behavior | Description |
|---|---|---|
preprocessing |
|
No preprocessing |
text_content |
|
Normalize text (collapse whitespace) |
structural_whitespace |
|
Normalize whitespace |
attribute_presence |
|
All attributes must be present |
attribute_order |
|
Attribute order must match |
attribute_values |
|
Attribute values must match exactly |
element_position |
|
Element positions must match |
comments |
|
Comments are ignored |
Use when: You care about what the browser displays, not source formatting.
Profile: :html4
HTML4 rendered output - HTML4 normalizes attribute whitespace.
| Dimension | Behavior | Description |
|---|---|---|
preprocessing |
|
Rendered HTML preprocessing |
text_content |
|
Normalize text |
structural_whitespace |
|
Normalize whitespace |
attribute_presence |
|
All attributes must be present |
attribute_order |
|
Attribute order must match |
attribute_values |
|
Normalize attribute values |
element_position |
|
Element position doesn’t matter |
comments |
|
Comments are ignored |
Use when: Testing HTML4 output where attribute whitespace may vary.
Profile: :html5
HTML5 rendered output - same as :rendered.
| Dimension | Behavior | Description |
|---|---|---|
preprocessing |
|
Rendered HTML preprocessing |
text_content |
|
Normalize text |
structural_whitespace |
|
Normalize whitespace |
attribute_presence |
|
All attributes must be present |
attribute_order |
|
Attribute order must match |
attribute_values |
|
Attribute values must match exactly |
element_position |
|
Element position doesn’t matter |
comments |
|
Comments are ignored |
Use when: Testing HTML5 output.
Profile: :spec_friendly
Test-friendly - ignores formatting, focuses on content.
| Dimension | Behavior | Description |
|---|---|---|
preprocessing |
|
Rendered HTML preprocessing |
text_content |
|
Normalize text |
structural_whitespace |
|
Whitespace ignored |
attribute_presence |
|
All attributes must be present |
attribute_order |
|
Attribute order ignored |
attribute_values |
|
Normalize attribute values |
element_position |
|
Element position ignored |
comments |
|
Comments ignored |
Use when: Writing tests where formatting changes are acceptable.
Profile: :content_only
Maximum tolerance - only semantic content matters.
| Dimension | Behavior | Description |
|---|---|---|
preprocessing |
|
Canonical XML preprocessing |
text_content |
|
Normalize text |
structural_whitespace |
|
Whitespace ignored |
attribute_presence |
|
All attributes must be present |
attribute_order |
|
Attribute order ignored |
attribute_values |
|
Normalize attribute values |
element_position |
|
Element position ignored |
comments |
|
Comments ignored |
Use when: You only care about semantic content, not structure or formatting.
6.6.2. JSON profiles
JSON has 3 preset profiles: :strict, :spec_friendly, and :content_only.
| Dimension | :strict |
:spec_friendly |
:content_only |
|---|---|---|---|
preprocessing |
|
|
|
text_content |
|
|
|
structural_whitespace |
|
|
|
key_order |
|
|
|
Use cases:
-
:strict- Exact JSON matching (order-sensitive) -
:spec_friendly- Order-independent JSON comparison -
:content_only- Normalized values, order and formatting ignored
6.6.3. YAML profiles
YAML has 3 preset profiles: :strict, :spec_friendly, and :content_only.
| Dimension | :strict |
:spec_friendly |
:content_only |
|---|---|---|---|
preprocessing |
|
|
|
text_content |
|
|
|
structural_whitespace |
|
|
|
key_order |
|
|
|
comments |
|
|
|
Use cases:
-
:strict- Exact YAML matching (order and comments matter) -
:spec_friendly- Order-independent, comments ignored -
:content_only- Maximum tolerance, only values matter
6.7. Customizing profiles
Canon provides two ways to customize comparison behavior: inline custom profiles and named custom profiles.
6.7.1. Inline custom profiles
For one-off comparisons, pass a Hash directly to the profile parameter:
Canon::Comparison.equivalent?(html1, html2,
profile: {
text_content: :normalize,
structural_whitespace: :ignore,
comments: :ignore
}
)
Validation: Inline profiles are validated at comparison time. Invalid dimensions or behaviors will raise a Canon::Error.
# This raises Canon::Error
Canon::Comparison.equivalent?(html1, html2,
profile: {
unknown_dimension: :strict # => Error: Unknown dimension: unknown_dimension
}
)
6.7.2. Named custom profiles (Profile DSL)
For reusable custom profiles, define them using the Profile DSL:
# Define a custom profile
Canon::Comparison.define_profile(:content_focused) do
text_content :normalize
comments :ignore
structural_whitespace :ignore
attribute_values :normalize
preprocessing :rendered
end
# Use the custom profile
Canon::Comparison.equivalent?(html1, html2, profile: :content_focused)
# List all available profiles (includes custom profiles)
Canon::Comparison.available_profiles
# => [:strict, :rendered, :html4, :html5, :spec_friendly, :content_only, :content_focused]
Available DSL methods:
-
text_content- Text within elements -
structural_whitespace- Whitespace between elements -
attribute_presence- Which attributes exist -
attribute_order- Order of attributes -
attribute_values- Attribute value content -
element_position- Position of elements -
comments- Comment content and placement -
preprocessing- Preprocessing option (:none,:c14n,:normalize,:format,:rendered)
Behaviors for each dimension:
-
:strict- Must match exactly -
:normalize- Match after normalization -
:ignore- Don’t compare -
:strip- (attribute_values only) Strip leading/trailing whitespace -
:compact- (attribute_values only) Collapse internal whitespace
Validation at definition time:
The Profile DSL validates immediately when you define the profile:
# This raises Canon::Error at definition time
Canon::Comparison.define_profile(:invalid) do
unknown_dimension :strict # => Error: Unknown dimension: unknown_dimension
text_content :invalid_behavior # => Error: Invalid behavior 'invalid_behavior'
end
This prevents invalid profiles from ever being used in comparisons.
Removing custom profiles:
# Remove a custom profile
Canon::Comparison.remove_profile(:content_focused)
Profile best practices:
-
Use preset profiles when possible - they’re well-tested and documented
-
Name custom profiles descriptively (e.g.,
:content_focused,:seo_test) -
Define profiles at application startup, not during request handling
-
Document why a custom profile is needed in comments
6.8. Algorithm interaction with match options
Both algorithms (DOM and Semantic) work with ALL formats. The algorithm determines HOW nodes are matched, not WHAT is compared:
-
DOM algorithm: Position-based matching (element at position 0 matches element at position 0)
-
Semantic algorithm: Signature-based matching (nodes with similar signatures match)
Once nodes are matched, both algorithms use the same dimension comparisons configured by the profile.
6.9. Usage
profile parameter# Using a preset profile
Canon::Comparison.equivalent?(doc1, doc2,
profile: :spec_friendly
)
# Using an inline custom profile
Canon::Comparison.equivalent?(doc1, doc2,
profile: {
text_content: :normalize,
structural_whitespace: :ignore,
comments: :ignore
}
)
# Defining and using a custom profile
Canon::Comparison.define_profile(:my_custom) do
text_content :normalize
comments :ignore
preprocessing :rendered
end
Canon::Comparison.equivalent?(doc1, doc2,
profile: :my_custom
)
Canon::Comparison.equivalent?(doc1, doc2,
match: {
text_content: :normalize,
structural_whitespace: :ignore,
comments: :ignore
}
)
See Match Options for complete reference.
7. Layer 4: Diff formatting
7.1. Purpose
Control how differences are displayed. This layer is algorithm-specific - each algorithm generates different output types.
7.2. Diff modes
by_line-
Traditional line-by-line diff
-
Natural fit for DOM algorithm
-
Shows positional changes
-
Traditional diff format
-
by_object-
Tree-based semantic diff
-
Natural fit for Semantic algorithm
-
Shows structural operations
-
Visual tree representation
-
7.3. Algorithm-specific output
Critical: Each algorithm produces fundamentally different output!
-
DOM algorithm: Generates line-based differences
-
Semantic algorithm: Generates operation-based differences (INSERT, DELETE, UPDATE, MOVE)
See Algorithm-Specific Output for detailed comparison.
7.4. Diff options
use_color-
Enable/disable ANSI color codes (default:
true) context_lines-
Number of unchanged lines around changes (default:
3) diff_grouping_lines-
Group changes within N lines (default:
nil)
See Diff Formatting for details.
8. Complete example: All 4 layers
Here’s a full configuration showing all 4 layers working together:
result = Canon::Comparison.equivalent?(doc1, doc2,
# Layer 1: Preprocessing
preprocessing: :normalize,
# Layer 2: Algorithm
diff_algorithm: :semantic,
# Layer 3: Match Options (new unified profile API)
profile: :spec_friendly,
# Layer 4: Diff Formatting
verbose: true,
diff_mode: :by_object,
use_color: true,
context_lines: 3
)
See Comparison Pipeline for layer-by-layer examples.
8.1. DiffNode: Representation of differences
8.1.1. Purpose
DiffNode objects represent individual differences between documents. Each DiffNode carries complete information about what changed, where it changed, and how to display it.
8.1.2. DiffNode structure
class DiffNode
# Core properties
attr_reader :node1, :node2 # Raw node references
attr_accessor :dimension, :reason # What changed and why
attr_accessor :normative, :formatting # Classification
# Location and display information
attr_accessor :path # Canonical path with ordinal indices
attr_accessor :serialized_before # Serialized "before" content
attr_accessor :serialized_after # Serialized "after" content
attr_accessor :attributes_before # Normalized "before" attributes
attr_accessor :attributes_after # Normalized "after" attributes
# Character-level enrichment (populated by DiffNodeEnricher)
attr_accessor :char_ranges # Array<DiffCharRange> char positions
attr_accessor :line_range_before # [start_line, end_line] in text1
attr_accessor :line_range_after # [start_line, end_line] in text2
end
Properties explained
Core properties:
node1, node2-
Raw node references from the original documents
dimension-
What type of difference (
:text_content,:attribute_values,:element_structure, etc.) reason-
Human-readable explanation of the difference
normative-
Whether this difference affects semantic equivalence (true) or is just formatting (false)
formatting-
Whether this is a purely cosmetic whitespace difference
Location and display properties:
path-
Canonical XPath-like path with ordinal indices that uniquely identifies the node location (e.g.,
/#document/div[0]/body[0]/p[1]/span[2]) serialized_before-
Serialized content of the "before" state captured at comparison time
serialized_after-
Serialized content of the "after" state captured at comparison time
attributes_before-
Normalized attribute hash from the "before" state
attributes_after-
Normalized attribute hash from the "after" state
8.1.3. Using DiffNode in verbose output
When you enable verbose mode, Canon returns a ComparisonResult containing DiffNode objects:
result = Canon::Comparison.equivalent?(doc1, doc2, verbose: true)
# Access individual differences
result.differences.each do |diff|
puts "Location: #{diff.path}"
puts "Dimension: #{diff.dimension}"
puts "Reason: #{diff.reason}"
puts "Normative: #{diff.normative?}"
end
8.1.4. Canonical paths with ordinal indices
DiffNode paths use ordinal indices to uniquely identify nodes. Instead of ambiguous paths like:
/#document-fragment/div/p/span/span
Canon generates precise paths like:
/#document-fragment/div[0]/p[1]/span[2]/span[0]
This tells you exactly which element changed:
* div[0] - First div element
* p[1] - Second paragraph element
* span[2] - Third span element
* span[0] - First nested span element
8.1.5. Enriched metadata in diff output
Layer 4 (diff formatting) uses enriched metadata to display accurate before/after content:
🔍 DIFFERENCE #1/3 [NORMATIVE]
════════════════════════════════════════════════════════════════════════
Dimension: element_structure
Location: /#document/div[0]/body[0]/p[1]/span[2]
⊖ Expected (File 1):
(not present)
⊕ Actual (File 2):
<span id="new-element">Added content</span>
✨ Changes:
Element inserted
The Location field shows the enriched path, and the before/after content uses serialized_before and serialized_after to ensure accurate display.
See Internals for implementation details on PathBuilder, NodeSerializer, and how metadata flows through the comparison layers.
9. Configuration precedence
When options are specified in multiple places, Canon resolves them using this hierarchy (highest to lowest priority):
1. Per-comparison explicit options (highest)
↓
2. Per-comparison profile
↓
3. Global configuration explicit options
↓
4. Global configuration profile
↓
5. Format defaults (lowest)
Global configuration:
Canon::RSpecMatchers.configure do |config|
config.xml.match.profile = :spec_friendly
config.xml.match.options = { comments: :strict }
end
Per-test usage:
expect(actual).to be_xml_equivalent_to(expected)
.with_profile(:rendered)
.with_options(structural_whitespace: :ignore)
Final resolved options:
-
text_content: :normalize(from:renderedper-test profile) -
structural_whitespace: :ignore(from per-test explicit option) -
comments: :strict(from global explicit option) -
Other dimensions use
:renderedprofile or format defaults
10. Benefits of 4-layer architecture
- Separation of concerns
-
Each layer has a single responsibility
- Composability
-
Mix and match preprocessing, algorithm, matching, and rendering options
- Algorithm flexibility
-
Choose between speed (DOM) and intelligence (Semantic)
- Testability
-
Each layer can be tested independently
- Flexibility
-
Fine-grained control over comparison behavior
- Clarity
-
Clear data flow from input to output
- Extensibility
-
Easy to add new preprocessing, algorithms, dimensions, or rendering modes
11. Profile DSL and Dimension System
11.1. Overview
Canon 2.0 introduces a Profile DSL and Dimension system for cleaner, more maintainable comparison configuration:
-
Profile DSL - Define custom comparison profiles with validation
-
Dimension Classes - Object-oriented dimension handling with reusable behaviors
11.2. Profile DSL
The Profile DSL provides a clean, validated way to define custom comparison profiles:
# Define a custom profile
Canon::Comparison.define_profile(:content_focused) do
text_content :normalize
comments :ignore
structural_whitespace :ignore
attribute_values :normalize
preprocessing :rendered
end
# Use the custom profile
Canon::Comparison.equivalent?(html1, html2, profile: :content_focused)
# List all available profiles
Canon::Comparison.available_profiles
# => [:strict, :rendered, :html4, :html5, :spec_friendly, :content_only, :content_focused]
Available dimensions:
-
text_content- Text within elements/values -
structural_whitespace- Whitespace between elements -
attribute_presence- Which attributes exist -
attribute_order- Order of attributes -
attribute_values- Attribute value content -
element_position- Position of elements -
comments- Comment content and placement
Behaviors for each dimension:
-
:strict- Must match exactly -
:normalize- Match after normalization -
:ignore- Don’t compare
Validation:
The Profile DSL validates at definition time:
# This raises an error at definition time
Canon::Comparison.define_profile(:invalid) do
unknown_dimension :strict # => Error: Unknown dimension: unknown_dimension
text_content :invalid_behavior # => Error: Invalid behavior 'invalid_behavior'
end
11.3. Dimension Classes
Behind the scenes, Canon uses dimension classes that encapsulate comparison logic:
# Each dimension knows how to extract and compare data
dimension = Canon::Comparison::Dimensions::Registry.get(:text_content)
# Extract data from a node
text = dimension.extract_data(node)
# Compare according to behavior
dimension.equivalent?(node1, node2, :normalize)
Available dimension classes:
-
TextContentDimension- Text content comparison -
CommentsDimension- Comment comparison -
AttributeValuesDimension- Attribute values comparison -
AttributePresenceDimension- Attribute presence comparison -
AttributeOrderDimension- Attribute order comparison -
ElementPositionDimension- Element position comparison -
StructuralWhitespaceDimension- Structural whitespace comparison
11.4. Refactored Module Structure
Canon’s internal modules have been reorganized for better separation of concerns:
-
XmlComparatorHelpers - Node parsing, attribute comparison, namespace comparison
-
DiffDetailFormatterHelpers - Location extraction, node utilities, text utilities, dimension formatting
-
Dimensions - Reusable dimension classes for comparison
This refactoring improves: - Maintainability - Each module has a single responsibility - Testability - Modules can be tested independently - Extensibility - New dimensions/formatters can be added easily - Code organization - Related functionality is grouped together
12. See also
-
Comparison Pipeline - Complete 4-layer walkthrough
-
Algorithms - DOM and Semantic algorithm details
-
Internals - Implementation details and data structures
-
DiffNode Enrichment - How metadata flows from Layer 2 to Layer 4