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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ result, err := matcher.Find(ctx, "log in button", elements, semantic.FindOptions
// result.BestScore = 0.82
```

Structured locators are also supported when descriptors include the corresponding fields:

```go
result, err := matcher.Find(ctx, "role:button Sign In", elements, semantic.FindOptions{})
result, err = matcher.Find(ctx, "placeholder:Search", elements, semantic.FindOptions{})
result, err = matcher.Find(ctx, "nth:1:role:button", elements, semantic.FindOptions{})
```

`nth:<n>` is 1-based: `nth:1` selects the first ordered candidate, `nth:2` selects the second, and `nth:0` is not the first match.

Use `find:<query>` or `semantic:<query>` to force natural-language matching for locator-like text.

## Package Layout

```
Expand Down
22 changes: 22 additions & 0 deletions cmd/semantic/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,18 @@ type snapshotElement struct {
Role string `json:"role"`
Name string `json:"name"`
Value string `json:"value"`
Label string `json:"label"`
Placeholder string `json:"placeholder"`
Alt string `json:"alt"`
Title string `json:"title"`
TestID string `json:"testid"`
TestIDAlt string `json:"test_id"`
Text string `json:"text"`
Tag string `json:"tag"`
Interactive bool `json:"interactive"`
Parent string `json:"parent"`
Section string `json:"section"`
DocumentIdx int `json:"document_idx"`
Depth int `json:"depth"`
SiblingIdx int `json:"sibling_index"`
SiblingCnt int `json:"sibling_count"`
Expand Down Expand Up @@ -122,6 +131,11 @@ func loadSnapshot(path string) ([]semantic.ElementDescriptor, error) {

descs := make([]semantic.ElementDescriptor, len(elements))
for i, e := range elements {
testID := e.TestID
if testID == "" {
testID = e.TestIDAlt
}

labelledBy := e.LabelledBy
depth := e.Depth
siblingIdx := e.SiblingIdx
Expand Down Expand Up @@ -173,9 +187,17 @@ func loadSnapshot(path string) ([]semantic.ElementDescriptor, error) {
Role: e.Role,
Name: e.Name,
Value: e.Value,
Label: e.Label,
Placeholder: e.Placeholder,
Alt: e.Alt,
Title: e.Title,
TestID: testID,
Text: e.Text,
Tag: e.Tag,
Interactive: e.Interactive,
Parent: e.Parent,
Section: e.Section,
DocumentIdx: e.DocumentIdx,
Positional: semantic.PositionalHints{
Depth: depth,
SiblingIndex: siblingIdx,
Expand Down
31 changes: 29 additions & 2 deletions cmd/semantic/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ func TestLoadSnapshot_PropagatesInteractiveFlag(t *testing.T) {
}

json := `[
{"ref":"e1","role":"button","name":"Submit","interactive":true,"parent":"Login form","section":"Authentication","depth":3,"sibling_index":1,"sibling_count":2,"labelled_by":"Primary Action","x":20,"y":40,"width":120,"height":30},
{"ref":"e2","role":"text","name":"Submit","interactive":false,"parent":"Payment form","section":"Checkout","positional":{"depth":2,"sibling_index":0,"sibling_count":1,"labelled_by":"Secondary Action","left":300,"top":640,"width":200,"height":44}}
{"ref":"e1","role":"button","name":"Submit","label":"Primary Action","placeholder":"Search","alt":"Submit icon","title":"Submit form","testid":"submit-button","text":"Submit","tag":"button","document_idx":7,"interactive":true,"parent":"Login form","section":"Authentication","depth":3,"sibling_index":1,"sibling_count":2,"labelled_by":"Primary Action","x":20,"y":40,"width":120,"height":30},
{"ref":"e2","role":"text","name":"Submit","test_id":"legacy-submit","interactive":false,"parent":"Payment form","section":"Checkout","positional":{"depth":2,"sibling_index":0,"sibling_count":1,"labelled_by":"Secondary Action","left":300,"top":640,"width":200,"height":44}}
]`
if _, err := f.WriteString(json); err != nil {
t.Fatalf("WriteString failed: %v", err)
Expand All @@ -38,6 +38,30 @@ func TestLoadSnapshot_PropagatesInteractiveFlag(t *testing.T) {
if descs[0].Section != "Authentication" {
t.Fatalf("expected first descriptor section=Authentication, got %q", descs[0].Section)
}
if descs[0].Label != "Primary Action" {
t.Fatalf("expected first descriptor label=Primary Action, got %q", descs[0].Label)
}
if descs[0].Placeholder != "Search" {
t.Fatalf("expected first descriptor placeholder=Search, got %q", descs[0].Placeholder)
}
if descs[0].Alt != "Submit icon" {
t.Fatalf("expected first descriptor alt=Submit icon, got %q", descs[0].Alt)
}
if descs[0].Title != "Submit form" {
t.Fatalf("expected first descriptor title=Submit form, got %q", descs[0].Title)
}
if descs[0].TestID != "submit-button" {
t.Fatalf("expected first descriptor testid=submit-button, got %q", descs[0].TestID)
}
if descs[0].Text != "Submit" {
t.Fatalf("expected first descriptor text=Submit, got %q", descs[0].Text)
}
if descs[0].Tag != "button" {
t.Fatalf("expected first descriptor tag=button, got %q", descs[0].Tag)
}
if descs[0].DocumentIdx != 7 {
t.Fatalf("expected first descriptor document_idx=7, got %d", descs[0].DocumentIdx)
}
if descs[0].Positional.Depth != 3 {
t.Fatalf("expected first descriptor depth=3, got %d", descs[0].Positional.Depth)
}
Expand Down Expand Up @@ -65,6 +89,9 @@ func TestLoadSnapshot_PropagatesInteractiveFlag(t *testing.T) {
if descs[1].Section != "Checkout" {
t.Fatalf("expected second descriptor section=Checkout, got %q", descs[1].Section)
}
if descs[1].TestID != "legacy-submit" {
t.Fatalf("expected second descriptor test_id fallback=legacy-submit, got %q", descs[1].TestID)
}
if descs[1].Positional.Depth != 2 {
t.Fatalf("expected second descriptor depth=2, got %d", descs[1].Positional.Depth)
}
Expand Down
32 changes: 27 additions & 5 deletions docs/architecture/implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,36 @@ The input unit. Each element from the accessibility tree becomes a descriptor:

```go
type ElementDescriptor struct {
Ref string // "e4" — the element reference
Role string // "button" — ARIA role
Name string // "Sign In" — accessible name
Value string // "" — current value (for inputs)
Ref string
Role string
Name string
Value string
Label string
Placeholder string
Alt string
Title string
TestID string
Text string
Tag string
}
```

The `Composite()` method produces a single searchable string: `"button: Sign In"`. This is what matchers score against.
The `Composite()` method produces a single searchable string such as `"button: Sign In"`. Natural-language matchers score against this composite; structured locators score explicit fields directly.

Structured locator queries are parsed before generic semantic matching:

- `role:<role> [name]`
- `text:<text>`
- `label:<label>`
- `placeholder:<text>`
- `alt:<text>`
- `title:<text>`
- `testid:<id>`
- `first:<selector>`, `last:<selector>`, `nth:<n>:<selector>`

`nth:<n>` is 1-based: `nth:1` selects the first ordered candidate, `nth:2` selects the second, and `nth:0` is not the first match.

Exact normalized field matches outrank substring matches. Role locators prefer `Role` and fall back to the implicit role inferred from `Tag` when the explicit role is empty or generic. `find:<query>` and `semantic:<query>` force natural-language matching.

## Lexical Matcher

Expand Down
30 changes: 26 additions & 4 deletions docs/core-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,37 @@ The input unit. Each element from the accessibility tree becomes a descriptor:

```go
semantic.ElementDescriptor{
Ref: "e4", // element reference
Role: "button", // ARIA role
Name: "Sign In", // accessible name
Value: "", // current value (for inputs)
Ref: "e4", // element reference
Role: "button", // explicit/accessibility role
Name: "Sign In", // accessible name
Value: "", // current value (for inputs)
Label: "Email", // associated label text
Placeholder: "Search", // placeholder text
Alt: "Logo", // image alt text
Title: "Help", // title attribute text
TestID: "submit", // test id attribute
Text: "Sign In", // visible text
Tag: "button", // HTML tag for implicit role fallback
}
```

`Composite()` produces a single searchable string: `"button: Sign In"`.

Structured locators are parsed before natural-language matching:

| Query | Meaning |
|-------|---------|
| `role:button Sign In` | Role plus optional accessible name |
| `text:Sign In` | Visible text |
| `label:Email` | Associated label text |
| `placeholder:Search` | Placeholder text |
| `alt:Logo` | Image alt text |
| `title:Help` | Title text |
| `testid:submit` | Test id |
| `first:role:button`, `last:role:button`, `nth:1:role:button` | Ordered candidate selection; `nth` is 1-based |

`nth:<n>` is 1-based: `nth:1` selects the first ordered candidate, `nth:2` selects the second, and `nth:0` is not the first match. Use `find:<query>` or `semantic:<query>` to force natural-language matching when a query starts with a locator-like prefix.

## Matchers

All matchers implement the `ElementMatcher` interface:
Expand Down
25 changes: 25 additions & 0 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,31 @@ func CosineSimilarity(a, b []float32) float64
| `EmbeddingWeight` | `float64` | 0 | Per-request weight override |
| `Explain` | `bool` | false | Include per-strategy breakdown |

### ElementDescriptor Fields

| Field | Description |
|-------|-------------|
| `Ref` | Snapshot element reference |
| `Role` | Explicit/accessibility role |
| `Name` | Accessible name |
| `Value` | Current value |
| `Label` | Associated label text |
| `Placeholder` | Placeholder text |
| `Alt` | Image alt text |
| `Title` | Title text |
| `TestID` | Test id attribute |
| `Text` | Visible text |
| `Tag` | HTML tag used for implicit role fallback |
| `Interactive` | Whether the element is interactive |
| `Parent` | Parent context |
| `Section` | Section context |
| `DocumentIdx` | Document order hint |
| `Positional` | AX-tree and visual position hints |

### Structured Locator Queries

`Find` parses these locator forms before natural-language scoring: `role:<role> [name]`, `text:<text>`, `label:<label>`, `placeholder:<text>`, `alt:<text>`, `title:<text>`, `testid:<id>`, `first:<selector>`, `last:<selector>`, and `nth:<n>:<selector>`. `nth:<n>` is 1-based: `nth:1` selects the first ordered candidate, `nth:2` selects the second, and `nth:0` is not the first match. `find:<query>` and `semantic:<query>` force natural-language matching.

---

## Recovery Package (`github.com/pinchtab/semantic/recovery`)
Expand Down
2 changes: 2 additions & 0 deletions docs/semantic.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Zero-dependency Go library for semantic matching of accessibility tree elements.
## What Semantic Does

Matches natural language queries like "sign in button" against structured UI element descriptors using lexical similarity, synonym expansion, and embedding-based fuzzy matching.
It also supports structured locator queries such as `role:button Sign In`, `text:Submit`, `label:Email`, and `testid:checkout-submit` when descriptors include those fields.

Browser automation tools find UI elements via CSS selectors, XPath, or IDs — all brittle. When the DOM changes (SPA re-renders, layout shifts, framework updates), selectors break. AI agents make it worse: they describe elements in natural language but the accessibility tree has structured labels.

Expand All @@ -14,5 +15,6 @@ Semantic bridges that gap.

- **Zero dependencies** — only Go standard library
- **Stateless** — every `Find()` call is independent, thread-safe by default
- **Structured locators** — exact field matching for role, text, label, placeholder, alt, title, and test id
- **Sub-millisecond** — < 1ms for 100 elements with combined matcher
- **Self-healing** — recovery engine re-locates stale elements after DOM changes
53 changes: 53 additions & 0 deletions internal/engine/benchmark_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,3 +361,56 @@ func BenchmarkCombinedFind_WeightVariants(b *testing.B) {
})
}
}

func BenchmarkStructuredLocatorFind(b *testing.B) {
m := NewCombinedMatcher(NewHashingEmbedder(128))
elements := []types.ElementDescriptor{
{Ref: "e0", Role: "heading", Name: "Welcome Back", Text: "Welcome Back", DocumentIdx: 0},
{Ref: "e1", Role: "textbox", Name: "Email address", Label: "Email address", Placeholder: "name@example.com", Tag: "input", Interactive: true, DocumentIdx: 1},
{Ref: "e2", Role: "textbox", Name: "Password", Label: "Password", Placeholder: "Password", Tag: "input", Interactive: true, DocumentIdx: 2},
{Ref: "e3", Role: "checkbox", Name: "Remember me", Label: "Remember me", Tag: "input", Interactive: true, DocumentIdx: 3},
{Ref: "e4", Role: "button", Name: "Sign In", Text: "Sign In", TestID: "submit-login", Tag: "button", Interactive: true, DocumentIdx: 4},
{Ref: "e5", Role: "link", Name: "Forgot password?", Text: "Forgot password?", Tag: "a", Interactive: true, DocumentIdx: 5},
{Ref: "e6", Role: "img", Name: "Company Logo", Alt: "Company Logo", Tag: "img", DocumentIdx: 6},
}
ctx := context.Background()
opts := types.FindOptions{Threshold: 0, TopK: 3}
queries := []string{
"role:button Sign In",
"text:Forgot password?",
"label:Email address",
"placeholder:Password",
"alt:Company Logo",
"testid:submit-login",
"nth:1:role:textbox",
}

for _, query := range queries {
b.Run(query, func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = m.Find(ctx, query, elements, opts)
}
})
}
}

func BenchmarkParseStructuredLocator(b *testing.B) {
queries := []string{
"role:button Sign In",
"text:Forgot password?",
"label:Email address",
"placeholder:Password",
"alt:Company Logo",
"testid:submit-login",
"nth:2:role:button Save",
}
b.ReportAllocs()

for b.Loop() {
for _, query := range queries {
parseStructuredLocator(query)
}
}
}
7 changes: 7 additions & 0 deletions internal/engine/combined.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ func (c *CombinedMatcher) Find(ctx context.Context, query string, elements []typ
ctx = context.Background()
}

query, forceNatural := normalizeSemanticQuery(query)
if !forceNatural {
if result, ok := findStructuredLocator(query, elements, opts, c.Strategy()); ok {
return result, nil
}
}

opts = sanitizeFindOptions(opts, len(elements), 3)

parsed := ParseQueryContext(query)
Expand Down
19 changes: 19 additions & 0 deletions internal/engine/descriptor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,25 @@ func TestComposite(t *testing.T) {
desc: types.ElementDescriptor{Ref: "e1", Role: "textbox", Name: "Email", Value: "user@pinchtab.com"},
want: "textbox: Email [user@pinchtab.com]",
},
{
name: "locator identity fields",
desc: types.ElementDescriptor{
Ref: "e4",
Role: "textbox",
Name: "Email",
Label: "Work Email",
Placeholder: "name@example.com",
Title: "Primary email address",
Text: "Email",
TestID: "email-input",
},
want: "textbox: Email label:Work Email placeholder:name@example.com title:Primary email address",
},
{
name: "tag when role missing",
desc: types.ElementDescriptor{Ref: "e5", Tag: "button", Text: "Save"},
want: "button: Save",
},
{
name: "name only",
desc: types.ElementDescriptor{Ref: "e2", Name: "Heading"},
Expand Down
7 changes: 7 additions & 0 deletions internal/engine/embedding.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ func (m *EmbeddingMatcher) Find(ctx context.Context, query string, elements []ty
return types.FindResult{}, err
}

query, forceNatural := normalizeSemanticQuery(query)
if !forceNatural {
if result, ok := findStructuredLocator(query, elements, opts, m.Strategy()); ok {
return result, nil
}
}

queryCtx := ParseQueryContext(query)
return m.findWithParsedContext(ctx, queryCtx, elements, opts)
}
Expand Down
7 changes: 7 additions & 0 deletions internal/engine/lexical.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ func (m *LexicalMatcher) Find(ctx context.Context, query string, elements []type
return types.FindResult{}, err
}

query, forceNatural := normalizeSemanticQuery(query)
if !forceNatural {
if result, ok := findStructuredLocator(query, elements, opts, m.Strategy()); ok {
return result, nil
}
}

queryCtx := ParseQueryContext(query)
return m.findWithParsedContext(ctx, queryCtx, elements, opts), nil
}
Expand Down
6 changes: 6 additions & 0 deletions internal/engine/query_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ func matchesExcludedContext(el types.ElementDescriptor, excludeTokens []string)
el.Role,
el.Name,
el.Value,
el.Label,
el.Placeholder,
el.Alt,
el.Title,
el.Text,
el.Tag,
}, " "))
if len(ctxTokens) == 0 {
return false
Expand Down
Loading
Loading