Specyfikacja WCCL L0

Adam Radziszewski i Adam Wardyński
Październik 2010 — kwiecień 2011

W ramach podjęzyka L0 wydzielono najbardziej podstawową część: wyrażenia funkcyjne. L0 odpowiada z grubsza JOSKIPI zaimplementowanemu w C++ z wyjątkiem reguł oraz składni klas niejednoznaczności tagera.

Typy danych, składnia zapisu stałych i mechanizm zmiennych

Chociaż wyrażenia języka ewaluowane są na zdaniach i tokenach, żaden z tych bytów nie ma odpowiadającego typu wspieranego przez język. Dla wyrażeń WCCL bieżące zdanie jest „całym uniwersum”, więc nie musimy go nazywać (za odpalenie na konkretnym zdaniu odpowiedzialny jest użytkownik implementacji). Jeśli chodzi o tokeny, odwołujemy się do pozycji, pod którymi widzimy różne cechy, np. orth, wartość przypadka. Fizycznie te cechy są wyłuskiwane z tokenu leżącego na podanej pozycji, lecz na tym poziomie abstrakcji widzimy konkretne cechy, a nie tokeny, leksemy itp.

Język jest statycznie typizowany, typy są wywodzone automatycznie z danych (type-inferred).

Typy danych wspierane przez język:
  • zbiór symboli z tagsetu, np. {subst, fin, praet}, {nom}
  • zbiór napisów, np. ["być", "zostać"], ['nie']
  • wartości logiczne: True, False
  • pozycje, czyli wskazanie na konkretny token w zdaniu względem bieżącej pozycji lub begin / end (bezwzględne odwołanie do początku/końca zdania)
Niepełnowartościowe typy danych:
  • liczba całkowita, np. 0, 2

Typy zbiór symboli z tagsetu i zbiór napisów nie mają odpowiadających typów „pojedynczy symbol”, „napis”[1]. Wszystkie operacje na symbolach i napisach zakładają, że przetwarzany jest zbiór. Jeśli mówimy o pojedynczej wartości, jest to zbiór jednoelementowy. To jest celowe. WCCL z założenia operuje na wieloznacznych interpretacjach: tokenowi przypisane może być wiele alternatywnych tagów i wiele alternatywnych lematów. Poniekąd podobnie zachowuje się typ str i unicode w Pythonie: nie ma typu char, 'kocur'[1] == 'o'.

1 niepełnowartościowy typ „napis” pojawia się na poziomie L1.

Typ liczba jest niepełnowartościowy, ponieważ 1) takich bytów nie można zwrócić, 2) dopuszczamy tylko stałe, 3) wartości te są używane w bardzo wąskich kontekstach. Co więcej, obsługa niepełnowartościowych typów danych na poziomie implementacji może być zamknięta w parserze — wartości tych typów nie można zwrócić, więc i tak nie mogą one opuścić parsera.

Konkretne stałe z tagsetu nie należą do gramatyki, ponieważ tagsety mogą być różne. Gramatyka określa jedynie warunki, które musi spełniać ciąg znaków, by mógł być uznany za symbol z tagsetu. Podczas parsowania stałe z tagsetu zamieniane są na wewnętrzną reprezentację (np. liczby wg wartości zdefiniowanych w tagsecie). Reprezentacja ta jest używana tylko wewnątrz implementacji i użytkownik WCCL może uzyskać jedynie reprezentację tekstową. Stwarza to możliwość optymalizacji.

Symbole z tagsetu

Zbiór pusty: {}
Zbiór niepusty: {w1, w2, …, wn}
Zbiór jednoelementowy (cukier składniowy): w1

Każdy z symboli w1wn musi być zdefiniowany w obecnie używanym przez parser tagsecie jako:
  • wartość klasy gramatycznej (np. subst) albo
  • wartość jednego z atrybutów (np. nom) albo
  • nazwa jednego z atrybutów (np. cas; klasa gramatyczna to nie atrybut).

