1. Purpose
Canon distinguishes between two types of differences when comparing documents:
-
Normative diffs: Differences that affect equivalence based on your match options
-
Informative diffs: Differences that don’t affect equivalence (semantically equivalent per match options)
This allows you to focus on differences that matter while optionally viewing formatting-only changes.
2. Classification System
2.1. How Classification Works
Each difference is classified based on the match dimension’s behavior:
| Match Behavior | Classification | Meaning |
|---|---|---|
|
Normative |
Must match exactly - any difference fails equivalence |
|
Normative |
Must match after normalization - remaining differences fail equivalence |
|
Informative |
Difference exists but doesn’t affect equivalence |
Given:
<div class="TOC" id="_"> <!-- Document 1 -->
<div id="_" class="TOC"> <!-- Document 2 -->
Classification depends on attribute_order setting:
-
attribute_order: :strict→ NORMATIVE (order matters)-
Shown in red/green
-
Causes equivalence to fail
-
-
attribute_order: :ignore→ INFORMATIVE (order doesn’t matter)-
Shown in cyan with
~~markers -
Documents are still equivalent
-
3. Architecture
┌──────────────────────────────────────────────────────────────────┐
│ COMPARISON LAYER │
│ │
│ Tree Comparator creates DiffNodes for each difference │
│ ├─ References to nodes in both trees │
│ ├─ Dimension (:text_content, :attribute_order, etc.) │
│ ├─ Reason code (UNEQUAL_TEXT_CONTENTS, etc.) │
│ └─ Normative flag (nil, to be classified) │
└───────────────────────────────────┬───────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────────┐
│ CLASSIFICATION LAYER │
│ │
│ DiffClassifier examines each DiffNode: │
│ │
│ 1. Serialization-level formatting (XmlSerializationFormatter) │
│ → XML syntax differences: <tag/> vs <tag></tag> │
│ → ALWAYS formatting-only (non-normative) │
│ │
│ 2. Content-level formatting (text_content: :normalize) │
│ → Whitespace differences in content │
│ → Formatting-only when normalized content matches │
│ │
│ 3. CompareProfile policy (normative vs informative) │
│ → behavior == :ignore → INFORMATIVE │
│ → behavior == :strict → NORMATIVE │
│ → behavior == :normalize → Check content normalization │
│ │
│ Sets diff_node.formatting = true/false │
│ Sets diff_node.normative = true/false │
└───────────────────────────────────┬───────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────────┐
│ RENDERING LAYER │
│ │
│ Normative diffs: Informative diffs: │
│ 18| - | <div ...> 18| ~~ | <div ...> │
│ 18| + | <div ...> 18| ~~ | <div ...> │
│ ▲ ▲ │
│ Red/Green markers Cyan markers │
└──────────────────────────────────────────────────────────────────┘
3.1. Three-Level Classification System
Canon distinguishes between three distinct kinds of differences:
| Kind | formatting: | normative: | Meaning | Examples |
|------|---------------|--------------|---------|----------|
| Serialization formatting | true | false | XML syntax differences | <tag/> vs <tag></tag> |
| Content formatting | true | false | Whitespace in content | Hello world vs Hello world |
| Informative | false | false | Tracked but doesn’t affect equivalence | Attribute order (when :ignore) |
| Normative | false | true | Affects equivalence | Different words, missing elements |
Key distinction:
-
Serialization-level formatting: XML syntax differences that are ALWAYS non-normative regardless of match options, because they represent different valid serializations of the same semantic content. Detected by
XmlSerializationFormatter. -
Content-level formatting: Whitespace differences in document content. These are formatting-only (non-normative) when normalized content matches (using
text_content: :normalize). -
Informative: Differences tracked for reference but don’t affect equivalence (when behavior is
:ignore). -
Normative: Semantic content differences that affect equivalence (when behavior is
:strictor when normalized content differs).
4. CompareProfile-Based Classification
4.1. Overview
Canon’s classification system uses CompareProfile to determine which differences
are normative (affect equivalence) versus informative (do not affect equivalence).
The classification flow:
DiffNode → DiffClassifier → CompareProfile → normative?
↓
dimension + behavior → decision
4.2. Classification Hierarchy
Canon uses a multi-level hierarchy for classifying differences:
DiffNode → DiffClassifier → XmlSerializationFormatter → serialization formatting?
↓
CompareProfile → normative dimension?
↓
FormattingDetector → formatting-only?
↓
Final classification
Classification priority (from highest to lowest specificity):
-
Serialization-level formatting (highest priority)
-
XML syntax differences:
<tag/>vs<tag></tag> -
Detected by
XmlSerializationFormatter -
ALWAYS
formatting: true, normative: false -
Bypasses all other classification logic
-
-
Content-level formatting
-
Whitespace differences in document content
-
Detected by
FormattingDetectorwhentext_content: :normalize -
formatting: true, normative: falsewhen normalized content matches -
Respects element-level whitespace sensitivity
-
-
Informative (based on
:ignorebehavior)-
Tracked but doesn’t affect equivalence
-
formatting: false, normative: false -
Example: Attribute order when
attribute_order: :ignore
-
-
Normative (based on
:strictbehavior or content mismatch)-
Affects equivalence
-
formatting: false, normative: true -
Example: Different words, missing elements
-
4.3. Format-Specific Policies
Each format can define its own policies through CompareProfile subclasses:
4.3.1. XML (Base CompareProfile)
Default policies:
- Comments: normative (:strict)
- Whitespace: normative (:strict)
- All dimensions: strict by default
4.4. Dimension Policy Examples
4.4.1. Structural Whitespace
-
:strictbehavior → Normative-
Whitespace must match exactly
-
Any whitespace difference causes non-equivalence
-
-
:normalizebehavior → Informative (if formatting-only)-
Whitespace differences detected as formatting-only when normalized content matches
-
Uses FormattingDetector to determine if difference is purely whitespace
-
-
:ignorebehavior → Informative-
Whitespace differences tracked but don’t affect equivalence
-
xml1 = '<root><p>Hello world</p></root>'
xml2 = '<root><p>Hello\nworld</p></root>'
# Normalize mode: formatting-only
result = Canon::Comparison.equivalent?(
xml1, xml2,
match: { text_content: :normalize, structural_whitespace: :normalize }
)
# => true (line break is formatting-only)
# Strict mode: normative
result = Canon::Comparison.equivalent?(xml1, xml2)
# => false (line break is normative in strict mode)
4.4.2. Comments
-
:strictbehavior → Normative-
Comments must match exactly
-
Comment differences cause non-equivalence
-
-
:ignorebehavior → Informative-
Comment differences tracked but don’t affect equivalence
-
Default for HTML (comments are presentational)
-
4.4.3. Element Structure
element_structure is a derived dimension — produced by comparators when children
differ structurally (insertions, deletions, name changes). It is not a user-configurable
match dimension but follows the same normative rule as all general dimensions:
-
Default (no explicit behavior) → Normative
-
Structural differences (added/removed/renamed elements) affect equivalence
-
-
:ignorebehavior → Informative-
Structural differences tracked but don’t affect equivalence
-
Useful for content-only comparisons where wrapper elements don’t matter
-
4.4.4. Whitespace Adjacency
:whitespace_adjacency is a derived dimension — emitted when the
alignment walk pairs a whitespace-only text node on one side against a
content node on the other. The Reason line describes the whitespace’s
direction relative to the partner content: before, after, or
adjacent to.
-
Always normative — differences always affect equivalence
-
Not user-configurable — dimension is always tracked when the re-alignment walk encounters an asymmetric whitespace node
-
Report-only — does not change equivalence outcomes compared to pre-#137 behaviour; only changes the diff-report shape (see Whitespace adjacency for details)
xml1 = '<root><!-- v1 --><data>text</data></root>'
xml2 = '<root><!-- v2 --><data>text</data></root>'
# Ignore mode: informative
result = Canon::Comparison.equivalent?(
xml1, xml2,
match: { comments: :ignore }
)
# => true (comment difference is informative)
# Strict mode: normative
result = Canon::Comparison.equivalent?(
xml1, xml2,
match: { comments: :strict }
)
# => false (comment difference is normative)
4.4.5. Text Content
-
:strictbehavior → Normative-
Text must match exactly, including all whitespace
-
Any text difference causes non-equivalence
-
-
:normalizebehavior → Normative (after normalization) or Informative (if formatting-only)-
Whitespace is normalized (collapsed/trimmed) before comparison
-
If normalized texts match but originals differ, classified as formatting-only (informative)
-
This ensures that whitespace-only differences don’t affect equivalence
-
Element-level sensitivity is respected (e.g.,
<pre>,<code>preserve whitespace)
-
-
:ignorebehavior → Informative-
Text content differences tracked but don’t affect equivalence
-
# Formatting-only difference - normalized texts match
xml1 = '<p>Hello world</p>'
xml2 = '<p>Hello world</p>'
result = Canon::Comparison.equivalent?(
xml1, xml2,
match: { text_content: :normalize }
)
# => true (extra space is formatting-only, classified as informative)
# Shows as informative in verbose output
result.differences.first.normative?
# => false
result.differences.first.formatting?
# => true
# HTML defaults: <code> is whitespace-sensitive
html1 = '<code> indented </code><p> text </p>'
html2 = '<code>indented</code><p>text</p>'
# With <code> blacklisted from sensitive elements
Canon::Comparison.equivalent?(html1, html2,
format: :html,
match: {
strip_whitespace_elements: [:code],
}
)
# => true
# - <code> whitespace: formatting-only (informative)
# - <p> whitespace: formatting-only (informative)
# Without blacklisting (default HTML behavior)
Canon::Comparison.equivalent?(html1, html2, format: :html)
# => false
# - <code> whitespace: normative (sensitive element)
# - <p> whitespace: formatting-only (informative)
Per XML standards, <tag/> and <tag></tag> are semantically equivalent (both represent empty elements). Canon classifies differences in serialisation format as formatting-only (non-normative):
# Self-closing vs explicit closing - always equivalent
xml1 = '<svg><rect x="10" y="10"/></svg>'
xml2 = '<svg><rect x="10" y="10"></rect></svg>'
Canon::Comparison.equivalent?(xml1, xml2, format: :xml)
# => true
# Empty/whitespace-only text nodes from serialisation are formatting-only
result = Canon::Comparison.equivalent?(xml1, xml2, format: :xml, verbose: true)
result.differences.each do |diff|
if diff.dimension == :text_content
puts "Normative: #{diff.normative?}" # => false
puts "Formatting: #{diff.formatting?}" # => true
end
end
This applies regardless of text_content behavior setting, as these differences are purely serialisation format variations (similar to attribute order).
The key insight: empty or whitespace-only text nodes created by different serialisation styles (<tag/> vs <tag></tag>) are always classified as formatting-only, not normative.
=== FormattingDetector Integration
For dimensions that support it (:text_content, :structural_whitespace),
FormattingDetector is consulted to determine if a difference is formatting-only:
-
Check normative status based on CompareProfile policy
-
If non-normative, check if formatting-only using FormattingDetector
-
If formatting-only, mark with
formatting: true
<!-- Extra spaces around tag delimiter -->
<div>content</div> <!-- xml1 -->
<div >content</div> <!-- xml2 - space before > -->
With :normalize mode:
- Normalized: <div>content</div> (both)
- Classification: Formatting-only
- Markers: [ and ]
- Equivalent: true
=== Implementation Details
The classification system uses three main classes:
-
XmlSerializationFormatter- Detects XML serialization-level formatting differences-
Self-closing vs explicit closing tags:
<tag/>vs<tag></tag> -
Always returns
formatting: true, normative: false -
These differences are ALWAYS non-normative regardless of match options
-
-
CompareProfile- Determines dimension behavior and policy-
normative_dimension?(dimension)- Is this dimension normative? -
affects_equivalence?(dimension)- Does this dimension affect equivalence? -
supports_formatting_detection?(dimension)- Can this dimension have formatting-only diffs? -
Normative rules:
structural_whitespacerequires:strictfor normative status; all other dimensions are normative unless behavior is:ignore
-
-
DiffClassifier- Orchestrates classification using the above-
First checks
XmlSerializationFormatterfor serialization formatting -
Then handles content-level formatting (text_content: :normalize)
-
Finally applies
CompareProfilepolicy for normative vs informative
-
def classify(diff_node)
# FIRST: Check for XML serialization-level formatting differences
# These are ALWAYS non-normative (formatting-only) regardless of match options
if XmlSerializationFormatter.serialization_formatting?(diff_node)
diff_node.formatting = true
diff_node.normative = false
return diff_node
end
# SECOND: Handle content-level formatting for text_content with :normalize
# Skipped for :preserve elements (<pre>, <code>, etc.) where whitespace is always normative
if diff_node.dimension == :text_content &&
profile.send(:behavior_for, :text_content) == :normalize &&
!inside_preserve_element?(diff_node) &&
formatting_only_diff?(diff_node)
diff_node.formatting = true
diff_node.normative = false
return diff_node
end
# THIRD: Apply CompareProfile policy
is_normative = profile.normative_dimension?(diff_node.dimension)
# FOURTH: Check FormattingDetector for non-normative dimensions
if !is_normative && profile.supports_formatting_detection?(diff_node.dimension)
if formatting_only_diff?(diff_node)
diff_node.formatting = true
diff_node.normative = false
return diff_node
end
end
# FIFTH: Apply normative determination
diff_node.normative = is_normative
diff_node
end
The key distinction for text_content: :normalize:
-
Formatting-only detection: Uses
normalized_equivalent?method to compare normalized texts -
Element sensitivity: Respects element-level whitespace sensitivity (
<pre>,<code>, etc.) -
Result: Whitespace-only differences are classified as informative (non-normative) when using
:normalize
== Visual Indicators
=== Normative Diffs
Colors: Red (deletions) and Green (additions)
Symbols:
* - for deletions
* + for additions
* ~ for changes
10| - | <title>Old Title</title>
| 11+ | <title>New Title</title>
Red and green indicate this difference causes files to be non-equivalent.
=== Informative Diffs
Color: Cyan
Symbol: ~~ (double tilde)
10| ~~ | <div class="TOC" id="_">
| ~~ | <div id="_" class="TOC">
Cyan color indicates this difference is informational only - files are still equivalent.
== Controlling Diff Display
Use the show_diffs option to filter which types of diffs are shown:
| Option | What’s Shown |
|---|---|
|
Both normative and informative diffs |
|
Only normative diffs (differences that matter) |
|
Only informative diffs (for reference) |
=== CLI
# Show only normative diffs
canon diff file1.xml file2.xml --show-diffs normative
# Show only informative diffs
canon diff file1.xml file2.xml --show-diffs informative
# Show all diffs (default)
canon diff file1.xml file2.xml --show-diffs all
=== Ruby API
# Show only normative diffs
Canon.compare(file1, file2,
format: :xml,
show_diffs: :normative
)
# Show all diffs
Canon.compare(file1, file2,
format: :xml,
show_diffs: :all
)
=== RSpec
# Configure globally
RSpec.configure do |config|
config.canon.xml.diff.show_diffs = :normative
end
# Or per-test
expect(actual).to match_xml(expected).with_options(
show_diffs: :normative
)
== Common Scenarios
=== Whitespace Differences
5| ~~ | <p>Hello world</p> # Extra space (informative)
| ~~ | <p>Hello world</p>
The difference is shown in cyan because extra whitespace is ignored.
5| - | <p>Hello world</p> # Extra space (normative)
| 6+ | <p>Hello world</p>
The difference is shown in red/green because whitespace must match exactly.
=== Attribute Order
10| ~~ | <div class="x" id="y"> # Informative
| ~~ | <div id="y" class="x">
10| - | <div class="x" id="y"> # Normative
| 11+ | <div id="y" class="x">
=== Comments
3| ~~ | <!-- Old comment --> # Informative
| ~~ | <!-- New comment -->
3| - | <!-- Old comment --> # Normative
| 4+ | <!-- New comment -->
== Implementation
=== DiffClassifier
The [DiffClassifier](../../lib/canon/diff/diff_classifier.rb) class handles classification:
class DiffClassifier
def initialize(match_options)
@match_options = match_options
end
def classify(diff_node)
behavior = @match_options.behavior_for(diff_node.dimension)
diff_node.normative = (behavior != :ignore)
diff_node
end
end
=== Match Options Integration
Classification uses the resolved match options:
# Match options define behavior for each dimension
match_options = ResolvedMatchOptions.new(
text_content: :normalize, # Normative after normalization
structural_whitespace: :ignore, # Informative
attribute_order: :ignore, # Informative
comments: :strict # Normative
)
# Classifier uses these to determine normative/informative
classifier = DiffClassifier.new(match_options)
== Use Cases
=== Focus on Real Differences
Use show_diffs: :normative to hide formatting changes:
canon diff spec1.xml spec2.xml \
--match-profile spec_friendly \
--show-diffs normative
This shows only semantic differences, hiding attribute order, whitespace, and comments.
=== Review All Changes
Use show_diffs: :all during code review to see everything:
canon diff old.xml new.xml --show-diffs all
This shows both semantic and formatting changes for complete visibility.
=== Understand Match Configuration
Use show_diffs: :informative to see what’s being ignored:
canon diff file1.xml file2.xml \
--match-profile strict \
--show-diffs informative
This helps verify your match configuration is working as expected.
== See Also
-
Match Options - Configure match behavior
-
Colors and Symbols - Visual indicators
-
Semantic Diff Report - Detailed difference analysis
-
Choosing Configuration - Match profile selection