Logic: better support for tag optimization and simplifying expressions

This commit is contained in:
Pieter Vander Vennet 2025-01-09 20:39:21 +01:00
parent bd228a6129
commit a3d26db84a
11 changed files with 430 additions and 260 deletions

View file

@ -130,39 +130,38 @@ export class And extends TagsFilter {
* t1.shadows(t2) // => false
* t2.shadows(t0) // => false
* t2.shadows(t1) // => false
*
*
* const t1 = new And([new Tag("shop","clothes"), new Or([new Tag("brand","XYZ"),new Tag("brand:wikidata","Q1234")])])
* const t2 = new And([new RegexTag("shop","mall",true), new Or([TagUtils.Tag("shop~*"), new Tag("craft","shoemaker")])])
* t1.shadows(t2) // => true
*/
shadows(other: TagsFilter): boolean {
if (!(other instanceof And)) {
return false
}
const phrases: TagsFilter[] = other instanceof And ? other.and : [other];
// A phrase might be shadowed by a certain subsection. We keep track of this here
const shadowedOthers = phrases.map(() => false)
for (const selfTag of this.and) {
let matchFound = false
for (const otherTag of other.and) {
matchFound = selfTag.shadows(otherTag)
if (matchFound) {
break
let shadowsSome = false;
let shadowsAll = true;
for (let i = 0; i < phrases.length; i++){
const otherTag = phrases[i]
const doesShadow = selfTag.shadows(otherTag)
if(doesShadow){
shadowedOthers[i] = true;
}
shadowsSome ||= doesShadow;
shadowsAll &&= doesShadow;
}
if (!matchFound) {
return false
// If A => X and A => Y, then
// A&B implies X&Y. We discovered an A that implies all needed values
if (shadowsAll) {
return true;
}
if (!shadowsSome) {
return false;
}
}
for (const otherTag of other.and) {
let matchFound = false
for (const selfTag of this.and) {
matchFound = selfTag.shadows(otherTag)
if (matchFound) {
break
}
}
if (!matchFound) {
return false
}
}
return true
return !shadowedOthers.some(v => !v);
}
usedKeys(): string[] {
@ -182,11 +181,13 @@ export class And extends TagsFilter {
}
/**
* IN some contexts, some expressions can be considered true, e.g.
* In some contexts, some expressions can be considered true, e.g.
* (X=Y | (A=B & X=Y))
* ^---------^
* When the evaluation hits (A=B & X=Y), we know _for sure_ that X=Y does _not_ match, as it would have matched the first clause otherwise.
* This means that the entire 'AND' is considered FALSE
* This means that the entire 'AND' is considered FALSE in this case; but this is already handled by the first half.
* In other words: this long expression is equivalent to (A=B | X=Y).
*
*
* @return only phrases that should be kept.
* @param knownExpression The expression which is known in the subexpression and for which calculations can be done
@ -204,13 +205,14 @@ export class And extends TagsFilter {
* const expr = <And> TagUtils.Tag({and: ["sport=climbing", {or:["club~*", "office~*"]}]} )
* expr.removePhraseConsideredKnown(new Tag("club","climbing"), false) // => expr
*/
removePhraseConsideredKnown(
public removePhraseConsideredKnown(
knownExpression: TagsFilter,
value: boolean
): (TagsFilterClosed & OptimizedTag) | boolean {
const newAnds: TagsFilter[] = []
for (const tag of this.and) {
if (tag instanceof And) {
console.trace("Improper optimization")
throw (
"Optimize expressions before using removePhraseConsideredKnown. Found an AND in an AND: " +
this.asHumanString()

View file

@ -83,6 +83,7 @@ export class Or extends TagsFilter {
return false
}
shadows(other: TagsFilter): boolean {
if (other instanceof Or) {
for (const selfTag of this.or) {

View file

@ -4,6 +4,8 @@ import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
import { ExpressionSpecification } from "maplibre-gl"
import { RegexTag } from "./RegexTag"
import { OptimizedTag } from "./TagTypes"
import { Or } from "./Or"
import { And } from "./And"
export class Tag extends TagsFilter {
public key: string
@ -148,6 +150,12 @@ export class Tag extends TagsFilter {
return other.matchesProperties({ [this.key]: this.value })
}
}
if(other instanceof Or){
return other.or.some(other => this.shadows(other))
}
if(other instanceof And){
return !other.and.some(other => !this.shadows(other))
}
return false
}

View file

@ -133,11 +133,11 @@ export class TagUtils {
"\n" +
"```json\n" +
"{\n" +
' "mappings": [\n' +
" \"mappings\": [\n" +
" {\n" +
' "if":"key:={some_other_key}",\n' +
' "then": "...",\n' +
' "hideInAnswer": "some_other_key="\n' +
" \"if\":\"key:={some_other_key}\",\n" +
" \"then\": \"...\",\n" +
" \"hideInAnswer\": \"some_other_key=\"\n" +
" }\n" +
" ]\n" +
"}\n" +
@ -175,10 +175,10 @@ export class TagUtils {
"\n" +
"```json\n" +
"{\n" +
' "osmTags": {\n' +
' "or": [\n' +
' "amenity=school",\n' +
' "amenity=kindergarten"\n' +
" \"osmTags\": {\n" +
" \"or\": [\n" +
" \"amenity=school\",\n" +
" \"amenity=kindergarten\"\n" +
" ]\n" +
" }\n" +
"}\n" +
@ -194,7 +194,7 @@ export class TagUtils {
"If the schema-files note a type [`TagConfigJson`](https://github.com/pietervdvn/MapComplete/blob/develop/src/Models/ThemeConfig/Json/TagConfigJson.ts), you can use one of these values.\n" +
"\n" +
"In some cases, not every type of tags-filter can be used. For example, _rendering_ an option with a regex is\n" +
'fine (`"if": "brand~[Bb]randname", "then":" The brand is Brandname"`); but this regex can not be used to write a value\n' +
"fine (`\"if\": \"brand~[Bb]randname\", \"then\":\" The brand is Brandname\"`); but this regex can not be used to write a value\n" +
"into the database. The theme loader will however refuse to work with such inconsistencies and notify you of this while\n" +
"you are building your theme.\n" +
"\n" +
@ -205,18 +205,18 @@ export class TagUtils {
"\n" +
"```json\n" +
"{\n" +
' "and": [\n' +
' "key=value",\n' +
" \"and\": [\n" +
" \"key=value\",\n" +
" {\n" +
' "or": [\n' +
' "other_key=value",\n' +
' "other_key=some_other_value"\n' +
" \"or\": [\n" +
" \"other_key=value\",\n" +
" \"other_key=some_other_value\"\n" +
" ]\n" +
" },\n" +
' "key_which_should_be_missing=",\n' +
' "key_which_should_have_a_value~*",\n' +
' "key~.*some_regex_a*_b+_[a-z]?",\n' +
' "height<1"\n' +
" \"key_which_should_be_missing=\",\n" +
" \"key_which_should_have_a_value~*\",\n" +
" \"key~.*some_regex_a*_b+_[a-z]?\",\n" +
" \"height<1\"\n" +
" ]\n" +
"}\n" +
"```\n" +
@ -246,7 +246,7 @@ export class TagUtils {
static asProperties(
tags: TagsFilter | TagsFilter[],
baseproperties: Record<string, string> = {}
baseproperties: Record<string, string> = {},
) {
if (Array.isArray(tags)) {
tags = new And(tags)
@ -274,11 +274,11 @@ export class TagUtils {
static SplitKeysRegex(tagsFilters: UploadableTag[], allowRegex: false): Record<string, string[]>
static SplitKeysRegex(
tagsFilters: UploadableTag[],
allowRegex: boolean
allowRegex: boolean,
): Record<string, (string | RegexTag)[]>
static SplitKeysRegex(
tagsFilters: UploadableTag[],
allowRegex: boolean
allowRegex: boolean,
): Record<string, (string | RegexTag)[]> {
const keyValues: Record<string, (string | RegexTag)[]> = {}
tagsFilters = [...tagsFilters] // copy all, use as queue
@ -307,7 +307,7 @@ export class TagUtils {
if (typeof key !== "string") {
console.error(
"Invalid type to flatten the multiAnswer: key is a regex too",
tagsFilter
tagsFilter,
)
throw "Invalid type to FlattenMultiAnswer: key is a regex too"
}
@ -508,7 +508,7 @@ export class TagUtils {
public static Tag(json: TagConfigJson, context?: string | ConversionContext): TagsFilterClosed
public static Tag(
json: TagConfigJson,
context: string | ConversionContext = ""
context: string | ConversionContext = "",
): TagsFilterClosed {
try {
const ctx = typeof context === "string" ? context : context.path.join(".")
@ -540,7 +540,7 @@ export class TagUtils {
throw `Error at ${context}: detected a non-uploadable tag at a location where this is not supported: ${t.asHumanString(
false,
false,
{}
{},
)}`
})
@ -661,7 +661,7 @@ export class TagUtils {
*/
public static removeShadowedElementsFrom(
blacklist: TagsFilter[],
listToFilter: TagsFilter[]
listToFilter: TagsFilter[],
): TagsFilter[] {
return listToFilter.filter((tf) => !blacklist.some((guard) => guard.shadows(tf)))
}
@ -699,7 +699,7 @@ export class TagUtils {
*/
public static containsEquivalents(
guards: ReadonlyArray<TagsFilter>,
listToFilter: ReadonlyArray<TagsFilter>
listToFilter: ReadonlyArray<TagsFilter>,
): boolean {
return listToFilter.some((tf) => guards.some((guard) => guard.shadows(tf)))
}
@ -743,7 +743,7 @@ export class TagUtils {
values.push(i + "")
}
return values
})
}),
)
return Utils.NoNull(spec)
}
@ -751,13 +751,13 @@ export class TagUtils {
private static ParseTagUnsafe(json: TagConfigJson, context: string = ""): TagsFilterClosed {
if (json === undefined) {
throw new Error(
`Error while parsing a tag: 'json' is undefined in ${context}. Make sure all the tags are defined and at least one tag is present in a complex expression`
`Error while parsing a tag: 'json' is undefined in ${context}. Make sure all the tags are defined and at least one tag is present in a complex expression`,
)
}
if (typeof json != "string") {
if (json["and"] !== undefined && json["or"] !== undefined) {
throw `${context}: Error while parsing a TagConfig: got an object where both 'and' and 'or' are defined. Did you override a value? Perhaps use \`"=parent": { ... }\` instead of "parent": {...}\` to trigger a replacement and not a fuse of values. The value is ${JSON.stringify(
json
json,
)}`
}
if (json["and"] !== undefined) {
@ -839,13 +839,13 @@ export class TagUtils {
return new RegexTag(
withRegex.key,
new RegExp(".+", "si" + withRegex.modifier),
withRegex.invert
withRegex.invert,
)
}
return new RegexTag(
withRegex.key,
new RegExp("^(" + value + ")$", "s" + withRegex.modifier),
withRegex.invert
withRegex.invert,
)
}
@ -967,10 +967,19 @@ export class TagUtils {
return ["", "## `" + mode + "` " + doc.name, "", doc.docs, "", ""].join("\n")
}),
"## " +
TagUtils.comparators.map((comparator) => "`" + comparator[0] + "`").join(" ") +
" Logical comparators",
TagUtils.comparators.map((comparator) => "`" + comparator[0] + "`").join(" ") +
" Logical comparators",
TagUtils.numberAndDateComparisonDocs,
TagUtils.logicalOperator,
].join("\n")
}
static fromProperties(tags: Record<string, string>): TagConfigJson | boolean {
const opt = new And(Object.keys(tags).map(k => new Tag(k, tags[k]))).optimize()
if (opt === true || opt === false) {
return opt
}
return opt.asJson()
}
}

View file

@ -9,7 +9,8 @@ export abstract class TagsFilter {
/**
* Indicates some form of equivalency:
* if `this.shadows(t)`, then `this.matches(properties)` implies that `t.matches(properties)` for all possible properties
* if `this.shadows(t)`, then `this.matches(properties)` implies that `t.matches(properties)` for all possible properties.
* In other words: 'this' is a _stronger_ condition then 't'
*/
abstract shadows(other: TagsFilter): boolean

View file

@ -41,14 +41,14 @@ interface NSIEntry {
* Represents a single brand/operator/flagpole/...
*/
export interface NSIItem {
displayName: string
id: string
readonly displayName: string
readonly id: string
locationSet: {
include: string[]
exclude: string[]
}
tags: Record<string, string>
fromTemplate?: boolean
readonly tags: Readonly<Record<string, string>>
readonly fromTemplate?: boolean
}
export default class NameSuggestionIndex {
@ -77,7 +77,7 @@ export default class NameSuggestionIndex {
}
>
>,
features: Readonly<FeatureCollection>
features: Readonly<FeatureCollection>,
) {
this.nsiFile = nsiFile
this.nsiWdFile = nsiWdFile
@ -92,10 +92,10 @@ export default class NameSuggestionIndex {
}
const [nsi, nsiWd, features] = await Promise.all(
["./assets/data/nsi/nsi.min.json", "./assets/data/nsi/wikidata.min.json", "./assets/data/nsi/featureCollection.min.json"].map((url) =>
Utils.downloadJsonCached(url, 1000 * 60 * 60 * 24 * 30)
)
Utils.downloadJsonCached(url, 1000 * 60 * 60 * 24 * 30),
),
)
NameSuggestionIndex.inited = new NameSuggestionIndex(<any>nsi, <any>nsiWd["wikidata"], <any> features)
NameSuggestionIndex.inited = new NameSuggestionIndex(<any>nsi, <any>nsiWd["wikidata"], <any>features)
return NameSuggestionIndex.inited
}
@ -126,13 +126,13 @@ export default class NameSuggestionIndex {
try {
return Utils.downloadJsonCached<Record<string, number>>(
`./assets/data/nsi/stats/${type}.${c.toUpperCase()}.json`,
24 * 60 * 60 * 1000
24 * 60 * 60 * 1000,
)
} catch (e) {
console.error("Could not fetch " + type + " statistics due to", e)
return undefined
}
})
}),
)
stats = Utils.NoNull(stats)
if (stats.length === 1) {
@ -173,17 +173,17 @@ export default class NameSuggestionIndex {
public async generateMappings(
type: string,
tags: Record<string, string>,
country: string[],
country?: string[],
location?: [number, number],
options?: {
/**
* If set, sort by frequency instead of alphabetically
*/
sortByFrequency: boolean
}
},
): Promise<Mapping[]> {
const mappings: (Mapping & { frequency: number })[] = []
const frequencies = await NameSuggestionIndex.fetchFrequenciesFor(type, country)
const frequencies = country !== undefined ? await NameSuggestionIndex.fetchFrequenciesFor(type, country) : {}
for (const key in tags) {
if (key.startsWith("_")) {
continue
@ -194,7 +194,7 @@ export default class NameSuggestionIndex {
key,
value,
country.join(";"),
location
location,
)
if (!actualBrands) {
continue
@ -202,8 +202,7 @@ export default class NameSuggestionIndex {
for (const nsiItem of actualBrands) {
const tags = nsiItem.tags
const frequency = frequencies[nsiItem.displayName]
const logos = this.nsiWdFile[nsiItem.tags[type + ":wikidata"]]?.logos
const iconUrl = logos?.facebook ?? logos?.wikidata
const iconUrl = this.getIconExternalUrl(nsiItem, type)
const hasIcon = iconUrl !== undefined
let icon = undefined
if (hasIcon) {
@ -240,7 +239,7 @@ export default class NameSuggestionIndex {
}
public supportedTags(
type: "operator" | "brand" | "flag" | "transit" | string
type: "operator" | "brand" | "flag" | "transit" | string,
): Record<string, string[]> {
const tags: Record<string, string[]> = {}
const keys = Object.keys(this.nsiFile.nsi)
@ -263,7 +262,7 @@ export default class NameSuggestionIndex {
* Returns a list of all brands/operators
* @param type
*/
public allPossible(type: "brand" | "operator"): NSIItem[] {
public allPossible(type: string): NSIItem[] {
const options: NSIItem[] = []
const tags = this.supportedTags(type)
for (const osmKey in tags) {
@ -285,10 +284,10 @@ export default class NameSuggestionIndex {
type: string,
tags: { key: string; value: string }[],
country: string = undefined,
location: [number, number] = undefined
location: [number, number] = undefined,
): NSIItem[] {
return tags.flatMap((tag) =>
this.getSuggestionsForKV(type, tag.key, tag.value, country, location)
this.getSuggestionsForKV(type, tag.key, tag.value, country, location),
)
}
@ -311,7 +310,7 @@ export default class NameSuggestionIndex {
key: string,
value: string,
country: string = undefined,
location: [number, number] = undefined
location: [number, number] = undefined,
): NSIItem[] {
const path = `${type}s/${key}/${value}`
const entry = this.nsiFile.nsi[path]
@ -375,9 +374,29 @@ export default class NameSuggestionIndex {
center: [number, number],
options: {
sortByFrequency: boolean
}
},
): Promise<Mapping[]> {
const nsi = await NameSuggestionIndex.getNsiIndex()
return nsi.generateMappings(key, tags, country, center, options)
}
/**
* Where can we find the URL on the world wide web?
* Probably facebook! Don't use in the website, might expose people
* @param nsiItem
* @param type
*/
private getIconExternalUrl(nsiItem: NSIItem, type: string): string {
const logos = this.nsiWdFile[nsiItem.tags[type + ":wikidata"]]?.logos
return logos?.facebook ?? logos?.wikidata
}
public getIconUrl(nsiItem: NSIItem, type: string) {
let icon = "./assets/data/nsi/logos/" + nsiItem.id
if (this.isSvg(nsiItem, type)) {
icon = icon + ".svg"
}
return icon
}
}