Dla każdego symbolu z tagsetu dopuszczalna jest również składnia `w1`, gdzie znakiem jest gravis (U+0060 GRAVE ACCENT, „backquote”; podobną funkcję ma w SQL-u: pozwala odwołać się do tabeli, której nazwa jest zbieżna ze słowem kluczowym).

W ostatnim przypadku każde wystąpienie nazwy atrybutu rozwijane jest do wszystkich jego wartości. Rozwijanie ma miejsce podczas parsowania, różne sposoby określenia tego samego zbioru dają ten sam wynik.
Przykładowo, w tagsecie KIPI mamy {gnd} = {sg, pl}
{nmb, gnd, cas} = {nmb, sg, pl, cas} = … = {m1,m2,m3,n,f,sg,pl,nom,gen,dat,acc,loc,inst,voc}

Uwaga: symbole określające nazwy atrybutów stają się automatycznie nazwami operatorów do pobierania wartości atrybutu. Te oraz pozostałe symbole brane są z pliku definiującego tagset. Autorzy tagsetów KIPI i NKJP nie definiują takich symboli (w poprzednich JOSKIPI określiliśmy własne symbole, np. cas i ppr; dla porównania, Spejd i Pantera korzystają z pełnych nazw atrybutów, np. case, post-prepositionality).

Zbiór napisów

Zbiór pusty: []
Zbiór niepusty: [s1, s2, …, sn]
Zbiór jednoelementowy (cukier składniowy): s1

każdy z symboli s1 to napis otoczony cudzysłowami ASCII (U+0022 QUOTATION MARK) bądź apostrofami ASCII (U+0027 APOSTROPHE). Dopuszczalne są sekwencje kontrolne (escape sequences) zdefiniowane przez ICU.

Liczba całkowita

Składnia standardowa.

Typ ten jest używany do określenia, ile razy musi być spełniony warunek operatora ustalającego wartość zmiennej.

Zmienne, ich składnia i zasięg

W WCCL L0 dostępne są jedynie zmienne po pozycjach, tj. zmienne, których wartość jest typu pozycja.

Składnia. Odwołanie do zmiennej po pozycji to wyrażenie $Var, gdzie Var to nazwa zmiennej (musi rozpoczynać się wielką literą lub podkreśleniem).

WCCL stosuje dynamiczny zasięg zmiennych przy założeniu, że zasięgów nie można zagnieżdżać (dążymy do prostoty kosztem uniwersalności). Przez zasięg będziemy tutaj rozumieć przypisanie identyfikatorom zmiennych ich wartości.

Wszystkie wyrażenia funkcyjne z WCCL L0 działają w zasięgu zmiennych. Oznacza to, że do przeparsowania dowolnego wyrażenia funkcyjnego potrzebne jest podanie zasięgu, w którym wyrażenie ma być ewaluowane. Zmienne nie wymagają deklaracji; pierwsze odwołanie do zmiennej o danej nazwie powoduje utworzenie w zasięgu obiektu danego typu (w L0 mamy tylko pozycje) ustawionego na wartość domyślną (w przypadku typu pozycja to nowhere). Kolejne odwołania mogą użyć tej wartości lub ją zmienić. Nie ma możliwości przesłonięcia zasięgu innym zasięgiem.

Uwaga nt. implementacji: parser powinien zawierać reguły parsowania dodatkowych wyrażeń, które typem odpowiadają wyrażeniom funkcyjnym (po jednym dla każdego ze zwracanych typów), natomiast nie wymagają podania obiektu zasięgu. Reguły takie pozwolą użytkownikowi na przeparsowanie „zewnętrznego wyrażenia WCCL”, tj. zakłada się, że tworzymy nowy obiekt zasięgu i w nim osadzane jest parsowane wyrażenie (fizycznie, obiekt zasięgu przekazywany jest niżej do reguły parsowania wyrażenia funkcyjnego). Takie funkcje powinny stanowić podstawowe API parsera. Podobny mechanizm może zostać zastosowany w implementacji L1 i wyżej dla parsowania pojedynczej reguły, wzorca itp.

Pozycja

Pozycja wskazuje na token w przetwarzanym zdaniu. Wartością pozycji może być:
  • nowhere (nieustawiona),
  • begin (pierwszy token w zdaniu), end (ostatni token w zdaniu),
  • liczba całkowita, np. -3, 0, 1 (wskazuje token względem bieżącej pozycji w zdaniu.

Pozycja to abstrakcyjne określenie tokenu w zdaniu: pozycję określamy z góry w ramach danego wyrażenia, nie wiedząc jeszcze do jakich zdań to wyrażenie zostanie zaaplikowane. W związku z tym nie możemy na tym etapie wymagać, by pozycja mieściła się w granicach zdania. Konkretne zachowanie operatora w sytuacji, gdy jeden z jego argumentów to pozycja, która ewaluuje się do indeksu poza granicami zdania zależy od semantyki danego operatora. Wszystkie jednoargumentowe operatory przyjmujące pozycję i zwracające zbiór symboli zwracają w takiej sytuacji zbiór pusty. Zachowanie innych operatorów może być różne; np. llook i rlook wymagają, by obie pozycje znalazły się w granicach zdania, w przeciwnym razie operator od razu kończy.

Składnia i semantyka

Uwaga: token rozumiany jest jako para (forma napotkana, zbiór par (lemat, tag)).

Wyrażenie WCCL L0 to wyrażenie funkcyjne WCCL L0 (inne typy wyrażeń WCCL zostaną wprowadzone w ramach L2).

Wyrażenie funkcyjne WCCL L0 to
  • stała określonego typu lub
  • odwołanie do zmiennej określonego typu lub
  • operator zwracający wartość, czyli funkcja zwracająca wartość jednego ze zdefiniowanych wcześniej typów.

Wyrażenie funkcyjne nie musi być czysto funkcyjne, tu chodzi o typ zwracanej wartości.

Odwołanie do zmiennej może być zaimplementowane jako operator. Ważne jest to, że wszystkie powyższe przypadki są dla nas wyrażeniami funkcyjnymi, które po aplikacji na kontekście zwracają wartość.

Interpretacja wyrażenia WCCL to abstrakcyjne ujęcie struktury, która powstaje po przeparsowaniu wyrażenia.

Interpretacją wyrażenia funkcyjnego WCCL jest funktor typu kontekst -> zwracany typ. Kontekst to opakowanie na zdanie i bieżącą pozycję. Idea: raz przeparsowane wyrażenie można zaaplikować do różnych zdań względem różnego tokenu centralnego (pozycji bieżącej w zdaniu). Interpretacją stałej jest funktor, który niezaleznie od kontekstu, zwraca tę samą wartość. Dzięki temu samemu typowi interpretacji, możliwe jest używanie stałych i operatorów w tych samych kontekstach.

Kod WCCL może zawierać komentarze:
  • komentarze do końca linii po znakach //
  • komentarze wieloliniowe /* … */ (bez zagnieżdżeń)

Odwołania do pozycji

Składnia stałej określającej pozycję: patrz Typy danych. Składnia odwołania do zmiennej: patrz Zmienne, ich składnia i zasięg.

Wskazanie zmiennej po pozycji:
  • składnia odwołania do zmiennej ($Var, gdzie Var to nazwa zmiennej po pozycji)
Odwołanie do pozycji może być:
  • stałą określającą pozycję, np. nowhere, -2, begin
  • wyłuskanie wartości zmiennej: składnia odwołania do zmiennej ($Var, gdzie Var to nazwa zmiennej po pozycji)

Wskazanie zmiennej i odwołanie do zmiennej są zdefiniowane jako osobne konstrukcje składniowe, ponieważ operatory ustawiające wartość zmiennej mogą przyjąć tylko odwołanie do zmiennej (nie powinny akceptować np. stałych).

Interpretacją wskazania zmiennej jest specjalna struktura reprezentująca wskazanie pozycji. Struktura ta może być użyta do zlokalizowania obiektu reprezentującego wartość zmiennej, której nazwę określa parsowane wyrażenie.

Wyrażenia czysto funkcyjne (w tym ograniczenia)

Wyrażenia funkcyjne można podzielić ze względu na typ zwracanej wartości: 1) zbiór symboli z tagsetu, 2) wartość logiczną, 3) zbiór napisów 4) pozycja.

Operatory pobierające wartości z tagsetu.

Odtąd w definicjach składni operatorów wszystkie argumenty nazwane pos, pos1, pos2 to wskazania pozycji.

Operatory mają składnię op[pos], op to
  • class (klasa gramatyczna, w starym WCCL zwała się flex) lub
  • nazwa atrybutu z tagsetu,
  • nazwa wartości z tagsetu (nowe).

Jeśli podana pozycja leży poza granicami zdania, operatory te zwrócą zbiór pusty.
Operatory zwracają sumę wartości danego atrybutu lub klasy gramatycznej (class) wszystkich tagów tokenu na podanej pozycji (tu, jak i w całym języku nie patrzymy na znaczniki disamb). W przypadku podania konkretnej wartości (np. gen[0] w tagsecie KIPI), operator zwróci albo zbiór jednoelementowy zawierający podaną wartość (wtt. gdy podana wartość wystąpi w którymkolwiek tagu na podanej pozycji) lub zbiór pusty (w przeciwnym razie). Operator odpowiadający atrybutowi zwróci zbiór pusty, jeśli żaden z tagów tokenu na podanej pozycji nie ma określonej wartości podanego atrybutu (np. jeśli spytamy o przypadek, a żaden z tagów nie ma określonego przypadka).

range(symbol, pos1, pos2)
  • symbol to nazwa atrybutu z tagsetu lub class
  • jeśli któraś z pos1, pos2 ewaluuje się do indeksu poza granicami zdania, zwracamy pusty zbiór symboli
  • w przeciwny razie zwracamy sumę zbiorów symbol(pos) dla pos należ. do [pos1,pos2] włącznie, gdzie symbol

Operatory filtrujące

Niech tag|maska to część wspólna zbioru zwracanego przez operator maska i zbioru wartości wszystkich atrybutów i klasy gramatycznej tagu tag. Np. subst:pl:nom:m1|{subst,m1,m2,m3} = {subst,m1}, subst:pl:nom:m1|{m1,m2,m3} = {m1}.

catflt(pos, which, filter) zwraca zbiór symboli będący podzbiorem zbioru zwróconego przez filter
  • which i filter to wyrażenia zwracające zbiory symboli,
  • niech tags = { (tag należące do tokenu na pozycji pos) : tag|which != {} }
  • catflt(pos, which, filter) = suma zbiorów ( tag|filter dla tag należących do tags )

Disaster: ops.s_cat_filter

agrflt(pos1, pos2, agr_attrs, filter) zwraca zbiór symboli będący podzbiorem zbioru zwróconego przez filter
  • agr_attrs i filter to wyrażenia zwracające zbiory symboli,
  • sprawdzane jest słabe uzgodnienie na podanym zakresie pozycji
  • niech frags = weak_agr_constrs(pos1, pos2, agr_attrs)
  • zwracamy {}, jeśli fset jest pusty
  • w innym razie zwracamy sumę zbiorów ( część wspólna ( filter, fset ) dla fset należącego do frags )

Disaster: ops_agr.s_weak_rng_agr_filter (min_vals = count_categories(s_mask(context)))

Operatory zwracające zbiór napisów

Odtąd w definicjach składni operatorów wszystkie argumenty nazwane strset, strset1, strset2 to wyrażenie funkcyjne WCCL typu zbiór napisów. Wszystkie argumenty nazwane tset, tset1, … to wyrażenia funkcyjne WCCL typy zbiór symboli z tagsetu.

orth[pos] zwraca formę napotkaną tokenu na podanej pozycji: zbiór jednoelementowy (lub pusty, jeśli pozycja wychodzi poza zakresem zdania)

base[pos] zwraca zbiór lematów tokenu na podanej pozycji: zbiór niepusty, jeśli w granicach zdania

lower(strset) = { lstr : lstr = lowercase ( str ) dla każdego str w strset }

upper(strset) = { ustr : ustr = uppercase ( str ) dla każdego str w strset }

affix(strset, n) =
n < 0: { suff : suff = suffix ( str, len = -n or whole) dla każdego str w strset }
n > 0: { pref : pref = prefix ( str, len = n or whole) dla każdego str w strset }
n = 0: strset

Jeśli długość prefiksu lub sufiksu jest dłuższa niż dany napis, pozostaje cały. Jeśli długość jest równa zero, zwraca niezmieniony zbiór.

Predykaty

Predykaty (ograniczenia) to wyrażenia funkcyjne WCCL typu logicznego.

Odtąd w definicjach składni operatorów wszystkie argumenty nazwane pred, pred1, pred2, predn to predykaty.

and(pred1, …, predn) ewaluuje predykaty po kolei aż do pierwszej porażki i zwraca False, a w pp. True
or(pred1, …, predn) ewaluuje predykaty po kolei aż do pierwszego sukcesu i zwraca True, a w pp. False
not(pred1, …, predn) (operacja NOR == not(or(pred1, …, predn))) ewaluuje predykaty po kolei aż do pierwszego sukcesu i zwraca False, a w pp. True

TODO czy próbujemy wprowadzić bardziej intuicyjną składnię? Jeśli zostawimy tę składnię, to dla wygody dopuszczamy jednoargumentowe warianty (zeroargumentowych nie).

in(strset1, strset2) wtt. gdy strset1 != {} i strset1 jest podzbiorem strset2 TODO nazwa in jest myląca.
inter(strset1, strset2) wtt. gdy przecięcie_zbiorów ( strset1, strset2 ) != {}
equal(strset1, strset2) wtt. gdy strset1 == strset2

I analogicznie dla zbiorów symboli:
in(tset1, tset2) wtt. gdy tset1 != {} i tset1 jest podzbiorem tset2
inter
equal

Dla typu pozycja definiujemy operator equal:
equal(pos1, pos2) wtt. gdy pos1 == pos2

Składnia dla obu typów jest taka sama. Nie stworzy to problemu, bo typ wyrażenia da się wywieść z typów argumentów (o ile zmienimy uporządkujemy składnię zbiorów).

inside(pos) zwraca True, gdy pozycja leży w granicach zdania.
outside(pos — na odwrót.

regex(strset, re_text) sprawdza, czy wszystkie elementy zbioru strset spełniają podane wyrażenie regularne (re_text element typu nazwa zawierający wyrażenie regularne w formacie ICU)

TODO robimy dwa warianty? Np. anyregex, allregex?

TODO hasnum, isbigczy to zostawiamy? regex załatwi wszystko, a te nazwy są nieeleganckie

Osobną grupą predykatów są operatory sprawdzające, czy zachodzi uzgodnienie morfo-syntaktyczne. Interpretacja uzgodnienia różnych typów omówiona jest tu. Poniższe definicje odwołują się do tych typów. agr_attrs to zbiór wartości atrybutów, których uzgodnienie zachodzi lub nie.

agr(pos1, pos2, agr_attrs) wtt., gdy zachodzi silne uzgodnienie na podanym zakresie pozycji

Disaster: ops_agr.p_strong_rng_agr

agrpp(pos1, pos2, agr_attrs) wtt, gdy zachodzi silne uzgodnienie między podanymi dwoma pozycjami

Disaster: ops_agr.p_strong_pp_agr (min_vals = count_categories(s_mask(context)))

wagr(pos1, pos2, agr_attrs) wtt, gdy zachodzi słabe uzgodnienie na podanym zakresie pozycji

Disaster: ops_agr.p_weak_rng_agr (min_vals = count_categories(s_mask(context)))

Operatory warunkowe

Konstrukcja warunkowa. Mamy tę samą składnię dla czterech typów. Pierwszy arg. to warunek, drugi to wartość do zwrócenia, gdy warunek spełniony, trzeci to wartość „else”. Typ całego wyrażenia można wywieść z typów zwracanych.
if(pred, tset1, tset2)
if(pred, strset1, strset2)
if(pred, pred1, pred2)
if(pred, pos1, pos2)

Operator warunkowy: j.w., ale mamy tylko jedną wartość do zwrócenia; jeśli warunek nie jest spełniony, wartość zwracana zależy od typu:
if(pred, tset1) zwraca wartość wyrażenia tset1, jeśli pred, w pp. zwraca pusty zbiór symboli
if(pred, strset1) zwraca wartość wyrażenia strset1, jeśli pred, w pp. zwraca pusty zbiór napisów
if(pred, pred1) zwraca wartość wyrażenia pred1, jeśli pred, w pp. zwraca False
if(pred, pos1) zwraca wartość wyrażenia pos1, jeśli pred, w pp. zwraca nowhere

Wyrażenia zwracające pozycje

Operator + (pos + int) zwraca pozycję po zaaplikowaniu przesunięcia (liczby całkowitej).

Wyrażenia zmieniające wartości zmiennych

Wyrażenia te zachowują się jak wyrażenia funkcyjne, lecz ich skutkiem ubocznym może być zmiana wartości zmiennych.

Jawne ustawienie wartości

setvar(varname, pos) zawsze zwraca True, powoduje ustawienie zmiennej varname na określoną wartość (pozycję)

Iteracja za pomocą zmiennej

Zakres między pozycjami pos1 i pos2 to ciąg kolejnych indeksów w tablicy tokenów danego zdania (to ma sens dopiero po zastosowaniu przeparsowanego operatora do konkretnego zdania). Jeśli którakolwiek z pozycji jest nieustawiona (nowhere), otrzymujemy zakres pusty. W przeciwnym wypadku otrzymujemy ciąg kolejnych indeksów począwszy od ewaluowanej pos1 do ewaluowanej pos2, przycięty do granic zdania.

only(pos1, pos2, posvar, pred) wtt., gdy predykat jest spełniony na wszystkich pozycjach z zakresu
Skutki uboczne:
  • w razie sukcesu posvar pozostaje ustawiona na ostatniej pozycji z zakresu
  • w razie otrzymania pustego zakresu, wartość posvar pozostaje niezmieniona
  • w razie niepustego zakresu i porażki, posvar zostaje ustawiona na nowhere

Disaster: ops.p_all

atleast(pos1, pos2, posvar, pred, n) wtt., gdy predykat jest spełniony na co najmniej n pozycjach z zakresu
Skutki uboczne:
  • w razie sukcesu posvar pozostaje ustawiona na n-tej pozycji, która spełnia predykat
  • w razie otrzymania pustego zakresu, wartość posvar pozostaje niezmieniona
  • w razie niepustego zakresu i porażki, posvar zostaje ustawiona na nowhere

Disaster: ops.p_atleast

UWAGA: wcześniej w specyfikacji był błąd. llook iteruje w lewo, rlook iteruje w prawo. A więc llook znajduje pierwszy dopasowany token z prawej, rlook — z lewej. Poza tym przy llook zakres podajemy w odwrotnej kolejności.

llook(pos2, pos1, posvar, pred) wtt., gdy predykat jest spełniony na którejś pozycji z zakresu oraz pos2 >= pos1.
Skutki uboczne:
  • w razie porażki posvar jest ustawiana na nowhere
  • w razie sukcesu, ustawiana jest na pierwszym od prawej tokenie spełniającym predykat

Disaster: ops.p_look (step=-1)

rlook(pos1, pos2, posvar, pred) wtt., gdy predykat jest spełniony na którejś pozycji z zakresu oraz pos2 >= pos1.
Skutki uboczne:
  • w razie porażki posvar jest ustawiana na nowhere
  • w razie sukcesu, ustawiana jest na pierwszym od lewej tokenie spełniającym predykat

Disaster: ops.p_look (step=+1)